diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 793277bcb..cadbb3a65 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -112,6 +112,16 @@ jobs: # targets, and both are cached on the resolved revision, so both # need resolving the same way. One step rather than two so the # ls-remote handling below exists once. + # The Makefile's own default, not a second copy of it. These + # refs are pinned there (ZK_CRED_LONGFELLOW_REF is a commit of + # the crate's nym_auth branch), and a cache key resolved from a + # hardcoded "main" would track a ref the build does not use: + # bumping the pin would leave the key unchanged and restore a + # stage built from the OLD pin. Same resolver as + # .github/workflows/docker-build-push.yml. + read_ref() { + sed -n "s/^$1[[:space:]]*?=[[:space:]]*//p" Makefile | head -n1 + } resolve() { local name="$1" repo="$2" ref="$3" sha refs if printf '%s' "$ref" | grep -Eiq '^[0-9a-f]{40}$'; then @@ -134,8 +144,13 @@ jobs: fi echo "$sha" } - echo "sha=$(resolve zk-cred-longfellow https://github.com/sirosfoundation/zk-cred-longfellow.git "${ZK_CRED_LONGFELLOW_REF:-main}")" >> "$GITHUB_OUTPUT" - echo "vega_sha=$(resolve zk-cred-vega https://github.com/sirosfoundation/zk-cred-vega.git "${ZK_CRED_VEGA_REF:-main}")" >> "$GITHUB_OUTPUT" + LONGFELLOW_REF=$(read_ref ZK_CRED_LONGFELLOW_REF) + VEGA_REF=$(read_ref ZK_CRED_VEGA_REF) + for pair in "ZK_CRED_LONGFELLOW_REF=$LONGFELLOW_REF" "ZK_CRED_VEGA_REF=$VEGA_REF"; do + case "$pair" in *=) echo "::error::could not read ${pair%=} default from Makefile"; exit 1 ;; esac + done + echo "sha=$(resolve zk-cred-longfellow https://github.com/sirosfoundation/zk-cred-longfellow.git "$LONGFELLOW_REF")" >> "$GITHUB_OUTPUT" + echo "vega_sha=$(resolve zk-cred-vega https://github.com/sirosfoundation/zk-cred-vega.git "$VEGA_REF")" >> "$GITHUB_OUTPUT" - name: Cache the built C ABI library uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/Makefile b/Makefile index 5721525fb..7ea57a2e9 100755 --- a/Makefile +++ b/Makefile @@ -67,20 +67,41 @@ RESERVED_TAGS := latest testing demo dev # netgo,osusergo ride along under CGO to bypass glibc NSS. ZKNATIVE_TAG := zknative ZK_CRED_LONGFELLOW_REPO ?= https://github.com/sirosfoundation/zk-cred-longfellow.git -ZK_CRED_LONGFELLOW_REF ?= main +# Pinned; bump deliberately. This is main plus the nym_auth commits of +# that crate's `feat/nymauth-prove` branch, which is what carries +# include/zk_cred_longfellow_nymauth.h (pkg/nymauth/verifier/zknative +# needs it, and the staging below requires it). Move this back to a +# main SHA once that branch lands upstream. +# +# This SHA and the vendored challenge vectors move TOGETHER. The +# vectors are the shared conformance set, and the zknative tests +# assert that this library refuses exactly what pkg/nymauth/verifier +# refuses - so a library older than the vectors fails those tests, by +# design. e09fdcd is the commit that refuses invalid UTF-8 and +# non-canonical simple values, which is what the current vectors +# (sha256 697d016b) require; its copy of challenge-vectors.json is +# byte-identical to the vendored one. +ZK_CRED_LONGFELLOW_REF ?= e09fdcd16252c9043e50032c3ae1cc4259ed7d08 ZK_CRED_LONGFELLOW_CHECKOUT := third_party/.zk-cred-longfellow-src ZK_CRED_LONGFELLOW_STAGE := third_party/zk-cred-longfellow -# Vega (zk-cred-vega): the second native ZK library, linked only into -# cmd/zkvegaverifyworker (subprocess-isolated from the verifier). Shares -# ZKNATIVE_TAG. +# Vega (zk-cred-vega): the second native ZK library. Linked into +# cmd/zkvegaverifyworker, which is subprocess-isolated and carries the +# mdoc ZK/PPID path, AND - since the nym_auth backend - into the +# verifier itself, in process, the way Longfellow's nym_auth verifier +# already is. Shares ZKNATIVE_TAG. +# +# Unpinned, unlike Longfellow above. The nym_auth test vectors are +# staged from this same ref, so the library and the fixture it is +# checked against move together; pin it if that stops being true. ZK_CRED_VEGA_REPO ?= https://github.com/sirosfoundation/zk-cred-vega.git ZK_CRED_VEGA_REF ?= main ZK_CRED_VEGA_CHECKOUT := third_party/.zk-cred-vega-src ZK_CRED_VEGA_STAGE := third_party/zk-cred-vega -# The verifier links Longfellow and execs zkvegaverifyworker (Vega); both -# libraries must be reachable at load time. +# The verifier links BOTH - Longfellow and Vega, each with an in-process +# nym_auth verifier - and also execs zkvegaverifyworker for the mdoc Vega +# path. Both libraries must be reachable at load time. ZKNATIVE_LD_PATH := $(CURDIR)/$(ZK_CRED_LONGFELLOW_STAGE)/lib:$(CURDIR)/$(ZK_CRED_VEGA_STAGE)/lib ZK_CRED_BBS_REPO ?= https://github.com/sirosfoundation/zk-cred-bbs.git @@ -163,7 +184,7 @@ help: ## Show this help message $(info make zk-native-lib - Fetch/build zk-cred-longfellow's Go C-ABI lib (needs cmake; required by verifier build)) $(info make zk-native-lib-vega - Fetch/build zk-cred-vega's Go C-ABI lib (needs Rust; required by verifier build)) $(info make test-bbsnative - Run pkg/bbs's cgo tests (requires bbs-native-lib)) - $(info make test-zknative - Run pkg/mdoc's zknative tests (requires zk-native-lib zk-native-lib-vega)) + $(info make test-zknative - Run the zknative tests (pkg/mdoc, pkg/nymauth) (requires zk-native-lib zk-native-lib-vega)) $(info ) $(info Local dev binaries (native ZK, produced outside the Docker image):) $(info make build-verifier-zknative - Build the verifier binary with zknative to ./bin (dynamic-libc, needs LD_LIBRARY_PATH)) @@ -296,10 +317,24 @@ test-pkg: bbs-native-lib-staged ## Test the shared packages under pkg/ LD_LIBRARY_PATH=$(CURDIR)/$(ZK_CRED_BBS_STAGE)/lib \ $(CGO_ENABLED_DYNAMIC) go test ./pkg/... -test-js: ## Run JS unit tests for staticembed helpers +test-js: test-js-deps ## Run JS unit tests for staticembed helpers $(info Running JS unit tests) @node --test $(APIGW_STATIC)/tests/*.test.js $(VERIFIER_STATIC)/tests/*.test.js +# node_modules is gitignored, so a clean checkout has none - and a test +# that imports a dependency (req-schema.test.js needs valibot, the +# library whose behaviour it pins) then fails for a reason that has +# nothing to do with the code. Installed only when missing, so the common +# case costs nothing and an offline run of an already-installed tree still +# works. +test-js-deps: ## Install the staticembed JS dev dependencies when missing + @for dir in $(APIGW_STATIC) $(VERIFIER_STATIC); do \ + test -f "$$dir/package.json" || continue; \ + test -d "$$dir/node_modules" && continue; \ + echo "Installing JS dev dependencies in $$dir"; \ + (cd "$$dir" && npm install --silent --no-audit --no-fund) || exit 1; \ + done + # Test targets with build tags # The SoftHSM integration tests in pkg/pki and pkg/jose shell out to # softhsm2-util/pkcs11-tool; keep them behind the `pkcs11` build tag so @@ -341,10 +376,39 @@ zk-native-lib: ## Fetch/build zk-cred-longfellow's Go C-ABI library for native Z git -C "$(ZK_CRED_LONGFELLOW_CHECKOUT)" fetch --depth 1 origin "$(ZK_CRED_LONGFELLOW_REF)" git -C "$(ZK_CRED_LONGFELLOW_CHECKOUT)" checkout FETCH_HEAD $(MAKE) -C "$(ZK_CRED_LONGFELLOW_CHECKOUT)" go-cabi - @mkdir -p "$(ZK_CRED_LONGFELLOW_STAGE)/include" "$(ZK_CRED_LONGFELLOW_STAGE)/lib" - cp "$(ZK_CRED_LONGFELLOW_CHECKOUT)/target/go-cabi/zk_cred_longfellow_go.h" "$(ZK_CRED_LONGFELLOW_STAGE)/include/" + @mkdir -p "$(ZK_CRED_LONGFELLOW_STAGE)/include" "$(ZK_CRED_LONGFELLOW_STAGE)/lib" "$(ZK_CRED_LONGFELLOW_STAGE)/test-vectors/nymauth" + @# Both headers, because this repo has two consumers of that crate's + @# plain C ABI: pkg/mdoc/zknative (the mdoc ZK/PPID verifier, + @# zk_cred_longfellow_go.h) and pkg/nymauth/verifier/zknative (the + @# nym_auth verifier, zk_cred_longfellow_nymauth.h). Name the missing + @# one rather than letting `cp` report a path: a REF without the + @# nym_auth header is the likely cause, and that is not something a + @# "No such file or directory" tells the operator. + @# `exit 1` here is NOT inside a subshell, and the cp carries its own + @# `|| exit 1`, because neither failure stops a `for` loop on its own: + @# make takes the recipe line's status from the LAST command, which is + @# the loop's final iteration. Written with `|| ( ...; exit 1)` the + @# subshell exits, the loop runs on, and a stage missing its first + @# header reports success - which is the guard reporting that the + @# guard passed. + @for h in zk_cred_longfellow_go.h zk_cred_longfellow_nymauth.h; do \ + if [ ! -f "$(ZK_CRED_LONGFELLOW_CHECKOUT)/target/go-cabi/$$h" ]; then \ + echo "zk-cred-longfellow@$(ZK_CRED_LONGFELLOW_REF) staged no $$h." >&2; \ + echo "Both headers are required. Check that ZK_CRED_LONGFELLOW_REF points at a commit carrying include/$$h." >&2; \ + exit 1; \ + fi; \ + cp "$(ZK_CRED_LONGFELLOW_CHECKOUT)/target/go-cabi/$$h" "$(ZK_CRED_LONGFELLOW_STAGE)/include/" || exit 1; \ + done cp "$(ZK_CRED_LONGFELLOW_CHECKOUT)/target/go-cabi"/libzk_cred_longfellow.* "$(ZK_CRED_LONGFELLOW_STAGE)/lib/" - @echo "Staged zk-cred-longfellow's Go C-ABI lib + header in $(ZK_CRED_LONGFELLOW_STAGE)" + @# The nym_auth vectors ship with the crate (the circuit artifact, a + @# real proof and the request values that produced it), and + @# pkg/nymauth/verifier's zknative test verifies that proof rather + @# than only comparing derivations. Staged here, the way + @# bbs-native-lib stages emlun_reference.json, so the fixture + @# arrives with the library it belongs to instead of being vendored + @# into this repo and going stale against it. + cp "$(ZK_CRED_LONGFELLOW_CHECKOUT)/test-vectors/nymauth"/* "$(ZK_CRED_LONGFELLOW_STAGE)/test-vectors/nymauth/" + @echo "Staged zk-cred-longfellow's Go C-ABI lib + headers + nym_auth vectors in $(ZK_CRED_LONGFELLOW_STAGE)" @echo "Build/test with: CGO_ENABLED=1 LD_LIBRARY_PATH=$(ZKNATIVE_LD_PATH) go {build,test} -tags $(ZKNATIVE_TAG) ./..." zk-native-lib-staged: ## Fail with a useful message if zk-cred-longfellow is not staged @@ -354,14 +418,15 @@ zk-native-lib-staged: ## Fail with a useful message if zk-cred-longfellow is not @# valid `.so`-only stage. Guard is shared between build-verifier-zknative @# and test-zknative; the message names both so the operator re-runs the @# right one. - @test -f "$(ZK_CRED_LONGFELLOW_STAGE)/lib/libzk_cred_longfellow.so" -a -f "$(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_go.h" || ( \ + @test -f "$(ZK_CRED_LONGFELLOW_STAGE)/lib/libzk_cred_longfellow.so" -a -f "$(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_go.h" -a -f "$(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_nymauth.h" || ( \ echo "The 'zknative' build tag was requested but zk-cred-longfellow is not staged." >&2; \ - echo "Both $(ZK_CRED_LONGFELLOW_STAGE)/lib/libzk_cred_longfellow.so and $(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_go.h are required." >&2; \ + echo "$(ZK_CRED_LONGFELLOW_STAGE)/lib/libzk_cred_longfellow.so and both headers under $(ZK_CRED_LONGFELLOW_STAGE)/include (zk_cred_longfellow_go.h, zk_cred_longfellow_nymauth.h) are required." >&2; \ + echo "A stage made before the nym_auth header existed has only the first: re-run 'make zk-native-lib' to refresh it." >&2; \ echo "Run 'make zk-native-lib' first (needs network and a C++ toolchain), then re-run the requesting target ('make build-verifier-zknative' or 'make test-zknative')." >&2; \ exit 1) zk-native-lib-ensure: _check-native-host ## Stage zk-cred-longfellow only when it's missing (for local build-*) - @test -f "$(ZK_CRED_LONGFELLOW_STAGE)/lib/libzk_cred_longfellow.so" -a -f "$(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_go.h" || $(MAKE) --no-print-directory zk-native-lib + @test -f "$(ZK_CRED_LONGFELLOW_STAGE)/lib/libzk_cred_longfellow.so" -a -f "$(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_go.h" -a -f "$(ZK_CRED_LONGFELLOW_STAGE)/include/zk_cred_longfellow_nymauth.h" || $(MAKE) --no-print-directory zk-native-lib bbs-native-lib-staged: ## Fail with a useful message if zk-cred-bbs is not staged @# Header for cgo compile, archive for the static link (pkg/bbs uses -l:libzk_cred_bbs.a). @@ -417,10 +482,21 @@ zk-native-lib-vega: ## Fetch/build zk-cred-vega's Go C-ABI library for native Ve git -C "$(ZK_CRED_VEGA_CHECKOUT)" fetch --depth 1 origin "$(ZK_CRED_VEGA_REF)" git -C "$(ZK_CRED_VEGA_CHECKOUT)" checkout FETCH_HEAD $(MAKE) -C "$(ZK_CRED_VEGA_CHECKOUT)" go-cabi - @mkdir -p "$(ZK_CRED_VEGA_STAGE)/include" "$(ZK_CRED_VEGA_STAGE)/lib" + @mkdir -p "$(ZK_CRED_VEGA_STAGE)/include" "$(ZK_CRED_VEGA_STAGE)/lib" "$(ZK_CRED_VEGA_STAGE)/test-vectors/nymauth" + @# BOTH headers. The crate's own go-cabi target stages the mdoc + @# header and the nym_auth one into a single libzk_cred_vega, and + @# copying only the first left pkg/nymauth/verifier/zknative_vega + @# with no header to compile against. cp "$(ZK_CRED_VEGA_CHECKOUT)/target/go-cabi/zk_cred_vega_go.h" "$(ZK_CRED_VEGA_STAGE)/include/" + cp "$(ZK_CRED_VEGA_CHECKOUT)/target/go-cabi/zk_cred_vega_nymauth_go.h" "$(ZK_CRED_VEGA_STAGE)/include/" cp "$(ZK_CRED_VEGA_CHECKOUT)/target/go-cabi"/libzk_cred_vega.* "$(ZK_CRED_VEGA_STAGE)/lib/" - @echo "Staged zk-cred-vega's Go C-ABI lib + header in $(ZK_CRED_VEGA_STAGE)" + @# The crate's own nym_auth vectors - the published mdoc circuit's + @# shapes, a reference proof made against them, and the statement + @# that produced it. Staged beside the library, the same way the + @# Longfellow ones are, so the fixture and the library it belongs + @# to cannot drift apart. + cp "$(ZK_CRED_VEGA_CHECKOUT)/test-vectors/nymauth"/* "$(ZK_CRED_VEGA_STAGE)/test-vectors/nymauth/" + @echo "Staged zk-cred-vega's Go C-ABI lib + headers + nym_auth vectors in $(ZK_CRED_VEGA_STAGE)" zk-native-lib-vega-staged: ## Fail with a useful message if zk-cred-vega is not staged @# build-zkvegaverifyworker and dockerfiles/verifier-zknative both link @@ -434,10 +510,16 @@ zk-native-lib-vega-staged: ## Fail with a useful message if zk-cred-vega is not zk-native-lib-vega-ensure: _check-native-host ## Stage zk-cred-vega only when it's missing (for local build-*) @test -f "$(ZK_CRED_VEGA_STAGE)/lib/libzk_cred_vega.so" -a -f "$(ZK_CRED_VEGA_STAGE)/include/zk_cred_vega_go.h" || $(MAKE) --no-print-directory zk-native-lib-vega -test-zknative: bbs-native-lib-staged zk-native-lib-staged zk-native-lib-vega-staged ## Run pkg/mdoc's zknative-tagged tests (requires: make bbs-native-lib zk-native-lib zk-native-lib-vega) +test-zknative: bbs-native-lib-staged zk-native-lib-staged zk-native-lib-vega-staged ## Run the zknative-tagged tests (requires: make bbs-native-lib zk-native-lib zk-native-lib-vega) $(info Testing with zknative build tag - requires 'make bbs-native-lib zk-native-lib zk-native-lib-vega' first) + @# internal/verifier/... is here because the circuit_zk proof check + @# lives in internal/verifier/apiv1/circuit_zk_native_cgo.go, behind + @# the same tag. build-verifier-zknative compiled it and nothing ran + @# it, which is how a predicate-only presentation came to verify + @# without its proof being looked at - the stub refuses everything, + @# so no default-build test can reach that code. CGO_ENABLED=1 LD_LIBRARY_PATH=$(ZKNATIVE_LD_PATH) \ - go test -tags $(ZKNATIVE_TAG) -v ./pkg/mdoc/... + go test -tags $(ZKNATIVE_TAG) -v ./pkg/mdoc/... ./pkg/nymauth/... ./internal/verifier/... # DIDComm v2.1 Test targets test-didcomm: ## Test DIDComm v2.1 implementation @@ -575,6 +657,38 @@ build-wallet: bbs-native-lib-ensure ## Build wallet test tool $(BUILD_FLAGS) -o ./bin/$(NAME)_wallet \ $(LDFLAGS) ./cmd/wallet/ +# The circuit_zk planner vectors are the shared conformance set, vendored +# from siros-dc-matcher so the suite does not depend on the network. A +# vendored snapshot goes stale silently - if the request shape moves, the +# tests keep passing against the old one - so refreshing is a command +# rather than a note in a file somebody has to find. +# +# MATCHER=/path/to/siros-dc-matcher to point at a different checkout. +MATCHER ?= ../siros-dc-matcher +vendor-circuit-zk-vectors: ## Refresh the vendored circuit_zk conformance vectors + $(info Refreshing circuit_zk vectors from $(MATCHER)) + @test -d "$(MATCHER)/crates/siros-dc-matcher-core/tests/vectors/circuit_zk" || \ + { echo "no matcher checkout at $(MATCHER); set MATCHER="; exit 1; } +# The SOURCE file names a commit as the provenance of these vectors, so the +# files copied have to BE that commit. Copying from a dirty or +# partially-untracked working tree records a commit that cannot reproduce +# them, which is a provenance claim that is simply false - and the vectors +# are the authority this package settles wire-shape questions against. + @cd "$(MATCHER)" && git diff --quiet -- crates/siros-dc-matcher-core/tests/vectors/circuit_zk || \ + { echo "$(MATCHER) has uncommitted changes under tests/vectors/circuit_zk; commit or stash them first"; exit 1; } + @cd "$(MATCHER)" && git diff --cached --quiet -- crates/siros-dc-matcher-core/tests/vectors/circuit_zk || \ + { echo "$(MATCHER) has staged changes under tests/vectors/circuit_zk; commit them first"; exit 1; } + @cd "$(MATCHER)" && test -z "$$(git ls-files --others --exclude-standard -- crates/siros-dc-matcher-core/tests/vectors/circuit_zk)" || \ + { echo "$(MATCHER) has untracked files under tests/vectors/circuit_zk; commit or remove them first"; exit 1; } + @rm -f pkg/openid4vp/testdata/circuit_zk/*.json + @cp "$(MATCHER)"/crates/siros-dc-matcher-core/tests/vectors/circuit_zk/*.json \ + "$(MATCHER)"/crates/siros-dc-matcher-core/tests/vectors/circuit_zk/README.md \ + pkg/openid4vp/testdata/circuit_zk/ + @cd "$(MATCHER)" && git rev-parse HEAD > /dev/null + @sed -i.bak "s|^commit .*|commit $$(cd "$(MATCHER)" && git rev-parse HEAD) ($$(cd "$(MATCHER)" && git describe --tags --always))|" \ + pkg/openid4vp/testdata/circuit_zk/SOURCE && rm -f pkg/openid4vp/testdata/circuit_zk/SOURCE.bak + @echo "Vendored $$(ls pkg/openid4vp/testdata/circuit_zk/*.json | wc -l) vectors; review the diff before committing" + # Reads the issuer's own config, so it links the same way the issuer does - # pkg/model reaches pkg/bbs, which is cgo. build-seed-recover: bbs-native-lib-ensure ## Build the seed_recover developer tool @@ -726,7 +840,13 @@ docker-build-wallet: _check-reserved-tag ## Build Docker image for wallet test t # Optional Feature Builds (with build tags) # ============================================================================== -build-verifier-zknative: bbs-native-lib-ensure zk-native-lib-ensure ## Build verifier with native ZK/PPID proof verification +# Vega is a prerequisite here too, now that the verifier links it +# directly: pkg/nymauth/verifier/zknative_vega carries its own cgo +# directives against $(ZK_CRED_VEGA_STAGE), so an unstaged Vega is a +# linker error in the middle of a build rather than a missing feature. +# The flags below name only Longfellow because that is the one with no +# package-level directive; both libraries are found either way. +build-verifier-zknative: bbs-native-lib-ensure zk-native-lib-ensure zk-native-lib-vega-ensure ## Build verifier with native ZK/PPID proof verification $(info Building verifier with native ZK/PPID proof verification) $(CGO_ENABLED_DYNAMIC) GOOS=$(BUILD_OS) GOARCH=$(BUILD_ARCH) \ CGO_CFLAGS="-I$(CURDIR)/$(ZK_CRED_LONGFELLOW_STAGE)/include" \ diff --git a/README.md b/README.md index bcb06adeb..de2a521ac 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,19 @@ gated by the `zk_verifier` config block. wire protocol and `docs/ZK_VEGA_DIGESTID_WIRE_EXTENSION.md` for a real wire-format addition Vega's verification needed (flagged there, not yet raised with multipaz upstream). +- **nym_auth** - the pseudonym circuits, a *second* C ABI of the same + zk-cred-longfellow build (`include/zk_cred_longfellow_nymauth.h`, + bound in `pkg/nymauth/verifier/zknative`). `make zk-native-lib` stages + both headers and requires both: a stage carrying only the mdoc header + is refused rather than silently building without pseudonym support. + The request a proof is checked against - and the rule that none of it + may come from the holder's response - lives in + `pkg/nymauth/verifier`, which builds without cgo; only the proof check + itself is behind the tag. + + `ZK_CRED_LONGFELLOW_REF` is pinned to a commit of that crate's + `feat/nymauth-prove` branch, because the nym_auth C ABI is not on its + `main` yet. Move the pin to a `main` SHA once that branch lands. Setup: @@ -400,9 +413,9 @@ LD_LIBRARY_PATH=$(pwd)/third_party/zk-cred-longfellow/lib:$(pwd)/third_party/zk- PATH=$(pwd)/bin:$PATH \ ./bin/vc_verifier-zknative -# ...or run pkg/mdoc's zknative-tagged tests directly (covers both -# systems; make test-zknative sets the loader path for both libraries -# itself): +# ...or run the zknative-tagged tests directly (pkg/mdoc for both ZK +# systems, pkg/nymauth for the pseudonym circuits; make test-zknative +# sets the loader path for both libraries itself): make test-zknative ``` diff --git a/developer_tools/scripts/seed_recover/main.go b/developer_tools/scripts/seed_recover/main.go index cbbb7e769..6154bfdc9 100644 --- a/developer_tools/scripts/seed_recover/main.go +++ b/developer_tools/scripts/seed_recover/main.go @@ -47,7 +47,7 @@ func run() error { subjects subjectList derivContext = flag.String("context", "", "the derivation context, if the credential configuration sets one") pseudonymConfig = flag.String("pseudonym-config", "", "nym_auth pseudonym configuration to compute, e.g. known-offender-matching-ssc") - verifierID = flag.String("verifier-id", "", "the verifier_id EXACTLY as the Verifier publishes it: bare (verifier.example) for an x509_san_dns Verifier, or semantic_id: for a semantic one - never the prefixed client_id") + verifierID = flag.String("verifier-id", "", "the BARE verifier_id, e.g. verifier.example - not the prefixed client_id") verifierContext = flag.String("verifier-context", "", "verifier_context, required for account-recovery-ssc") userValue = flag.String("user-value", "", "the user-supplied linking value, for user-managed-pseudonym-xsite-ssc") expect = flag.String("expect", "", "a pseudonym in hex to compare against; exit 0 on a match, 2 on a mismatch") diff --git a/dockerfiles/worker b/dockerfiles/worker index ef1bab5e8..82c8ff3fe 100644 --- a/dockerfiles/worker +++ b/dockerfiles/worker @@ -51,9 +51,13 @@ RUN make proto-${SERVICE_NAME} # Every worker links libzk_cred_bbs.so via pkg/openid4vci → pkg/bbs. RUN make bbs-native-lib ${ZK_CRED_BBS_REF:+ZK_CRED_BBS_REF="$ZK_CRED_BBS_REF"} -# Only the verifier links libzk_cred_longfellow.so (native ZK/PPID via the -# zknative build tag) and only the verifier's separate zkvegaverifyworker -# links libzk_cred_vega.so. +# Only the verifier links the ZK libraries (zknative build tag). It links +# BOTH: libzk_cred_longfellow.so, and libzk_cred_vega.so for the in-process +# Vega nym_auth verifier - the latter through +# pkg/nymauth/verifier/zknative_vega's own cgo directives against +# third_party/zk-cred-vega, which is why the build step below sets explicit +# flags for Longfellow only. The separate zkvegaverifyworker links Vega too, +# for the subprocess-isolated mdoc path. RUN if [ "$SERVICE_NAME" = "verifier" ]; then \ make zk-native-lib ${ZK_CRED_LONGFELLOW_REF:+ZK_CRED_LONGFELLOW_REF="$ZK_CRED_LONGFELLOW_REF"} && \ make zk-native-lib-vega ${ZK_CRED_VEGA_REF:+ZK_CRED_VEGA_REF="$ZK_CRED_VEGA_REF"} ; \ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d551a9ee7..74445d48b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1599,29 +1599,30 @@ Configuration for the Verifier service that verifies credentials and acts as an > **Path:** `.verifier` -| Field | Type | Description | Example | Default | Required | -| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------- | -| `api_server` | `object` | HTTP API server configuration | - | - | Yes | -| `public_url` | `string` | Public URL of this service (must be valid HTTP/HTTPS URL) | `"https://verifier.sunet.se"` | - | Yes | -| `key_config` | `object` | Signing key configuration | - | - | Yes | -| `client_id_scheme` | `string` | ClientIDScheme determines how the verifier identifies itself to wallets. Supported values: "x509_san_dns" (default), "x509_hash", "did". When "did", the DID field must be set and /.well-known/did.json is served. When "x509_hash" (the scheme the EUDI ARF mandates for Relying Party authentication), the client_id is the base64url SHA-256 of the signing certificate, so key_config must supply one and it is always sent in x5c. | - | `x509_san_dns` | No | -| `did` | `string` | Verifier's DID identity. | `"did:web:verifier.example.com"` | - | Yes (if client_id_scheme is "did") | -| `access_certificate` | `object` | AccessCertificate validates the verifier's own wallet-facing certificate as an EUDI Relying Party access certificate (WRPAC, ETSI TS 119 411-8). Off by default; deployments outside an ARF trust framework are unaffected. | - | - | No | -| `registration_certificate` | `object` | RegistrationCertificate points at a Relying Party registration certificate (WRPRC, ETSI TS 119 475) issued to this verifier by a national Registrar, to be presented to wallets in the OpenID4VP verifier_info parameter. vc does not issue these. | - | - | No | -| `preferred_vp_formats` | `object` | Informational VP formats and algorithms supported by wallets | - | - | No | -| `supported_wallets` | `object` | Supported wallet configurations | - | - | No | -| `inbound` | `object` | Inbound groups inbound credential verification | - | - | No | -| `outbound` | `object` | Outbound groups outbound identity assertion | - | - | No | -| `digital_credentials` | `object` | W3C Digital Credentials API configuration | - | - | No | -| `authorization_page_css` | `object` | Authorization page styling configuration | - | - | No | -| `credential_display` | `object` | Credential display settings | - | - | No | -| `trust` | `object` | Trust evaluation configuration | - | - | No | -| `federation` | `object` | OpenID Federation entity configuration. When enabled, serves /.well-known/openid-federation as a self-signed JWT. | - | - | No | -| `presets` | `object` | Predefined verification request presets shown in the UI. The map key is the human-readable label; see PresetDefinition for what each entry configures. | `"PID":{"credentials":{"pid":null}},"PID + EHIC":{"credentials":{"pid":null,"ehic":null},"category":"Combined"}` | - | No | -| `combined_presentation` | `object` | Combined presentation verification (ARF 3.0 §6.6.3.10). When multiple credentials are presented, this verifies they belong to the same holder. | - | - | No | -| `revocation` | `object` | Credential revocation checking at presentation time (ARF 3.0 §6.6.3.7). When enabled, the Verifier checks Token Status List references in presented credentials. | - | - | No | -| `zk_circuits` | `object` | The zk-circuits catalog service used to resolve "mso_mdoc_zk" (Longfellow ZK/PPID) proof circuits for native verification. Only consulted by builds with the "zknative" Go build tag (see pkg/mdoc/zk_native_cgo.go) - ignored by the default build. | - | - | No | -| `zk_key_cache` | `object` | The on-disk Vega verifier-key store. Verifier only: these keys exist to verify a presented proof, and the issuer never touches one. | - | - | No | +| Field | Type | Description | Example | Default | Required | +| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------- | +| `api_server` | `object` | HTTP API server configuration | - | - | Yes | +| `public_url` | `string` | Public URL of this service (must be valid HTTP/HTTPS URL) | `"https://verifier.sunet.se"` | - | Yes | +| `key_config` | `object` | Signing key configuration | - | - | Yes | +| `client_id_scheme` | `string` | ClientIDScheme determines how the verifier identifies itself to wallets. Supported values: "x509_san_dns" (default), "x509_hash", "did". When "did", the DID field must be set and /.well-known/did.json is served. When "x509_hash" (the scheme the EUDI ARF mandates for Relying Party authentication), the client_id is the base64url SHA-256 of the signing certificate, so key_config must supply one and it is always sent in x5c. | - | `x509_san_dns` | No | +| `did` | `string` | Verifier's DID identity. | `"did:web:verifier.example.com"` | - | Yes (if client_id_scheme is "did") | +| `access_certificate` | `object` | AccessCertificate validates the verifier's own wallet-facing certificate as an EUDI Relying Party access certificate (WRPAC, ETSI TS 119 411-8). Off by default; deployments outside an ARF trust framework are unaffected. | - | - | No | +| `registration_certificate` | `object` | RegistrationCertificate points at a Relying Party registration certificate (WRPRC, ETSI TS 119 475) issued to this verifier by a national Registrar, to be presented to wallets in the OpenID4VP verifier_info parameter. vc does not issue these. | - | - | No | +| `preferred_vp_formats` | `object` | Informational VP formats and algorithms supported by wallets. Required for a zkp_req preset. The circuit filter - which proof systems and circuits this Verifier will accept a proof from - lives at preferred_vp_formats.zkp_req and nowhere else, so a preset using that format is NOT PUBLISHED unless it is set. An empty `zkp_req: {}` is a complete answer and means "any circuit"; no default is supplied, because those identifiers are deployment-specific and guessing one would pin this Verifier to a single implementation. | - | - | No | +| `supported_wallets` | `object` | Supported wallet configurations | - | - | No | +| `inbound` | `object` | Inbound groups inbound credential verification | - | - | No | +| `outbound` | `object` | Outbound groups outbound identity assertion | - | - | No | +| `digital_credentials` | `object` | W3C Digital Credentials API configuration | - | - | No | +| `authorization_page_css` | `object` | Authorization page styling configuration | - | - | No | +| `credential_display` | `object` | Credential display settings | - | - | No | +| `trust` | `object` | Trust evaluation configuration | - | - | No | +| `federation` | `object` | OpenID Federation entity configuration. When enabled, serves /.well-known/openid-federation as a self-signed JWT. | - | - | No | +| `presets` | `object` | Predefined verification request presets shown in the UI. The map key is the human-readable label; see PresetDefinition for what each entry configures. | `"PID":{"credentials":{"pid":null}},"PID + EHIC":{"credentials":{"pid":null,"ehic":null},"category":"Combined"}` | - | No | +| `combined_presentation` | `object` | Combined presentation verification (ARF 3.0 §6.6.3.10). When multiple credentials are presented, this verifies they belong to the same holder. | - | - | No | +| `revocation` | `object` | Credential revocation checking at presentation time (ARF 3.0 §6.6.3.7). When enabled, the Verifier checks Token Status List references in presented credentials. | - | - | No | +| `zk_circuits` | `object` | The zk-circuits catalog service used to resolve "mso_mdoc_zk" (Longfellow ZK/PPID) proof circuits for native verification. Only consulted by builds with the "zknative" Go build tag (see pkg/mdoc/zk_native_cgo.go) - ignored by the default build. | - | - | No | +| `nym_auth` | `object` | Verification of nym_auth pseudonym proofs (the "zkp_req" presentation format). Off unless enable is set, and only consulted by builds with the "zknative" Go build tag - the default build cannot check a proof at all. | - | - | No | +| `zk_key_cache` | `object` | The on-disk Vega verifier-key store. Verifier only: these keys exist to verify a presented proof, and the issuer never touches one. | - | - | No | ### `access_certificate` @@ -1643,12 +1644,13 @@ unaffected. Used in client_metadata and Wallet metadata to indicate supported formats and algorithms. -| Field | Type | Description | Example | Default | Required | -| ------------- | -------- | ------------------------------------------------------- | ------- | ------- | -------- | -| `ldp_vc` | `object` | Configuration for W3C VC Data Integrity format (ldp_vc) | - | - | No | -| `jwt_vc_json` | `object` | Configuration for JWT-based W3C VC format (jwt_vc_json) | - | - | No | -| `dc+sd-jwt` | `object` | Configuration for SD-JWT VC format (dc+sd-jwt) | - | - | No | -| `mso_mdoc` | `object` | Configuration for ISO mdoc format (mso_mdoc) | - | - | No | +| Field | Type | Description | Example | Default | Required | +| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | ------- | ------- | -------- | +| `ldp_vc` | `object` | Configuration for W3C VC Data Integrity format (ldp_vc) | - | - | No | +| `jwt_vc_json` | `object` | Configuration for JWT-based W3C VC format (jwt_vc_json) | - | - | No | +| `dc+sd-jwt` | `object` | Configuration for SD-JWT VC format (dc+sd-jwt) | - | - | No | +| `mso_mdoc` | `object` | Configuration for ISO mdoc format (mso_mdoc) | - | - | No | +| `zkp_req` | `object` | Configuration for zero-knowledge circuit presentations (zkp_req): which proof systems and circuits a proof may come from. | - | - | No | ### `ldp_vc` @@ -1685,6 +1687,24 @@ Used in client_metadata and Wallet metadata to indicate supported formats and al | `issuerauth_alg_values` | `[]int` | Non-empty array containing cryptographic algorithm identifiers supported for IssuerAuth COSE signatures. | - | - | No | | `deviceauth_alg_values` | `[]int` | Non-empty array containing cryptographic algorithm identifiers supported for DeviceAuth COSE signatures or MACs. | - | - | No | +### `zkp_req` + +> **Path:** `.verifier.preferred_vp_formats.zkp_req` + +vp_formats_supported: which proof systems and circuits the Verifier will +accept a proof from. + +Entirely an open map, with the two known arrays as a convenience. The +rulebook's own example wraps the same information in a +{"fn_id":..., "input": base64(json)} envelope, and the matcher accepts +both - so an operator talking to a wallet generation that wants the +envelope has to be able to send it without waiting for a release here. + +| Field | Type | Description | Example | Default | Required | +| --------------------- | ---------- | --------------------------------------------------------- | ----------------------------------------- | ------- | -------- | +| `proof_system_values` | `[]string` | Acceptable proof systems. | `["longfellow-libzk-v1"]` | - | No | +| `system_id_values` | `[]string` | Acceptable circuit identifiers, from the circuit catalog. | `["urn:circuits:longfellow:nym_auth:v1"]` | - | No | + ### `inbound` > **Path:** `.verifier.inbound` @@ -1845,13 +1865,15 @@ as the human-readable label. > **Path:** `.verifier.presets..credentials.` -| Field | Type | Description | Example | Default | Required | -| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ------- | -------- | -| `claims` | `array` | Specific claims to request. If empty, all VCTM claims are used. | - | - | No | -| `exclude_claims` | `array` | Claims to exclude from the DCQL query. | - | - | No | -| `validations` | `array` | Optional rules applied server-side after claims extraction | - | - | No | -| `format` | `string` | Format overrides the scope's own credential_metadata format (e.g. requesting "mso_mdoc_zk" - a zero-knowledge proof - over a scope whose credential_metadata format is the plain "mso_mdoc" it's actually issued as). Empty means use the scope's own format unchanged. | - | - | No | -| `zk_system_type` | `array` | ZKSystemType overrides meta.zk_system_type - required whenever Format is set to "mso_mdoc_zk" (openid4vp.FormatMsoMdocZk), since a ZK-mdoc DCQL query has no other way to say which proof system/circuit a verifier accepts. See openid4vp.ZKSystemTypeSpec's own doc comment. | - | - | No | +| Field | Type | Description | Example | Default | Required | +| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | ------- | -------- | +| `claims` | `array` | Specific claims to request. If empty, all VCTM claims are used. Ignored entirely when the effective format is "zkp_req". Such a query discloses no issuer-signed attribute at all - every value it yields is the output of a function named in Req - so neither these claims nor the VCTM fall-back are attached to it. Listing claims on a zkp_req credential is a configuration mistake rather than an instruction, and is dropped rather than honoured. | - | - | No | +| `exclude_claims` | `array` | Claims to exclude from the DCQL query. Also ignored for "zkp_req", which carries no claims to exclude. | - | - | No | +| `validations` | `array` | Optional rules applied server-side after claims extraction | - | - | No | +| `format` | `string` | Format overrides the scope's own credential_metadata format (e.g. requesting "mso_mdoc_zk" - a zero-knowledge proof - over a scope whose credential_metadata format is the plain "mso_mdoc" it's actually issued as). Empty means use the scope's own format unchanged. | - | - | No | +| `zk_system_type` | `array` | ZKSystemType overrides meta.zk_system_type - required whenever Format is set to "mso_mdoc_zk" (openid4vp.FormatMsoMdocZk), since a ZK-mdoc DCQL query has no other way to say which proof system/circuit a verifier accepts. See openid4vp.ZKSystemTypeSpec's own doc comment. | - | - | No | +| `req` | `array` | Zkp_req format's list of requested calculations - required whenever Format is "zkp_req", since such a query discloses nothing and every value comes from a function named here. A zkp_req preset needs TWO things to be published: a non-empty req here, and verifier.preferred_vp_formats.zkp_req advertising the format (an empty mapping will do - see that field). Without either, the preset is omitted and the reason is logged, rather than a button being offered that asks a wallet for a proof the request declares no support for. A worked example is in the "req entry" section below, which renders as prose rather than as a table cell - an indented one here is flattened into a single run-on line an operator cannot copy. Passed through to the wire as configured. Nothing judges what it MEANS unless Validate names a profile: this verifier is how wallets are tested, so a request that breaks a rulebook on purpose has to be sendable, and the specifications underneath are still moving. One transport restriction applies regardless, because it is not a judgement about the request. The picker builds the query in the browser and posts it back, where every number is a float64 - so a numeric param the browser cannot carry exactly (an integer past 2^53, or past what the shortest round-tripping decimal denotes; a decimal with more digits than a float64 holds) would reach the wallet CHANGED. A param is an input to a proof, so a changed one yields a different pseudonym, and the only symptom is two systems disagreeing about who someone is. Such a credential is omitted from the preset and the reason is logged, rather than published and quietly altered. Quote the value as a string if the exact digits are what you mean. | - | - | No | +| `validate` | `string` | Validate names a profile to check this credential's request against AT STARTUP. Empty means no checking, which is the default. Startup rather than request time, and named rather than a strict flag. An operator who wants to be told their preset violates the nym_auth rulebook asks for it by name and is told while they are still editing configuration; traffic is never validated, because a zkp_req request that is not a nym_auth request is not invalid and this server is not the one to decide. | `"nym_auth"` | - | No | ### `claims` entry @@ -1894,6 +1916,53 @@ are intentionally loosely typed at this layer. | `id` | `string` | ID identifies this specific system+circuit combination (e.g. "longfellow-libzk-v1_8_1_4259_2945"). A presented ZK document's own `zkSystemId` (pkg/mdoc.ZkDocumentDataMdoc.ZkSystemID) is expected to equal one of a request's ZKSystemType[].ID entries - this is how a verifier confirms the wallet actually used a circuit it offered, rather than some other one. | - | - | Yes | | `system` | `string` | ZK proof system identifier (e.g. "longfellow-libzk-v1"). Params other than "id"/"system" are format-specific (circuit_hash, num_attributes, block_enc_hash, block_enc_sig for Longfellow). | - | - | Yes | +### `req` entry + +> **Path:** `.verifier.presets..credentials..req[]` + +query: a function, a handle its result is returned under, and the +function's parameters. + +A zkp_req preset needs two things to be published - a non-empty req, +and verifier.preferred_vp_formats.zkp_req advertising the format: + +```yaml +verifier: + preferred_vp_formats: + zkp_req: {} # or a circuit filter + presets: + "Pseudonym": + credentials: + nym_auth: + format: zkp_req + validate: nym_auth # optional + # `req` is the PRESET key; the wire member it becomes is + # `_req`. They differ on purpose - the preset is this + # repository's configuration surface, not the wire. + req: + - fn_id: pseudonym + return: nym-123 + params: + config: known-offender-matching-ssc + ver: 1 + prf: sha-256 +``` + +Params is an open map and is serialised exactly as given. Nothing here +coerces a value, drops an empty one, or repairs a malformed one, and +that is deliberate twice over. The rulebook and the OpenID4VP and DC API +specifications under it are all still moving, and several generations of +wallet will be in the field at once - so a Verifier has to be able to +send a shape this build has never been taught. And this Verifier is the +tool we test wallets WITH: a builder that quietly fixed a bad request +could not produce the bad requests a negative test needs. + +| Field | Type | Description | Example | Default | Required | +| -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------- | -------- | +| `fn_id` | `string` | Function identifier, "fn_id" on the wire. Named for the wire member rather than Function, since the wire is what an operator configures and reads back. | - | - | No | +| `return` | `string` | Handle the result is keyed by in the response's results map. The rulebook requires it to be unique within the credential query; nothing here enforces that, because a duplicate is a thing a wallet should be tested against. | - | - | No | +| `params` | `object` | Function's parameters, passed through unchanged. | - | - | No | + ### `combined_presentation` > **Path:** `.verifier.combined_presentation` @@ -1937,6 +2006,58 @@ its own IssuerZkCircuitsConfig, which adds a resolver cache. | --------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------- | -------- | | `sources` | `[]string` | Zk-circuits catalog mirror base URLs, tried in order until one succeeds (see pkg/mdoc/zkcircuit.Client - these are mirrors of the SAME catalog, not distinct registries). Defaults to the live deployed service if empty. A "file:///path/to/mirror" entry is a vendored local mirror: a directory laid out like the service itself, as written by developer_tools/scripts/vendor_zk_circuits. Artifact hashes are verified against the descriptors exactly as they are for a remote source, so vendoring pins the bytes as far as the mirror filesystem is itself trusted. | `["https://zk-circuits.fly.dev","file:///etc/vc/zk-circuits"]` | `["https://zk-circuits.fly.dev"]` | No | +### `nym_auth` + +> **Path:** `.verifier.nym_auth` + +Separate from zk_circuits, which is the mdoc ZK path: that one +resolves a circuit by the zkSystemId a presented document names, +while this one selects a circuit by the pseudonym CONFIGURATION the +verifier asked for. They are different questions and sharing one +block would make the answer to each depend on the other. + +| Field | Type | Description | Example | Default | Required | +| ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------- | ---------------- | +| `enable` | `bool` | Enable turns nym_auth verification on. Off by default: a deployment that does not verify pseudonym proofs is unaffected by any of this existing. | `true` | - | No | +| `verifier_id` | `string` | Verifier_id this Verifier is known by, and SHA-256 of it is verifier_input - the value that scopes every pseudonym to this Verifier. CONFIGURED, and never derived from client_id. Rulebook v0.4 defines exactly two forms and the whole string is hashed, prefix included: x509_san_dns: semantic_id: Nothing is stripped and nothing is normalised, case included. A value that is neither form is refused at startup: the BARE DNS name was the correct value until 2026-10-20, so a configuration written before then still carries one, and hashing it produces a pseudonym no other implementation reports - a wrong answer about a person rather than an error anyone sees. See pkg/nymauth.CheckVerifierID. | `"x509_san_dns:verifier.example"` | - | Yes (if enabled) | +| `circuits` | `object` | Where the circuit builds this Verifier will load come from. | - | - | No | +| `proof_time_skew_seconds` | `int` | ProofTimeSkewSeconds and ProofTimeMaxAgeSeconds bound how far a presentation's proof_time may be from this verifier's clock. T is the PROVER'S: the circuit binds all 20 bytes of it into its public inputs, so only the prover knows which second it proved at. This window is what makes accepting its value safe - without one a holder could replay a proof made long ago, against a credential since revoked, or one made with a clock set forward. Outside the window is REFUSED, never clamped: verifying against a T the prover did not use cannot succeed, and would report the wrong reason when it failed. Zero means the shared recommendation - 60 seconds of skew into the future, 600 seconds of age. They are recommendations and not protocol constants: the tolerance is this verifier's to set, and a deployment that has not thought about it should behave the way other implementations assume. | `60` | - | No | +| `proof_time_max_age_seconds` | `int` | Proof Time Max Age Seconds | `600` | - | No | + +### `circuits` + +> **Path:** `.verifier.nym_auth.circuits` + +where their bytes come from. + +| Field | Type | Description | Example | Default | Required | +| --------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------- | -------- | +| `allow` | `array` | Operator's allowlist. NOTHING outside it is ever loaded. It is the only statement of which configuration a circuit implements. The pseudonym's domain separator is baked into the circuit build - it is a code constant in the prover, not a parameter - so ssc and psc are different artifacts with different ids, and nothing in the artifact itself tells this Verifier which one it has. Until the native library exposes a configuration accessor, the entry below is that statement, and an entry naming the wrong configuration would verify proofs against a pseudonym function nobody asked for. No struct-tag validation on the entries: the validator recurses into this subtree whether or not Enable is set, so a `required` here would stop a deployment that has nym_auth switched OFF and a half-written entry left behind. Every field is checked in circuits.NewAllowlist instead, which runs only when enabled - and checking in one place rather than two is what stops the two from disagreeing. | - | - | No | +| `sources` | `[]string` | Zk-circuits catalog mirrors, used only by an allowlist entry that names a catalog id instead of a local file. Optional, and empty is the ordinary case today: the published catalog carries mdoc Longfellow entries only, with no nym_auth descriptors and none of the four parameters this ABI needs. An entry therefore supplies its own artifact. No struct-tag validation, for the same reason the entries above have none: the validator recurses into this subtree whether or not Enable is set, so an `httpurl` tag here stops a deployment that has nym_auth switched OFF and a half-written source left behind. Checked in circuits.NewAllowlist instead, which runs only when the feature is enabled. | - | - | No | + +### `allow` entry + +> **Path:** `.verifier.nym_auth.circuits.allow[]` + +Everything the native loader checks is here, because the loader +checks the artifact against its own entry and refuses anything else +before parsing it. An entry that does not match the artifact is a +circuit that will not load, which is the right failure: the +alternative is loading whatever was served. + +| Field | Type | Description | Example | Default | Required | +| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------- | -------- | +| `system_id` | `string` | Proof system this build belongs to, named by the catalog alias a presentation states in zkp_req's "system_id" - "urn:circuits:longfellow:nym_auth:v1" or "urn:circuits:vega:nym_auth:v1". First-class and REQUIRED, beside Config, because the two together are what selects a build. One verifier may allow both systems for the same pseudonym configuration - that is the point of naming it - and an entry that did not say which system it is would be an artifact handed to whichever circuit library happened to be asked for it. It does not change the pseudonym. The domain separator is built from Config alone ("nym_auth/v1/" + config), so the same subject under the same configuration has the same pseudonym whichever system proved it; the system decides only how the proof is checked. That is what makes it safe for the holder to choose between the systems a Verifier offers. | `"urn:circuits:longfellow:nym_auth:v1"` | - | No | +| `config` | `string` | Pseudonym configuration this build implements - "known-offender-matching-ssc" or "known-offender-matching-psc". First-class, because it is how a request selects a build. The two produce DIFFERENT pseudonyms from the same inputs (the domain separator is "nym_auth/v1/" + config), so verifying against the wrong one yields a value that matches nothing and reads as an innocent subject. | `"known-offender-matching-ssc"` | - | No | +| `hash_circuit_id` | `string` | HashCircuitID and SigCircuitID are the descriptor's circuit ids, hex as the catalog writes them. LONGFELLOW ONLY, with the two block_enc parameters below: they describe a Ligero build, and its loader refuses an artifact that does not match them. Required for a Longfellow entry and refused on a Vega one, which has no counterpart - Vega derives its verifier key from the R1CS shapes, so the artifact digest is the circuit's whole identity. | `"5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729"` | - | No | +| `sig_circuit_id` | `string` | Sig Circuit ID | `"00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb"` | - | No | +| `block_enc_hash` | `object` | BlockEncHash and BlockEncSig are the Ligero block_enc parameters of the two circuits. Longfellow only, as above. | `6983` | - | No | +| `block_enc_sig` | `object` | Block Enc Sig | `4679` | - | No | +| `artifact_path` | `string` | Local file holding the DECOMPRESSED circuit artifact. Mutually exclusive with ArtifactID. | `"/etc/vc/circuits/nym_auth-ssc.bin"` | - | No | +| `artifact_id` | `string` | ArtifactID names a circuit in the configured catalog sources, for a deployment whose catalog publishes nym_auth descriptors. Mutually exclusive with ArtifactPath. | - | - | No | +| `artifact_sha256` | `string` | ArtifactSHA256 and ArtifactBytes are over the DECOMPRESSED artifact, which is what the native loader hashes. Both, not either. The length is not redundant: it is checked before the bytes are read, so an artifact that is the wrong size is refused without hashing however much of it was served. The example values here are the REAL ones for the nym_auth build, taken from zk-cred-longfellow's src/nymauth/profile.rs. They are not decoration: gen_config_docs copies them verbatim into docs/CONFIGURATION.md, and an operator configuring this reads them there. This digest was previously a placeholder made by keeping the real value's first eight and last four hex characters and zeroing the middle, which made it survive exactly the check someone would perform on it. Never write a plausible-looking fake here; if a real value cannot be published, leave the example out. | `"92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f"` | - | No | +| `artifact_bytes` | `int64` | Artifact Bytes | `1062842` | - | No | + ### `zk_key_cache` > **Path:** `.verifier.zk_key_cache` diff --git a/docs/issuer/SEED_COMMITMENTS.md b/docs/issuer/SEED_COMMITMENTS.md index f1592c1c7..9d422fada 100644 --- a/docs/issuer/SEED_COMMITMENTS.md +++ b/docs/issuer/SEED_COMMITMENTS.md @@ -324,29 +324,11 @@ SHA256("verifier.example") = be6a6400…4a41 (MUST) SHA256("x509_san_dns:verifier.example") = 6eae85a0…6bab (MUST NOT) ``` -A value carrying `x509_san_dns:` is therefore **refused** rather than -stripped. Hashing it would succeed and return a pseudonym no Verifier could -have reported, so the loop would simply never match and the operator would -conclude the subject is innocent — and stripping it would quietly accept a -caller that is confused about which value it holds. - -**`semantic_id:` is not the same kind of thing, and must NOT be stripped.** -It looks like a Client Identifier Prefix and is not one: for a semantic -Verifier the verifier_id *is* the whole string `semantic_id:`, -and that whole string is what is hashed. Pass it exactly as the Verifier -publishes it. - -``` --verifier-id verifier.example # x509_san_dns Verifier: bare, no prefix --verifier-id semantic_id:age-over-18-check # semantic Verifier: the marker is part of the value -``` - -So the rule is not "strip anything before a colon". It is: a Client -Identifier Prefix is never hashed, and a semantic identifier always is. -Getting this backwards fails the same silent way as the prefixed form — -a pseudonym no Verifier reports, a loop that never matches, and a subject -who looks innocent. The one case refused is a bare `semantic_id:` with -nothing after it, which names no Verifier at all. +A value carrying `semantic_id:` or `x509_san_dns:` is therefore **refused** +rather than stripped. Hashing it would succeed and return a pseudonym no +Verifier could have reported, so the loop would simply never match and the +operator would conclude the subject is innocent — and stripping it would +quietly accept a caller that is confused about which value it holds. It reads the issuer's config and secret, so it has to run where those are. That is the intended restriction rather than an inconvenience: whoever can diff --git a/internal/apigw/apiv1/handlers_verifier.go b/internal/apigw/apiv1/handlers_verifier.go index 1d425a54f..195b66653 100644 --- a/internal/apigw/apiv1/handlers_verifier.go +++ b/internal/apigw/apiv1/handlers_verifier.go @@ -797,10 +797,12 @@ func buildIssuanceAuthDCQL(vpAuth *model.OpenID4VPCredentialAuth, cfg *model.Cfg return nil, fmt.Errorf("auth scope %q has no usable DCQL meta constraint (format %q); check credential_metadata", authScope, format) } credentialQueries = append(credentialQueries, openid4vp.CredentialQuery{ - ID: authScope, - Format: format, - Multiple: false, - Meta: meta, + ID: authScope, + Format: format, + // Multiple is left unset too: it is a *bool on this branch, + // and nil is the specification's default of false - the same + // request main sent explicitly. + Meta: meta, // Left unset, which is the specification's default of TRUE. // // It said false, which told every wallet that a presentation diff --git a/internal/verifier/apiv1/circuit_zk_dispatch_zknative_test.go b/internal/verifier/apiv1/circuit_zk_dispatch_zknative_test.go new file mode 100644 index 000000000..d0fe37f7f --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_dispatch_zknative_test.go @@ -0,0 +1,121 @@ +//go:build zknative + +package apiv1 + +import ( + "os" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/SUNET/vc/pkg/nymauth" + "github.com/SUNET/vc/pkg/nymauth/circuits" + "github.com/SUNET/vc/pkg/nymauth/verifier" +) + +// Which library opens an allowed artifact is decided by the entry's +// proof system, and these check that it is decided rather than +// defaulted. The failure this guards against is quiet: before the +// dispatch existed, every entry was handed to Longfellow, and a Vega +// entry's shapes reached a loader that cannot read them - which looks +// to an operator like a bad artifact rather than a verifier that never +// learned the system. +// +// zknative only, because a default build cannot construct either +// verifier: both constructors return ErrNativeNotBuilt, so there would +// be nothing to tell apart. + +// The staged fixtures, both of them the real published circuits - the +// point of the cross-feed case below is that each is genuinely the +// other's library's artifact, not a handful of bytes. +const ( + longfellowCircuitPath = "../../../third_party/zk-cred-longfellow/test-vectors/nymauth/mdoc-circuits.zst" + vegaShapesPath = "../../../third_party/zk-cred-vega/test-vectors/nymauth/shapes.zst" +) + +// The Longfellow archive circuit's catalog parameters, as +// pkg/nymauth/verifier's own fixture states them. A stale value here +// is a load failure, never a verification against the wrong circuit: +// the loader checks the artifact against them. +var longfellowFixtureParams = circuits.Entry{ + SystemID: nymauth.SystemLongfellowNymAuth, + Config: "default", + BlockEncHash: 6983, + BlockEncSig: 4679, + HashCircuitID: "5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729", + SigCircuitID: "00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb", +} + +// decompressedArtifact reads one of the staged .zst fixtures. The +// allowlist pins, and both ABIs take, the DECOMPRESSED bytes. +func decompressedArtifact(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + require.NoErrorf(t, err, "the nym_auth vectors are not staged (%s); run `make zk-native-lib zk-native-lib-vega`", + filepath.Clean(path)) + + decoder, err := zstd.NewReader(nil) + require.NoError(t, err) + defer decoder.Close() + artifact, err := decoder.DecodeAll(raw, nil) + require.NoError(t, err) + return artifact +} + +// A Longfellow entry loads Longfellow's verifier. +func TestNymAuthBackendForALongfellowEntryIsTheLongfellowVerifier(t *testing.T) { + backend, err := newNymAuthBackend(longfellowFixtureParams, decompressedArtifact(t, longfellowCircuitPath)) + require.NoError(t, err) + t.Cleanup(func() { _ = backend.Close() }) + assert.IsType(t, (*verifier.NativeVerifier)(nil), backend) +} + +// A Vega entry loads Vega's, from the same call and with no +// parameters: its artifact is R1CS shapes, and the verifier key is +// derived from them rather than named beside them. +func TestNymAuthBackendForAVegaEntryIsTheVegaVerifier(t *testing.T) { + entry := circuits.Entry{SystemID: nymauth.SystemVegaNymAuth, Config: "default"} + + backend, err := newNymAuthBackend(entry, decompressedArtifact(t, vegaShapesPath)) + require.NoError(t, err) + t.Cleanup(func() { _ = backend.Close() }) + assert.IsType(t, (*verifier.VegaVerifier)(nil), backend) +} + +// The system decides, not the artifact. Vega's shapes under a +// Longfellow entry must fail to load rather than be read by whichever +// library happens to be wired in - this is the case that failed +// silently before, and it fails loudly now. +func TestNymAuthBackendDoesNotOpenVegaShapesWithLongfellow(t *testing.T) { + entry := longfellowFixtureParams + backend, err := newNymAuthBackend(entry, decompressedArtifact(t, vegaShapesPath)) + require.Error(t, err, "Longfellow must not load an artifact that belongs to another proof system") + assert.Nil(t, backend) +} + +// And the other way round, so neither direction is an accident of +// which library is more tolerant of the bytes it is handed. +func TestNymAuthBackendDoesNotOpenALongfellowCircuitWithVega(t *testing.T) { + entry := circuits.Entry{SystemID: nymauth.SystemVegaNymAuth, Config: "default"} + backend, err := newNymAuthBackend(entry, decompressedArtifact(t, longfellowCircuitPath)) + require.Error(t, err, "Vega must not load an artifact that belongs to another proof system") + assert.Nil(t, backend) +} + +// An entry naming a system this build has no verifier for is refused. +// +// Unreachable through a loaded allowlist, which runs +// nymauth.CheckSystemID when it is built - and checked anyway, because +// the alternative to a backend is no verification at all. +func TestNymAuthBackendRefusesAnUnknownSystem(t *testing.T) { + entry := circuits.Entry{SystemID: "urn:circuits:nonesuch:nym_auth:v1", Config: "default"} + + backend, err := newNymAuthBackend(entry, decompressedArtifact(t, vegaShapesPath)) + require.Error(t, err) + assert.Nil(t, backend) + assert.Contains(t, err.Error(), "urn:circuits:nonesuch:nym_auth:v1", + "the refusal must name the system, so an operator can find the entry that asked for it") +} diff --git a/internal/verifier/apiv1/circuit_zk_issuer_keys.go b/internal/verifier/apiv1/circuit_zk_issuer_keys.go new file mode 100644 index 000000000..8e67269d1 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_issuer_keys.go @@ -0,0 +1,199 @@ +package apiv1 + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/SUNET/vc/pkg/mdoc" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/SUNET/vc/pkg/trust" +) + +// Q - the key the credential was signed with - is a PUBLIC INPUT to +// the circuit, so the verifier has to know it before it can check +// anything. +// +// WHY THE HOLDER MAY SEND IT. At first reading, taking the key from +// the response looks like letting the holder choose what it is +// measured against. It is not, because the two things are checked +// separately: +// +// - The CIRCUIT proves "this credential was signed by Q", for the +// Q given to it. A holder who substitutes a key it controls gets +// a proof about a credential that key signed - which is a +// credential it issued to itself. +// - The TRUST LAYER then says whether Q belongs to an issuer this +// verifier accepts. That is what the holder cannot forge: the +// chain is evaluated by the same go-trust PDP, under the same +// mdoc-issuer-auth role, as a plain mdoc presentation's DS +// certificate. A self-signed key does not get a decision. +// +// So the holder supplies a candidate and the PDP vouches for it. +// Neither half is sufficient alone, and refusing either one refuses +// the presentation. +// +// It is also the only design that works. A circuit_zk presentation +// carries no credential, and go-trust cannot answer "which keys may +// sign this docType" - its question is "is THIS name bound to THIS +// key". The IACA root cannot stand in for Q either: one IACA signs +// many DS certificates, and the circuit needs the exact signing key. +// +// The wire shape is normative in the matcher repository's +// docs/circuit_zk_response.md rule 5a; pkg/openid4vp checks the +// encoding and the limits, and this is where the X.509 is parsed. +// +// PRIVACY. The chain is issuer-level, not holder-level: a DS +// certificate identifies the issuer and says nothing about which +// credential or which subject. It carries no more than the issuer +// identity a plain mdoc presentation already reveals. +// +// The docType the chain is evaluated for comes from the REQUEST, not +// from the response. A holder choosing the docType would be choosing +// which trust policy it is judged under. + +// longfellowIssuerKeySetSize is how many certificates the Longfellow +// system's issuer key set carries: exactly one, the Document Signer. +// +// From the archive circuits, which are authoritative on circuit +// semantics. The shared response shape permits up to eight because it +// describes every system; this verifier knows which system it is +// verifying for. +const longfellowIssuerKeySetSize = 1 + +// ErrNoTrustEvaluator is the refusal a verifier with no PDP gives. +// +// Fail closed, and deliberately not "nothing is trusted, carry on": a +// verifier that cannot ask whether an issuer is trusted must not read +// its own silence as a yes. +var ErrNoTrustEvaluator = errors.New("zkp_req: no trust evaluator is configured, so no issuer key can be trusted") + +// ErrAllowAllTrustEvaluator is the refusal for a deployment whose trust +// configuration trusts everything. +// +// A nil evaluator is not the only way to have no trust decision. +// NewTrustEvaluatorFromConfig returns an AllowAllEvaluator when +// verifier.trust.pdp_url is empty, and that evaluator answers +// Trusted=true to every question - which for this path means a chain +// the HOLDER minted is accepted as a trusted issuer. +// +// That breaks the whole construction rather than weakening it. The +// circuit proves "this credential was signed by Q" for the Q it is +// given; the only thing standing between that and a holder issuing a +// credential to itself is the trust layer saying Q belongs to a real +// issuer. With allow-all there is nothing on that side, so a holder +// can self-sign, choose its own issuer key, and so choose its own +// pseudonym - which for known-offender matching means a flagged +// person presents a value that matches nothing and reads as innocent. +// +// Allow-all is a legitimate mode for other formats, where a holder +// cannot supply the key it is measured against. It is not legitimate +// here, so this refuses rather than inheriting it. +var ErrAllowAllTrustEvaluator = errors.New("zkp_req: verifier.trust.pdp_url is not set, so every issuer is trusted; " + + "nym_auth takes the issuer key from the holder's own certificate chain and cannot be used without a PDP") + +// circuitZkIssuerKey validates the presentation's issuer chain and +// returns the leaf's public key as Q. +// +// docType is the REQUEST's. now is passed in so a test can state the +// moment a certificate is judged at. +func (c *Client) circuitZkIssuerKey( + ctx context.Context, + presentation openid4vp.ZkpPresentation, + docType string, + now time.Time, +) (*ecdsa.PublicKey, error) { + if c.trustEvaluator == nil { + return nil, ErrNoTrustEvaluator + } + if _, allowAll := c.trustEvaluator.(*trust.AllowAllEvaluator); allowAll { + // Defence in depth: configuration validation refuses this at + // startup. Checked here too because the consequence of being + // wrong is a holder-chosen pseudonym, and this is the point + // that cannot be bypassed. + return nil, ErrAllowAllTrustEvaluator + } + if len(presentation.IssuerKeySet) == 0 { + // Unreachable through ValidateZkpReqResponse, which makes + // issuer_key_set a required non-empty member. Kept because + // the consequence of being wrong about that is verifying a + // proof against no key, which is not verifying it - a belief + // worth one comparison. + return nil, refuseZkpReq(CodeZkpReqIssuerKeySetEmpty, + "the presentation carries no issuer key set", nil) + } + if len(presentation.IssuerKeySet) != longfellowIssuerKeySetSize { + // The shared shape allows up to eight; the ARCHIVE CIRCUITS + // take exactly one for this system, and on circuit semantics + // the archive wins over the shared shape. More than one would + // mean choosing which to treat as the leaf, and the circuit + // has no notion of a chain to walk. + return nil, refuseZkpReq(CodeZkpReqIssuerKeySetSize, + fmt.Sprintf("the issuer key set has %d elements; the %s system takes exactly %d - send the Document Signer certificate alone", + len(presentation.IssuerKeySet), presentation.SystemID, longfellowIssuerKeySetSize), nil) + } + + chain := make([]*x509.Certificate, 0, len(presentation.IssuerKeySet)) + for i, der := range presentation.IssuerKeySet { + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, refuseZkpReq(CodeZkpReqIssuerKeyNotACert, + fmt.Sprintf("issuer key %d does not parse as a certificate", i), err) + } + chain = append(chain, cert) + } + + // Exactly the plain-mdoc issuer check, through the same function: + // one implementation, so a policy change reaches both paths and + // the two cannot drift into trusting different things. + // + // The issuer URL is left empty on purpose - it is taken from the + // leaf's SAN, as the SDKs and the deployed PDP request shape + // expect - and the docType is the request's. + if err := mdoc.VerifyIssuerChain(ctx, c.trustEvaluator, chain, docType, "", now); err != nil { + return nil, refuseZkpReq(CodeZkpReqIssuerNotTrusted, + fmt.Sprintf("the issuer key set is not trusted for docType %q", docType), err) + } + + // Only after the chain is trusted: an untrusted chain's key is + // not a key this verifier has any business looking at. + key, isECDSA := chain[0].PublicKey.(*ecdsa.PublicKey) + if !isECDSA { + return nil, refuseZkpReq(CodeZkpReqIssuerKeyNotECDSA, + fmt.Sprintf("the issuer certificate holds a %T, and the circuit takes a P-256 key", chain[0].PublicKey), nil) + } + if key.Curve != elliptic.P256() { + // Not a limitation to be lifted quietly: the circuit's field + // arithmetic is over P-256. A key on another curve cannot be + // a public input to it, so accepting one would mean verifying + // against something the circuit never saw. + return nil, refuseZkpReq(CodeZkpReqIssuerKeyWrongCurve, + fmt.Sprintf("the issuer key is on %s, and the circuit takes P-256", key.Curve.Params().Name), nil) + } + return key, nil +} + +// issuerKeyThumbprint identifies a key in a log line without putting +// one in it. +// +// SHA-256 over the uncompressed point, base64url. A thumbprint is +// enough to say WHICH issuer key verified, which is the only thing an +// operator needs from the line. +func issuerKeyThumbprint(key *ecdsa.PublicKey) string { + if key == nil || key.X == nil || key.Y == nil { + return "" + } + const coordinate = 32 + point := make([]byte, 1+2*coordinate) + point[0] = 4 + key.X.FillBytes(point[1 : 1+coordinate]) + key.Y.FillBytes(point[1+coordinate:]) + sum := sha256.Sum256(point) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/internal/verifier/apiv1/circuit_zk_issuer_keys_test.go b/internal/verifier/apiv1/circuit_zk_issuer_keys_test.go new file mode 100644 index 000000000..5cba64cce --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_issuer_keys_test.go @@ -0,0 +1,314 @@ +package apiv1 + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/SUNET/vc/pkg/trust" +) + +// fakeTrustEvaluator records what it was asked and answers as told. +// +// A fake rather than a live PDP because what these cases are about is +// the REQUEST this verifier builds and what it does with the answer - +// that the docType comes from the request, that a refusal is a +// refusal. Whether a given chain is trusted is go-trust's question, +// tested there. +type fakeTrustEvaluator struct { + trusted bool + reason string + err error + seen *trust.EvaluationRequest +} + +func (f *fakeTrustEvaluator) Evaluate(_ context.Context, req *trust.EvaluationRequest) (*trust.TrustDecision, error) { + f.seen = req + if f.err != nil { + return nil, f.err + } + return &trust.TrustDecision{Trusted: f.trusted, Reason: f.reason}, nil +} + +func (f *fakeTrustEvaluator) SupportsKeyType(trust.KeyType) bool { return true } +func (f *fakeTrustEvaluator) Name() string { return "fake" } +func (f *fakeTrustEvaluator) Healthy() bool { return true } + +const testIssuerURL = "https://issuer.example" + +// testDSCertificate builds a DS certificate the way the fixture shape +// has it: a URI SAN naming the issuer, which is where +// mdoc.VerifyIssuerChain takes the subject id from. +// +// PROVISIONAL fixture: the matcher PR that defines the wire shape is +// still in review. What is being asserted here is this verifier's +// behaviour given a chain, not the chain's exact bytes. +func testDSCertificate(t *testing.T, signer any, public any, notBefore, notAfter time.Time) *x509.Certificate { + t.Helper() + + issuerURI, err := url.Parse(testIssuerURL) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test DS"}, + NotBefore: notBefore, + NotAfter: notAfter, + URIs: []*url.URL{issuerURI}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, public, signer) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +// testP256Chain is a one-certificate chain and the key inside it. +func testP256Chain(t *testing.T) (openid4vp.ZkpPresentation, *ecdsa.PublicKey) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + now := time.Now() + cert := testDSCertificate(t, key, &key.PublicKey, now.Add(-time.Hour), now.Add(time.Hour)) + return openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{cert.Raw}}, &key.PublicKey +} + +func issuerKeyClient(evaluator trust.TrustEvaluator) *Client { + return &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}, trustEvaluator: evaluator} +} + +// Q comes from the presentation's chain, and only after the trust +// layer has said the chain belongs to an issuer this verifier accepts. +func TestCircuitZkIssuerKeyFromATrustedChain(t *testing.T) { + presentation, want := testP256Chain(t) + evaluator := &fakeTrustEvaluator{trusted: true} + + got, err := issuerKeyClient(evaluator).circuitZkIssuerKey( + t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + require.NoError(t, err) + assert.True(t, want.Equal(got), "Q is the leaf's public key") +} + +// The trust request is the one a plain mdoc presentation makes: the +// issuer URL from the leaf's SAN, the chain as x5c, the credential +// issuer role - and the docType from the REQUEST. +func TestCircuitZkIssuerKeyAsksTheSameQuestionAsPlainMdoc(t *testing.T) { + presentation, _ := testP256Chain(t) + evaluator := &fakeTrustEvaluator{trusted: true} + + _, err := issuerKeyClient(evaluator).circuitZkIssuerKey( + t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + require.NoError(t, err) + require.NotNil(t, evaluator.seen) + + assert.Equal(t, testIssuerURL, evaluator.seen.SubjectID, "the issuer id comes from the leaf's URI SAN") + assert.Equal(t, trust.KeyTypeX5C, evaluator.seen.KeyType) + assert.Equal(t, trust.RoleCredentialIssuer, evaluator.seen.Role) + assert.Equal(t, "eu.europa.ec.eudi.nym_auth.1", evaluator.seen.DocType, + "the docType is the request's; the holder does not choose the trust policy it is judged under") + assert.Len(t, evaluator.seen.Key, 1, "the chain is passed as-is") +} + +// The docType is the REQUEST's and never the response's. A holder +// that could name it would be choosing which issuers count as +// trusted for its own presentation. +func TestCircuitZkIssuerKeyDocTypeComesFromTheCaller(t *testing.T) { + presentation, _ := testP256Chain(t) + evaluator := &fakeTrustEvaluator{trusted: true} + + _, err := issuerKeyClient(evaluator).circuitZkIssuerKey( + t.Context(), presentation, "org.example.some.other.doctype", time.Now()) + require.NoError(t, err) + assert.Equal(t, "org.example.some.other.doctype", evaluator.seen.DocType) +} + +func TestCircuitZkIssuerKeyRefusals(t *testing.T) { + trustedChain, _ := testP256Chain(t) + + for _, tc := range []struct { + name string + client func(t *testing.T) *Client + presentation func(t *testing.T) openid4vp.ZkpPresentation + contains string + }{{ + // Fail closed: a verifier that cannot ask whether an issuer + // is trusted must not read its own silence as a yes. + name: "no trust evaluator configured", + client: func(*testing.T) *Client { return issuerKeyClient(nil) }, + contains: "no trust evaluator is configured", + }, { + // The shared vectors do not carry x5chain yet, so the + // validator lets a chainless presentation through. It stops + // here: there is no key, and a proof checked against no key + // is not checked. + name: "no chain in the presentation", + client: func(*testing.T) *Client { return issuerKeyClient(&fakeTrustEvaluator{trusted: true}) }, + presentation: func(*testing.T) openid4vp.ZkpPresentation { return openid4vp.ZkpPresentation{} }, + contains: "carries no issuer key set", + }, { + name: "the chain is not DER", + client: func(*testing.T) *Client { return issuerKeyClient(&fakeTrustEvaluator{trusted: true}) }, + presentation: func(*testing.T) openid4vp.ZkpPresentation { + return openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{[]byte("not a certificate")}} + }, + contains: "does not parse", + }, { + // decision=false is a refusal, not an empty answer to carry + // on from. + name: "the PDP says no", + client: func(*testing.T) *Client { + return issuerKeyClient(&fakeTrustEvaluator{trusted: false, reason: "unknown issuer"}) + }, + contains: "is not trusted for docType", + }, { + // A PDP that could not answer is not a PDP that said no, and + // either way nothing is trusted. + name: "the PDP could not be reached", + client: func(*testing.T) *Client { + return issuerKeyClient(&fakeTrustEvaluator{err: assert.AnError}) + }, + contains: "is not trusted for docType", + }, { + // The circuit's arithmetic is over P-256. A key on another + // curve cannot be a public input to it. + name: "the leaf key is not P-256", + client: func(*testing.T) *Client { return issuerKeyClient(&fakeTrustEvaluator{trusted: true}) }, + presentation: func(t *testing.T) openid4vp.ZkpPresentation { + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + now := time.Now() + cert := testDSCertificate(t, key, &key.PublicKey, now.Add(-time.Hour), now.Add(time.Hour)) + return openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{cert.Raw}} + }, + contains: "is on P-384, and the circuit takes P-256", + }, { + name: "the leaf key is not ECDSA at all", + client: func(*testing.T) *Client { return issuerKeyClient(&fakeTrustEvaluator{trusted: true}) }, + presentation: func(t *testing.T) openid4vp.ZkpPresentation { + public, private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + now := time.Now() + cert := testDSCertificate(t, private, public, now.Add(-time.Hour), now.Add(time.Hour)) + return openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{cert.Raw}} + }, + contains: "and the circuit takes a P-256 key", + }} { + t.Run(tc.name, func(t *testing.T) { + presentation := trustedChain + if tc.presentation != nil { + presentation = tc.presentation(t) + } + _, err := tc.client(t).circuitZkIssuerKey( + t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.contains) + }) + } +} + +// An expired DS certificate is refused, and the moment it is judged +// at is the one passed in - not whenever the test happens to run. +func TestCircuitZkIssuerKeyHonoursTheCertificateValidityPeriod(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + issued := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + cert := testDSCertificate(t, key, &key.PublicKey, issued, issued.Add(24*time.Hour)) + presentation := openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{cert.Raw}} + client := issuerKeyClient(&fakeTrustEvaluator{trusted: true}) + + _, err = client.circuitZkIssuerKey(t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", issued.Add(time.Hour)) + require.NoError(t, err, "inside the validity period") + + _, err = client.circuitZkIssuerKey(t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", issued.Add(48*time.Hour)) + require.Error(t, err) + assert.Contains(t, err.Error(), "expired") + + _, err = client.circuitZkIssuerKey(t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", issued.Add(-time.Hour)) + require.Error(t, err) + assert.Contains(t, err.Error(), "not yet valid") +} + +// The key is taken only AFTER the chain is trusted. An untrusted +// chain's key is not one this verifier has any business looking at, +// and a refusal that still returned a key would invite a caller to +// use it. +func TestCircuitZkIssuerKeyReturnsNothingWhenUntrusted(t *testing.T) { + presentation, _ := testP256Chain(t) + + got, err := issuerKeyClient(&fakeTrustEvaluator{trusted: false}).circuitZkIssuerKey( + t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + require.Error(t, err) + assert.Nil(t, got) +} + +// An allow-all trust evaluator is refused, and the case that makes it +// matter is a chain the HOLDER minted. +// +// NewTrustEvaluatorFromConfig returns AllowAllEvaluator when +// verifier.trust.pdp_url is empty, and it is not nil - so the nil +// check above does not catch it, and every chain gets Trusted=true. +// +// That is not a weaker check, it is no check. The circuit proves "this +// credential was signed by Q" for whatever Q it is handed; the only +// thing standing between that and a holder issuing a credential to +// itself is the trust layer. Without it the holder picks its own +// issuer key and therefore its own pseudonym, and a flagged person +// presents a value that matches nothing and reads as innocent. +func TestCircuitZkIssuerKeyRefusesAnAllowAllEvaluator(t *testing.T) { + // A certificate the "holder" signed for itself, which is exactly + // what a real attacker would send. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + now := time.Now() + selfSigned := testDSCertificate(t, key, &key.PublicKey, now.Add(-time.Hour), now.Add(time.Hour)) + presentation := openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{selfSigned.Raw}} + + client := issuerKeyClient(trust.NewTrustEvaluatorFromConfig("")) + got, err := client.circuitZkIssuerKey(t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", now) + + require.ErrorIs(t, err, ErrAllowAllTrustEvaluator) + assert.Nil(t, got, "a self-signed holder-minted issuer must yield no key") + + // And a real evaluator still works, so this refuses the mode and + // not the path. + got, err = issuerKeyClient(&fakeTrustEvaluator{trusted: true}).circuitZkIssuerKey( + t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", now) + require.NoError(t, err) + assert.NotNil(t, got) +} + +// The thumbprint names a key in a log line without putting one in it, +// and two keys are two thumbprints. +func TestIssuerKeyThumbprint(t *testing.T) { + one, two := testKey(t), testKey(t) + + assert.NotEmpty(t, issuerKeyThumbprint(one)) + assert.Equal(t, issuerKeyThumbprint(one), issuerKeyThumbprint(one), "stable for one key") + assert.NotEqual(t, issuerKeyThumbprint(one), issuerKeyThumbprint(two)) + assert.Empty(t, issuerKeyThumbprint(nil)) + assert.Empty(t, issuerKeyThumbprint(&ecdsa.PublicKey{}), "an incomplete key names nothing") +} + +func testKey(t *testing.T) *ecdsa.PublicKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + return &key.PublicKey +} diff --git a/internal/verifier/apiv1/circuit_zk_native_built.go b/internal/verifier/apiv1/circuit_zk_native_built.go new file mode 100644 index 000000000..8582a5ee3 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_native_built.go @@ -0,0 +1,7 @@ +//go:build !zknative + +package apiv1 + +// circuitZkNativeBuilt reports whether this build can check a +// nym_auth proof. False here; see circuit_zk_native_built_cgo.go. +const circuitZkNativeBuilt = false diff --git a/internal/verifier/apiv1/circuit_zk_native_built_cgo.go b/internal/verifier/apiv1/circuit_zk_native_built_cgo.go new file mode 100644 index 000000000..c763e0a20 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_native_built_cgo.go @@ -0,0 +1,7 @@ +//go:build zknative + +package apiv1 + +// circuitZkNativeBuilt reports whether this build can check a +// nym_auth proof. +const circuitZkNativeBuilt = true diff --git a/internal/verifier/apiv1/circuit_zk_native_cgo.go b/internal/verifier/apiv1/circuit_zk_native_cgo.go new file mode 100644 index 000000000..46c9f1da3 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_native_cgo.go @@ -0,0 +1,230 @@ +//go:build zknative + +package apiv1 + +import ( + "context" + "crypto/ecdsa" + "fmt" + "sync" + + "github.com/SUNET/vc/pkg/nymauth" + "github.com/SUNET/vc/pkg/nymauth/circuits" + "github.com/SUNET/vc/pkg/nymauth/verifier" + "github.com/SUNET/vc/pkg/openid4vp" +) + +// verifyCircuitZkPresentation checks one validated presentation's +// proof, and returns the pseudonyms the caller may act on. +// +// The returned map is the ONLY source of a verified pseudonym. The +// values in the presentation are what the holder sent; they become +// identifiers this verifier may use only once a proof has been +// checked against them, and that happens here. +// +// issuerKey must come from the resolved and trusted issuer chain. +// Everything else comes from session and items, which are this +// verifier's own - Request.Check refuses a request built any other +// way. +func (c *Client) verifyCircuitZkPresentation( + ctx context.Context, + presentation openid4vp.ZkpPresentation, + session circuitZkSession, + items []openid4vp.ReqItem, + issuerKey any, +) (map[string][]byte, error) { + key, ok := issuerKey.(*ecdsa.PublicKey) + if !ok || key == nil { + return nil, fmt.Errorf("zkp_req: no issuer key resolved for this presentation") + } + + request, err := circuitZkVerifierRequest(session, key, items) + if err != nil { + return nil, err + } + + // checked counts the items whose proof was ACTUALLY verified. + // + // The loop below calls the native verifier once per pseudonym + // item and not at all for anything else, so a request carrying + // only predicates - an age_check with no pseudonym - would run it + // zero times and return an empty map and a nil error. The caller + // reads nil as "verified" and caches the presentation's + // "satisfied": true as a checked fact. It is not: in that path + // nothing looked at the proof at all, so any base64url bytes + // would do, and the predicate would be asserted by the holder and + // believed by the verifier. + // + // Counted rather than special-cased on item.FnID, because the + // invariant is "something was checked", not "a pseudonym was + // asked for" - a function added later that this loop skips would + // otherwise reopen exactly this hole, silently. + checked := 0 + verified := map[string][]byte{} + for _, item := range items { + if item.FnID != openid4vp.FunctionPseudonym { + continue + } + claimed, present := presentation.Pseudonyms[item.Return] + if !present { + // Unreachable after validation, which requires exactly + // the request's handles - and checked anyway, because + // the alternative is verifying against a zero pseudonym. + return nil, refuseZkpReq(CodeZkpReqNoPseudonym, + fmt.Sprintf("no pseudonym for handle %q", item.Return), nil) + } + + config, err := circuitZkConfigOf(item) + if err != nil { + return nil, err + } + backend, err := c.circuitZkVerifier(ctx, presentation.SystemID, config) + if err != nil { + return nil, err + } + + var claimedArray [32]byte + copy(claimedArray[:], claimed) + pseudonym, err := backend.Verify(request, claimedArray, presentation.Proof) + if err != nil { + // The proof did not verify against the issuer key the key + // set established. Which handle it was is the useful part + // for a prover; WHY is deliberately not elaborated, since + // the only honest answer is "the proof is not valid for + // these public inputs". + return nil, refuseZkpReq(CodeZkpReqProofDidNotVerify, + fmt.Sprintf("the proof did not verify for handle %q", item.Return), err) + } + // The RETURNED value, never the claimed one. + verified[item.Return] = pseudonym[:] + checked++ + } + + if checked == 0 { + // Fail closed. The proof is one proof over the whole request, + // and the only way this build has to check it is a pseudonym + // item's Verify - so with none, there is no verification to + // do and no way to say the predicates hold. + // + // A predicate-only request is therefore one this verifier + // must not send; refused here rather than only where requests + // are built, because this is the point that cannot be + // bypassed. + return nil, refuseZkpReq(CodeZkpReqNothingChecked, + fmt.Sprintf("this request asks only for predicates, and the proof is checked by verifying a pseudonym; "+ + "a predicate-only request has no verification path in this build, so its %d result(s) cannot be accepted", len(items)), nil) + } + return verified, nil +} + +// circuitZkConfigOf reads the pseudonym configuration a req item asked +// for. +// +// From the REQUEST, which this verifier sent. The configuration +// selects the circuit build, and the build decides the pseudonym's +// domain separator - so taking it from the response would let the +// holder choose which pseudonym function it is measured by. +func circuitZkConfigOf(item openid4vp.ReqItem) (string, error) { + value, present := item.Params["config"] + if !present { + return "", fmt.Errorf("zkp_req: the %q request item names no pseudonym configuration", item.Return) + } + config, ok := value.(string) + if !ok || config == "" { + return "", fmt.Errorf("zkp_req: the %q request item's configuration is not a string", item.Return) + } + return config, nil +} + +// circuitZkVerifier returns a loaded verifier for a proof system and +// configuration, loading it once. +// +// Loading is the expensive step - about 0.15 s and a megabyte of +// artifact for Longfellow, and for Vega roughly 12.5 MB of verifier key +// derived from the shapes - and neither handle holds any interior +// mutability, so one per build is reused across presentations and +// goroutines. +// +// systemID is the system the PRESENTATION states, already checked +// against the ones this request offered. It selects the build beside +// the configuration, and the two together are what the allowlist is +// keyed by - a verifier may allow both Longfellow and Vega for one +// configuration, and they are different artifacts. +func (c *Client) circuitZkVerifier(ctx context.Context, systemID, config string) (verifier.Backend, error) { + c.nymAuthMu.Lock() + defer c.nymAuthMu.Unlock() + + key := nymAuthVerifierKey{systemID: systemID, config: config} + if loaded, ok := c.nymAuthVerifiers[key]; ok { + return loaded, nil + } + if c.nymAuthCircuits == nil { + return nil, fmt.Errorf("zkp_req: verifier.nym_auth is not enabled") + } + + entry, err := c.nymAuthCircuits.Entry(systemID, config) + if err != nil { + return nil, err + } + artifact, err := c.nymAuthCircuits.Artifact(ctx, systemID, config) + if err != nil { + return nil, err + } + + backend, err := newNymAuthBackend(entry, artifact) + if err != nil { + return nil, fmt.Errorf("zkp_req: loading the %q %q circuit: %w", systemID, config, err) + } + + if c.nymAuthVerifiers == nil { + c.nymAuthVerifiers = map[nymAuthVerifierKey]verifier.Backend{} + } + c.nymAuthVerifiers[key] = backend + c.log.Info("Loaded a nym_auth circuit", "system_id", systemID, "config", config, + "artifact_bytes", entry.Bytes, "artifact_sha256", entry.SHA256) + return backend, nil +} + +// newNymAuthBackend builds the verifier that belongs to an allowed +// entry's proof system. +// +// The system decides what loading means, and the two are not alike. +// Longfellow loads a circuit artifact together with the four Ligero +// parameters the entry carries. Vega loads R1CS SHAPES and takes no +// parameters at all: it derives the verifier key from them, so the +// artifact digest the allowlist already pins is the circuit's whole +// identity, and there is nothing else for an entry to name. That is +// why pkg/nymauth/circuits REFUSES a Vega entry that carries Ligero +// parameters - they would be configuration nothing ever reads. +// +// Taken from the ENTRY rather than from the presented system_id. The +// two are equal here by construction, since the entry was looked up by +// that key, but the entry is the operator's statement about which +// library may open this artifact, and that is the right thing to +// dispatch on. +func newNymAuthBackend(entry circuits.Entry, artifact []byte) (verifier.Backend, error) { + switch entry.SystemID { + case nymauth.SystemLongfellowNymAuth: + return verifier.NewNativeVerifier(artifact, verifier.CircuitParams{ + BlockEncHash: entry.BlockEncHash, + BlockEncSig: entry.BlockEncSig, + HashCircuitID: entry.HashCircuitID, + SigCircuitID: entry.SigCircuitID, + }) + case nymauth.SystemVegaNymAuth: + return verifier.NewVegaVerifier(artifact) + default: + // Unreachable: nymauth.CheckSystemID runs when the allowlist is + // built, so an entry with any other system never reaches a + // loaded Allowlist. Refused rather than left to fall through, + // because the alternative to a backend is no verification. + return nil, fmt.Errorf("no nym_auth verifier for proof system %q; this build knows %v", + entry.SystemID, nymauth.Systems()) + } +} + +// Compile-time assertion that the allowlist type is the one the +// client holds, so the stub build and this one cannot drift. +var _ = (*circuits.Allowlist)(nil) + +var _ sync.Locker = (*sync.Mutex)(nil) diff --git a/internal/verifier/apiv1/circuit_zk_native_stub.go b/internal/verifier/apiv1/circuit_zk_native_stub.go new file mode 100644 index 000000000..725e774c2 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_native_stub.go @@ -0,0 +1,34 @@ +//go:build !zknative + +package apiv1 + +import ( + "context" + + "github.com/SUNET/vc/pkg/openid4vp" +) + +// This file is what every default build of this repo gets: no cgo, no +// zk-cred-longfellow, and so no way to check a nym_auth proof. See +// circuit_zk_native_cgo.go for the real implementation, opt-in via +// `-tags zknative` - the same shape as pkg/mdoc's zk_native_stub.go / +// zk_native_cgo.go pair, and for the same reason: the default +// vc-verifier build is CGO_ENABLED=0 and fully static. +// +// It REFUSES rather than returning a verified-looking zero value. +// Everything upstream - the request's own checks, the response's +// shape, its binding to the offer, the circuit this verifier allows - +// is real and has already run by the time this is called, so a nil +// error here would hand all of that back as a successful verification +// of a proof nothing looked at. + +// verifyCircuitZkPresentation always fails in this build. +func (c *Client) verifyCircuitZkPresentation( + _ context.Context, + _ openid4vp.ZkpPresentation, + _ circuitZkSession, + _ []openid4vp.ReqItem, + _ any, +) (map[string][]byte, error) { + return nil, ErrCircuitZkNotBuilt +} diff --git a/internal/verifier/apiv1/circuit_zk_native_zknative_test.go b/internal/verifier/apiv1/circuit_zk_native_zknative_test.go new file mode 100644 index 000000000..acbf76df2 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_native_zknative_test.go @@ -0,0 +1,102 @@ +//go:build zknative + +package apiv1 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth/verifier" + "github.com/SUNET/vc/pkg/openid4vp" +) + +// These run only in a zknative build, which is the only build where +// verifyCircuitZkPresentation does anything. The !zknative stub +// refuses everything, so a default build cannot reach what is checked +// here - and before this file existed, no test compiled the cgo +// implementation at all. That is how the hole below survived: the +// `make build-verifier-zknative` step compiled it, nothing ran it. + +func zknativeTestSession(t *testing.T) (circuitZkSession, *ecdsa.PublicKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // A well-formed three-item transcript; nothing here reaches the + // native library, so its contents only have to be well-formed. + transcript, err := verifier.MdocSessionTranscript( + "https://verifier.example", "pOA0oqEAhLnqug2r7Ve4tQ", nil) + require.NoError(t, err) + + return circuitZkSession{ + Transcript: transcript, + DocType: "eu.europa.ec.eudi.nym_auth.1", + Now: time.Now(), + }, &key.PublicKey +} + +// A presentation that asks only for a PREDICATE must be refused. +// +// The proof is one proof over the whole request, and the only way +// this build has to check it is a pseudonym item's Verify. With no +// pseudonym item that call never happens, so nothing looks at the +// proof - and a nil error here is the caller's signal to cache +// "satisfied": true as a verified fact. A holder could then send any +// base64url bytes as the proof, with a genuine public issuer chain, +// and have the predicate believed. +func TestCircuitZkPredicateOnlyPresentationIsRefused(t *testing.T) { + session, issuerKey := zknativeTestSession(t) + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + + items := []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionAgeCheck, + Return: "age-range-123", + Params: map[string]any{"ge": "2008-10-10", "le": "2013-10-09"}, + }} + // Proof bytes nothing would accept, which is the point: no part + // of this presentation is checkable, so none of it may pass. + presentation := openid4vp.ZkpPresentation{ + SystemID: "urn:circuits:longfellow:nym_auth:v1", + + Proof: []byte("not a proof"), + Satisfied: []string{"age-range-123"}, + } + + verified, err := client.verifyCircuitZkPresentation(t.Context(), presentation, session, items, issuerKey) + require.Error(t, err, "a presentation whose proof nothing checked must not verify") + assert.Nil(t, verified) + assert.Contains(t, err.Error(), "no verification path") +} + +// The same refusal with NO items at all: there is nothing to check, +// which is not the same as everything checking out. +func TestCircuitZkNoItemsIsRefused(t *testing.T) { + session, issuerKey := zknativeTestSession(t) + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + + verified, err := client.verifyCircuitZkPresentation( + t.Context(), openid4vp.ZkpPresentation{Proof: []byte("not a proof")}, session, nil, issuerKey) + require.Error(t, err) + assert.Nil(t, verified) +} + +// A missing issuer key is refused before anything else: the key is +// what the circuit proves a signature under, so there is nothing to +// verify against without it. +func TestCircuitZkNoIssuerKeyIsRefused(t *testing.T) { + session, _ := zknativeTestSession(t) + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + + items := []openid4vp.ReqItem{{FnID: openid4vp.FunctionAgeCheck, Return: "age-1"}} + _, err := client.verifyCircuitZkPresentation( + t.Context(), openid4vp.ZkpPresentation{}, session, items, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no issuer key") +} diff --git a/internal/verifier/apiv1/circuit_zk_preset_test.go b/internal/verifier/apiv1/circuit_zk_preset_test.go new file mode 100644 index 000000000..2361e928a --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_preset_test.go @@ -0,0 +1,333 @@ +package apiv1 + +import ( + "testing" + + "github.com/SUNET/vc/pkg/mdoc" + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/SUNET/vc/pkg/sdjwtvc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func circuitZkPresetConfig(t *testing.T, scopeCfg *model.VerificationPresetScope, vctm *sdjwtvc.VCTM) *model.Cfg { + t.Helper() + schema := &mdoc.MDDLSchema{ + Format: "mso_mdoc", + DocType: "eu.europa.ec.eudi.nym_auth.1", + Claims: map[string]mdoc.NamespaceClaims{ + "eu.europa.ec.eudi.nym_auth.1": { + "secret_seed_commitment": {Mandatory: true, ValueType: "bstr"}, + }, + }, + } + return &model.Cfg{ + Common: &model.Common{ + CredentialMetadata: map[string]*model.CredentialMetadata{ + "nym": {Format: "mso_mdoc", MDDL: schema, Attributes: schema.Attributes(), VCTM: vctm}, + }, + }, + Verifier: &model.Verifier{ + // A circuit_zk query is answered with a proof, and which + // circuits this verifier accepts one from lives here. Without + // it the preset is not published at all, which is the subject + // of TestCircuitZkPresetNeedsTheFormatAdvertised. + PreferredVPFormats: &openid4vp.VPFormatsSupported{ + MsoMdoc: &openid4vp.MsoMdocFormat{}, + ZkpReq: &openid4vp.ZkpReqFormat{}, + }, + Presets: map[string]model.PresetDefinition{ + "Nym": {Credentials: model.VerificationPreset{"nym": scopeCfg}}, + }, + }, + } +} + +var nymNamespace = func() *string { s := "eu.europa.ec.eudi.nym_auth.1"; return &s }() + +var nymReq = []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, + Return: "nym-123", + Params: map[string]any{"config": "known-offender-matching-ssc"}, +}} + +// The picker builds the DCQL query in the browser and posts it back, so +// anything the query needs has to reach it. A circuit_zk query discloses +// nothing - every value it yields is the output of a function named in +// req - so a req left server-side produces a request asking for nothing, +// which a wallet answers with nothing, and the failure reads as the +// wallet's. +func TestCircuitZkPresetPublishesReq(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + preset := reply.Presets["Nym"] + require.NotNil(t, preset) + require.Len(t, preset.Credentials, 1) + + credential := preset.Credentials[0] + assert.Equal(t, openid4vp.FormatZkpReq, credential.Format) + assert.Equal(t, "eu.europa.ec.eudi.nym_auth.1", credential.Meta.DoctypeValue, + "circuit_zk is matched on doctype_value, like the mdoc it proves over") + require.Equal(t, nymReq, credential.Req) +} + +// The claims fall-back exists for formats whose claims ARE the query. +// Applied to circuit_zk it attaches every attribute in the document to a +// request whose whole point is to disclose none of them - and the UI +// would then post that query back, so it is not cosmetic. +func TestCircuitZkPresetCarriesNoClaims(t *testing.T) { + for _, tc := range []struct { + name string + claims []model.VerificationPresetClaim + vctm *sdjwtvc.VCTM + }{ + {"with claims configured, which for this format is a mistake", []model.VerificationPresetClaim{ + {Path: []string{"eu.europa.ec.eudi.nym_auth.1", "secret_seed_commitment"}}, + }, nil}, + // The fall-back reads GetVCTM(), which an mdoc scope normally has + // nothing in - so this case gives it one. Not idle: the fall-back + // is keyed on the document, not on the format, and a scope + // carrying both documents is a configuration an operator can + // write today. + {"with none, where the metadata document would supply them", nil, &sdjwtvc.VCTM{ + VCT: "urn:eudi:nym_auth:1", + Claims: []sdjwtvc.Claim{ + {Path: []*string{nymNamespace}}, + }, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + Claims: tc.claims, + }, tc.vctm) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + preset := reply.Presets["Nym"] + require.NotNil(t, preset) + require.Len(t, preset.Credentials, 1) + assert.Empty(t, preset.Credentials[0].Claims) + }) + } +} + +// Same shape of guard the mso_mdoc_zk override already has: publish a +// preset the format cannot satisfy and the operator sees a button that +// returns nothing, with no error anywhere. +func TestCircuitZkPresetWithoutReqIsOmitted(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{Format: openid4vp.FormatZkpReq}, nil) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + if preset := reply.Presets["Nym"]; preset != nil { + assert.Empty(t, preset.Credentials, + "a circuit_zk preset with no req asks for nothing and must not be published") + } +} + +// An override may not change what the credential is matched BY. +// circuit_zk matches on doctype_value, so it can be asked of an mdoc +// scope and not of an SD-JWT one - pairing it with vct_values would send +// a query no wallet can satisfy. +func TestCircuitZkOverrideKeepsTheConstraintFamily(t *testing.T) { + assert.True(t, sameConstraintFamily(openid4vp.FormatMsoMdoc, openid4vp.FormatZkpReq)) + assert.False(t, sameConstraintFamily(openid4vp.FormatSDJWTVC, openid4vp.FormatZkpReq)) +} + +// The circuit filter - which proof systems and circuits this verifier +// will accept a proof from - lives in +// client_metadata.vp_formats_supported.circuit_zk and nowhere else. +// vpFormatsOrDefault's default names only dc+sd-jwt and mso_mdoc, so a +// deployment that never configured preferred_vp_formats would publish +// this preset and then send the query alongside metadata declaring no +// support for its format at all. +// +// No default is invented: those identifiers are deployment-specific, and +// guessing one would pin this verifier to whichever implementation +// happened to be in front of whoever wrote it. An EMPTY circuit_zk object +// is a perfectly good answer - the shared vectors read a missing filter +// as "any circuit" - but it has to be the operator's answer. +func TestCircuitZkPresetNeedsTheFormatAdvertised(t *testing.T) { + for _, tc := range []struct { + name string + formats *openid4vp.VPFormatsSupported + publish bool + }{ + {"nothing configured, so the default applies and names no circuit_zk", nil, false}, + {"configured, but without circuit_zk", &openid4vp.VPFormatsSupported{ + MsoMdoc: &openid4vp.MsoMdocFormat{}, + }, false}, + {"an empty circuit_zk, which means any circuit", &openid4vp.VPFormatsSupported{ + ZkpReq: &openid4vp.ZkpReqFormat{}, + }, true}, + {"and one naming a filter", &openid4vp.VPFormatsSupported{ + ZkpReq: &openid4vp.ZkpReqFormat{ + ProofSystemValues: []string{"longfellow-libzk-v1"}, + }, + }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + cfg.Verifier.PreferredVPFormats = tc.formats + + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + + preset := reply.Presets["Nym"] + if !tc.publish { + if preset != nil { + assert.Empty(t, preset.Credentials, + "a wallet would be asked for a proof the request declares no support for") + } + return + } + require.NotNil(t, preset) + require.Len(t, preset.Credentials, 1) + }) + } +} + +// The circuit_zk requirement must not reach any other format. My first +// version of that guard was not gated on the format at all, so it dropped +// every preset carrying ANY format override whenever circuit_zk was +// unadvertised - which the existing mso_mdoc_zk test caught. This keeps +// the two apart on purpose rather than by accident. +func TestTheCircuitZkRequirementDoesNotTouchOtherFormats(t *testing.T) { + schema := &mdoc.MDDLSchema{Format: "mso_mdoc", DocType: "eu.europa.ec.eudi.pid.1"} + cfg := &model.Cfg{ + Common: &model.Common{ + CredentialMetadata: map[string]*model.CredentialMetadata{ + "pid": {Format: "mso_mdoc", MDDL: schema, Attributes: schema.Attributes()}, + }, + }, + Verifier: &model.Verifier{ + // Deliberately no circuit_zk. + PreferredVPFormats: &openid4vp.VPFormatsSupported{MsoMdoc: &openid4vp.MsoMdocFormat{}}, + Presets: map[string]model.PresetDefinition{ + "ZK": {Credentials: model.VerificationPreset{"pid": &model.VerificationPresetScope{ + Format: openid4vp.FormatMsoMdocZk, + ZKSystemType: []openid4vp.ZKSystemTypeSpec{{ID: "circuit-1", System: "longfellow"}}, + }}}, + "Plain": {Credentials: model.VerificationPreset{"pid": nil}}, + }, + }, + } + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + require.Contains(t, reply.Presets, "ZK") + require.Len(t, reply.Presets["ZK"].Credentials, 1, + "mso_mdoc_zk declares its circuits in meta.zk_system_type, not in vp_formats_supported.circuit_zk") + require.Contains(t, reply.Presets, "Plain") + require.Len(t, reply.Presets["Plain"].Credentials, 1) +} + +// A preset is a NAMED REQUEST. Dropping only the unusable credential +// publishes "PID + Pseudonym" as PID-only: a button that silently asks +// for something other than what it says, which is worse than no button - +// and the configuration reference says the preset is omitted. +func TestAMixedPresetIsNotPublishedWithoutItsCircuitCredential(t *testing.T) { + pidSchema := &mdoc.MDDLSchema{Format: "mso_mdoc", DocType: "eu.europa.ec.eudi.pid.1"} + nymSchema := &mdoc.MDDLSchema{Format: "mso_mdoc", DocType: "eu.europa.ec.eudi.nym_auth.1"} + + build := func(scopeCfg *model.VerificationPresetScope, formats *openid4vp.VPFormatsSupported) *model.Cfg { + return &model.Cfg{ + Common: &model.Common{ + CredentialMetadata: map[string]*model.CredentialMetadata{ + "pid": {Format: "mso_mdoc", MDDL: pidSchema, Attributes: pidSchema.Attributes()}, + "nym": {Format: "mso_mdoc", MDDL: nymSchema, Attributes: nymSchema.Attributes()}, + }, + }, + Verifier: &model.Verifier{ + PreferredVPFormats: formats, + Presets: map[string]model.PresetDefinition{ + "PID + Pseudonym": {Credentials: model.VerificationPreset{ + "pid": nil, + "nym": scopeCfg, + }}, + }, + }, + } + } + + advertised := &openid4vp.VPFormatsSupported{ + MsoMdoc: &openid4vp.MsoMdocFormat{}, + ZkpReq: &openid4vp.ZkpReqFormat{}, + } + + for _, tc := range []struct { + name string + scopeCfg *model.VerificationPresetScope + formats *openid4vp.VPFormatsSupported + }{ + { + name: "with no req", + scopeCfg: &model.VerificationPresetScope{Format: openid4vp.FormatZkpReq}, + formats: advertised, + }, + { + name: "with circuit_zk unadvertised", + scopeCfg: &model.VerificationPresetScope{Format: openid4vp.FormatZkpReq, Req: nymReq}, + formats: &openid4vp.VPFormatsSupported{MsoMdoc: &openid4vp.MsoMdocFormat{}}, + }, + { + name: "with a param the browser would change", + scopeCfg: &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, Return: "nym-1", + Params: map[string]any{"counter": int64(9007199254740993)}, + }}, + }, + formats: advertised, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := build(tc.scopeCfg, tc.formats) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + + if preset := reply.Presets["PID + Pseudonym"]; preset != nil { + assert.Failf(t, "the preset was published", + "it asks for %d credential(s) instead of the 2 configured, under a label promising both", + len(preset.Credentials)) + } + }) + } + + t.Run("and it IS published when the circuit credential is usable", func(t *testing.T) { + cfg := build(&model.VerificationPresetScope{Format: openid4vp.FormatZkpReq, Req: nymReq}, advertised) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + preset := reply.Presets["PID + Pseudonym"] + require.NotNil(t, preset) + assert.Len(t, preset.Credentials, 2, "both credentials, as configured") + }) +} diff --git a/internal/verifier/apiv1/circuit_zk_refusal.go b/internal/verifier/apiv1/circuit_zk_refusal.go new file mode 100644 index 000000000..e747a6804 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_refusal.go @@ -0,0 +1,110 @@ +package apiv1 + +import ( + "errors" + "fmt" + "net/http" + + "github.com/SUNET/vc/pkg/helpers" + "github.com/SUNET/vc/pkg/openid4vp" +) + +// Stable refusal codes for the zkp_req presentation path. +// +// A refused presentation is the WALLET'S business. It arrived as a 500 +// "internal_server_error" with nothing but a req_id, which tells a wallet +// author that this verifier broke - so the first live Pixel round spent +// its time looking at the wallet for a refusal the verifier had decided +// and could name. +// +// These are codes, not messages: content-free, stable across releases, +// and safe to branch on. The human sentence stays in the detail, where it +// can be reworded without breaking anybody. The naming follows the shared +// matcher vectors, whose reason codes this path already passes through +// unchanged for shape refusals - one vocabulary, not two. +// +// What is NOT here is as deliberate. A verifier that is misconfigured or +// built without the zknative tag has not refused a presentation: it +// cannot judge one. Those stay 5xx and unclassified, because telling a +// wallet "your presentation is bad" when the fault is ours sends its +// author hunting for a defect that is not there. +const ( + // The presentation, as it arrived. + CodeZkpReqNoPresentation = "zkp_req_no_presentation" + CodeZkpReqProofTimeMissing = "zkp_req_proof_time_missing" + CodeZkpReqProofTimeOutOfRange = "zkp_req_proof_time_out_of_range" + CodeZkpReqWrongChannel = "zkp_req_wrong_channel" + + // The issuer key set it carries. + CodeZkpReqIssuerKeySetEmpty = "zkp_req_issuer_key_set_empty" + CodeZkpReqIssuerKeySetSize = "zkp_req_issuer_key_set_size" + CodeZkpReqIssuerKeyNotACert = "zkp_req_issuer_key_not_a_certificate" + CodeZkpReqIssuerNotTrusted = "zkp_req_issuer_not_trusted" + CodeZkpReqIssuerKeyWrongCurve = "zkp_req_issuer_key_wrong_curve" + CodeZkpReqIssuerKeyNotECDSA = "zkp_req_issuer_key_not_ecdsa" + + // The proof itself. + CodeZkpReqProofDidNotVerify = "zkp_req_proof_did_not_verify" + CodeZkpReqNoPseudonym = "zkp_req_no_pseudonym_for_handle" + CodeZkpReqNothingChecked = "zkp_req_nothing_checked" +) + +// zkpReqRefusal is a presentation this verifier refuses, with the code it +// refuses it under. +// +// It carries the underlying error so a log line and a test can still see +// the whole story, and renders as a *helpers.Error so the HTTP layer +// returns the code with a 4xx instead of redacting it behind a req_id. +type zkpReqRefusal struct { + Code string + Detail string + Err error +} + +func (r *zkpReqRefusal) Error() string { + if r.Err != nil { + return fmt.Sprintf("%s: %s: %v", r.Code, r.Detail, r.Err) + } + return fmt.Sprintf("%s: %s", r.Code, r.Detail) +} + +func (r *zkpReqRefusal) Unwrap() error { return r.Err } + +// HTTPError renders the refusal for the client. +// +// 400 and never 500: the presentation is the thing being judged, and a +// judgement is not a server fault. The detail is the sentence this +// verifier already wrote; it names no holder data, because nothing in +// this path has any - a zkp_req presentation discloses no attribute. +func (r *zkpReqRefusal) HTTPError() *helpers.Error { + return helpers.NewErrorDetailsWithStatus(r.Code, r.Detail, http.StatusBadRequest) +} + +// refuseZkpReq builds a refusal. +func refuseZkpReq(code, detail string, err error) *zkpReqRefusal { + return &zkpReqRefusal{Code: code, Detail: detail, Err: err} +} + +// zkpReqRefusalFrom recovers the refusal from an error chain, so a caller +// that wrapped one with %w can still be classified. +func zkpReqRefusalFrom(err error) (*zkpReqRefusal, bool) { + var refusal *zkpReqRefusal + if errors.As(err, &refusal) { + return refusal, true + } + return nil, false +} + +// zkpReqShapeRefusal turns the shared response validator's own reason +// code into a refusal, unchanged. +// +// The matcher vectors and every other implementation already name these; +// inventing a second spelling here would mean a wallet author comparing +// two vocabularies for the same refusal. +func zkpReqShapeRefusal(err error) (*zkpReqRefusal, bool) { + var shape *openid4vp.ZkpReqResponseError + if !errors.As(err, &shape) { + return nil, false + } + return &zkpReqRefusal{Code: shape.Reason, Detail: shape.Detail, Err: err}, true +} diff --git a/internal/verifier/apiv1/circuit_zk_refusal_test.go b/internal/verifier/apiv1/circuit_zk_refusal_test.go new file mode 100644 index 000000000..12dd561b2 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_refusal_test.go @@ -0,0 +1,217 @@ +package apiv1 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/SUNET/vc/pkg/helpers" + "github.com/SUNET/vc/pkg/logger" + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/SUNET/vc/pkg/trust" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertRefusal is what every case below checks: the error names a stable +// code, renders as a 4xx, and is CLASSIFIED - so the HTTP layer returns +// the code instead of redacting it behind a req_id. +func assertRefusal(t *testing.T, err error, wantCode string) { + t.Helper() + require.Error(t, err) + + refusal, ok := zkpReqRefusalFrom(err) + if !ok { + shape, isShape := zkpReqShapeRefusal(err) + require.Truef(t, isShape, "not a classified refusal at all, so this reaches a wallet as a 500: %v", err) + refusal = shape + } + assert.Equal(t, wantCode, refusal.Code) + + shaped := refusal.HTTPError() + assert.Equal(t, http.StatusBadRequest, shaped.HTTPStatus, "a refused presentation is not a server fault") + assert.Equal(t, wantCode, shaped.Title, "the code is what a wallet branches on") + assert.Falsef(t, helpers.NewErrorFromError(shaped).IsUnclassified(), + "the HTTP layer would redact %s behind a req_id", wantCode) +} + +func refusalClient(t *testing.T, evaluator trust.TrustEvaluator) *Client { + t.Helper() + log, err := logger.New("test", "", false) + require.NoError(t, err) + return &Client{log: log, cfg: &model.Cfg{Verifier: &model.Verifier{}}, trustEvaluator: evaluator} +} + +// The refusal the first live Pixel round hit. It reached the wallet as +// HTTP 500 internal_server_error with a bare req_id, so the round was +// spent looking at the wallet for a decision the verifier had already +// made and could name. +func TestZkpReqIssuerKeySetSizeIsClassified(t *testing.T) { + presentation, _ := testP256Chain(t) + leaf := presentation.IssuerKeySet[0] + presentation.IssuerKeySet = [][]byte{leaf, leaf} // leaf + root, as the wallet sent + presentation.SystemID = "urn:circuits:longfellow:nym_auth:v1" + + _, err := refusalClient(t, &fakeTrustEvaluator{trusted: true}). + circuitZkIssuerKey(t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + + assertRefusal(t, err, CodeZkpReqIssuerKeySetSize) + assert.Contains(t, err.Error(), "Document Signer certificate alone", + "the refusal must say what to send instead, or the prover has to guess") +} + +func TestZkpReqIssuerKeyRefusalsAreClassified(t *testing.T) { + trusted, _ := testP256Chain(t) + + for _, tc := range []struct { + name string + evaluator trust.TrustEvaluator + presentation openid4vp.ZkpPresentation + code string + }{ + {"no key set", &fakeTrustEvaluator{trusted: true}, openid4vp.ZkpPresentation{}, CodeZkpReqIssuerKeySetEmpty}, + {"not a certificate", &fakeTrustEvaluator{trusted: true}, + openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{[]byte("not DER")}}, CodeZkpReqIssuerKeyNotACert}, + {"the PDP says no", &fakeTrustEvaluator{trusted: false, reason: "unknown issuer"}, + trusted, CodeZkpReqIssuerNotTrusted}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := refusalClient(t, tc.evaluator). + circuitZkIssuerKey(t.Context(), tc.presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + assertRefusal(t, err, tc.code) + }) + } +} + +// The circuit's field arithmetic is over P-256, so a key on another +// curve or of another kind cannot be a public input to it. Refused with +// its own code rather than folded into "not trusted": the chain may be +// perfectly trustworthy and still carry a key this circuit cannot use. +func TestZkpReqIssuerKeyShapeRefusalsAreClassified(t *testing.T) { + now := time.Now() + + t.Run("a key on another curve", func(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + cert := testDSCertificate(t, key, &key.PublicKey, now.Add(-time.Hour), now.Add(time.Hour)) + + _, err = refusalClient(t, &fakeTrustEvaluator{trusted: true}).circuitZkIssuerKey( + t.Context(), openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{cert.Raw}}, + "eu.europa.ec.eudi.nym_auth.1", now) + assertRefusal(t, err, CodeZkpReqIssuerKeyWrongCurve) + }) + + t.Run("a key that is not ECDSA at all", func(t *testing.T) { + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + cert := testDSCertificate(t, signer, &rsaKey.PublicKey, now.Add(-time.Hour), now.Add(time.Hour)) + + _, err = refusalClient(t, &fakeTrustEvaluator{trusted: true}).circuitZkIssuerKey( + t.Context(), openid4vp.ZkpPresentation{IssuerKeySet: [][]byte{cert.Raw}}, + "eu.europa.ec.eudi.nym_auth.1", now) + assertRefusal(t, err, CodeZkpReqIssuerKeyNotECDSA) + }) +} + +func TestZkpReqProofTimeRefusalsAreClassified(t *testing.T) { + client := refusalClient(t, &fakeTrustEvaluator{trusted: true}) + client.cfg.Verifier.NymAuth = model.NymAuthVerifier{} + + t.Run("absent", func(t *testing.T) { + _, err := client.circuitZkProofTime(openid4vp.ZkpPresentation{}, time.Now()) + assertRefusal(t, err, CodeZkpReqProofTimeMissing) + }) + + t.Run("outside the window", func(t *testing.T) { + stale := time.Now().Add(-48 * time.Hour) + _, err := client.circuitZkProofTime(openid4vp.ZkpPresentation{ + HasProofTime: true, + ProofTime: stale, + ProofTimeText: openid4vp.RenderProofTimeOrPanic(stale), + }, time.Now()) + assertRefusal(t, err, CodeZkpReqProofTimeOutOfRange) + // Without the window in the message a wallet cannot tell a + // wrong clock from a stale proof, and both land here. + assert.Contains(t, err.Error(), "window") + }) +} + +func TestZkpReqNoPresentationIsClassified(t *testing.T) { + _, err := circuitZkRawToken(openid4vp.VPResponse{ + VPTokenRaw: map[string]json.RawMessage{}, + VPTokenEnvelope: []byte(`{}`), + }, "nym") + assertRefusal(t, err, CodeZkpReqNoPresentation) +} + +func TestZkpReqWrongChannelIsClassified(t *testing.T) { + client := refusalClient(t, &fakeTrustEvaluator{trusted: true}) + client.cfg.Verifier.NymAuth = model.NymAuthVerifier{VerifierID: "x509_san_dns:verifier.example"} + + _, err := client.circuitZkSessionFor(nil, "no-such-channel", "", "client", "nonce", "", "doc", nil) + assertRefusal(t, err, CodeZkpReqWrongChannel) +} + +// A shape refusal keeps the SHARED validator's reason code rather than +// being renamed here, so a wallet author compares one vocabulary against +// the matcher vectors instead of two. +func TestZkpReqShapeRefusalKeepsTheSharedReasonCode(t *testing.T) { + refusal, ok := zkpReqShapeRefusal(&openid4vp.ZkpReqResponseError{ + Reason: "presentations_empty", Detail: "nothing to validate", + }) + require.True(t, ok) + assert.Equal(t, "presentations_empty", refusal.Code) + assert.Equal(t, http.StatusBadRequest, refusal.HTTPError().HTTPStatus) +} + +// A verifier that cannot judge a presentation has not refused one. Those +// must stay 5xx: a wallet author told "your presentation is bad" would go +// hunting for a defect that is not theirs. +func TestVerifierFaultsAreNotClassifiedAsRefusals(t *testing.T) { + t.Run("no trust evaluator", func(t *testing.T) { + presentation, _ := testP256Chain(t) + _, err := refusalClient(t, nil). + circuitZkIssuerKey(t.Context(), presentation, "eu.europa.ec.eudi.nym_auth.1", time.Now()) + require.Error(t, err) + _, isRefusal := zkpReqRefusalFrom(err) + assert.False(t, isRefusal, "an absent trust evaluator is this verifier's fault, not the wallet's") + }) +} + +// Every declared code must be exercised above. A code added without a +// test is a refusal nobody has seen rendered, which is how the 500 got +// there in the first place. +func TestEveryDeclaredRefusalCodeIsTested(t *testing.T) { + source, err := os.ReadFile("circuit_zk_refusal.go") + require.NoError(t, err) + // Every refusal test file, because three of the codes are on the + // cgo path and can only be exercised behind the zknative tag. + var tests strings.Builder + for _, name := range []string{"circuit_zk_refusal_test.go", "circuit_zk_refusal_zknative_test.go"} { + body, err := os.ReadFile(name) + require.NoErrorf(t, err, "%s is missing; this guard is reading the wrong files", name) + tests.Write(body) + } + + declared := regexp.MustCompile(`(CodeZkpReq[A-Za-z]+)\s+=`).FindAllStringSubmatch(string(source), -1) + require.NotEmpty(t, declared) + + for _, match := range declared { + name := match[1] + t.Run(name, func(t *testing.T) { + assert.Truef(t, strings.Contains(tests.String(), name), + "%s is declared but never asserted, so nobody has seen it rendered", name) + }) + } +} diff --git a/internal/verifier/apiv1/circuit_zk_refusal_zknative_test.go b/internal/verifier/apiv1/circuit_zk_refusal_zknative_test.go new file mode 100644 index 000000000..6032b2451 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_refusal_zknative_test.go @@ -0,0 +1,72 @@ +//go:build zknative + +package apiv1 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The three refusals on the cgo path. They exist only in a build that can +// actually check a proof, which is why they live behind the tag - and why +// no default-build test could reach the code that returns them. +func TestZkpReqProofRefusalsAreClassified(t *testing.T) { + client := refusalClient(t, &fakeTrustEvaluator{trusted: true}) + + // A real key: a nil one trips an earlier internal-invariant guard, + // which is deliberately NOT a refusal - it is this verifier losing + // track of its own state, not a wallet sending something wrong. + issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // A handle the request asks for and the presentation does not carry. + // Unreachable after response validation, which requires exactly the + // request's handles; checked because the alternative is verifying + // against a zero pseudonym. + t.Run("no pseudonym for a requested handle", func(t *testing.T) { + _, err := client.verifyCircuitZkPresentation( + t.Context(), + openid4vp.ZkpPresentation{Pseudonyms: map[string][]byte{}}, + circuitZkSession{}, + []openid4vp.ReqItem{{FnID: openid4vp.FunctionPseudonym, Return: "nym"}}, + &issuerKey.PublicKey, + ) + assertRefusal(t, err, CodeZkpReqNoPseudonym) + }) + + // Fail closed. The proof is one proof over the whole request and the + // only way this build has to check it is a pseudonym item's Verify, + // so with no pseudonym item there is nothing verifying anything. + // This is the hole that let a predicate-only presentation through + // once already. + t.Run("a predicate-only request verifies nothing", func(t *testing.T) { + _, err := client.verifyCircuitZkPresentation( + t.Context(), + openid4vp.ZkpPresentation{Pseudonyms: map[string][]byte{}}, + circuitZkSession{}, + []openid4vp.ReqItem{{FnID: openid4vp.FunctionAgeCheck, Return: "age"}}, + &issuerKey.PublicKey, + ) + assertRefusal(t, err, CodeZkpReqNothingChecked) + }) +} + +// CodeZkpReqProofDidNotVerify is returned when the native verifier +// rejects the proof for a handle. Asserted through the refusal +// constructor rather than by producing an invalid proof against a real +// circuit: loading one costs a circuit artifact this test has no +// business depending on, and what is under test here is the CLASSIFYING, +// not libzk's verdict. The verdict itself is covered by the archive +// proof tests in pkg/nymauth/verifier. +func TestZkpReqProofDidNotVerifyIsClassified(t *testing.T) { + err := refuseZkpReq(CodeZkpReqProofDidNotVerify, `the proof did not verify for handle "nym"`, nil) + require.Error(t, err) + assertRefusal(t, err, CodeZkpReqProofDidNotVerify) + assert.Contains(t, err.Error(), "nym", "a prover needs to know which handle failed") +} diff --git a/internal/verifier/apiv1/circuit_zk_scope.go b/internal/verifier/apiv1/circuit_zk_scope.go new file mode 100644 index 000000000..c9b75ea8f --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_scope.go @@ -0,0 +1,185 @@ +package apiv1 + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/SUNET/vc/internal/verifier/cache" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/SUNET/vc/pkg/sdjwtvc" +) + +// verifyCircuitZkScope verifies one scope answered in the circuit_zk +// format, and returns it as a credential for the scope cache. +// +// It is a separate path from the format switch below rather than a +// case in it, for a concrete reason: every other format's +// presentation is a STRING, and the shared extraction takes +// vpTokens[0] accordingly. A circuit_zk presentation is an object, and +// its validation needs the raw bytes - a repeated member or handle +// cannot be seen after decoding. Routing it through the string path +// would mean re-encoding the object, and re-encoded bytes are exactly +// the ones a duplicate check can no longer see. +// +// The REQUEST decides that this path is taken, never the response: +// the DCQL credential query says the format is circuit_zk. A wallet +// does not get to choose which format answers a scope. +// zkpReqError renders what verifyCircuitZkScope returns for the HTTP +// layer, and logs it at the level it deserves. +// +// A REFUSAL is the wallet's business: a 4xx carrying a stable code, at +// WARN. Anything else is this verifier failing to do its job - a missing +// build tag, an absent trust evaluator, a circuit that will not load - +// and stays an unclassified 5xx at ERROR, because a wallet author told +// "your presentation is bad" would go looking for a defect that is not +// theirs. +// +// A shape refusal keeps the shared validator's own reason code rather +// than being renamed here, so the vocabulary a wallet sees is the one +// the matcher vectors publish. +func (c *Client) zkpReqError(scope string, what string, err error, fields ...any) error { + refusal, isRefusal := zkpReqRefusalFrom(err) + if !isRefusal { + if shape, isShape := zkpReqShapeRefusal(err); isShape { + refusal, isRefusal = shape, true + } + } + if !isRefusal { + c.log.Error(err, what, append([]any{"scope", scope}, fields...)...) + return fmt.Errorf("scope %s: %w", scope, err) + } + c.log.Warn(what, append([]any{"scope", scope, "code", refusal.Code, "detail", refusal.Detail}, fields...)...) + return refusal.HTTPError() +} + +func (c *Client) verifyCircuitZkScope( + ctx context.Context, + authCtx *cache.AuthorizationContext, + vpResponse openid4vp.VPResponse, + scope string, + query openid4vp.CredentialQuery, + dcAPI bool, + responseURI string, +) ([]sdjwtvc.CredentialCache, error) { + if c.nymAuthCircuits == nil { + // Advertised as a circuit_zk verifier by its own request and + // unable to check one. Refused rather than passed, because + // everything upstream is real and returning no error would + // hand it back as a verified presentation. + return nil, fmt.Errorf("scope %s was requested as zkp_req but verifier.nym_auth is not enabled", scope) + } + + raw, err := circuitZkRawToken(vpResponse, query.ID) + if err != nil { + return nil, c.zkpReqError(scope, "no zkp_req presentation for scope", err, "query_id", query.ID) + } + + // Shape, and binding to what THIS request offered. Nothing here + // is a verified fact yet; a pseudonym is the holder's claim. + presentations, err := openid4vp.ValidateZkpReqResponse(raw, c.circuitZkRequestFor(query)) + if err != nil { + return nil, c.zkpReqError(scope, "zkp_req presentation refused", err, "query_id", query.ID) + } + + thumbprint, err := c.readerKeyThumbprint(ctx, authCtx, scope) + if err != nil { + return nil, err + } + origin := "" + if dcAPI { + if origin, err = c.dcAPIOrigin(); err != nil { + c.log.Error(err, "cannot determine the DC API origin for the zkp_req session transcript", "scope", scope) + return nil, err + } + } + + session, err := c.circuitZkSessionFor(authCtx, circuitZkChannelFor(dcAPI), origin, authCtx.ClientID, authCtx.Nonce, + responseURI, query.Meta.DoctypeValue, thumbprint) + if err != nil { + return nil, c.zkpReqError(scope, "cannot build the zkp_req session transcript", err, "dc_api", dcAPI) + } + + // T and where it came from, before any proof is checked. A + // time-binding mismatch is otherwise indistinguishable from a bad + // proof, and this line is what tells the two apart in a log. + c.log.Debug("zkp_req proof time", "scope", scope, + "t", session.Now.UTC().Format("2006-01-02T15:04:05Z"), "t_source", session.NowSource) + + credentials := make([]sdjwtvc.CredentialCache, 0, len(presentations)) + for _, presentation := range presentations { + // Q comes from the presentation's own issuer chain, and the + // chain is evaluated by the trust layer before the key is + // looked at. The circuit proves the credential was signed by + // Q; the PDP vouches that Q belongs to a trusted issuer. + // See circuitZkIssuerKey for why neither half is enough. + // + // Per presentation, not once per response: a response may + // carry presentations from different issuers, and verifying + // the second against the first's key would be verifying it + // against a key its issuer never held. + // + // session.DocType is the REQUEST's docType - the response + // does not get to choose the trust policy it is judged under. + // T first: it is the prover's, and a presentation whose T is + // outside this verifier's window is refused before any work + // is done on its behalf. + proofTime, err := c.circuitZkProofTime(presentation, time.Now()) + if err != nil { + return nil, c.zkpReqError(scope, "zkp_req proof_time refused", err, + "system_id", presentation.SystemID, "proof_time", presentation.ProofTimeText) + } + presentationSession := session + presentationSession.Now = proofTime + presentationSession.NowSource = proofTimeSource + + issuerKey, err := c.circuitZkIssuerKey(ctx, presentation, presentationSession.DocType, time.Now()) + if err != nil { + return nil, c.zkpReqError(scope, "no trusted issuer key for the zkp_req presentation", err, + "doc_type", session.DocType, "system_id", presentation.SystemID) + } + + verified, err := c.verifyCircuitZkPresentation(ctx, presentation, presentationSession, query.Req, issuerKey) + if err != nil { + return nil, c.zkpReqError(scope, "zkp_req proof did not verify", err, + "system_id", presentation.SystemID, "issuer_key", issuerKeyThumbprint(issuerKey), + "t", presentationSession.Now.UTC().Format("2006-01-02T15:04:05Z"), "t_source", presentationSession.NowSource) + } + c.log.Info("zkp_req proof verified", "scope", scope, + "circuit_id", presentation.SystemID, "issuer_key", issuerKeyThumbprint(issuerKey)) + + credentials = append(credentials, circuitZkCredential(scope, presentation, verified)) + } + + c.log.Info("zkp_req presentation verified", "scope", scope, + "presentations", len(presentations), "doc_type", session.DocType) + return credentials, nil +} + +// circuitZkCredential renders a verified presentation as claims. +// +// Only the pseudonyms the native verifier RETURNED, keyed by the +// handle the request chose, plus the handles whose predicate the +// proof asserts. The claimed values from the response are not here: +// they were the input to the check and mean nothing on their own. +// +// base64url because a pseudonym is 32 opaque bytes and every other +// binary value we publish is spelled that way. +func circuitZkCredential(scope string, presentation openid4vp.ZkpPresentation, verified map[string][]byte) sdjwtvc.CredentialCache { + claims := map[string]any{ + "circuit_id": presentation.SystemID, + "proof_system": presentation.SystemID, + } + for handle, pseudonym := range verified { + claims[handle] = base64.RawURLEncoding.EncodeToString(pseudonym) + } + for _, handle := range presentation.Satisfied { + claims[handle] = true + } + return sdjwtvc.CredentialCache{ + Scope: scope, + Credential: claims, + Claims: mapToDisclosers(claims), + } +} diff --git a/internal/verifier/apiv1/circuit_zk_verify.go b/internal/verifier/apiv1/circuit_zk_verify.go new file mode 100644 index 000000000..14099bf32 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_verify.go @@ -0,0 +1,306 @@ +package apiv1 + +import ( + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/SUNET/vc/internal/verifier/cache" + "github.com/SUNET/vc/pkg/nymauth/verifier" + "github.com/SUNET/vc/pkg/openid4vp" +) + +// ErrCircuitZkNotBuilt is what every build without the "zknative" tag +// returns for a circuit_zk presentation. +// +// A refusal and not a pass. Everything upstream - the request's own +// checks, the response's shape, its binding to the offer - is real and +// has already run by the time this is reached, so returning nil would +// hand all of that back as a verified proof that nothing looked at. +var ErrCircuitZkNotBuilt = errors.New("zkp_req: this verifier was built without the zknative tag, so no nym_auth proof can be checked") + +// circuitZkSession is everything the verification needs that comes +// from THIS VERIFIER's own session, resolved before any part of the +// response is read. +// +// Gathered into one value so that the native half receives no raw +// response bytes at all beyond the proof and the claimed pseudonym: +// every public input it passes to the circuit is one of these. +type circuitZkSession struct { + // Transcript is the SessionTranscript the proof is bound to. + Transcript []byte + // DocType is the credential type the challenge binds to. + DocType string + // VerifierInput is SHA-256 of the configured BARE verifier_id. + VerifierInput [32]byte + + // Now is T, the timestamp bound into the proof's public inputs. + // + // NOT a free-running clock reading, despite the name. The circuit + // binds all 20 bytes of YYYY-MM-DDThh:mm:ssZ bit by bit + // (hash_public_base in zk-cred-longfellow's src/nymauth/mod.rs), + // so the prover and this verifier must use the SAME second or the + // public inputs differ and the proof cannot verify. + Now time.Time + + // NowSource records where Now came from, for the log line. + NowSource string +} + +// proofTimeSource names T's origin in a log line. There is one: the +// prover supplies it in the presentation. +const proofTimeSource = "presentation proof_time" + +// circuitZkProofTime takes T from the presentation and checks it +// against this verifier's clock. +// +// T is the PROVER'S, per rulebook v0.4. That is not a weakening: the +// circuit binds all 20 bytes of T into its public inputs, so prover +// and verifier must use the same second, and only the prover knows +// which second it proved at. What makes a prover-supplied value safe +// is the window - without one a holder could replay a proof made long +// ago, against a credential since revoked, or one made with a clock +// set forward. +// +// REFUSED rather than clamped. Clamping would verify against a T the +// prover did not use, which cannot succeed and would then report the +// wrong reason for the failure. +func (c *Client) circuitZkProofTime(presentation openid4vp.ZkpPresentation, now time.Time) (time.Time, error) { + if !presentation.HasProofTime { + // Unreachable for mso_mdoc, which the response validator + // makes it required for. Kept because a proof checked against + // a guessed T is not checked. + return time.Time{}, refuseZkpReq(CodeZkpReqProofTimeMissing, + "the presentation carries no proof_time, so there is no T to verify against", nil) + } + skew, maxAge := c.circuitZkTolerance() + if err := openid4vp.CheckProofTimeTolerance(presentation.ProofTime, now, skew, maxAge); err != nil { + // The deviation and the limit are in the detail on purpose: + // without them a wallet cannot tell a clock that is wrong from + // a proof that is stale, and both arrive here. + return time.Time{}, refuseZkpReq(CodeZkpReqProofTimeOutOfRange, + fmt.Sprintf("proof_time %s is outside this verifier's window (skew %s into the future, %s of age)", + presentation.ProofTimeText, skew, maxAge), err) + } + return presentation.ProofTime, nil +} + +// circuitZkTolerance is the configured window, or the shared +// recommendation when a deployment has not chosen. +// +// The recommendation rather than something invented here: a verifier +// that has not thought about it should behave the way every other +// implementation assumes it does. +func (c *Client) circuitZkTolerance() (skew, maxAge time.Duration) { + skew, maxAge = openid4vp.RecommendedSkew, openid4vp.RecommendedMaxAge + if c.cfg == nil || c.cfg.Verifier == nil { + return skew, maxAge + } + if configured := c.cfg.Verifier.NymAuth.ProofTimeSkewSeconds; configured > 0 { + skew = time.Duration(configured) * time.Second + } + if configured := c.cfg.Verifier.NymAuth.ProofTimeMaxAgeSeconds; configured > 0 { + maxAge = time.Duration(configured) * time.Second + } + return skew, maxAge +} + +// circuitZkRequestFor builds the response-side request from the +// verifier's own DCQL query and its own offered formats. +// +// The offered circuit ids and proof systems come from +// verifier.preferred_vp_formats.circuit_zk - the verifier's +// configuration - and not from anything in the response. A request +// that offered neither list offered nothing, and +// ValidateCircuitZkResponse then refuses every response, which is the +// intended reading: a circuit this verifier never named is not one it +// can decide it trusts after seeing a proof from it. +func (c *Client) circuitZkRequestFor(query openid4vp.CredentialQuery) openid4vp.ZkpReqRequest { + request := openid4vp.ZkpReqRequest{ + QueryID: query.ID, + Items: query.Req, + Multiple: query.AllowsMultiple(), + // From the REQUEST's own meta: proof_time is required for + // mso_mdoc, because every approved circuit there checks the + // MSO validity period. + SourceFormat: query.Meta.SourceFormat, + } + if formats := c.cfg.Verifier.PreferredVPFormats; formats != nil && formats.ZkpReq != nil { + request.OfferedSystemIDs = formats.ZkpReq.SystemIDValues + } + return request +} + +// circuitZkRawToken returns the whole vp_token ENVELOPE, as it +// arrived, having checked that it carries an entry for this query. +// +// The envelope and not the entry, because the validator checks the +// envelope too - its shape, and a repeated query id - and handing it +// one entry would drop those checks and give it an array where it +// expects a map. +// +// Raw, because the validation has to run on the text: a repeated +// member or a repeated result handle cannot be seen after decoding, +// since Go keeps the last of a repeated key and discards the other +// without a word. +func circuitZkRawToken(vpResponse openid4vp.VPResponse, queryID string) (json.RawMessage, error) { + if entry, found := vpResponse.VPTokenRaw[queryID]; !found || len(entry) == 0 { + return nil, refuseZkpReq(CodeZkpReqNoPresentation, + fmt.Sprintf("the response carries no presentation for credential query %q", queryID), nil) + } + if len(vpResponse.VPTokenEnvelope) == 0 { + return nil, refuseZkpReq(CodeZkpReqNoPresentation, "the response carries no vp_token to validate", nil) + } + return vpResponse.VPTokenEnvelope, nil +} + +// circuitZkChannel names how a presentation reached this verifier. +// +// The channel decides the SessionTranscript, and the transcript is +// what the proof is bound to - so this is not a detail. A wallet only +// ever built one of them, and verifying against the other produces an +// opaque proof failure rather than a legible wiring error (the same +// trap as SUNET/vc#652 and #655 on the mdoc ZK path). +type circuitZkChannel string + +const ( + // channelDCAPI is a response returned inside a + // navigator.credentials.get call. + channelDCAPI circuitZkChannel = "dc_api" + // channelOpenID4VP is a response POSTed to response_uri by a + // wallet reached through request_uri - a scanned QR, typically. + channelOpenID4VP circuitZkChannel = "openid4vp" +) + +// circuitZkTranscriptBuilder builds the SessionTranscript for one +// channel. +type circuitZkTranscriptBuilder func(origin, clientID, nonce, responseURI string, readerThumbprint []byte) ([]byte, error) + +// circuitZkTranscripts is the per-channel transcript builder, and a +// deliberately extensible one. +// +// Only the DC API handover is here TODAY, because the nym_auth +// circuits define only that one: SPEC section 3, and the shared +// challenge vectors cover it alone. Link-delivered (QR) presentations +// are wanted and are coming - the OpenID4VPHandover variant is pending +// a SPEC patch and shared vectors for it, which is circuit-authoring +// work rather than something this verifier can settle by building a +// transcript of its own and hoping a prover agrees. +// +// When those vectors exist, adding the channel is adding an entry +// here. Nothing else in this file knows which channels exist. +var circuitZkTranscripts = map[circuitZkChannel]circuitZkTranscriptBuilder{ + channelDCAPI: func(origin, _, nonce, _ string, readerThumbprint []byte) ([]byte, error) { + return verifier.MdocSessionTranscript(origin, nonce, readerThumbprint) + }, +} + +// circuitZkChannelFor names the channel a response arrived through. +func circuitZkChannelFor(dcAPI bool) circuitZkChannel { + if dcAPI { + return channelDCAPI + } + return channelOpenID4VP +} + +// circuitZkSessionFor resolves the verifier's own inputs. +// +// A channel with no transcript builder is refused, which is the +// fail-closed half of the table above: verifying it would mean +// constructing a transcript nothing signed, and reporting the +// inevitable mismatch as a bad proof rather than as the wrong channel. +func (c *Client) circuitZkSessionFor(authCtx *cache.AuthorizationContext, channel circuitZkChannel, origin, clientID, nonce, responseURI, docType string, readerThumbprint []byte) (circuitZkSession, error) { + build, supported := circuitZkTranscripts[channel] + if !supported { + return circuitZkSession{}, refuseZkpReq(CodeZkpReqWrongChannel, + fmt.Sprintf("this presentation arrived over the %q channel, and the nym_auth circuits define a session "+ + "transcript only for %q; the handover for this channel is pending shared vectors", channel, channelDCAPI), nil) + } + + transcript, err := build(origin, clientID, nonce, responseURI, readerThumbprint) + if err != nil { + return circuitZkSession{}, fmt.Errorf("zkp_req: %w", err) + } + + verifierInput, err := verifier.VerifierInput(c.cfg.Verifier.NymAuth.VerifierID) + if err != nil { + return circuitZkSession{}, fmt.Errorf("zkp_req: verifier.nym_auth.verifier_id: %w", err) + } + + // Now is deliberately NOT set here. T is the prover's, carried in + // each presentation, so it belongs to the presentation and not to + // the session - a response may legally carry several, and one + // session-wide T would be the first presentation's imposed on the + // rest. + return circuitZkSession{ + Transcript: transcript, + DocType: docType, + VerifierInput: verifierInput, + }, nil +} + +// circuitZkVerifierRequest assembles the request a circuit is checked +// against, from the session and the verifier's own policy. +// +// issuerKey comes from the resolved and trusted issuer chain. Every +// other field is the verifier's. Request.Check refuses anything taken +// from the response, which is most of why it exists. +func circuitZkVerifierRequest(session circuitZkSession, issuerKey *ecdsa.PublicKey, items []openid4vp.ReqItem) (verifier.Request, error) { + if issuerKey == nil { + return verifier.Request{}, errors.New("zkp_req: no issuer key resolved for this presentation") + } + + inputs := make([]verifier.CircuitInput, 0, len(items)) + for _, item := range items { + if item.FnID != verifier.FunctionAgeCheck { + // Only the predicates this build can map. A pseudonym + // function contributes no circuit input of its own - it + // names the configuration, which selects the build. + continue + } + bounds, err := circuitZkAgeBounds(item) + if err != nil { + return verifier.Request{}, err + } + inputs = append(inputs, bounds.CircuitInput()) + } + + return verifier.Request{ + IssuerKey: issuerKey, + Now: session.Now, + Inputs: inputs, + SupportedFunctions: []string{verifier.FunctionAgeCheck}, + SessionTranscript: session.Transcript, + DocType: session.DocType, + VerifierInput: session.VerifierInput, + }, nil +} + +// circuitZkAgeBounds maps an age_check req item's parameters to the +// circuits' inclusive bounds. +// +// From the REQUEST's own parameters, which this verifier sent. Taking +// them from the response would be verifying a statement the holder +// chose. +func circuitZkAgeBounds(item openid4vp.ReqItem) (verifier.AgeBounds, error) { + check := verifier.AgeCheck{} + for name, target := range map[string]*string{ + "ge": &check.GE, + "gt": &check.GT, + "le": &check.LE, + "lt": &check.LT, + } { + value, present := item.Params[name] + if !present { + continue + } + text, ok := value.(string) + if !ok { + return verifier.AgeBounds{}, fmt.Errorf("zkp_req: the %q parameter of %q is not a string", name, item.Return) + } + *target = text + } + return check.Bounds() +} diff --git a/internal/verifier/apiv1/circuit_zk_verify_test.go b/internal/verifier/apiv1/circuit_zk_verify_test.go new file mode 100644 index 000000000..922bfa4a7 --- /dev/null +++ b/internal/verifier/apiv1/circuit_zk_verify_test.go @@ -0,0 +1,422 @@ +package apiv1 + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/fxamacker/cbor/v2" + + "github.com/SUNET/vc/internal/verifier/cache" + "github.com/SUNET/vc/internal/verifier/notify" + "github.com/SUNET/vc/pkg/logger" + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth/verifier" + "github.com/SUNET/vc/pkg/openid4vp" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The offered circuit ids and proof systems come from THIS +// VERIFIER's configuration, never from the response. +// +// A request that offered neither list offered nothing, and the +// response validator then refuses every response - which is the +// intended reading rather than an oversight: a circuit this verifier +// never named is not one it can decide it trusts after seeing a proof +// from it. +func TestCircuitZkRequestTakesTheOfferFromConfiguration(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{ + PreferredVPFormats: &openid4vp.VPFormatsSupported{ZkpReq: &openid4vp.ZkpReqFormat{ + SystemIDValues: []string{"urn:circuits:longfellow:nym_auth:v1"}, + ProofSystemValues: []string{"longfellow-libzk-v1"}, + }}, + }}} + + multiple := true + query := openid4vp.CredentialQuery{ + ID: "nym_attestation", + Format: openid4vp.FormatZkpReq, + Multiple: &multiple, + Req: []openid4vp.ReqItem{{FnID: "age_check", Return: "age-1"}}, + } + + request := client.circuitZkRequestFor(query) + assert.Equal(t, "nym_attestation", request.QueryID) + assert.True(t, request.Multiple) + assert.Equal(t, []string{"urn:circuits:longfellow:nym_auth:v1"}, request.OfferedSystemIDs) + assert.Equal(t, query.Req, request.Items) +} + +// A verifier that offered no circuit_zk formats offers nothing, and +// the empty lists are what makes the validator refuse. +func TestCircuitZkRequestWithNoOfferIsEmpty(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + + request := client.circuitZkRequestFor(openid4vp.CredentialQuery{ID: "q"}) + assert.Empty(t, request.OfferedSystemIDs) + assert.Empty(t, request.OfferedSystemIDs) + assert.False(t, request.Multiple, "DCQL multiple defaults to false") +} + +// The channel decides the SessionTranscript, and a channel with no +// transcript builder is refused rather than given one of this +// verifier's invention. +func TestCircuitZkSessionRefusesAnUnsupportedChannel(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{ + NymAuth: model.NymAuthVerifier{VerifierID: "x509_san_dns:verifier.example"}, + }}} + + _, err := client.circuitZkSessionFor(nil, channelOpenID4VP, "https://verifier.example", + "x509_san_dns:verifier.example", "pOA0oqEAhLnqug2r7Ve4tQ", + "https://verifier.example/verification/direct_post", "eu.europa.ec.eudi.nym_auth.1", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), string(channelOpenID4VP)) + assert.Contains(t, err.Error(), "pending shared vectors") +} + +// The DC API channel builds the transcript the circuits define, and +// verifier_input comes from the CONFIGURED bare verifier_id. +func TestCircuitZkSessionBuildsTheDCAPITranscript(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{ + NymAuth: model.NymAuthVerifier{VerifierID: "x509_san_dns:verifier.example"}, + }}} + + session, err := client.circuitZkSessionFor(nil, channelDCAPI, "https://verifier.example", "", + "pOA0oqEAhLnqug2r7Ve4tQ", "", "eu.europa.ec.eudi.nym_auth.1", nil) + require.NoError(t, err) + + assert.NotEmpty(t, session.Transcript) + assert.Equal(t, "eu.europa.ec.eudi.nym_auth.1", session.DocType) + assert.True(t, session.Now.IsZero(), + "T is the prover's and belongs to the presentation, not the session") + + // The rulebook's MUST digest for the bare verifier_id. + want, err := verifier.VerifierInput("x509_san_dns:verifier.example") + require.NoError(t, err) + assert.Equal(t, want, session.VerifierInput) +} + +// A verifier_id that is a prefixed Client Identifier is refused here +// too, not only at startup: hashing it yields a pseudonym no other +// implementation reports, so a comparison never matches and the +// subject looks innocent. +func TestCircuitZkSessionUsesThePrefixedVerifierID(t *testing.T) { + // v0.4 hashes the verifier_id exactly as published, prefix + // included, and the archive's reference pseudonyms are computed + // from that form. This test asserted the opposite until the rule + // reversed on 2026-10-20. + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{ + NymAuth: model.NymAuthVerifier{VerifierID: "x509_san_dns:verifier.example"}, + }}} + + session, err := client.circuitZkSessionFor(nil, channelDCAPI, "https://verifier.example", "", + "pOA0oqEAhLnqug2r7Ve4tQ", "", "eu.europa.ec.eudi.nym_auth.1", nil) + require.NoError(t, err) + + want, err := verifier.VerifierInput("x509_san_dns:verifier.example") + require.NoError(t, err) + assert.Equal(t, want, session.VerifierInput) + assert.Equal(t, "6eae85a0", hex.EncodeToString(session.VerifierInput[:])[:8], + "the archive's own verifier_input for this client_id") + + // A marker with nothing after it still names no Verifier. + client.cfg.Verifier.NymAuth.VerifierID = "x509_san_dns:" + _, err = client.circuitZkSessionFor(nil, channelDCAPI, "https://verifier.example", "", + "pOA0oqEAhLnqug2r7Ve4tQ", "", "eu.europa.ec.eudi.nym_auth.1", nil) + assert.ErrorContains(t, err, "verifier_id") +} + +// The age bounds come from the REQUEST's own parameters. Taking them +// from the response would be verifying a statement the holder chose. +func TestCircuitZkAgeBoundsComeFromTheRequest(t *testing.T) { + bounds, err := circuitZkAgeBounds(openid4vp.ReqItem{ + FnID: "age_check", Return: "age-1", + Params: map[string]any{"ge": "2008-10-10", "le": "2013-10-09"}, + }) + require.NoError(t, err) + assert.Equal(t, "2008-10-10", bounds.Earliest) + assert.Equal(t, "2013-10-09", bounds.Cutoff) + + _, err = circuitZkAgeBounds(openid4vp.ReqItem{ + FnID: "age_check", Return: "age-1", Params: map[string]any{"ge": 2008}, + }) + assert.ErrorContains(t, err, "is not a string") + + // A window narrower than the rulebook allows is refused by the + // mapping, not passed to a circuit. + _, err = circuitZkAgeBounds(openid4vp.ReqItem{ + FnID: "age_check", Return: "age-1", + Params: map[string]any{"ge": "2008-01-01", "le": "2008-06-01"}, + }) + assert.ErrorContains(t, err, "at least 365") +} + +// The published claims carry the pseudonyms the verifier RETURNED, +// never the ones the response claimed. +func TestCircuitZkCredentialPublishesTheVerifiedPseudonyms(t *testing.T) { + claimed := make([]byte, 32) + claimed[0] = 0xaa + returned := make([]byte, 32) + returned[0] = 0xbb + + presentation := openid4vp.ZkpPresentation{ + SystemID: "urn:c", + + Pseudonyms: map[string][]byte{"nym-1": claimed}, + Satisfied: []string{"age-1"}, + } + + credential := circuitZkCredential("nym_auth", presentation, map[string][]byte{"nym-1": returned}) + + encoded, err := json.Marshal(credential.Credential) + require.NoError(t, err) + assert.Contains(t, string(encoded), base64.RawURLEncoding.EncodeToString(returned), + "the published pseudonym is the one the verifier returned") + assert.NotContains(t, string(encoded), base64.RawURLEncoding.EncodeToString(claimed), + "and never the one the response claimed") + assert.Equal(t, true, credential.Credential["age-1"]) + assert.Equal(t, "urn:c", credential.Credential["circuit_id"]) + assert.Equal(t, "nym_auth", credential.Scope) +} + +// Without the native library there is no way to check a proof, and +// the stub must refuse rather than return a verified-looking nothing. +func TestCircuitZkNativeStubRefuses(t *testing.T) { + if circuitZkNativeBuilt { + t.Skip("this build has the zknative tag; the stub is not compiled in") + } + client := &Client{} + _, err := client.verifyCircuitZkPresentation(t.Context(), openid4vp.ZkpPresentation{}, + circuitZkSession{}, nil, nil) + assert.ErrorIs(t, err, ErrCircuitZkNotBuilt) +} + +// The validator receives the whole vp_token ENVELOPE, which is what +// it expects - handing it one entry gave it an array where it wants a +// map, and the response then failed as unparseable. +// +// This runs the two halves together, through a circuit_zk-ONLY +// response, because that is the combination neither half's own tests +// exercised: the validator's vectors are whole envelopes, and the +// dispatch's tests never called the validator. +func TestCircuitZkRawTokenFeedsTheValidator(t *testing.T) { + presentation := zkpPresentationB64(t, "urn:c", + [][]byte{[]byte(strings.Repeat("c", 48))}, nowProofTime(), + map[string]any{"age-1": true}) + body, err := json.Marshal(map[string]any{ + "state": "s", + "vp_token": map[string]any{"nym_attestation": []any{presentation}}, + }) + require.NoError(t, err) + + var response openid4vp.VPResponse + require.NoError(t, json.Unmarshal(body, &response)) + + raw, err := circuitZkRawToken(response, "nym_attestation") + require.NoError(t, err) + + got, err := openid4vp.ValidateZkpReqResponse(raw, openid4vp.ZkpReqRequest{ + QueryID: "nym_attestation", + Items: []openid4vp.ReqItem{{FnID: openid4vp.FunctionAgeCheck, Return: "age-1"}}, + OfferedSystemIDs: []string{"urn:c"}, + SourceFormat: openid4vp.SourceFormatMsoMdoc, + }) + require.NoError(t, err, "the dispatch must hand the validator what the validator expects") + require.Len(t, got, 1) + assert.Equal(t, []string{"age-1"}, got[0].Satisfied) +} + +// A query the response does not answer is named as such, rather than +// reaching the validator as an envelope with a missing key. +func TestCircuitZkRawTokenRefusesAMissingQuery(t *testing.T) { + var response openid4vp.VPResponse + require.NoError(t, json.Unmarshal([]byte(`{"state":"s","vp_token":{"other":[{"a":1}]}}`), &response)) + + _, err := circuitZkRawToken(response, "nym_attestation") + assert.ErrorContains(t, err, "no presentation for credential query") +} + +// The circuit_zk path is chosen by the scope's OWN credential query, +// never by requestedQuery's sole-query fallback. +// +// That fallback exists for a legacy session whose scope-to-query +// mapping was lost, and it is right where it is used: with exactly +// one query there is no ambiguity about which one a scope meant. As a +// ROUTING decision it is wrong. A scope that no query names would +// inherit the sole query's format, take the circuit_zk path, and have +// that query's presentation verified and cached under its own name - +// while the attribution check that refuses exactly this, in +// vpTokensForScope, sits on the branch that is no longer taken. +// +// Driven through the handler and not through requestedQueryExact, +// because the bug is WHICH lookup the routing line calls: a test on +// the helpers passes with either one wired up. +func TestCircuitZkRoutingIgnoresTheSingleQueryFallback(t *testing.T) { + client, _ := CreateTestClientWithMock(t, nil) + ctx := t.Context() + + openid4vpClient, err := openid4vp.New(ctx, &openid4vp.Config{}) + require.NoError(t, err) + client.openid4vp = openid4vpClient + + notifyService, err := notify.New(ctx, client.cfg, logger.NewSimple("test")) + require.NoError(t, err) + client.notify = notifyService + + const ( + kid = "czk-routing-kid" + state = "czk-routing-state" + ) + _, ephemeralPubJWK, err := client.ephemeralEncryptionKey(ctx, kid) + require.NoError(t, err) + // One circuit_zk query, and a scope that names none - the shape + // "openid profile custom_claim" produces, where profile selects + // the template and custom_claim is deliberately left unclaimed so + // that it fails in the validation loop rather than vanishing. + require.NoError(t, client.cacheService.AuthContext.Save(ctx, &cache.AuthorizationContext{ + SessionID: "czk-routing-session", + State: state, + EphemeralEncryptionKeyID: kid, + Nonce: "czk-routing-nonce", + ClientID: "x509_san_dns:verifier.example.com", + Scopes: []string{"custom_claim"}, + DCQLQuery: &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: "nym_attestation", + Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{DoctypeValue: "eu.europa.ec.eudi.nym_auth.1"}, + Req: []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionAgeCheck, + Return: "age-1", + Params: map[string]any{"ge": "2008-10-10", "le": "2013-10-09"}, + }}, + }}}, + })) + + leaf := []byte(strings.Repeat("c", 48)) + plaintext, err := json.Marshal(map[string]any{ + "state": state, + "vp_token": map[string]any{ + "nym_attestation": []any{zkpPresentationB64(t, + "urn:circuits:longfellow:nym_auth:v1", + [][]byte{leaf}, nowProofTime(), + map[string]any{"age-1": true}, + )}, + }, + }) + require.NoError(t, err) + body, err := jwe.Encrypt(plaintext, + jwe.WithKey(jwa.ECDH_ES(), ephemeralPubJWK), + jwe.WithContentEncryption(jwa.A256GCM()), + ) + require.NoError(t, err) + + _, err = client.VerificationDirectPost(ctx, &VerificationDirectPostRequest{Response: string(body)}) + require.Error(t, err, "an unclaimed scope cannot be satisfied by another query's presentation") + assert.Contains(t, err.Error(), "names no credential query in this request", + "the unclaimed scope must be refused by the attribution check") + assert.NotContains(t, err.Error(), "circuit_zk", + "it must never have reached the circuit_zk path: that path has no attribution check") +} + +// T is the PROVER'S proof_time, checked against this verifier's +// clock rather than replaced by it. +// +// The circuit binds all 20 bytes of T into its public inputs, so only +// the prover knows which second it proved at. The window is what +// makes accepting that value safe. +func TestCircuitZkProofTimeAcceptsAValueInsideTheWindow(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + + proved := now.Add(-30 * time.Second) + got, err := client.circuitZkProofTime(openid4vp.ZkpPresentation{ + HasProofTime: true, ProofTime: proved, + }, now) + require.NoError(t, err) + assert.True(t, got.Equal(proved), "T is the prover's value, unchanged") +} + +// Outside the window is REFUSED, not clamped, and the message names +// the deviation and the limit - the operator's next question is +// always "by how much". +func TestCircuitZkProofTimeRefusesOutsideTheWindow(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + + for _, tc := range []struct { + name string + at time.Time + names string + }{ + {name: "too far ahead", at: now.Add(2 * time.Minute), names: "in the future"}, + {name: "too old", at: now.Add(-20 * time.Minute), names: "old"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := client.circuitZkProofTime(openid4vp.ZkpPresentation{ + HasProofTime: true, ProofTime: tc.at, + }, now) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.names) + }) + } + + // And no proof_time at all is a refusal: a proof checked against a + // guessed T is not checked. + _, err := client.circuitZkProofTime(openid4vp.ZkpPresentation{}, now) + require.Error(t, err) + assert.Contains(t, err.Error(), "no proof_time") +} + +// The window is configurable, and defaults to the shared +// recommendation when a deployment has not chosen. +func TestCircuitZkToleranceDefaultsToTheRecommendation(t *testing.T) { + client := &Client{cfg: &model.Cfg{Verifier: &model.Verifier{}}} + skew, maxAge := client.circuitZkTolerance() + assert.Equal(t, openid4vp.RecommendedSkew, skew) + assert.Equal(t, openid4vp.RecommendedMaxAge, maxAge) + + client.cfg.Verifier.NymAuth.ProofTimeSkewSeconds = 5 + client.cfg.Verifier.NymAuth.ProofTimeMaxAgeSeconds = 7 + skew, maxAge = client.circuitZkTolerance() + assert.Equal(t, 5*time.Second, skew) + assert.Equal(t, 7*time.Second, maxAge) +} + +// zkpPresentationB64 builds one presentation the way a prover does: +// CBOR, then unpadded base64url. +// +// Written out here rather than hand-coded as a constant so a test +// reads as the shape it means. The validator's own conformance is +// pinned by the vendored vectors in pkg/openid4vp; what these tests +// need is a presentation that gets PAST it, so the dispatch and the +// trust path can be exercised. +func zkpPresentationB64(t *testing.T, systemID string, keySet [][]byte, proofTime string, results map[string]any) string { + t.Helper() + + presentation := map[string]any{ + "system_id": systemID, + "proof": []byte(strings.Repeat("p", 48)), + "issuer_key_set": keySet, + "results": results, + } + if proofTime != "" { + parsed, err := openid4vp.ParseProofTime(proofTime) + require.NoError(t, err) + presentation["proof_time"] = cbor.Tag{Number: 0, Content: parsed.UTC().Format(openid4vp.ProofTimeLayout)} + } + encoder, err := cbor.EncOptions{Sort: cbor.SortCanonical}.EncMode() + require.NoError(t, err) + encoded, err := encoder.Marshal(presentation) + require.NoError(t, err) + return base64.RawURLEncoding.EncodeToString(encoded) +} + +// nowProofTime is a proof_time inside any sane tolerance window. +func nowProofTime() string { return openid4vp.RenderProofTimeOrPanic(time.Now()) } diff --git a/internal/verifier/apiv1/client.go b/internal/verifier/apiv1/client.go index 37798bd39..3543b2851 100644 --- a/internal/verifier/apiv1/client.go +++ b/internal/verifier/apiv1/client.go @@ -11,6 +11,7 @@ import ( "net/http" "slices" "strings" + "sync" "time" "github.com/SUNET/vc/internal/verifier/cache" @@ -22,6 +23,8 @@ import ( "github.com/SUNET/vc/pkg/logger" "github.com/SUNET/vc/pkg/metric" "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth/circuits" + "github.com/SUNET/vc/pkg/nymauth/verifier" "github.com/SUNET/vc/pkg/oauth2" "github.com/SUNET/vc/pkg/openid4vp" "github.com/SUNET/vc/pkg/pki" @@ -50,6 +53,29 @@ type Client struct { pkiSigningCert *x509.Certificate pkiSignerChain []string + // nymAuthCircuits is the operator's allowlist of nym_auth circuit + // builds. Nil unless verifier.nym_auth.enable is set, and a + // circuit_zk presentation is refused without it: nothing outside + // the allowlist is ever loaded. + nymAuthCircuits *circuits.Allowlist + + // nymAuthVerifiers caches one loaded circuit handle per build - + // proof system AND pseudonym configuration, which is what the + // allowlist is keyed by. Loading is the expensive step - about + // 0.15 s and a megabyte of artifact for Longfellow, and for Vega + // roughly 12.5 MB of verifier key derived from the shapes - and + // neither handle holds any interior mutability, so one per build is + // reused across presentations and goroutines. + // + // The interface, not either concrete verifier: which library opens + // an artifact is the allowed entry's system_id, decided when the + // handle is loaded, and nothing above that point distinguishes them. + // + // Populated only in a build with the "zknative" tag; the default + // build cannot construct one and refuses before it would try. + nymAuthVerifiers map[nymAuthVerifierKey]verifier.Backend + nymAuthMu sync.Mutex + // registrationCertificate is the Registrar-issued WRPRC presented to // wallets in verifier_info. Nil when none is configured. registrationCertificate *model.LoadedRegistrationCertificate @@ -108,6 +134,21 @@ func New(ctx context.Context, db *db.Service, notify *notify.Service, cacheServi }), } + // The nym_auth circuit allowlist, when the feature is on. + // + // Here and not lazily at the first presentation: an allowlist + // that cannot be resolved is a deployment that cannot verify, and + // discovering that from a refused proof tells an operator nothing + // about the configuration. Config load already checks the same + // thing; this is where the resolved value is kept. + if c.cfg.Verifier.NymAuth.Enable { + c.nymAuthCircuits, err = circuits.NewAllowlist(c.cfg.Verifier.NymAuth.Circuits) + if err != nil { + return nil, err + } + c.log.Info("nym_auth verification enabled", "allowed", c.nymAuthCircuits.Allowed()) + } + // Load PKI signing key and chain for request object signing and OIDC c.pkiSigner, c.pkiSigningCert, c.pkiSignerChain, err = pki.LoadSigner(c.cfg.Verifier.KeyConfig) if err != nil { @@ -861,3 +902,14 @@ func (a jwksKeyResolverAdapter) ResolveKey(ctx context.Context, issuer string, k key, _, err := a.resolver.ResolveKeyByKID(ctx, issuer, keyID) return key, err } + +// nymAuthVerifierKey identifies a loaded nym_auth circuit handle: the +// proof system a presentation states, and the pseudonym configuration +// this Verifier asked for. +// +// Here rather than in the zknative-tagged file because the Client +// struct above names it, and that struct is in every build. +type nymAuthVerifierKey struct { + systemID string + config string +} diff --git a/internal/verifier/apiv1/handler_client_registration.go b/internal/verifier/apiv1/handler_client_registration.go index 9607a4a5b..d445a3d88 100644 --- a/internal/verifier/apiv1/handler_client_registration.go +++ b/internal/verifier/apiv1/handler_client_registration.go @@ -26,19 +26,19 @@ type ClientRegistrationRequest struct { // other grant - registering a client for refresh_token produced a // registration whose grant_types the OP would always refuse, and which // its own discovery document did not advertise (SUNET/vc#756). - GrantTypes []string `json:"grant_types,omitempty" default:"[\"authorization_code\"]" validate:"omitempty,dive,oneof=authorization_code"` - ResponseTypes []string `json:"response_types,omitempty" default:"[\"code\"]" validate:"omitempty,dive,oneof=code"` - ClientName string `json:"client_name,omitempty"` - ClientURI string `json:"client_uri,omitempty" validate:"omitempty,httpsurl"` - LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,httpsurl"` - Scope string `json:"scope,omitempty" default:"openid"` - Contacts []string `json:"contacts,omitempty"` - TosURI string `json:"tos_uri,omitempty" validate:"omitempty,httpsurl"` - PolicyURI string `json:"policy_uri,omitempty" validate:"omitempty,httpsurl"` - JWKSUri string `json:"jwks_uri,omitempty" validate:"omitempty,excluded_with=JWKS"` - JWKS any `json:"jwks,omitempty"` - SoftwareID string `json:"software_id,omitempty"` - SoftwareVersion string `json:"software_version,omitempty"` + GrantTypes []string `json:"grant_types,omitempty" default:"[\"authorization_code\"]" validate:"omitempty,dive,oneof=authorization_code"` + ResponseTypes []string `json:"response_types,omitempty" default:"[\"code\"]" validate:"omitempty,dive,oneof=code"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty" validate:"omitempty,httpsurl"` + LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,httpsurl"` + Scope string `json:"scope,omitempty" default:"openid"` + Contacts []string `json:"contacts,omitempty"` + TosURI string `json:"tos_uri,omitempty" validate:"omitempty,httpsurl"` + PolicyURI string `json:"policy_uri,omitempty" validate:"omitempty,httpsurl"` + JWKSUri string `json:"jwks_uri,omitempty" validate:"omitempty,excluded_with=JWKS"` + JWKS any `json:"jwks,omitempty"` + SoftwareID string `json:"software_id,omitempty"` + SoftwareVersion string `json:"software_version,omitempty"` // OpenID Connect specific ApplicationType string `json:"application_type,omitempty" default:"web" validate:"omitempty,oneof=web native"` diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go index 47794a0d8..5d9560e4b 100644 --- a/internal/verifier/apiv1/handlers_ui.go +++ b/internal/verifier/apiv1/handlers_ui.go @@ -67,10 +67,19 @@ type UIPreset struct { // UIPresetCredential is a credential query within a preset. type UIPresetCredential struct { - ID string `json:"id"` - Format string `json:"format"` - Meta UIPresetMeta `json:"meta"` - Claims []UIPresetClaim `json:"claims,omitempty"` + ID string `json:"id"` + Format string `json:"format"` + Meta UIPresetMeta `json:"meta"` + Claims []UIPresetClaim `json:"claims,omitempty"` + // Req is the circuit_zk format's requested calculations, from the + // preset's VerificationPresetScope.Req. + // + // It has to reach the browser. The picker builds the DCQL query here + // and posts it back, so a req that stayed server-side would leave the + // UI sending a circuit_zk query that asks for nothing - and such a + // query discloses nothing either, so the wallet would have no reason + // to return anything at all. + Req []openid4vp.ReqItem `json:"req,omitempty"` Validations []openid4vp.ClaimValidation `json:"validations,omitempty"` } @@ -85,6 +94,17 @@ type UIPresetMeta struct { // DoctypeValue is set for mdoc/ZK-mdoc scopes (openid4vp.MetaQuery's // mdoc-format field) - mirrors UICredentialInfo.VCT's mdoc branch. DoctypeValue string `json:"doctype_value,omitempty"` + + // SourceFormat is the stored attestation a zkp_req proof is made + // over, and is set ONLY for a zkp_req request. + // + // Derived rather than configured: it is the scope's own + // credential_metadata format - the credential the wallet actually + // holds - and a preset asking for zkp_req is asking for a proof + // over that. Making an operator state it twice would let the two + // disagree, and the disagreement would surface as a wallet + // refusing the request rather than as a configuration error. + SourceFormat string `json:"source_format,omitempty"` // TypeValues is the W3C formats' constraint, from credential_type_values // (expanded IRIs) - not the credential_types the issuer metadata uses. TypeValues [][]string `json:"type_values,omitempty"` @@ -144,7 +164,10 @@ func validZKSystemTypes(specs []openid4vp.ZKSystemTypeSpec) bool { // override a scope to a W3C identifier while keeping vct_values. func constraintFamily(format string) string { switch format { - case openid4vp.FormatMsoMdoc, openid4vp.FormatMsoMdocZk: + // circuit_zk is matched on doctype_value too (the nym_auth rulebook's + // own examples carry one), so an mdoc scope can be requested as a + // circuit proof over the same document. + case openid4vp.FormatMsoMdoc, openid4vp.FormatMsoMdocZk, openid4vp.FormatZkpReq: return "doctype" // Not jwt_vc_json: DCQLMetaQuery refuses it, so calling it family- // compatible here would approve an override the query builder then drops. @@ -283,6 +306,14 @@ func (c *Client) UIMetadata(ctx context.Context) (*UIMetadataReply, error) { Featured: model.BoolVal(def.Featured, false), Credentials: make([]UIPresetCredential, 0, len(preset)), } + // Set when a credential cannot be requested in a way that + // would change what the preset ASKS FOR rather than merely + // which of several credentials it collects. A preset is a + // named request: publishing "PID + Pseudonym" as PID-only + // gives an operator a button that silently does something + // else, which is worse than no button. + abandonPreset := false + scopeKeys := make([]string, 0, len(preset)) for scope := range preset { scopeKeys = append(scopeKeys, scope) @@ -341,8 +372,67 @@ func (c *Client) UIMetadata(ctx context.Context) (*UIMetadataReply, error) { "preset", label, "scope", scope) continue } + // Same shape of defect for circuit_zk, from the other + // direction: the query discloses nothing, so every value + // it yields is named in req. With req empty the preset + // publishes a button that asks a wallet for nothing and + // gets nothing back - which looks like a wallet fault + // and is not one. + if scopeCfg.Format == openid4vp.FormatZkpReq && len(scopeCfg.Req) == 0 { + c.log.Error(nil, "preset omitted from the verifier UI: a circuit_zk credential has no req", + "preset", label, "scope", scope) + abandonPreset = true + break + } + // req crosses the browser, where every number is a + // float64. A param that cannot survive that is a proof + // input the wallet would compute over a DIFFERENT value + // than the one configured - and the only symptom is two + // systems disagreeing about who someone is, with nothing + // malformed to point at. Refused here, where the + // operator can still fix it. + // A circuit_zk query is answered with a PROOF, and + // which circuits a verifier will accept one from lives + // in client_metadata.vp_formats_supported.circuit_zk - + // nowhere else. vpFormatsOrDefault's default names + // only dc+sd-jwt and mso_mdoc, so a deployment that + // never configured preferred_vp_formats would publish + // this preset and then send the query alongside + // metadata declaring no support for its format at all. + // + // No default is invented here. The circuit filter + // names proof systems and circuit identifiers that are + // deployment-specific, and guessing one would pin this + // verifier to whichever implementation happened to be + // in front of me. An empty circuit_zk object is a + // perfectly good answer - the shared vectors read a + // missing filter as "any circuit" - but it has to be + // the operator's answer. + if scopeCfg.Format == openid4vp.FormatZkpReq && + vpFormatsOrDefault(c.cfg.Verifier.PreferredVPFormats).ZkpReq == nil { + c.log.Error(nil, "preset omitted from the verifier UI: circuit_zk is not advertised in verifier.preferred_vp_formats, so a wallet would be asked for a proof the request declares no support for", + "preset", label, "scope", scope) + abandonPreset = true + break + } + if err := reqSurvivesTheBrowser(scopeCfg.Req); err != nil { + c.log.Error(err, "preset omitted from the verifier UI: a req param cannot survive the browser's JSON round trip", + "preset", label, "scope", scope) + abandonPreset = true + break + } uiCred.Format = scopeCfg.Format uiCred.Meta.ZKSystemType = scopeCfg.ZKSystemType + uiCred.Req = scopeCfg.Req + if scopeCfg.Format == openid4vp.FormatZkpReq { + // meta.source_format is REQUIRED for zkp_req: + // the matcher's planner refuses a meta without + // one (invalid_meta), so a request built + // without it is one no wallet will answer. + // It is the scope's stored format, which is + // what uiCred.Format held before the override. + uiCred.Meta.SourceFormat = meta.Format + } } // scopeCfg may be nil (scope with no overrides) @@ -358,8 +448,19 @@ func (c *Client) UIMetadata(ctx context.Context) (*UIMetadataReply, error) { uiCred.Validations = scopeCfg.Validations } + // A circuit_zk query carries no claims at all - it discloses + // nothing, and req says what it computes. Falling through to + // the VCTM list below would attach every claim in the + // document to a query whose whole point is to reveal none of + // them, and an operator's explicit claims on such a scope + // are a configuration mistake worth not silently honouring. + if uiCred.Format == openid4vp.FormatZkpReq { + claims = nil + excludeSet = nil + } + // Resolve claims: use explicit claims, or fall back to VCTM claims - if len(claims) == 0 && meta != nil { + if len(claims) == 0 && meta != nil && uiCred.Format != openid4vp.FormatZkpReq { if vctm := meta.GetVCTM(); vctm != nil { // First pass: collect all valid paths (including array-element paths with null) var allPaths [][]*string @@ -400,6 +501,19 @@ func (c *Client) UIMetadata(ctx context.Context) (*UIMetadataReply, error) { } uiPreset.Credentials = append(uiPreset.Credentials, uiCred) } + // A preset whose circuit_zk credential could not be requested + // is not published at all, even when its other credentials + // could be. The pseudonym IS what such a preset asks for, so + // publishing the rest under the same label is a different + // request wearing the configured one's name - and the + // configuration reference says the preset is omitted. + // + // Deliberately unlike the format-family and zk_system_type + // drops above, which remove one credential of several that + // each stand on their own. + if abandonPreset { + continue + } // An all-dropped preset would ask the wallet for nothing. if len(uiPreset.Credentials) == 0 { c.log.Error(nil, "preset omitted from the verifier UI: none of its credentials can be requested", "preset", label) @@ -503,6 +617,31 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( return nil, errors.New("dcql_query must request at least one credential") } + // meta.source_format for every zkp_req query, server-side. + // + // The preset listing already carries it, but the DCQL arrives + // from the browser and a client that rebuilds the query from the + // fields it knows would drop a member it has never heard of. The + // request would then go to the wallet without source_format, the + // matcher's planner would refuse it as invalid_meta, and the + // failure would look like a wallet problem. + // + // Filled from the scope's own credential_metadata - the + // credential the wallet holds - and only when absent, so a client + // that does send one is not overruled. + for i := range req.DCQLQuery.Credentials { + credential := &req.DCQLQuery.Credentials[i] + if !openid4vp.IsZkpReqFormat(credential.Format) || credential.Meta.SourceFormat != "" { + continue + } + meta, known := c.cfg.Common.CredentialMetadata[credential.ID] + if !known || meta == nil || meta.Format == "" { + return nil, fmt.Errorf("credential query %q asks for zkp_req but scope %q has no credential_metadata format to use as meta.source_format", + credential.ID, credential.ID) + } + credential.Meta.SourceFormat = meta.Format + } + for _, credential := range req.DCQLQuery.Credentials { if err := openid4vp.ValidateCredentialQuery(credential); err != nil { c.log.Error(err, "rejected an invalid credential query from the UI interaction request", "credential_id", credential.ID, "format", credential.Format) diff --git a/internal/verifier/apiv1/handlers_verification.go b/internal/verifier/apiv1/handlers_verification.go index d68035b9b..1a0da3228 100644 --- a/internal/verifier/apiv1/handlers_verification.go +++ b/internal/verifier/apiv1/handlers_verification.go @@ -3,7 +3,6 @@ package apiv1 import ( "bytes" "context" - "crypto" "encoding/base64" "encoding/json" "errors" @@ -226,6 +225,43 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi scopeCredentials := make(map[string][]sdjwtvc.CredentialCache, len(credentialScopes)) for _, scope := range credentialScopes { + // circuit_zk first, and out of the format switch below. + // + // Every other format's presentation is a STRING, and + // vpTokensForScope takes it as one; a circuit_zk presentation + // is an object whose validation needs the raw bytes, because + // a repeated member or handle cannot be seen after decoding. + // Routing it through the string path would re-encode it, and + // re-encoded bytes are exactly the ones a duplicate check can + // no longer see. + // + // Selected by the REQUEST - the DCQL credential query's own + // format - and never by anything in the response. + // + // requestedQueryExact, NOT requestedQuery: the latter falls + // back to the sole credential query when a scope maps to + // none, which is right for a legacy session whose mapping was + // lost but wrong as a routing decision. An unclaimed scope + // would inherit another query's format, take this path, and + // have that query's presentation cached under its own name - + // the attribution check in vpTokensForScope, which is what + // refuses exactly that, never runs on this path. + // + // A scope no credential query names belongs in the loop + // below, where it fails. + if query, known := c.requestedQueryExact(authCtx, scopeQueryIDs, scope); known && openid4vp.IsZkpReqFormat(query.Format) { + responseURI, err := c.zkResponseURI(scope) + if err != nil { + return nil, err + } + credentials, err := c.verifyCircuitZkScope(ctx, authCtx, vpResponse, scope, query, req.DCAPI, responseURI) + if err != nil { + return nil, err + } + scopeCredentials[scope] = append(scopeCredentials[scope], credentials...) + continue + } + vpTokens, err := c.vpTokensForScope(authCtx, scopeQueryIDs, defaultAllowed, vpResponse, scope) if err != nil { return nil, err @@ -478,10 +514,9 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi // "direct_post" responseURI can never verify against a // recomputed transcript using a different one, regardless of // whether the underlying disclosed claims are correct. - responseURI, err := url.JoinPath(c.cfg.Verifier.PublicURL, "verification", "direct_post") + responseURI, err := c.zkResponseURI(scope) if err != nil { - c.log.Error(err, "failed to construct response URI for ZK session transcript", "scope", scope) - return nil, fmt.Errorf("failed to construct response URI for scope %s: %w", scope, err) + return nil, err } // BuildOID4VPSessionTranscript's own doc comment: the JWK // thumbprint is nil "unless the request advertised an @@ -506,30 +541,9 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi // below is still on the key ID rather than on the response mode, // so a future caller that leaves it unset gets a nil thumbprint // rather than a wrong one. - var readerPubKeyThumbprint []byte - if authCtx.EphemeralEncryptionKeyID != "" { - if privKey, found := c.cacheService.EphemeralEncryptionKey.Get(ctx, authCtx.EphemeralEncryptionKeyID); found { - pubKeyIface, err := privKey.PublicKey() - if err != nil { - c.log.Error(err, "failed to derive public key for ZK session transcript", "scope", scope) - return nil, fmt.Errorf("failed to derive public key for scope %s: %w", scope, err) - } - tp, err := pubKeyIface.Thumbprint(crypto.SHA256) - if err != nil { - c.log.Error(err, "failed to compute JWK thumbprint for ZK session transcript", "scope", scope) - return nil, fmt.Errorf("failed to compute JWK thumbprint for scope %s: %w", scope, err) - } - readerPubKeyThumbprint = tp - } else { - // A miss means the key expired or was evicted between - // issuing the request and the wallet answering it. The - // wallet built ITS transcript with that key, so carrying - // on with a nil thumbprint guarantees a mismatch - and - // one that surfaces as an opaque proof failure rather - // than as the cache miss it actually is. - c.log.Error(nil, "ephemeral encryption key missing from cache for ZK session transcript", "scope", scope, "key_id", authCtx.EphemeralEncryptionKeyID) - return nil, fmt.Errorf("ephemeral encryption key %q is no longer cached, cannot rebuild the session transcript the wallet used for scope %s", authCtx.EphemeralEncryptionKeyID, scope) - } + readerPubKeyThumbprint, err := c.readerKeyThumbprint(ctx, authCtx, scope) + if err != nil { + return nil, err } // The handover follows how the response arrived, because the two // hash different things and a wallet only ever produced one of diff --git a/internal/verifier/apiv1/oidc_vc20_verification.go b/internal/verifier/apiv1/oidc_vc20_verification.go index 9c1c11d54..b57137aff 100644 --- a/internal/verifier/apiv1/oidc_vc20_verification.go +++ b/internal/verifier/apiv1/oidc_vc20_verification.go @@ -72,7 +72,7 @@ func (c *Client) verifyVC20ForOIDC(ctx context.Context, session *cache.Authoriza // read off a query the single-query fallback guessed at. scope := c.oidcScopeFor(session, queryID) requested, ok := c.requestedQueryExact(session, session.ScopeQueryIDs, scope) - if !ok || !requested.Multiple { + if !ok || !requested.AllowsMultiple() { return fmt.Errorf("the response carries %d credentials for one query, which the request did not permit; their claims would be merged", len(tokens)) } } diff --git a/internal/verifier/apiv1/oidc_vc20_verification_test.go b/internal/verifier/apiv1/oidc_vc20_verification_test.go index ff520811f..98cd5505b 100644 --- a/internal/verifier/apiv1/oidc_vc20_verification_test.go +++ b/internal/verifier/apiv1/oidc_vc20_verification_test.go @@ -644,7 +644,7 @@ func TestVerifyVC20ForOIDCRefusesExtraCredentialsForOneQuery(t *testing.T) { // multiple: true is the wallet's licence to return more than one, and // then it must be accepted. permitted := w3cOIDCSession(nil) - permitted.DCQLQuery.Credentials[0].Multiple = true + permitted.DCQLQuery.Credentials[0].Multiple = new(true) require.NoError(t, client.verifyVC20ForOIDC(t.Context(), permitted, string(two))) } @@ -709,7 +709,7 @@ func TestVerifyVC20ForOIDCDoesNotRelaxOnTheSingleQueryFallback(t *testing.T) { t.Run("multiple credentials stay refused", func(t *testing.T) { session := unmapped() - session.DCQLQuery.Credentials[0].Multiple = true + session.DCQLQuery.Credentials[0].Multiple = new(true) // Keyed by "" so the response still COVERS the one credential // scope - otherwise the coverage check refuses it first and this diff --git a/internal/verifier/apiv1/req_js_safe.go b/internal/verifier/apiv1/req_js_safe.go new file mode 100644 index 000000000..62b3e0417 --- /dev/null +++ b/internal/verifier/apiv1/req_js_safe.go @@ -0,0 +1,115 @@ +package apiv1 + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + + "github.com/SUNET/vc/pkg/openid4vp" +) + +// reqSurvivesTheBrowser reports whether every number in items can make the +// round trip through the picker unchanged, and names the first that +// cannot. +// +// The picker receives this metadata as JSON, parses it, and posts a query +// back - so every value crosses JavaScript, where a number is a float64 +// and nothing else. 9007199254740993 comes back as 9007199254740992. +// +// For most of this metadata that would be a curiosity. For req it is not: +// params are INPUTS TO A PROOF. A wallet computing a pseudonym over a +// changed parameter produces a different pseudonym, and the only symptom +// is two systems that disagree about who someone is - with nothing +// malformed anywhere to point at. +// +// Go's own serialisers preserve these exactly (decodeExact uses +// json.Decoder.UseNumber precisely so configuration survives verbatim), +// which is what makes this worth checking rather than assuming: the +// server half is lossless and the loss happens entirely in the browser. +// +// Checked by walking the ENCODED form rather than the map, so it does not +// matter whether a value arrived as an int from YAML, a json.Number from +// JSON, or a float64 from either. +func reqSurvivesTheBrowser(items []openid4vp.ReqItem) error { + for i, item := range items { + encoded, err := json.Marshal(item) + if err != nil { + return fmt.Errorf("req[%d]: %w", i, err) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var walk func(any) error + walk = func(value any) error { + switch typed := value.(type) { + case json.Number: + if !survivesTheBrowser(typed.String()) { + return fmt.Errorf("req[%d]: %s cannot be represented exactly as a JavaScript number, and the picker posts this query back through one", i, typed.String()) + } + case map[string]any: + for _, v := range typed { + if err := walk(v); err != nil { + return err + } + } + case []any: + for _, v := range typed { + if err := walk(v); err != nil { + return err + } + } + } + return nil + } + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return fmt.Errorf("req[%d]: %w", i, err) + } + if err := walk(decoded); err != nil { + return err + } + } + return nil +} + +// survivesTheBrowser reports whether a JSON number literal denotes the +// same number after a JavaScript JSON.parse/JSON.stringify round trip. +// +// Not the same question as "is it exactly representable as a float64", +// which is what this asked at first. 0.1 is NOT exactly representable and +// survives perfectly: ECMAScript prints the shortest decimal that parses +// back to the same double, so 0.1 comes out "0.1". Testing +// representability refused presets that were fine. +// +// And the two differ in the other direction as well, which is the part I +// would not have predicted: 4611686018427387904 (2^62) IS exactly +// representable and does not survive - the shortest round-tripping +// decimal for it is 4611686018427388000, so that is what the picker posts +// back. A wallet reading it as an integer gets a different number than +// the one configured. +// +// So the test is on the VALUE the wallet ends up with: take the literal +// to a float64 as the browser does, print the shortest decimal that +// round-trips as the browser does, and compare that against the original +// as exact rationals. Comparing values rather than text on purpose - JS +// prints 1.0 as "1", which is a different spelling of the same number and +// changes no proof. +func survivesTheBrowser(literal string) bool { + exact, ok := new(big.Rat).SetString(literal) + if !ok { + return false + } + asFloat, err := strconv.ParseFloat(literal, 64) + if err != nil { + // Out of float64 range. JSON.stringify turns an Infinity into + // null, so the parameter does not merely change - it disappears. + return false + } + if math.IsInf(asFloat, 0) || math.IsNaN(asFloat) { + return false + } + printed, ok := new(big.Rat).SetString(strconv.FormatFloat(asFloat, 'g', -1, 64)) + return ok && printed.Cmp(exact) == 0 +} diff --git a/internal/verifier/apiv1/req_js_safe_test.go b/internal/verifier/apiv1/req_js_safe_test.go new file mode 100644 index 000000000..0bb470bcd --- /dev/null +++ b/internal/verifier/apiv1/req_js_safe_test.go @@ -0,0 +1,99 @@ +package apiv1 + +import ( + "encoding/json" + "testing" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The server half of this is lossless on purpose - decodeExact uses +// json.Decoder.UseNumber so a configured value survives verbatim - and +// the loss happens entirely in the browser, which is why it has to be +// checked rather than assumed. +// +// Every expectation below was taken from node, not from reasoning about +// IEEE 754: JSON.stringify(JSON.parse(x)) was run on each literal and the +// result compared to the input as an exact decimal. Two of them came out +// against what I had assumed, which is the reason for doing it that way. +func TestReqParamsMustSurviveTheBrowsersNumbers(t *testing.T) { + for _, tc := range []struct { + name string + value any + ok bool + }{ + {"a small integer", 7, true}, + {"a negative one", -7, true}, + {"an ordinary decimal", 1.5, true}, + + // Not exactly representable as a float64, and survives anyway: + // ECMAScript prints the shortest decimal that parses back to the + // same double, so this comes out "0.1". Testing representability + // instead of round-tripping refused presets that were fine. + {"a decimal that is not exactly representable", json.Number("0.1"), true}, + {"the classic 0.1+0.2 result", json.Number("0.30000000000000004"), true}, + + {"the largest exact integer", int64(9007199254740992), true}, + {"one past it", int64(9007199254740993), false}, + {"two past it, which lands on a representable value", int64(9007199254740994), true}, + + // And the other direction, which I had assumed was safe and is + // not: 2^62 IS exactly representable, and the shortest decimal + // that round-trips to it is 4611686018427388000 - so that is what + // the picker posts back, and a wallet reading it as an integer + // gets a different number than the one configured. + {"2^62: exactly representable, and still changed", int64(1) << 62, false}, + {"the value it is changed INTO, which is stable", int64(4611686018427388000), true}, + + {"a decimal with more precision than a float64 holds", json.Number("1.00000000000000001"), false}, + {"an integer too long to survive", json.Number("123456789012345678901234567890"), false}, + + // Out of range entirely. JSON.stringify turns an Infinity into + // null, so the parameter does not change - it disappears. + {"beyond float64 range", json.Number("1e400"), false}, + {"and below it, which underflows to zero", json.Number("1e-400"), false}, + + {"a string, which has no such problem", "9007199254740993", true}, + {"nested inside an object", map[string]any{"inner": int64(9007199254740993)}, false}, + {"nested inside an array", []any{1, int64(9007199254740993)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := reqSurvivesTheBrowser([]openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, Return: "h", + Params: map[string]any{"p": tc.value}, + }}) + if tc.ok { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), "JavaScript number") + }) + } +} + +// And the refusal has to reach the publishing path, not just exist. +func TestCircuitZkPresetWithAnUnsafeParamIsOmitted(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, Return: "nym-123", + Params: map[string]any{ + "config": "known-offender-matching-ssc", + "counter": int64(9007199254740993), + }, + }}, + }, nil) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + if preset := reply.Presets["Nym"]; preset != nil { + assert.Empty(t, preset.Credentials, + "a preset whose proof input the browser would silently change must not be published") + } +} diff --git a/internal/verifier/apiv1/ui_preset_schema_guard_test.go b/internal/verifier/apiv1/ui_preset_schema_guard_test.go new file mode 100644 index 000000000..31080668c --- /dev/null +++ b/internal/verifier/apiv1/ui_preset_schema_guard_test.go @@ -0,0 +1,156 @@ +package apiv1 + +import ( + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/SUNET/vc/pkg/openid4vp" +) + +func presentationDefinitionJS(t *testing.T) string { + t.Helper() + source, err := os.ReadFile(filepath.Join("..", "staticembed", "presentation-definition.js")) + require.NoError(t, err) + return string(source) +} + +// between returns the slice of js from the first occurrence of start to +// the first occurrence of end after it, failing if either is missing. +// +// The region matters: searching the whole file would let a field declared +// in ONE schema satisfy a check meant for another, which is exactly the +// shape of every bug this guards against. +func between(t *testing.T, js, start, end string) string { + t.Helper() + from := strings.Index(js, start) + require.NotEqualf(t, -1, from, "presentation-definition.js no longer contains %q; this guard is reading the wrong file", start) + rest := js[from+len(start):] + to := strings.Index(rest, end) + require.NotEqualf(t, -1, to, "no %q after %q; this guard can no longer find the region it checks", end, start) + return rest[:to] +} + +// The picker parses the preset metadata through a valibot schema before +// anything else reads it, and v.object() STRIPS what the schema does not +// declare - silently, leaving a well-formed request, so the failure +// surfaces as a wallet that returned nothing. +// +// This has now happened three times: doctype_value, zk_system_type, and +// req. Each was found by someone noticing an empty response. The guard is +// derived from the struct tags rather than written as a list, because a +// list is the thing that goes stale: a fourth field added to +// UIPresetCredential fails this test on the commit that adds it. +func TestEveryPublishedPresetFieldSurvivesTheBrowserSchema(t *testing.T) { + // Only the preset schema. The DCQL schema below it covers the subset + // that goes on the wire, and which fields belong to that subset is a + // judgement the Go types do not encode - validations, for one, is read + // by the UI and never sent. + schema := between(t, presentationDefinitionJS(t), "presets: v.optional(", "/** @typedef") + + for _, tc := range []struct { + what string + of reflect.Type + }{ + {"UIPresetCredential", reflect.TypeOf(UIPresetCredential{})}, + {"UIPresetMeta", reflect.TypeOf(UIPresetMeta{})}, + } { + for i := 0; i < tc.of.NumField(); i++ { + name, _, _ := strings.Cut(tc.of.Field(i).Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + t.Run(tc.what+"."+name, func(t *testing.T) { + require.Containsf(t, schema, name+":", + "%s.%s is published to the picker, but the preset schema in presentation-definition.js never names it, so valibot strips it before the query is built", + tc.what, name) + }) + } + } +} + +// Surviving the preset schema is half of it. The query the browser posts +// back is assembled field by field from the parsed preset, so a field can +// pass the schema and still never reach the wallet - and then has to pass +// a second schema on the way out. +// +// The member is named DIFFERENTLY on the two wires, which is the whole +// difficulty: /ui/metadata serves UIPresetCredential.Req as "req", and the +// DCQL query that is posted names the same list CredentialQuery.Req, which +// is "_req". Both names are taken from the struct tags here rather than +// written out, because a guard that spells them itself is a guard that can +// agree with neither side - which is how the picker came to post "req", +// a member pkg/openid4vp refuses as req_missing_or_empty. +// +// Spelled out as to WHICH fields rather than derived, because which ones +// are DCQL-relevant is not something the Go types say: validations +// deliberately goes no further than the UI. +func TestZkpReqReqReachesThePostedQuery(t *testing.T) { + js := presentationDefinitionJS(t) + + uiName := jsonName(t, reflect.TypeOf(UIPresetCredential{}), "Req") + wireName := jsonName(t, reflect.TypeOf(openid4vp.CredentialQuery{}), "Req") + require.NotEqual(t, uiName, wireName, + "this test exists because the two wires name the list differently; if they no longer do, simplify it") + + preset := between(t, js, "presets: v.optional(", "/** @typedef") + require.True(t, declaresMember(preset, uiName), + "the preset schema must declare %q, the name /ui/metadata serves it under", uiName) + + dcql := between(t, js, "const dcqlQueryCredentialSchema = v.object({", "const credentialSetQuerySchema") + require.True(t, declaresMember(dcql, wireName), + "the DCQL schema must declare %q, or v.object strips the list on the way out", wireName) + require.False(t, declaresMember(dcql, uiName), + "the DCQL schema declares %q, the metadata name; the posted query's member is %q and %q is refused as req_missing_or_empty", + uiName, wireName, uiName) + + // The copy itself. It moved to preset-helpers.js so it could be tested + // under `node --test` without Alpine - see dcqlCredentialFromPreset + // there and its tests - so what is checked here is that the picker + // still goes through it rather than growing a second copy inline. + copied := between(t, js, "async handleSelectPredefinedPresentationDefinition(id)", "v.safeParse(dcqlQuerySchema") + require.Contains(t, copied, "dcqlCredentialFromPreset", + "the preset-to-query copy must go through dcqlCredentialFromPreset, which is the tested one") + + helpers, err := os.ReadFile(filepath.Join("..", "staticembed", "preset-helpers.js")) + require.NoError(t, err) + require.Containsf(t, string(helpers), "c."+wireName+" = cred."+uiName, + "dcqlCredentialFromPreset must map the metadata's %q onto the query's %q", uiName, wireName) + + // The item key, which is the half that broke the live operator UI: + // the schema validated `id` while the server published `fn_id`, so + // parsing /ui/metadata threw and the page never left its spinner. + itemName := jsonName(t, reflect.TypeOf(openid4vp.ReqItem{}), "FnID") + source, err := os.ReadFile(filepath.Join("..", "staticembed", "req-schema.js")) + require.NoError(t, err) + check := between(t, string(source), "export const reqSchema", "\n);") + require.Containsf(t, check, "item."+itemName, + "req-schema.js must check the item key %q, which is what ReqItem publishes", itemName) + require.NotContains(t, check, "item.id", + "req-schema.js still checks the pre-v0.4 item key id, which the server no longer publishes") +} + +// jsonName is a field's wire name, from its json tag. +func jsonName(t *testing.T, of reflect.Type, field string) string { + t.Helper() + f, ok := of.FieldByName(field) + require.Truef(t, ok, "%s has no field %s; this guard is reading the wrong type", of.Name(), field) + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + require.NotEmptyf(t, name, "%s.%s has no json name", of.Name(), field) + return name +} + +// declaresMember reports whether a valibot object schema declares exactly +// this member. +// +// Exactly, because "_req" CONTAINS "req": a plain substring search is +// satisfied by the other name and so can never tell the two apart, which +// is precisely the confusion this file is here to catch. +func declaresMember(schema, name string) bool { + return regexp.MustCompile(`(^|[^\w$])` + regexp.QuoteMeta(name) + `\s*:`).MatchString(schema) +} diff --git a/internal/verifier/apiv1/vp_formats_guard_test.go b/internal/verifier/apiv1/vp_formats_guard_test.go new file mode 100644 index 000000000..d4b90b33c --- /dev/null +++ b/internal/verifier/apiv1/vp_formats_guard_test.go @@ -0,0 +1,86 @@ +package apiv1 + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// vp_formats_supported is REQUIRED in client_metadata whenever the wallet +// cannot learn it another way (OpenID4VP 1.0 11.1), and +// verifier.preferred_vp_formats has no default - so reading the config +// field directly sends nothing at all on a deployment that never set it. +// That is exactly the bug vpFormatsOrDefault exists to have fixed, and it +// is invisible: the request object is well-formed, just missing a member, +// and only a conformant wallet complains. +// +// A new construction site is how it comes back, so the guard walks the +// package rather than naming the two sites that exist today - an +// enumeration would go stale the moment a third one is written, which is +// the moment it is needed. +func TestEveryVPFormatsAssignmentGoesThroughTheDefault(t *testing.T) { + const helper = "vpFormatsOrDefault" + + entries, err := os.ReadDir(".") + require.NoError(t, err) + + fset := token.NewFileSet() + var checked int + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution) + require.NoError(t, err) + + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + // ClientMetadata{VPFormatsSupported: ...} + case *ast.CompositeLit: + for _, element := range node.Elts { + kv, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "VPFormatsSupported" { + checked++ + assertCallsHelper(t, fset, kv.Value, helper) + } + } + // something.ClientMetadata.VPFormatsSupported = ... + case *ast.AssignStmt: + for i, lhs := range node.Lhs { + sel, ok := lhs.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "VPFormatsSupported" || i >= len(node.Rhs) { + continue + } + checked++ + assertCallsHelper(t, fset, node.Rhs[i], helper) + } + } + return true + }) + } + + // Fail closed. Zero sites means the walk found nothing - a renamed + // field, a moved package, a parser that silently skipped everything - + // and a guard that passes because it looked at nothing is worse than + // none, since it reports safety it never established. + require.NotZero(t, checked, "found no vp_formats_supported assignment to check; the guard is not looking at anything") +} + +func assertCallsHelper(t *testing.T, fset *token.FileSet, value ast.Expr, helper string) { + t.Helper() + where := filepath.Base(fset.Position(value.Pos()).String()) + call, ok := value.(*ast.CallExpr) + require.Truef(t, ok, "%s: vp_formats_supported must come from %s(), not a direct read", where, helper) + ident, ok := call.Fun.(*ast.Ident) + require.Truef(t, ok && ident.Name == helper, "%s: vp_formats_supported must come from %s()", where, helper) +} diff --git a/internal/verifier/apiv1/zk_session_transcript.go b/internal/verifier/apiv1/zk_session_transcript.go index d88bbc6b5..42ccb4b5b 100644 --- a/internal/verifier/apiv1/zk_session_transcript.go +++ b/internal/verifier/apiv1/zk_session_transcript.go @@ -1,6 +1,14 @@ package apiv1 -import "github.com/SUNET/vc/pkg/mdoc" +import ( + "context" + "crypto" + "fmt" + "net/url" + + "github.com/SUNET/vc/internal/verifier/cache" + "github.com/SUNET/vc/pkg/mdoc" +) // zkSessionTranscript builds the SessionTranscript a ZK proof must be // verified against, choosing the handover by how the response arrived. @@ -22,3 +30,72 @@ func zkSessionTranscript(dcAPI bool, origin, clientID, nonce, responseURI string } return mdoc.BuildOID4VPSessionTranscript(clientID, nonce, responseURI, readerPubKeyThumbprint) } + +// readerKeyThumbprint is the JWK thumbprint of this session's +// ephemeral encryption key, or nil when the session had none. +// +// It is part of the SessionTranscript a wallet binds its proof to, so +// a MISS is fatal rather than nil: the key expired or was evicted +// between issuing the request and the wallet answering it, the wallet +// built ITS transcript with that key, and carrying on without it +// guarantees a mismatch - surfacing as an opaque proof failure rather +// than as the cache miss it actually is. +// +// The guard is on the key ID rather than on the response mode, so a +// future caller that leaves it unset gets a nil thumbprint rather +// than a wrong one. +// +// Extracted so the circuit_zk path and the mdoc ZK path cannot come +// to disagree about what the wallet hashed. Two copies of this would +// be two chances to get a transcript input wrong, and a wrong +// transcript input is indistinguishable from a bad proof. +func (c *Client) readerKeyThumbprint(ctx context.Context, authCtx *cache.AuthorizationContext, scope string) ([]byte, error) { + if authCtx == nil || authCtx.EphemeralEncryptionKeyID == "" { + return nil, nil + } + + privKey, found := c.cacheService.EphemeralEncryptionKey.Get(ctx, authCtx.EphemeralEncryptionKeyID) + if !found { + c.log.Error(nil, "ephemeral encryption key missing from cache for ZK session transcript", + "scope", scope, "key_id", authCtx.EphemeralEncryptionKeyID) + return nil, fmt.Errorf("ephemeral encryption key %q is no longer cached, cannot rebuild the session transcript the wallet used for scope %s", + authCtx.EphemeralEncryptionKeyID, scope) + } + + pubKey, err := privKey.PublicKey() + if err != nil { + c.log.Error(err, "failed to derive public key for ZK session transcript", "scope", scope) + return nil, fmt.Errorf("failed to derive public key for scope %s: %w", scope, err) + } + thumbprint, err := pubKey.Thumbprint(crypto.SHA256) + if err != nil { + c.log.Error(err, "failed to compute JWK thumbprint for ZK session transcript", "scope", scope) + return nil, fmt.Errorf("failed to compute JWK thumbprint for scope %s: %w", scope, err) + } + return thumbprint, nil +} + +// zkResponseURI is the response_uri a ZK session transcript binds to. +// +// It MUST equal the one actually sent to the wallet as +// requestObject.ResponseURI - "direct_post", not "oidc-direct_post", +// which belongs to a wholly separate OIDC RP flow with its own +// session and state namespace. Any difference changes handoverInfo's +// bytes, which changes its SHA-256, which changes the whole +// SessionTranscript the proof's Fiat-Shamir transcript was built +// against: a proof built over the real response_uri can never verify +// against a transcript recomputed with a different one, however +// correct the disclosed claims are. +// +// That is not hypothetical - it was observed live as the cause of an +// "InvalidSumcheckProof" rejection of a genuinely valid proof. One +// function, so a second presentation path cannot reintroduce it by +// deriving the URI slightly differently. +func (c *Client) zkResponseURI(scope string) (string, error) { + responseURI, err := url.JoinPath(c.cfg.Verifier.PublicURL, "verification", "direct_post") + if err != nil { + c.log.Error(err, "failed to construct response URI for ZK session transcript", "scope", scope) + return "", fmt.Errorf("failed to construct response URI for scope %s: %w", scope, err) + } + return responseURI, nil +} diff --git a/internal/verifier/apiv1/zkp_req_preset_round_trip_test.go b/internal/verifier/apiv1/zkp_req_preset_round_trip_test.go new file mode 100644 index 000000000..a46794969 --- /dev/null +++ b/internal/verifier/apiv1/zkp_req_preset_round_trip_test.go @@ -0,0 +1,97 @@ +package apiv1 + +import ( + "encoding/json" + "testing" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// browserCopy is the preset-to-DCQL copy that presentation-definition.js +// performs, in the shape the browser performs it: field by field, over +// the JSON the server published, producing the JSON the browser posts. +// +// Written against the published JSON rather than the Go structs on +// purpose. The two key names this guards - the preset's `req` and the +// wire's `_req`, and the item's `fn_id` - exist only in the JSON; the Go +// side is one field with one tag at each end, so a test that stayed in +// Go would agree with itself no matter what either tag said. +func browserCopy(t *testing.T, preset *UIPreset) []byte { + t.Helper() + + published, err := json.Marshal(preset.Credentials) + require.NoError(t, err) + + var credentials []map[string]any + require.NoError(t, json.Unmarshal(published, &credentials)) + + out := make([]map[string]any, 0, len(credentials)) + for _, credential := range credentials { + copied := map[string]any{ + "id": credential["id"], + "format": credential["format"], + "meta": credential["meta"], + } + if claims, ok := credential["claims"]; ok { + copied["claims"] = claims + } + // The line this test exists for: the preset's `req` is the + // wire's `_req`. + if req, ok := credential["req"]; ok { + copied["_req"] = req + } + out = append(out, copied) + } + + query, err := json.Marshal(map[string]any{"credentials": out}) + require.NoError(t, err) + return query +} + +// What the operator UI does end to end: read /ui/metadata, copy the +// preset into a DCQL query, post it back. Both halves had a key wrong +// and neither said so. +// +// The item key `fn_id` was published while the browser's valibot schema +// still checked `id`, so parsing /ui/metadata threw and the live gdc +// operator page never left its spinner. Behind it, the browser copied +// the preset's `req` to a posted `req`, which CredentialQuery.Req (tag +// `_req`) does not match - encoding/json drops it - so the query would +// have gone out with no items, asking the wallet for nothing and +// reading as a wallet fault. +func TestPublishedPresetSurvivesTheBrowserRoundTrip(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + cfg.Verifier.PublicURL = "https://verifier.example.com" + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + preset := reply.Presets["Nym"] + require.NotNil(t, preset) + + posted := browserCopy(t, preset) + + var query openid4vp.DCQL + require.NoError(t, json.Unmarshal(posted, &query)) + require.Len(t, query.Credentials, 1) + + credential := query.Credentials[0] + require.NotEmpty(t, credential.Req, + "the posted query carries no req items, so the request would ask the wallet for nothing") + assert.Equal(t, nymReq, credential.Req, + "the items the operator configured must be the items that go out") + assert.Equal(t, openid4vp.FunctionPseudonym, credential.Req[0].FnID, + "the function identifier must survive as fn_id") + + // And the whole thing must be a query this verifier will accept. + _, err = client.UIInteraction(t.Context(), &UIInteractionRequest{DCQLQuery: &query}) + require.NoError(t, err, "the verifier refused the query its own preset produced") + assert.Equal(t, "mso_mdoc", query.Credentials[0].Meta.SourceFormat) +} diff --git a/internal/verifier/apiv1/zkp_req_source_format_test.go b/internal/verifier/apiv1/zkp_req_source_format_test.go new file mode 100644 index 000000000..f290b504e --- /dev/null +++ b/internal/verifier/apiv1/zkp_req_source_format_test.go @@ -0,0 +1,119 @@ +package apiv1 + +import ( + "testing" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// meta.source_format names the stored attestation the proof is made over, +// and the matcher's planner refuses a zkp_req meta without one +// (invalid_meta) before it looks at anything else. A request built without +// it is one no wallet can answer, and the refusal reads as the wallet's. +// +// It is derived, never configured: the scope's own credential_metadata +// format is the credential the wallet holds, and a second place to state +// it is a second place for the two to disagree. +func TestZkpReqPresetPublishesSourceFormat(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + reply, err := client.UIMetadata(t.Context()) + require.NoError(t, err) + preset := reply.Presets["Nym"] + require.NotNil(t, preset) + require.Len(t, preset.Credentials, 1) + + assert.Equal(t, "mso_mdoc", preset.Credentials[0].Meta.SourceFormat, + "the scope stores an mso_mdoc, so that is what a proof over it is made over") +} + +// The DCQL comes back from the browser, so the preset above is only half +// the path: a client that rebuilds the query from the fields it knows +// drops a member it has never heard of, and the request goes out +// unanswerable again. The server fills it rather than trusting the round +// trip. +func TestUIInteractionFillsSourceFormatForAZkpReqQuery(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + cfg.Verifier.PublicURL = "https://verifier.example.com" + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + query := &openid4vp.DCQL{ + Credentials: []openid4vp.CredentialQuery{{ + ID: "nym", + Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{DoctypeValue: "eu.europa.ec.eudi.nym_auth.1"}, + Req: nymReq, + }}, + } + _, err := client.UIInteraction(t.Context(), &UIInteractionRequest{DCQLQuery: query}) + require.NoError(t, err) + + assert.Equal(t, "mso_mdoc", query.Credentials[0].Meta.SourceFormat, + "the scope's credential_metadata format is what the proof is made over") +} + +// A client that does send one is not overruled: source_format is the +// wallet's side of the request, and a deliberate value - a negative test +// aimed at a wallet, say - has to survive a verifier whose whole purpose +// is testing wallets. +func TestUIInteractionKeepsAClientSuppliedSourceFormat(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + cfg.Verifier.PublicURL = "https://verifier.example.com" + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + query := &openid4vp.DCQL{ + Credentials: []openid4vp.CredentialQuery{{ + ID: "nym", + Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{ + DoctypeValue: "eu.europa.ec.eudi.nym_auth.1", + SourceFormat: "bbs_cd", + }, + Req: nymReq, + }}, + } + _, err := client.UIInteraction(t.Context(), &UIInteractionRequest{DCQLQuery: query}) + require.NoError(t, err) + + assert.Equal(t, "bbs_cd", query.Credentials[0].Meta.SourceFormat) +} + +// With no scope to derive it from there is nothing to fill in, and a +// request that goes out without one cannot be answered. Refuse at the +// verifier, where the operator sees it, rather than at a wallet. +func TestUIInteractionRefusesAZkpReqQueryWithNoScopeToDeriveFrom(t *testing.T) { + cfg := circuitZkPresetConfig(t, &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Req: nymReq, + }, nil) + cfg.Verifier.PublicURL = "https://verifier.example.com" + client, _ := CreateTestClientWithMock(t, cfg) + client.cfg = cfg + + _, err := client.UIInteraction(t.Context(), &UIInteractionRequest{DCQLQuery: &openid4vp.DCQL{ + Credentials: []openid4vp.CredentialQuery{{ + ID: "no_such_scope", + Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{DoctypeValue: "eu.europa.ec.eudi.nym_auth.1"}, + Req: nymReq, + }}, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "meta.source_format") +} diff --git a/internal/verifier/cache/request_object_raw_test.go b/internal/verifier/cache/request_object_raw_test.go new file mode 100644 index 000000000..ce268c323 --- /dev/null +++ b/internal/verifier/cache/request_object_raw_test.go @@ -0,0 +1,129 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/SUNET/vc/pkg/openid4vp" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// vendoredVectorQuery returns the "query" member of a vendored circuit_zk +// conformance vector. +func vendoredVectorQuery(t *testing.T, name string) json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "pkg", "openid4vp", "testdata", "zkp_req", "zkp_req", name)) + require.NoError(t, err) + var doc struct { + Query json.RawMessage `json:"query"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + require.NotEmpty(t, doc.Query) + return doc.Query +} + +// A request object this verifier published must reload from its own +// cache, whatever query it carries. +// +// The authorization-context decoder was fixed first and this one was +// missed, which is the shape of the defect: the request object is cached +// separately, and an HA request_uri lookup reads it back on another node. +// So a request the raw escape hatch built - a conformance harness +// deliberately sending a malformed query to see what a wallet does - +// could be PUBLISHED, and then the wallet sent to fetch it got nothing. +// +// Driven through the actual decoder the cache is constructed with, not +// through json.Unmarshal: the whole point is that this path differed +// from the other one. +func TestRequestObjectDecoderReloadsARawQuery(t *testing.T) { + for _, name := range []string{ + "err_meta_doctype_not_string.json", + "err_meta_vct_values_not_strings.json", + } { + t.Run(name, func(t *testing.T) { + wire := vendoredVectorQuery(t, name) + + built, err := openid4vp.NewRawCredentialQuery(wire) + require.NoError(t, err, "the constructor accepts it; that is the escape hatch") + + published := &openid4vp.RequestObject{ + ClientID: "x509_san_dns:verifier.example", + Nonce: "n-1", + State: "s-1", + DCQLQuery: &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{built}}, + } + stored, err := json.Marshal(published) + require.NoError(t, err) + + // The premise: a strict decode of these very bytes fails, + // which is what the request_uri lookup used to do. + require.Error(t, json.Unmarshal(stored, &openid4vp.RequestObject{}), + "if this stops failing, this test is pinning something else") + + loaded, err := requestObjectDecoder(stored) + require.NoError(t, err, "a request this verifier published must be deliverable") + require.NotNil(t, loaded.DCQLQuery) + require.Len(t, loaded.DCQLQuery.Credentials, 1) + require.NotNil(t, loaded.DCQLQuery.Credentials[0].Raw, + "a shape the model cannot hold must reload raw, not be repaired into it") + + // The rest of the request object survives the detour. + assert.Equal(t, published.ClientID, loaded.ClientID) + assert.Equal(t, published.Nonce, loaded.Nonce) + assert.Equal(t, published.State, loaded.State) + + // And the wallet receives what was published. + again, err := json.Marshal(loaded.DCQLQuery.Credentials[0]) + require.NoError(t, err) + assert.JSONEq(t, string(wire), string(again)) + }) + } +} + +// An ordinary request object still decodes, and a genuinely broken one +// still reports what is wrong with it rather than the retry's error. +func TestRequestObjectDecoderStillFailsClosed(t *testing.T) { + t.Run("an ordinary request object", func(t *testing.T) { + stored, err := json.Marshal(&openid4vp.RequestObject{ + ClientID: "x509_san_dns:verifier.example", Nonce: "n-1", + DCQLQuery: &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: "pid", Format: openid4vp.FormatSDJWTVC, + Meta: openid4vp.MetaQuery{VCTValues: []string{"https://example.com/pid"}}, + }}}, + }) + require.NoError(t, err) + + loaded, err := requestObjectDecoder(stored) + require.NoError(t, err) + require.NotNil(t, loaded.DCQLQuery) + assert.Equal(t, "pid", loaded.DCQLQuery.Credentials[0].ID) + assert.Nil(t, loaded.DCQLQuery.Credentials[0].Raw, + "an ordinary query must still decode into the model, not take the raw detour") + }) + + t.Run("not JSON at all", func(t *testing.T) { + _, err := requestObjectDecoder([]byte("{not json")) + require.Error(t, err) + }) + + t.Run("a failure that is not the query reports its own error", func(t *testing.T) { + // client_id is a string; an object there is an ordinary decode + // failure, and the retry must not mask it. + _, err := requestObjectDecoder([]byte(`{"client_id":{"not":"a string"},"nonce":"n-1"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "client_id", + "the error must name what actually failed, not the dcql retry") + }) + + t.Run("a query that means two things is still refused", func(t *testing.T) { + // checkRawConstraints runs inside UnmarshalTrustedDCQL, so a + // duplicate constraint does not reload however it got stored. + _, err := requestObjectDecoder([]byte( + `{"nonce":"n-1","dcql_query":{"credentials":[{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["a"]},"meta":{"vct_values":["b"]}}]}}`)) + require.Error(t, err) + }) +} diff --git a/internal/verifier/cache/service.go b/internal/verifier/cache/service.go index 6c853d3c5..ece21ee1c 100644 --- a/internal/verifier/cache/service.go +++ b/internal/verifier/cache/service.go @@ -106,9 +106,44 @@ func jwkKeyDecoder(data []byte) (jwk.Key, error) { // post-processing. func requestObjectDecoder(data []byte) (*openid4vp.RequestObject, error) { ro := &openid4vp.RequestObject{} - if err := json.Unmarshal(data, ro); err != nil { + err := json.Unmarshal(data, ro) + if err == nil { + return ro, nil + } + + // The DCQL may be one the raw escape hatch built, which strict + // ingress refuses on purpose - see openid4vp.UnmarshalTrustedDCQL. + // These bytes are ours: this verifier encoded the request object + // into its own cache, and an HA request_uri lookup reads it back on + // another node. Refusing it here meant a request this verifier had + // already published could not be delivered to the wallet that was + // sent to fetch it. + // + // Only the query is retried, and only with the ORIGINAL error kept + // for every other outcome: a request object that fails to decode for + // any other reason must still report what actually went wrong. + var envelope map[string]json.RawMessage + if envelopeErr := json.Unmarshal(data, &envelope); envelopeErr != nil { + return nil, err + } + rawQuery, ok := envelope["dcql_query"] + if !ok { + return nil, err + } + trusted, trustedErr := openid4vp.UnmarshalTrustedDCQL(rawQuery) + if trustedErr != nil { + return nil, err + } + delete(envelope, "dcql_query") + rest, restErr := json.Marshal(envelope) + if restErr != nil { + return nil, err + } + ro = &openid4vp.RequestObject{} + if retryErr := json.Unmarshal(rest, ro); retryErr != nil { return nil, err } + ro.DCQLQuery = trusted return ro, nil } diff --git a/internal/verifier/staticembed/presentation-definition.js b/internal/verifier/staticembed/presentation-definition.js index cdf140b57..b74ab6165 100644 --- a/internal/verifier/staticembed/presentation-definition.js +++ b/internal/verifier/staticembed/presentation-definition.js @@ -6,9 +6,10 @@ import { getBestSupportedProtocol, requestCredentialFromAuthorizationRequestURI, } from "./dc-api-polyfill.js"; -import { groupPresets } from "./preset-helpers.js"; +import { dcqlCredentialFromPreset, groupPresets } from "./preset-helpers.js"; import { claimsForLocale } from "./locale-helpers.js"; import { dcqlMetaFor } from "./dcql-meta.js"; +import { reqSchema } from "./req-schema.js"; import { browserHandoffEnv, handOffToWallet } from "./wallet-handoff.js"; /** @typedef {v.InferOutput} CredentialAttributes */ @@ -124,6 +125,13 @@ const metadataResponseSchema = v.object({ vct_values: v.optional(v.array(v.string())), doctype_value: v.optional(v.string()), type_values: v.optional(v.array(v.array(v.string()))), + // source_format is the stored attestation a zkp_req + // proof is made over, and is REQUIRED for that format: + // the wallet's matcher refuses a meta without one + // (invalid_meta). Undeclared here it would be stripped + // like doctype_value once was, and the request would + // reach the wallet unanswerable. + source_format: v.optional(v.string()), // zk_system_type entries are a flat {id, system, ...params} // string-keyed object on the wire (ZKSystemTypeSpec's own // MarshalJSON flattens params to the top level, no nested @@ -140,6 +148,19 @@ const metadataResponseSchema = v.object({ claims: v.optional(v.array(v.object({ path: v.array(v.nullable(v.string())), }))), + // req is the zkp_req format's requested calculations. A + // query in that format discloses nothing - every value comes + // from a function named here - so a stripped req asks the + // wallet for nothing and gets nothing back, which reads as a + // wallet fault. Its params are inputs to a proof and are + // passed through exactly as configured; see req-schema.js for + // why this validates without rebuilding them. + // + // `req` on THIS side: the preset is the verifier's own + // configuration surface and keeps the key the YAML uses. The + // posted DCQL below uses the wire member `_req`. The copy + // from one to the other is the only place they meet. + req: v.optional(reqSchema), validations: v.optional(v.array(v.object({ rule: v.string(), path: v.array(v.string()), @@ -168,11 +189,23 @@ const dcqlQueryCredentialSchema = v.object({ vct_values: v.optional(v.array(v.string())), doctype_value: v.optional(v.string()), type_values: v.optional(v.array(v.array(v.string()))), + source_format: v.optional(v.string()), zk_system_type: v.optional(v.array(v.record(v.string(), v.string()))), }), claims: v.optional(v.array(v.object({ path: v.array(v.nullable(v.string())), }))), + // The same schema as the metadata one above, and the same reason: + // this is the query that is actually posted, so a parameter dropped + // here never reaches the wallet at all. + // + // `_req`, not `req`: this object is a DCQL credential query, and + // v0.4 names the member `_req` on the wire. The server reads it into + // CredentialQuery.Req, whose tag is `_req`, and encoding/json does + // not match `req` to that tag - it drops it, leaving a zkp_req query + // with no items. No error anywhere: the request goes out asking the + // wallet for nothing. + _req: v.optional(reqSchema), }); const credentialSetQuerySchema = v.object({ @@ -644,12 +677,7 @@ Alpine.data("app", () => ({ // Extract only DCQL-relevant fields from the preset, stripping UI/validation extras const dcqlInput = { - credentials: (preset.credentials || []).map((/** @type {any} */ cred) => { - /** @type {any} */ - const c = { id: cred.id, format: cred.format, meta: cred.meta }; - if (cred.claims) c.claims = cred.claims; - return c; - }), + credentials: (preset.credentials || []).map(dcqlCredentialFromPreset), }; const result = v.safeParse(dcqlQuerySchema, dcqlInput); diff --git a/internal/verifier/staticembed/preset-helpers.js b/internal/verifier/staticembed/preset-helpers.js index dc057aeb9..c96dbaae2 100644 --- a/internal/verifier/staticembed/preset-helpers.js +++ b/internal/verifier/staticembed/preset-helpers.js @@ -112,3 +112,32 @@ export function groupPresets(entries, categoryOrder) { collapsible: featured.length > 0, }; } + +/** + * One preset credential as the DCQL query member it is posted as. + * + * Two names for one list, and getting them the wrong way round is silent. + * The metadata endpoint serves the requested calculations as `req` + * (UIPresetCredential.Req), and the DCQL query that is posted names the + * same list `_req` (CredentialQuery.Req, `pkg/openid4vp/dcql.go`). The + * shared vectors pin it: `err_req_not_underscore` is a query carrying + * `req`, and it is refused as `req_missing_or_empty`. + * + * It is silent because a zkp_req query discloses nothing of its own - + * every value comes from a function named in the list - so a query that + * lost the list asks the wallet for nothing, gets nothing back, and reads + * as a wallet that declined. + * + * Here rather than inline in the picker so it can be tested without a DOM + * or Alpine, the same reason as everything else in this file. + * + * @param {any} cred one credential of a preset, as /ui/metadata serves it + * @returns {any} the credential as the DCQL query carries it + */ +export function dcqlCredentialFromPreset(cred) { + /** @type {any} */ + const c = { id: cred.id, format: cred.format, meta: cred.meta }; + if (cred.claims) c.claims = cred.claims; + if (cred.req) c._req = cred.req; + return c; +} diff --git a/internal/verifier/staticembed/req-schema.js b/internal/verifier/staticembed/req-schema.js new file mode 100644 index 000000000..a4ec63bb6 --- /dev/null +++ b/internal/verifier/staticembed/req-schema.js @@ -0,0 +1,56 @@ +import * as v from "valibot"; + +/** + * reqSchema validates a zkp_req `req` WITHOUT rebuilding it. + * + * `req` is the zkp_req format's list of requested calculations, and its + * `params` are inputs to a proof: the server passes them through exactly + * as an operator configured them, and so must the browser. A parameter + * silently removed on the way out is a different proof input, which is a + * different pseudonym - computed from a request nobody wrote. + * + * Valibot's object schemas REBUILD their output, and two of their rules + * drop data here: + * + * - `v.record` excludes the prototype-polluting keys, so a parameter + * named `constructor` or `prototype` is validated and then dropped. + * - `v.looseObject` collects the item's extras but not inherited-name + * keys like `toString`, so an item extension named that is dropped. + * + * Measured, not assumed (valibot 1.1.0): + * + * params in -> config, constructor, prototype, normal + * params out -> config, normal + * item in -> fn_id, return, params, toString, extra + * item out -> fn_id, return, params, extra + * + * So the shape is CHECKED and the original value is handed back + * untouched: `v.any()` performs no transformation, and `v.check` cannot, + * by construction. The cost is that InferOutput is `any` rather than a + * typed item - which is the right trade, because the alternative is a + * type that lies about what was sent. + * + * One definition, imported by every schema that carries a `req`. Fixing + * one schema and not the other loses the parameters anyway, so there is + * only one to fix. + */ +export const reqSchema = v.pipe( + v.any(), + v.check( + (items) => + Array.isArray(items) && + items.every( + (item) => + item !== null && + typeof item === "object" && + !Array.isArray(item) && + typeof item.fn_id === "string" && + typeof item.return === "string" && + (item.params === undefined || + (typeof item.params === "object" && + item.params !== null && + !Array.isArray(item.params))), + ), + "req must be an array of {fn_id, return, params?} items", + ), +); diff --git a/internal/verifier/staticembed/tests/preset-helpers.test.js b/internal/verifier/staticembed/tests/preset-helpers.test.js index 82a9e83f3..cd05a850b 100644 --- a/internal/verifier/staticembed/tests/preset-helpers.test.js +++ b/internal/verifier/staticembed/tests/preset-helpers.test.js @@ -5,7 +5,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { groupPresets, byOrderThenLabel, OTHER_CATEGORY } from "../preset-helpers.js"; +import { dcqlCredentialFromPreset, groupPresets, byOrderThenLabel, OTHER_CATEGORY } from "../preset-helpers.js"; /** @param {string} label @param {object} extra */ const preset = (label, extra = {}) => [label, { label, ...extra }]; @@ -162,3 +162,54 @@ describe("byOrderThenLabel", () => { assert.ok(byOrderThenLabel(preset("x", { order: -1 }), preset("y")) < 0); }); }); + +describe("dcqlCredentialFromPreset", () => { + // The metadata endpoint serves `req`; the posted DCQL query names the + // same list `_req`. The picker built the query with `req`, which + // pkg/openid4vp refuses as req_missing_or_empty - and a zkp_req query + // with no list discloses nothing either, so the wallet had nothing to + // return and the failure read as a wallet that declined. + it("renames the preset's req to the query's _req", () => { + const c = dcqlCredentialFromPreset({ + id: "nym_attestation", + format: "zkp_req", + meta: { source_format: "mso_mdoc", doctype_value: "eu.europa.ec.eudi.nym_auth.1" }, + req: [{ fn_id: "pseudonym", return: "nym-123", params: { config: "c", ver: 1, prf: "sha-256" } }], + }); + assert.deepEqual(c._req, [ + { fn_id: "pseudonym", return: "nym-123", params: { config: "c", ver: 1, prf: "sha-256" } }, + ]); + assert.equal("req" in c, false, "the pre-v0.4 member name must not be posted"); + }); + + // The member is the SAME ARRAY, not a copy. A params value is an input + // to a proof: rebuilding it is how a member added later gets dropped. + it("passes the list through rather than rebuilding it", () => { + const req = [{ fn_id: "age_check", return: "a", params: { ge: "2008-10-10" } }]; + const c = dcqlCredentialFromPreset({ id: "q", format: "zkp_req", meta: {}, req }); + assert.equal(c._req, req); + }); + + // A preset for any other format carries no req, and must not grow one. + it("adds no _req to a credential that has none", () => { + const c = dcqlCredentialFromPreset({ + id: "pid", format: "dc+sd-jwt", meta: { vct_values: ["urn:eudi:pid:arf-1.8:1"] }, + claims: [{ path: ["given_name"] }], + }); + assert.equal("_req" in c, false); + assert.equal("req" in c, false); + assert.deepEqual(c.claims, [{ path: ["given_name"] }]); + }); + + // UI-only members stay server-side: validations never belong in a DCQL + // query, and label/category are the picker's business. + it("carries only the DCQL members", () => { + const c = dcqlCredentialFromPreset({ + id: "q", format: "zkp_req", meta: {}, + req: [{ fn_id: "pseudonym", return: "n" }], + validations: [{ rule: "eq", path: ["a"], value: 1 }], + label: "a label", + }); + assert.deepEqual(Object.keys(c).sort(), ["_req", "format", "id", "meta"]); + }); +}); diff --git a/internal/verifier/staticembed/tests/req-schema.test.js b/internal/verifier/staticembed/tests/req-schema.test.js new file mode 100644 index 000000000..605b98b6b --- /dev/null +++ b/internal/verifier/staticembed/tests/req-schema.test.js @@ -0,0 +1,156 @@ +// Unit tests for the zkp_req `req` schema. Runs under Node's built-in +// test runner (`node --test`). + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as v from "valibot"; + +import { reqSchema } from "../req-schema.js"; + +// The names Valibot's object schemas drop. v.record excludes the +// prototype-polluting keys from its output, and v.looseObject collects an +// item's extras but not one named like an inherited member - so the +// earlier schema validated these and then silently removed them. A req +// param is an input to a proof: a dropped one is a different pseudonym, +// computed from a request nobody wrote. +const req = [{ + fn_id: "pseudonym", + return: "nym-1", + params: { + config: "known-offender-matching-ssc", + constructor: "dropped-by-record", + prototype: "dropped-by-record", + valueOf: "also-a-prototype-name", + ordinary: 1, + }, + toString: "dropped-by-looseObject", + extension: "kept-either-way", +}]; + +describe("reqSchema", () => { + it("keeps every parameter, including the ones Valibot's record drops", () => { + const result = v.safeParse(reqSchema, req); + assert.equal(result.success, true); + assert.deepEqual( + Object.keys(result.output[0].params).sort(), + ["config", "constructor", "ordinary", "prototype", "valueOf"], + ); + assert.equal(result.output[0].params.constructor, "dropped-by-record"); + assert.equal(result.output[0].params.prototype, "dropped-by-record"); + }); + + it("keeps item extensions, including ones named like inherited members", () => { + const result = v.safeParse(reqSchema, req); + assert.equal(result.success, true); + assert.deepEqual( + Object.keys(result.output[0]).sort(), + ["extension", "fn_id", "params", "return", "toString"], + ); + }); + + // The strongest form of "nothing was rebuilt": the value that comes + // back IS the value that went in. A schema that reconstructed an + // equal object would pass the key assertions above and still be the + // defect, because the next member added to a request would be lost. + it("hands back the original value rather than a copy", () => { + const result = v.safeParse(reqSchema, req); + assert.equal(result.success, true); + assert.equal(result.output, req); + assert.equal(result.output[0].params, req[0].params); + }); + + // Validating without rebuilding must not become validating nothing. + it("still refuses a req that is not an array of well-formed items", () => { + for (const bad of [ + "not-an-array", + {}, + [null], + ["a string item"], + [{ return: "h" }], // no fn_id + [{ fn_id: "f" }], // no return + [{ fn_id: 7, return: "h" }], // fn_id not a string + [{ fn_id: "f", return: 7 }], // return not a string + [{ fn_id: "f", return: "h", params: [] }], // params not an object + [{ fn_id: "f", return: "h", params: "x" }], + // The pre-v0.4 spelling. ReqItem is `fn_id` on the wire and + // the shared vectors agree, so an item naming the function + // `id` is one no server sends and no wallet reads. + [{ id: "f", return: "h" }], + ]) { + assert.equal( + v.safeParse(reqSchema, bad).success, + false, + `must refuse ${JSON.stringify(bad)}`, + ); + } + }); + + // The schema's idea of an item and the server's must be the SAME idea, + // and this file used to hold its own: every fixture above named the + // function `id`, so the schema and its tests agreed with each other and + // both disagreed with ReqItem. The preset list then failed to parse, + // which reads as a server that sent nothing. + // + // Read out of the vendored conformance vector rather than retyped, so + // the two cannot drift apart again. + it("accepts the _req of the normative shared vector, as the server sends it", () => { + const vector = JSON.parse( + readFileSync( + new URL("../../../../pkg/openid4vp/testdata/zkp_req/zkp_req/ok_normative_plain.json", import.meta.url), + "utf8", + ), + ); + const items = vector.query._req; + assert.ok(Array.isArray(items) && items.length > 0, "the vector must carry a _req"); + assert.equal(v.safeParse(reqSchema, items).success, true); + + // And the same items under the obsolete name are refused, so this + // is a check on the member name and not just on the vector parsing. + const renamed = items.map((/** @type {any} */ item) => { + const { fn_id, ...rest } = item; + return { id: fn_id, ...rest }; + }); + assert.equal(v.safeParse(reqSchema, renamed).success, false); + }); + + it("accepts the shapes a conforming request actually takes", () => { + for (const good of [ + [], + [{ fn_id: "f", return: "h" }], + [{ fn_id: "f", return: "h", params: {} }], + [{ fn_id: "pseudonym", return: "n", params: { config: "c", ver: 1, prf: "sha-256" } }, + { fn_id: "age_check", return: "a", params: { ge: "2008-10-10" } }], + ]) { + assert.equal( + v.safeParse(reqSchema, good).success, + true, + `must accept ${JSON.stringify(good)}`, + ); + } + }); +}); + +// The item key moved from `id` to `fn_id` in rulebook v0.4, and this +// fixture was not moved with it - which is the only reason the schema +// kept passing while it validated a shape the server had stopped +// publishing. The live operator UI then threw on /ui/metadata and never +// left its spinner. +// +// Both directions are pinned, because either one alone goes stale the +// same way: the published shape is accepted, and the shape it replaced +// is refused rather than quietly tolerated. +describe("reqSchema and the v0.4 item key", () => { + const item = { return: "nym", params: { config: "known-offender-matching-ssc" } }; + + it("accepts the fn_id the server publishes", () => { + const result = v.safeParse(reqSchema, [{ fn_id: "pseudonym", ...item }]); + assert.equal(result.success, true); + }); + + it("refuses the pre-v0.4 id", () => { + const result = v.safeParse(reqSchema, [{ id: "pseudonym", ...item }]); + assert.equal(result.success, false); + assert.match(result.issues[0].message, /fn_id/); + }); +}); diff --git a/internal/wallet/apiv1/vci_presentation.go b/internal/wallet/apiv1/vci_presentation.go index 13a35c0d5..e0287fbd3 100644 --- a/internal/wallet/apiv1/vci_presentation.go +++ b/internal/wallet/apiv1/vci_presentation.go @@ -385,7 +385,10 @@ func (c *Client) buildIAVPTokens(ctx context.Context, ro *openid4vp.RequestObjec continue } matched := answerable[query.ID] - if !query.Multiple { + // AllowsMultiple, not !query.Multiple: the member became a + // pointer so an absent one is distinguishable from an explicit + // false, and OpenID4VP 1.0 6.1 defaults an absent one to false. + if !query.AllowsMultiple() { matched = matched[:1] } diff --git a/pkg/cache/authcontext_bson.go b/pkg/cache/authcontext_bson.go new file mode 100644 index 000000000..844759809 --- /dev/null +++ b/pkg/cache/authcontext_bson.go @@ -0,0 +1,110 @@ +package cache + +import ( + "encoding/json" + "fmt" + + "github.com/SUNET/vc/pkg/openid4vp" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// MarshalBSON writes the authorization context, with the DCQL query +// written twice: once as the document every build can read, and once as +// JSON text, which is the only form that keeps the query's numbers. +// +// See DCQLQueryExact for why both. In short: BSON rounds a req param +// beyond int64 or float64 precision, a req param is an input to a proof, +// and replacing the stored representation outright would break a rolling +// upgrade in both directions. +func (a AuthorizationContext) MarshalBSON() ([]byte, error) { + type plain AuthorizationContext // no methods, so no recursion + document := plain(a) + + // Derived from DCQLQuery, so it is cleared before being rebuilt. A + // context loaded from BSON carries the exact text in memory; a caller + // that then CLEARS DCQLQuery and saves would otherwise leave the old + // text in the replacement document, and the next read - which prefers + // it - would restore the query that had just been removed. + document.DCQLQueryExact = "" + + if a.DCQLQuery != nil { + encoded, err := json.Marshal(a.DCQLQuery) + if err != nil { + return nil, fmt.Errorf("authorization context: encoding the dcql query: %w", err) + } + document.DCQLQueryExact = string(encoded) + } + + stored, err := bson.Marshal(document) + if err == nil { + return stored, nil + } + if document.DCQLQuery == nil { + return nil, err + } + + // The structured copy has a value BSON cannot hold at all, and + // without this the whole document is unwritable: the driver's + // json.Number encoder tries Int64 and then Float64, and a literal + // past float64's range - 1e400, say - fails both, so bson.Marshal + // returns strconv's range error and session creation fails before + // the request ever reaches a wallet. The exact JSON above holds that + // literal perfectly well, which is the point of it; it is only the + // legacy document-valued copy that has nowhere to put it. + // + // Dropped rather than approximated. BSON would take the value as + // +Inf, and an old replica reading that would send a request with a + // number nobody configured - a req param is an input to a proof, so + // a wrong one is a wrong pseudonym computed in silence. Omitting the + // legacy copy instead leaves an old replica with no query, which it + // can only fail on. A replica that understands dcql_query_exact is + // unaffected either way, since it prefers that copy. + // + // Note this is strictly better than the behaviour it replaces: before + // the exact copy existed, such a request could not be stored by ANY + // build, so no old replica is losing a session it used to be able to + // serve. + document.DCQLQuery = nil + retried, retryErr := bson.Marshal(document) + if retryErr != nil { + // The query was not the cause. Report what actually failed. + return nil, err + } + return retried, nil +} + +// UnmarshalBSON prefers the exact copy when one is there. +// +// A document written before this change carries only dcql_query, and is +// read exactly as it was before - rounded, which is what that build did +// anyway. Nothing regresses; what is new is that a document written by a +// current replica comes back with its numbers intact. +func (a *AuthorizationContext) UnmarshalBSON(data []byte) error { + type plain AuthorizationContext + var document plain + if err := bson.Unmarshal(data, &document); err != nil { + return err + } + *a = AuthorizationContext(document) + + if a.DCQLQueryExact == "" { + return nil + } + // The TRUSTED decoder, because these are bytes this verifier wrote. + // A request the raw escape hatch built - one a conformance harness + // sends to see what a wallet does with a malformed query - is + // refused by wire ingress on purpose, and refusing it here too meant + // such a request could be published and then reload nowhere, not + // even on the node that built it. + exact, err := openid4vp.UnmarshalTrustedDCQL([]byte(a.DCQLQueryExact)) + if err != nil { + // Not fatal on its own - the rounded copy is still there and is + // what an older build would have used - but it means the exact + // copy was corrupted in storage, which is worth refusing rather + // than silently falling back to a query whose numbers may differ + // from the one that was sent. + return fmt.Errorf("authorization context: the exact dcql query is unreadable: %w", err) + } + a.DCQLQuery = exact + return nil +} diff --git a/pkg/cache/authcontext_types.go b/pkg/cache/authcontext_types.go index 667bf7919..cfd6291b4 100644 --- a/pkg/cache/authcontext_types.go +++ b/pkg/cache/authcontext_types.go @@ -231,6 +231,30 @@ type AuthorizationContext struct { RequestObjectID string `json:"request_object_id,omitempty" bson:"request_object_id,omitempty" validate:"omitempty,max=128,printascii"` RequestObjectNonce string `json:"request_object_nonce,omitempty" bson:"request_object_nonce,omitempty" validate:"omitempty,max=128,printascii"` DCQLQuery *openid4vp.DCQL `json:"dcql_query,omitempty" bson:"dcql_query,omitempty"` + // DCQLQueryExact carries the same query as JSON text, which is the + // only representation that keeps its numbers. + // + // BSON does not. The driver's json.Number encoder tries Int64 and + // then Float64, so a req param beyond either - a decimal past float64 + // precision, an integer past int64 - is ROUNDED in the stored + // document, and the replica that reads it back sends a different + // request than the one that wrote it. A req param is an input to a + // proof, so that is two systems computing different pseudonyms from + // what reads as one configuration. + // + // Additive on purpose. Replacing dcql_query's representation would + // have broken a rolling upgrade in BOTH directions: a new replica + // cannot read an old document-valued query, and an old replica cannot + // read a new string-valued one, so a session bouncing between + // versions fails intermittently for as long as the rollout lasts. + // Written alongside and preferred on read, it is safe either way - + // an old replica ignores a field it does not model and keeps the + // behaviour it already had, and a new one gets the exact copy + // whenever there is one. + // + // Once every replica writes this, dcql_query can be dropped. That is + // a separate change, and it needs the rollout to have finished. + DCQLQueryExact string `json:"-" bson:"dcql_query_exact,omitempty"` // ScopeQueryIDs maps a requested scope to the id of the DCQL credential // query that stands for it, for the scopes where the two differ. // diff --git a/pkg/cache/dcql_bson_roundtrip_test.go b/pkg/cache/dcql_bson_roundtrip_test.go new file mode 100644 index 000000000..1a8a5a7c7 --- /dev/null +++ b/pkg/cache/dcql_bson_roundtrip_test.go @@ -0,0 +1,433 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// The HA cache persists the whole authorization context - this query +// inside it - and another replica reads it back, so whatever BSON does to +// these values is what the OTHER replica sends. +// +// The driver's json.Number encoder tries Int64 and then Float64, which +// rounds a decimal beyond float64 precision and an integer beyond int64 - +// the exact values the JSON and YAML paths preserve. A req param is an +// input to a proof, so a replica rounding one computes a different +// pseudonym than the replica that built the request. +// +// Written here rather than in pkg/openid4vp because this is the question +// being asked: not "does the codec work" but "does the real driver, on +// the real struct, keep what was configured". +func TestAuthorizationContextKeepsExactNumbersThroughBSON(t *testing.T) { + for _, tc := range []struct{ name, literal string }{ + {"a decimal beyond float64 precision", "1.00000000000000001"}, + {"an integer beyond int64", "123456789012345678901234567890"}, + {"the first integer a float64 cannot hold", "9007199254740993"}, + {"and an ordinary one, which must also be untouched", "7"}, + } { + t.Run(tc.name, func(t *testing.T) { + query := &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: "nym", Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{DoctypeValue: "eu.europa.ec.eudi.nym_auth.1"}, + Req: []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, Return: "nym-1", + Params: map[string]any{"p": json.Number(tc.literal)}, + }}, + }}} + + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: query}) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded)) + require.NotNil(t, loaded.DCQLQuery) + + // Compared as TEXT: a float64 comparison is exactly what + // cannot see this defect. + encoded, err := json.Marshal(loaded.DCQLQuery) + require.NoError(t, err) + assert.Contains(t, string(encoded), tc.literal, + "the other replica has to send the number that was configured, not a rounding of it") + }) + } +} + +// And a query the typed model cannot hold survives too - the raw escape +// hatch is no use if it only works on the replica that built it. +func TestAuthorizationContextKeepsARawQueryThroughBSON(t *testing.T) { + const wire = `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[{"fn_id":"age_check"}]}}` + credential, err := openid4vp.NewRawCredentialQuery([]byte(wire)) + require.NoError(t, err) + + stored, err := bson.Marshal(&AuthorizationContext{ + SessionID: "s", + DCQLQuery: &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{credential}}, + }) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded)) + require.NotNil(t, loaded.DCQLQuery) + require.Len(t, loaded.DCQLQuery.Credentials, 1) + + encoded, err := json.Marshal(loaded.DCQLQuery.Credentials[0]) + require.NoError(t, err) + assert.JSONEq(t, wire, string(encoded)) + assert.Equal(t, "q", loaded.DCQLQuery.Credentials[0].ID, + "and the shadow the response path matches by") +} + +// A query the MODEL cannot hold survives the cache too, not just one the +// model has never seen. +// +// Wire ingress refuses a modelled member of the right JSON kind whose +// value the model rejects - `"doctype_value": 1` - on purpose: sending +// it to Raw would launder a decode error into a query that validates. +// NewRawCredentialQuery accepts exactly those shapes, also on purpose, +// because a conformance harness has to be able to SEND a malformed +// request to see what a wallet does with it. The two together meant such +// a request could be published and then reload nowhere, not even on the +// node that built it, once its context had been through the cache. +// +// Driven from the vendored vectors rather than hand-written JSON: these +// are the shapes the shared conformance set says a verifier must be able +// to send, so if they change, this changes with them. +func TestAuthorizationContextKeepsAModelRejectedQueryThroughBSON(t *testing.T) { + for _, name := range []string{ + "err_meta_doctype_not_string.json", // meta.doctype_value is a number + "err_meta_vct_values_not_strings.json", // meta.vct_values holds a number + } { + t.Run(name, func(t *testing.T) { + wire := vendoredVectorQuery(t, name) + + // The premise, in both directions: the escape hatch accepts + // it and strict ingress does not. If either changes, this + // test is pinning something else. + built, err := openid4vp.NewRawCredentialQuery(wire) + require.NoError(t, err, "the constructor accepts it; that is the escape hatch") + var strict openid4vp.CredentialQuery + require.Error(t, json.Unmarshal(wire, &strict), + "and wire ingress refuses it, which is what made the reload fail") + + stored, err := bson.Marshal(&AuthorizationContext{ + SessionID: "s", + DCQLQuery: &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{built}}, + }) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded), + "a request this verifier published must reload") + require.NotNil(t, loaded.DCQLQuery) + require.Len(t, loaded.DCQLQuery.Credentials, 1) + require.NotNil(t, loaded.DCQLQuery.Credentials[0].Raw, + "a shape the model cannot hold must reload raw, not be repaired into it") + + // And what reloaded is what was stored, byte for byte in + // meaning: a reload that normalised the request would pass + // everything above and still change what the wallet gets. + again, err := json.Marshal(loaded.DCQLQuery.Credentials[0]) + require.NoError(t, err) + assert.JSONEq(t, string(wire), string(again)) + }) + } +} + +// What is NOT relaxed by the trusted decode: a stored query whose +// constraint members mean two different things to two readers is still +// refused on the way back in. +// +// The trust is only that this verifier wrote the bytes. A duplicate or +// case-aliased constraint is a request whose meaning depends on the +// reader, and reloading one would resurrect exactly the hazard +// checkRawConstraints exists to stop - the shadow reports one +// constraint, the emitted bytes carry another. +func TestAuthorizationContextStillRefusesAnAmbiguousStoredQuery(t *testing.T) { + const ambiguous = `{"credentials":[{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["a"]},"meta":{"vct_values":["b"]}}]}` + + type plain struct { + SessionID string `bson:"session_id"` + DCQLQueryExact string `bson:"dcql_query_exact,omitempty"` + } + stored, err := bson.Marshal(plain{SessionID: "s", DCQLQueryExact: ambiguous}) + require.NoError(t, err) + + var loaded AuthorizationContext + err = bson.Unmarshal(stored, &loaded) + require.Error(t, err, "a query that means two things must not reload, however it got into the store") + assert.Contains(t, err.Error(), "more than once") +} + +// An absent query stays absent rather than becoming an empty one. +func TestAuthorizationContextWithoutADCQLQuery(t *testing.T) { + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s"}) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded)) + assert.Nil(t, loaded.DCQLQuery) +} + +// Rolling upgrades, both directions. The exact copy is ADDITIVE, so +// neither build is broken by the other's documents. +func TestAuthorizationContextSurvivesARollingUpgrade(t *testing.T) { + query := &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: "nym", Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{DoctypeValue: "d"}, + Req: []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, Return: "nym-1", + Params: map[string]any{"p": json.Number("1.00000000000000001")}, + }}, + }}} + + t.Run("a new replica reads an old document", func(t *testing.T) { + // What an older build wrote: the query as a document, with no + // exact copy beside it. + type oldShape struct { + SessionID string `bson:"session_id"` + DCQLQuery *openid4vp.DCQL `bson:"dcql_query,omitempty"` + } + stored, err := bson.Marshal(&oldShape{SessionID: "s", DCQLQuery: query}) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded), + "an old document must still load; a rollout cannot require every session to be new") + require.NotNil(t, loaded.DCQLQuery) + require.Len(t, loaded.DCQLQuery.Credentials, 1) + assert.Equal(t, "nym", loaded.DCQLQuery.Credentials[0].ID) + + // Rounded, because that is what the old build stored. Nothing + // regresses; the exactness is what is new, not a repair of what + // was already written. + encoded, err := json.Marshal(loaded.DCQLQuery) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "1.00000000000000001") + }) + + t.Run("an old replica reads a new document", func(t *testing.T) { + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: query}) + require.NoError(t, err) + + // An older build models only dcql_query and ignores the rest. + var loaded struct { + SessionID string `bson:"session_id"` + DCQLQuery *openid4vp.DCQL `bson:"dcql_query,omitempty"` + } + require.NoError(t, bson.Unmarshal(stored, &loaded), + "a new document must still load on an old replica, or a rollout fails in the other direction") + require.NotNil(t, loaded.DCQLQuery) + require.Len(t, loaded.DCQLQuery.Credentials, 1) + assert.Equal(t, "nym", loaded.DCQLQuery.Credentials[0].ID) + }) + + t.Run("and a new replica reads a new document exactly", func(t *testing.T) { + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: query}) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded)) + encoded, err := json.Marshal(loaded.DCQLQuery) + require.NoError(t, err) + assert.Contains(t, string(encoded), "1.00000000000000001") + }) +} + +// A corrupted exact copy is refused rather than quietly replaced by the +// rounded one, which would send numbers that differ from those stored. +func TestAuthorizationContextRefusesACorruptExactQuery(t *testing.T) { + stored, err := bson.Marshal(bson.M{"session_id": "s", "dcql_query_exact": "{not json"}) + require.NoError(t, err) + + var loaded AuthorizationContext + require.Error(t, bson.Unmarshal(stored, &loaded)) +} + +// The exact copy is DERIVED from DCQLQuery, so clearing the query has to +// clear it too. +// +// A context loaded from BSON carries the exact text in memory. A caller +// that then clears DCQLQuery and saves would otherwise leave the old text +// in the replacement document - and the next read prefers it, restoring +// the query that had just been removed. +func TestClearingTheQueryClearsTheExactCopy(t *testing.T) { + query := &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: "nym", Format: openid4vp.FormatZkpReq, + Meta: openid4vp.MetaQuery{DoctypeValue: "d"}, + }}} + + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: query}) + require.NoError(t, err) + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded)) + require.NotNil(t, loaded.DCQLQuery) + require.NotEmpty(t, loaded.DCQLQueryExact, "the exact copy is what makes this worth testing") + + // What a caller removing the query does. + loaded.DCQLQuery = nil + resaved, err := bson.Marshal(&loaded) + require.NoError(t, err) + + var reloaded AuthorizationContext + require.NoError(t, bson.Unmarshal(resaved, &reloaded)) + assert.Nil(t, reloaded.DCQLQuery, "a removed query must not come back") + assert.Empty(t, reloaded.DCQLQueryExact) +} + +// A literal past float64's range is the case the rounding above cannot +// reach: the driver's json.Number encoder fails outright, so before this +// was handled the WHOLE document was unwritable and session creation +// failed before the request reached a wallet. +// +// It arrives the way a Verifier actually sends one - decoded from the +// JSON of a direct interaction request - because that is what puts a +// json.Number in the structured copy. The preset path's browser guard +// does not cover this: a direct JSON request never passes through it. +func TestAuthorizationContextStoresNumbersBeyondFloat64Range(t *testing.T) { + for _, literal := range []string{"1e400", "-1e400"} { + t.Run(literal, func(t *testing.T) { + var query openid4vp.DCQL + require.NoError(t, json.Unmarshal([]byte(`{"credentials":[{ + "id":"nym","format":"zkp_req", + "meta":{"doctype_value":"eu.europa.ec.eudi.nym_auth.1"}, + "_req":[{"fn_id":"pseudonym","return":"nym-1", + "params":{"p":`+literal+`}}]}]}`), &query)) + + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: &query}) + require.NoError(t, err, "a request this verifier accepted has to be storable") + + // The legacy document-valued copy is deliberately absent: + // BSON would have taken the value as +Inf, and an old replica + // reading that would send a number nobody configured. + var document bson.M + require.NoError(t, bson.Unmarshal(stored, &document)) + assert.NotContains(t, document, "dcql_query", + "an approximated legacy copy is worse than none") + require.Contains(t, document, "dcql_query_exact") + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded)) + require.NotNil(t, loaded.DCQLQuery) + encoded, err := json.Marshal(loaded.DCQLQuery) + require.NoError(t, err) + assert.Contains(t, string(encoded), literal, + "the other replica has to send the number that was configured") + }) + } +} + +// And the legacy copy is still written for every query that has a BSON +// representation - dropping it is the exception, not the rule, and a test +// that only covers the exception would not notice if it became the rule. +func TestAuthorizationContextKeepsTheLegacyCopyWhenItCan(t *testing.T) { + var query openid4vp.DCQL + require.NoError(t, json.Unmarshal([]byte(`{"credentials":[{ + "id":"nym","format":"zkp_req", + "meta":{"doctype_value":"eu.europa.ec.eudi.nym_auth.1"}, + "_req":[{"fn_id":"pseudonym","return":"nym-1","params":{"p":7}}]}]}`), &query)) + + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: &query}) + require.NoError(t, err) + + var document bson.M + require.NoError(t, bson.Unmarshal(stored, &document)) + assert.Contains(t, document, "dcql_query", "an old replica still reads this one") + assert.Contains(t, document, "dcql_query_exact") +} + +// A document that fails to encode for a reason OTHER than the query is +// still an error. Without this the retry above would swallow any encoding +// failure in the rest of the context and store it with the query silently +// removed. +func TestAuthorizationContextStillFailsWhenTheQueryIsNotTheCause(t *testing.T) { + var query openid4vp.DCQL + require.NoError(t, json.Unmarshal([]byte(`{"credentials":[{ + "id":"nym","format":"zkp_req", + "meta":{"doctype_value":"eu.europa.ec.eudi.nym_auth.1"}}]}`), &query)) + + _, err := bson.Marshal(&AuthorizationContext{ + SessionID: "s", + DCQLQuery: &query, + PresentationSubmission: json.Number("1e400"), + }) + // Asserted on the MESSAGE, not merely on being non-nil. A retry that + // swallowed retryErr would still surface an error - the driver + // rejects the empty document it would hand back - and a test that + // only checked for "an error" would pass on it while the real cause + // had been replaced by a confusing one. + require.ErrorContains(t, err, "1e400", + "the failure has to name what actually could not be encoded") +} + +// claims[].values carries integers too, and the exact-JSON copy must not +// be the thing that loses them. +// +// A typed query may legitimately hold 9007199254740993 - the first +// integer a float64 cannot represent - and it survived BSON storage +// before the exact copy existed. Making that copy the preferred +// representation then made such a context UNREADABLE: ClaimQuery decoded +// its values with a plain json.Unmarshal, so the literal became a +// float64, lost its last digit, and the validator rejected it for being +// outside the safe integer range. Saved fine, failed on the way back. +// +// The same family as the req[].params tests above, and the same answer: +// decode with UseNumber and judge the literal. +func TestAuthorizationContextKeepsExactClaimValuesThroughBSON(t *testing.T) { + for _, tc := range []struct { + name string + value any + want string + }{ + {"the first integer a float64 cannot hold", int64(9007199254740993), "9007199254740993"}, + {"and its negative", int64(-9007199254740993), "-9007199254740993"}, + {"an integer beyond int64", json.Number("123456789012345678901234567890"), "123456789012345678901234567890"}, + {"an ordinary one, which must also be untouched", int64(7), "7"}, + {"a string, which shares the array", "pid", `"pid"`}, + {"and a boolean", true, "true"}, + } { + t.Run(tc.name, func(t *testing.T) { + query := &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: "pid", Format: "dc+sd-jwt", + Meta: openid4vp.MetaQuery{VCTValues: []string{"urn:x"}}, + Claims: []openid4vp.ClaimQuery{{Path: openid4vp.StringPath("age"), Values: []any{tc.value}}}, + }}} + + stored, err := bson.Marshal(&AuthorizationContext{SessionID: "s", DCQLQuery: query}) + require.NoError(t, err, "a query the typed model accepts has to be storable") + + var loaded AuthorizationContext + require.NoError(t, bson.Unmarshal(stored, &loaded), + "and readable again; this is the step that used to fail") + require.NotNil(t, loaded.DCQLQuery) + + // Compared as TEXT: a float64 comparison is exactly what + // cannot see this defect. + encoded, err := json.Marshal(loaded.DCQLQuery) + require.NoError(t, err) + assert.Contains(t, string(encoded), tc.want, + "the other replica has to send the value that was configured") + }) + } +} + +// vendoredVectorQuery returns the "query" member of a vendored circuit_zk +// conformance vector. +func vendoredVectorQuery(t *testing.T, name string) json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "openid4vp", "testdata", "zkp_req", "zkp_req", name)) + require.NoError(t, err) + var doc struct { + Query json.RawMessage `json:"query"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + require.NotEmpty(t, doc.Query, "the vector must carry a query") + return doc.Query +} diff --git a/pkg/configuration/config.go b/pkg/configuration/config.go index 8babb0d07..0f3c6a579 100755 --- a/pkg/configuration/config.go +++ b/pkg/configuration/config.go @@ -15,6 +15,8 @@ import ( "github.com/SUNET/vc/pkg/helpers" "github.com/SUNET/vc/pkg/logger" "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth" + "github.com/SUNET/vc/pkg/nymauth/circuits" "github.com/SUNET/vc/pkg/oauth2" "github.com/SUNET/vc/pkg/openid4vp" "github.com/SUNET/vc/pkg/vc20/credential" @@ -290,6 +292,10 @@ func New(ctx context.Context, serviceName string) (*model.Cfg, error) { return nil, err } + if err := checkNymAuthVerifier(cfg); err != nil { + return nil, err + } + if err := checkCredentialEncryption(cfg); err != nil { return nil, err } @@ -302,6 +308,14 @@ func New(ctx context.Context, serviceName string) (*model.Cfg, error) { return nil, err } + if err := checkZkpReqPresetsAskForSomething(cfg); err != nil { + return nil, err + } + + if err := checkPresetValidationProfiles(cfg); err != nil { + return nil, err + } + return cfg, nil } @@ -422,6 +436,74 @@ func checkLegacyCredentialResponseEncryption(cfg *model.Cfg) error { return nil } +// checkNymAuthVerifier refuses a nym_auth verifier configuration that +// cannot be honoured. +// +// At config load, because every failure it catches would otherwise +// surface as a REFUSED PROOF: an allowlist with two builds for one +// configuration, an artifact that is not the one allowed, a +// verifier_id that is a Client Identifier rather than the bare value. +// Each of those produces a pseudonym nobody reports or a circuit that +// will not load, and an operator looking at a failed presentation has +// no reason to suspect the configuration. +func checkNymAuthVerifier(cfg *model.Cfg) error { + if cfg == nil || cfg.Verifier == nil { + return nil + } + // The pre-v0.4 format and member names, refused BEFORE the enable + // check: a configuration carrying them was written against the + // old wire shape, and starting with it silently ignored is the + // failure this refuses. They land in Extra rather than erroring + // on their own, so nothing else would notice. + // + // Named individually, with the replacement, because an operator + // reading "unknown member" would reasonably go looking for a typo + // rather than for a rename. + if err := refuseLegacyZkpReqKeys(cfg.Verifier.PreferredVPFormats); err != nil { + return err + } + if !cfg.Verifier.NymAuth.Enable { + return nil + } + + if err := nymauth.CheckVerifierID(cfg.Verifier.NymAuth.VerifierID); err != nil { + return fmt.Errorf("verifier.nym_auth.verifier_id: %w", err) + } + if cfg.Verifier.Trust.PDPURL == "" { + // nym_auth takes the issuer key from the holder's own + // certificate chain, so the PDP is the only thing that says + // the key belongs to a real issuer. With no PDP the evaluator + // is AllowAllEvaluator, which answers Trusted=true to + // everything - and a holder can then self-sign, choose its own + // issuer key, and so choose its own pseudonym. + // + // Allow-all is legitimate for formats where the holder cannot + // supply the key it is measured against. Here it removes the + // only check there is, so a deployment that configures it is + // refused rather than started. + return fmt.Errorf("verifier.nym_auth is enabled but verifier.trust.pdp_url is empty, so every issuer would be trusted; " + + "nym_auth takes the issuer key from the holder's own certificate chain and cannot be used without a PDP") + } + if len(cfg.Verifier.NymAuth.Circuits.Allow) == 0 { + // Enabled with nothing allowed verifies nothing. Refused + // rather than started, because "on" and "cannot check a + // single proof" is a state nobody means to configure. + return fmt.Errorf("verifier.nym_auth is enabled but verifier.nym_auth.circuits.allow is empty, so no proof could be verified") + } + allowlist, err := circuits.NewAllowlist(cfg.Verifier.NymAuth.Circuits) + if err != nil { + return err + } + // And the ARTIFACTS, not only the entries describing them. An + // entry can be perfectly well-formed and name a file that is not + // there, or one whose digest moved when it was rebuilt - and both + // of those would otherwise surface as a refused proof. + if err := allowlist.Preload(context.Background()); err != nil { + return err + } + return nil +} + // checkCredentialEncryption verifies that the configured encryption keys can // actually be loaded. func checkCredentialEncryption(cfg *model.Cfg) error { @@ -1114,6 +1196,41 @@ func checkInteractiveAuthorization(cfg *model.Cfg, serviceName string) error { return nil } +// legacyZkpReqKeys maps every pre-v0.4 name onto the one that +// replaced it. There are no aliases: rulebook v0.4 renamed these on +// the wire, and a verifier that quietly accepted the old spelling +// would send one shape while its operator believed it sent another. +var legacyZkpReqKeys = map[string]string{ + "circuit_id_values": "system_id_values", + "circuit_proof_system_values": "proof_system_values", +} + +// refuseLegacyZkpReqKeys stops a deployment whose configuration still +// speaks the pre-v0.4 names. +// +// Startup rather than first presentation: the old keys would be +// carried as unknown members, the offered lists would be empty, and +// EVERY presentation would then be refused as naming a system the +// request never offered - which reads as a wallet problem. +func refuseLegacyZkpReqKeys(formats *openid4vp.VPFormatsSupported) error { + if formats == nil { + return nil + } + if _, legacy := formats.Extra["circuit_zk"]; legacy { + return fmt.Errorf("verifier.preferred_vp_formats.circuit_zk is the pre-v0.4 name; rename it to zkp_req") + } + if formats.ZkpReq == nil { + return nil + } + for old, replacement := range legacyZkpReqKeys { + if _, legacy := formats.ZkpReq.Extra[old]; legacy { + return fmt.Errorf("verifier.preferred_vp_formats.zkp_req.%s is the pre-v0.4 name; rename it to %s", + old, replacement) + } + } + return nil +} + // apiAuthenticationConfigured reports whether a caller reaching the admin // API has to be anybody in particular. // diff --git a/pkg/configuration/nymauth_verifier_test.go b/pkg/configuration/nymauth_verifier_test.go new file mode 100644 index 000000000..63fc819d1 --- /dev/null +++ b/pkg/configuration/nymauth_verifier_test.go @@ -0,0 +1,326 @@ +package configuration + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/SUNET/vc/pkg/helpers" + "github.com/SUNET/vc/pkg/logger" + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth" + "github.com/SUNET/vc/pkg/openid4vp" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func nymAuthCfg(t *testing.T) *model.Cfg { + t.Helper() + const content = "a circuit" + path := filepath.Join(t.TempDir(), "circuit.bin") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + sum := sha256.Sum256([]byte(content)) + + return &model.Cfg{Verifier: &model.Verifier{ + // A PDP is part of a valid nym_auth configuration, not an + // extra: the issuer key comes from the holder's own chain, + // so without one there is nothing that says the key belongs + // to a real issuer. + Trust: model.TrustConfig{PDPURL: "https://pdp.example/access/v1/evaluation"}, + NymAuth: model.NymAuthVerifier{ + Enable: true, + VerifierID: "x509_san_dns:verifier.example", + Circuits: model.NymAuthCircuits{Allow: []model.NymAuthCircuit{{ + SystemID: nymauth.SystemLongfellowNymAuth, + Config: nymauth.ConfigKnownOffenderMatchingSSC, + HashCircuitID: "5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729", + SigCircuitID: "00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb", + BlockEncHash: 6983, + BlockEncSig: 4679, + ArtifactPath: path, + ArtifactSHA256: hex.EncodeToString(sum[:]), + ArtifactBytes: int64(len(content)), + }}}, + }}} +} + +// Every one of these would otherwise surface as a REFUSED PROOF, with +// nothing pointing at the configuration. +func TestNymAuthVerifierConfigurationIsCheckedAtLoad(t *testing.T) { + require.NoError(t, checkNymAuthVerifier(nymAuthCfg(t)), "the fixture must be valid") + + t.Run("a prefixed Client Identifier is the verifier_id", func(t *testing.T) { + // Accepted under v0.4: the verifier_id is hashed exactly as + // published, prefix included, and the archive's reference + // pseudonyms are computed from that form. This was refused + // until 2026-10-20, when the rule reversed. + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = "x509_san_dns:verifier.example" + assert.NoError(t, checkNymAuthVerifier(cfg)) + }) + + t.Run("a prefix with no identifier after it", func(t *testing.T) { + // Names no Verifier, so it is refused - the one thing the + // reversal did not change. + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = "x509_san_dns:" + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "verifier_id") + }) + + t.Run("a semantic verifier_id is accepted whole", func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = "semantic_id:VATSE-556000000101" + assert.NoError(t, checkNymAuthVerifier(cfg)) + }) + + t.Run("no verifier_id at all", func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = "" + assert.Error(t, checkNymAuthVerifier(cfg)) + }) + + t.Run("enabled with no PDP", func(t *testing.T) { + // NewTrustEvaluatorFromConfig returns AllowAllEvaluator for + // an empty pdp_url, and it answers Trusted=true to every + // question. For nym_auth that means a chain the HOLDER + // minted is accepted as a trusted issuer - so the holder + // picks its own issuer key and therefore its own pseudonym, + // and a flagged person presents a value that matches nothing. + cfg := nymAuthCfg(t) + cfg.Verifier.Trust.PDPURL = "" + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "pdp_url") + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "holder's own certificate chain") + }) + + t.Run("enabled with nothing allowed", func(t *testing.T) { + // "On" and "cannot check a single proof" is not a state + // anybody means to configure. + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Allow = nil + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "no proof could be verified") + }) + + t.Run("a source that is not an http(s) URL", func(t *testing.T) { + // Still refused when the feature is ON. Moving the check out + // of the struct tag must not mean losing it. + for _, bad := range []string{"", "not-a-url", "ftp://catalog.example", "https://"} { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Sources = []string{bad} + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "sources[0]", + "source %q must be refused when nym_auth is enabled", bad) + } + + // And a good one is accepted, so this refuses bad values + // rather than the field. + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Sources = []string{"https://zk-circuits.fly.dev"} + assert.NoError(t, checkNymAuthVerifier(cfg)) + }) + + t.Run("an allowlist that cannot be resolved", func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Allow[0].Config = "something-else" + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "known-offender-matching") + }) + + // The ARTIFACTS too, not only the entries describing them. An + // entry can be well-formed and name a file that is not there, or + // one whose digest moved when it was rebuilt, and both would + // otherwise surface as a refused proof. + t.Run("a Ligero parameter left out", func(t *testing.T) { + // Zero is not a default here: the loader compares both + // against its allowed build and refuses - after reading and + // hashing the artifact - with a status code that says "bad + // circuit" and not "you left a number out". + for _, tc := range []struct { + name string + mutate func(*model.NymAuthCircuit) + want string + }{ + {"block_enc_hash", func(e *model.NymAuthCircuit) { e.BlockEncHash = 0 }, "block_enc_hash is zero"}, + {"block_enc_sig", func(e *model.NymAuthCircuit) { e.BlockEncSig = 0 }, "block_enc_sig is zero"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := nymAuthCfg(t) + tc.mutate(&cfg.Verifier.NymAuth.Circuits.Allow[0]) + assert.ErrorContains(t, checkNymAuthVerifier(cfg), tc.want) + }) + } + }) + + t.Run("an artifact that is not there", func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Allow[0].ArtifactPath = filepath.Join(t.TempDir(), "absent.bin") + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "circuit artifact") + }) + + t.Run("an artifact whose digest moved", func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Allow[0].ArtifactSHA256 = hex.EncodeToString(make([]byte, sha256.Size)) + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "hashes to") + }) + + t.Run("an artifact of the wrong length", func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Circuits.Allow[0].ArtifactBytes++ + assert.ErrorContains(t, checkNymAuthVerifier(cfg), "the allowlist says") + }) +} + +// A deployment with nym_auth OFF must start, whatever is left in the +// block - including a half-written allowlist entry. +// +// Struct-tag validation recurses into the subtree whether or not +// Enable is set, so a `required` tag on an entry field would stop a +// deployment that has the feature switched off. The entries are +// validated in NewAllowlist instead, which runs only when enabled. +func TestNymAuthVerifierDisabledWithAHalfWrittenEntryStillLoads(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Enable = false + cfg.Verifier.NymAuth.Circuits.Allow = []model.NymAuthCircuit{{Config: nymauth.ConfigKnownOffenderMatchingSSC}} + // A half-written SOURCE too, not only a half-written entry: the + // sources list carried an `httpurl` struct tag, which recursed + // the same way and stopped a disabled deployment over a URL + // nothing would fetch. + cfg.Verifier.NymAuth.Circuits.Sources = []string{"", "not-a-url"} + + require.NoError(t, checkNymAuthVerifier(cfg)) + + // And through the ordinary validator, which is the path that + // recursed into the subtree. The fixture is a bare Verifier so it + // fails on its own required fields; what matters is that NONE of + // the complaints is about nym_auth. + err := helpers.Check(t.Context(), cfg, cfg, logger.NewSimple("test")) + if err != nil { + assert.NotContains(t, err.Error(), "nym_auth", + "a disabled feature must not hold up startup over an entry nothing will read") + } +} + +// A deployment that does not verify pseudonym proofs is unaffected by +// any of it, including one that left a half-written block behind. +func TestNymAuthVerifierConfigurationIsSkippedWhenDisabled(t *testing.T) { + assert.NoError(t, checkNymAuthVerifier(nil)) + assert.NoError(t, checkNymAuthVerifier(&model.Cfg{})) + + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.Enable = false + cfg.Verifier.NymAuth.VerifierID = "x509_san_dns:verifier.example" + assert.NoError(t, checkNymAuthVerifier(cfg)) +} + +// The pre-v0.4 configuration names are refused at startup, by name, +// with the replacement. +// +// They are not aliased: rulebook v0.4 renamed them on the wire, and a +// verifier that quietly accepted the old spelling would send one +// shape while its operator believed it sent another. Worse, the old +// keys land in Extra rather than erroring on their own - so the +// offered lists would be empty, EVERY presentation would be refused +// as naming a system the request never offered, and that reads as a +// wallet problem rather than a configuration one. +func TestLegacyZkpReqConfigurationNamesAreRefused(t *testing.T) { + for _, tc := range []struct { + name string + formats *openid4vp.VPFormatsSupported + names string + }{ + { + name: "the circuit_zk format key", + formats: &openid4vp.VPFormatsSupported{Extra: map[string]any{"circuit_zk": map[string]any{}}}, + names: "zkp_req", + }, + { + name: "circuit_id_values", + formats: &openid4vp.VPFormatsSupported{ZkpReq: &openid4vp.ZkpReqFormat{ + Extra: map[string]any{"circuit_id_values": []any{"urn:c"}}, + }}, + names: "system_id_values", + }, + { + name: "circuit_proof_system_values", + formats: &openid4vp.VPFormatsSupported{ZkpReq: &openid4vp.ZkpReqFormat{ + Extra: map[string]any{"circuit_proof_system_values": []any{"longfellow-libzk-v1"}}, + }}, + names: "proof_system_values", + }, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.PreferredVPFormats = tc.formats + + err := checkNymAuthVerifier(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.names, "the message must name the replacement") + + // And with nym_auth switched OFF too: the configuration + // is still wrong, and a deployment that turns the feature + // on later would find out then rather than now. + cfg.Verifier.NymAuth.Enable = false + assert.Error(t, checkNymAuthVerifier(cfg), + "a pre-v0.4 name is wrong whether or not the feature is enabled") + }) + } + + // The new names are accepted, so this refuses the old spelling + // rather than the field. + cfg := nymAuthCfg(t) + cfg.Verifier.PreferredVPFormats = &openid4vp.VPFormatsSupported{ZkpReq: &openid4vp.ZkpReqFormat{ + SystemIDValues: []string{"urn:circuits:longfellow:nym_auth:v1"}, + ProofSystemValues: []string{"longfellow-libzk-v1"}, + }} + assert.NoError(t, checkNymAuthVerifier(cfg)) +} + +// A bare DNS name as verifier_id must stop startup. +// +// It was the CORRECT value until 2026-10-20, so every configuration +// written before the reversal still carries one - and after the +// reversal nothing was left checking the shape, so such a deployment +// started cleanly and produced pseudonyms no Verifier reports. A +// wrong answer about a person, with no error anywhere. +// +// Found by sirosid-dev starting a gdc image with exactly the value +// below. Rulebook v0.4 defines two forms and no others, so anything +// starting with neither is refused rather than hashed. +func TestBareVerifierIDIsRefusedAtStartup(t *testing.T) { + for _, bare := range []string{ + "sirosid-gdc-vc-verifier.fly.dev", // the value that started cleanly + "verifier.example", + "https://verifier.example", + "x509_hash:abcd", // a real Client Identifier Prefix, but not one of the two + "X509_SAN_DNS:verifier.example", // v0.4 normalises nothing, case included + } { + t.Run(bare, func(t *testing.T) { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = bare + + err := checkNymAuthVerifier(cfg) + require.Error(t, err, "a value that is neither defined form must not start") + assert.Contains(t, err.Error(), "x509_san_dns:", "the message names the accepted forms") + assert.Contains(t, err.Error(), "semantic_id:") + }) + } + + // A value that looks like a host gets the fix spelled out, since + // that is the mistake and the operator should not have to infer + // it from the list of forms. + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = "sirosid-gdc-vc-verifier.fly.dev" + assert.ErrorContains(t, checkNymAuthVerifier(cfg), + `"x509_san_dns:sirosid-gdc-vc-verifier.fly.dev"`, + "a DNS-shaped value should be told what it probably meant") + + // Both defined forms start. + for _, good := range []string{ + "x509_san_dns:sirosid-gdc-vc-verifier.fly.dev", + "semantic_id:age-over-18-check", + } { + cfg := nymAuthCfg(t) + cfg.Verifier.NymAuth.VerifierID = good + assert.NoErrorf(t, checkNymAuthVerifier(cfg), "%q is a defined form", good) + } +} diff --git a/pkg/configuration/preset_profiles.go b/pkg/configuration/preset_profiles.go new file mode 100644 index 000000000..965d340eb --- /dev/null +++ b/pkg/configuration/preset_profiles.go @@ -0,0 +1,140 @@ +package configuration + +import ( + "fmt" + "maps" + "slices" + "strings" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" +) + +// checkPresetValidationProfiles applies each preset credential's opt-in +// `validate:` profile, at startup. +// +// Startup is the only place this runs. The browser builds the DCQL query +// and posts it back with no preset identity attached, so by the time a +// request arrives there is nothing left to say which profile - if any - +// its author had in mind; and judging live traffic against a rulebook is +// not this verifier's job anyway, since sending a deliberately +// non-conformant request is how a wallet gets tested. An operator who +// wants to be told their preset violates the nym_auth rulebook asks by +// name, and is told while they are still editing configuration. +// +// Opt-in, so a config that names no profile is unaffected. +func checkPresetValidationProfiles(cfg *model.Cfg) error { + if cfg == nil || cfg.Verifier == nil { + return nil + } + var problems []string + for _, label := range slices.Sorted(maps.Keys(cfg.Verifier.Presets)) { + preset := cfg.Verifier.Presets[label] + for _, scope := range slices.Sorted(maps.Keys(preset.Credentials)) { + scopeCfg := preset.Credentials[scope] + if scopeCfg == nil || scopeCfg.Validate == "" { + continue + } + query := presetQuery(cfg, scope, scopeCfg) + if err := openid4vp.ValidateProfile(scopeCfg.Validate, query); err != nil { + problems = append(problems, fmt.Sprintf( + "verifier.presets.%q.credentials.%q (validate: %s): %v", label, scope, scopeCfg.Validate, err)) + } + } + } + if len(problems) > 0 { + return fmt.Errorf("preset validation profile failed:\n\t%s", strings.Join(problems, "\n\t")) + } + return nil +} + +// presetQuery reconstructs the credential query a preset would produce, +// the same way internal/verifier/apiv1 builds it for the UI: the scope's +// own credential_metadata supplies format and meta, and the preset's +// overrides are applied on top. +// +// Built here rather than imported from that package because the check has +// to run before any service wiring exists - and because a check that +// judged something other than what the UI publishes would be worse than +// no check at all. +func presetQuery(cfg *model.Cfg, scope string, scopeCfg *model.VerificationPresetScope) openid4vp.CredentialQuery { + query := openid4vp.CredentialQuery{ID: scope, Req: scopeCfg.Req} + if cfg.Common != nil { + if meta := cfg.Common.CredentialMetadata[scope]; meta != nil { + query.Format = meta.Format + if mq, ok := meta.DCQLMetaQuery(); ok { + query.Meta = mq + } + } + } + if scopeCfg.Format != "" { + query.Format = scopeCfg.Format + } + // Mirrors the derivation in internal/verifier/apiv1: a zkp_req + // request carries the scope's own stored format as + // meta.source_format, and is unanswerable without it. Judging a + // query that lacked it would be judging a request the UI never + // publishes. + if openid4vp.IsZkpReqFormat(query.Format) && cfg.Common != nil { + if meta := cfg.Common.CredentialMetadata[scope]; meta != nil { + query.Meta.SourceFormat = meta.Format + } + } + if len(scopeCfg.ZKSystemType) > 0 { + query.Meta.ZKSystemType = scopeCfg.ZKSystemType + } + // The UI drops a circuit_zk scope's claims entirely, so judging them + // here would report a conflict the wire never carries. Everything + // else keeps them, because for those formats the claims ARE the query. + if !openid4vp.IsZkpReqFormat(query.Format) { + for _, claim := range scopeCfg.Claims { + query.Claims = append(query.Claims, openid4vp.ClaimQuery{Path: stringPathToPtrPath(claim.Path)}) + } + } + return query +} + +func stringPathToPtrPath(path []string) []*string { + out := make([]*string, len(path)) + for i := range path { + out[i] = &path[i] + } + return out +} + +// checkZkpReqPresetsAskForSomething refuses a zkp_req preset whose `req` +// is empty, at startup, whether or not the preset opted into a profile. +// +// Unconditional because of how the key is spelled. v0.4 renamed the WIRE +// member from `req` to `_req`; the preset key - this repository's own +// configuration surface - stayed `req`. An operator reading the rulebook +// writes `_req`, and configuration is deliberately NOT loaded with +// UnmarshalStrict, so nothing says a word: the preset simply has no +// items, the request asks the wallet for nothing, and the wallet answers +// with nothing. That failure reads as the wallet's. +// +// The rule itself is the matcher's (req_missing_or_empty): a zkp_req +// query with no items is refused there too. This only says it at the +// point where it is still a configuration mistake. +func checkZkpReqPresetsAskForSomething(cfg *model.Cfg) error { + if cfg == nil || cfg.Verifier == nil { + return nil + } + var problems []string + for _, label := range slices.Sorted(maps.Keys(cfg.Verifier.Presets)) { + preset := cfg.Verifier.Presets[label] + for _, scope := range slices.Sorted(maps.Keys(preset.Credentials)) { + scopeCfg := preset.Credentials[scope] + if scopeCfg == nil || !openid4vp.IsZkpReqFormat(scopeCfg.Format) || len(scopeCfg.Req) > 0 { + continue + } + problems = append(problems, fmt.Sprintf( + "verifier.presets.%q.credentials.%q asks for format %s but its req is empty, so the request would ask the wallet for nothing; the preset key is `req` (the wire member it becomes is `_req`)", + label, scope, openid4vp.FormatZkpReq)) + } + } + if len(problems) > 0 { + return fmt.Errorf("zkp_req preset asks for nothing:\n\t%s", strings.Join(problems, "\n\t")) + } + return nil +} diff --git a/pkg/configuration/preset_profiles_test.go b/pkg/configuration/preset_profiles_test.go new file mode 100644 index 000000000..f08ba1466 --- /dev/null +++ b/pkg/configuration/preset_profiles_test.go @@ -0,0 +1,138 @@ +package configuration + +import ( + "testing" + + "github.com/SUNET/vc/pkg/mdoc" + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func presetCfg(scopeCfg *model.VerificationPresetScope) *model.Cfg { + schema := &mdoc.MDDLSchema{ + Format: "mso_mdoc", + DocType: "eu.europa.ec.eudi.nym_auth.1", + Claims: map[string]mdoc.NamespaceClaims{ + "eu.europa.ec.eudi.nym_auth.1": {"secret_seed_commitment": {Mandatory: true, ValueType: "bstr"}}, + }, + } + return &model.Cfg{ + Common: &model.Common{ + CredentialMetadata: map[string]*model.CredentialMetadata{ + "nym": {Format: "mso_mdoc", MDDL: schema}, + }, + }, + Verifier: &model.Verifier{ + Presets: map[string]model.PresetDefinition{ + "Nym": {Credentials: model.VerificationPreset{"nym": scopeCfg}}, + }, + }, + } +} + +func conformingScope() *model.VerificationPresetScope { + return &model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + Validate: openid4vp.ProfileNymAuth, + Req: []openid4vp.ReqItem{{ + FnID: openid4vp.FunctionPseudonym, + Return: "nym-123", + Params: map[string]any{"config": "known-offender-matching-ssc", "ver": openid4vp.PseudonymVer1, "prf": openid4vp.PrfSHA256}, + }}, + } +} + +// The profile judges the query the UI would PUBLISH, not the config +// struct. A check that judged something else would be worse than none: +// it would report on a request the verifier never sends. +func TestPresetProfileAcceptsAConformingPreset(t *testing.T) { + require.NoError(t, checkPresetValidationProfiles(presetCfg(conformingScope()))) +} + +func TestPresetProfileReportsWhereTheViolationIs(t *testing.T) { + scope := conformingScope() + scope.Req[0].Params["config"] = "not-a-rulebook-configuration" + + err := checkPresetValidationProfiles(presetCfg(scope)) + require.Error(t, err) + assert.Contains(t, err.Error(), "verifier.presets") + assert.Contains(t, err.Error(), `"Nym"`) + assert.Contains(t, err.Error(), `"nym"`) + assert.Contains(t, err.Error(), "not-a-rulebook-configuration") +} + +// The doctype comes from the scope's own credential_metadata, not from +// the preset - so a profile that requires one has to see it. Dropping the +// metadata is how the reconstruction would silently stop matching what +// the UI builds. +func TestPresetProfileReadsMetaFromCredentialMetadata(t *testing.T) { + cfg := presetCfg(conformingScope()) + require.NoError(t, checkPresetValidationProfiles(cfg)) + + cfg.Common.CredentialMetadata["nym"].MDDL = nil + err := checkPresetValidationProfiles(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "doctype_value") +} + +// Opt-in. A preset that names no profile is not judged, because a +// circuit_zk request that is not a nym_auth request is not invalid and +// this server is not the one to decide. +func TestPresetProfileIsOptIn(t *testing.T) { + scope := conformingScope() + scope.Validate = "" + scope.Req[0].Params["config"] = "not-a-rulebook-configuration" + require.NoError(t, checkPresetValidationProfiles(presetCfg(scope))) + + // Including the case with no req at all, which the profile requires. + require.NoError(t, checkPresetValidationProfiles(presetCfg(&model.VerificationPresetScope{ + Format: openid4vp.FormatZkpReq, + }))) +} + +// The UI drops a circuit_zk scope's claims, so judging them here would +// report a conflict the wire never carries. +func TestPresetProfileIgnoresClaimsTheUIWouldDrop(t *testing.T) { + scope := conformingScope() + scope.Claims = []model.VerificationPresetClaim{ + {Path: []string{"eu.europa.ec.eudi.nym_auth.1", "secret_seed_commitment"}}, + } + require.NoError(t, checkPresetValidationProfiles(presetCfg(scope))) +} + +// A nil scope value is a preset credential with no overrides at all, and +// a nil Verifier stanza is every non-verifier service. Both reach this. +func TestPresetProfileToleratesAbsentConfiguration(t *testing.T) { + require.NoError(t, checkPresetValidationProfiles(presetCfg(nil))) + require.NoError(t, checkPresetValidationProfiles(&model.Cfg{})) + require.NoError(t, checkPresetValidationProfiles(nil)) +} + +// `_req` is the wire member; `req` is the preset key. An operator +// reading the rulebook writes the wire name, configuration is not loaded +// with UnmarshalStrict, and the preset silently ends up with no items - +// a request that asks the wallet for nothing, answered with nothing. +func TestZkpReqPresetWithNoItemsIsRefused(t *testing.T) { + scope := conformingScope() + scope.Req = nil + + err := checkZkpReqPresetsAskForSomething(presetCfg(scope)) + require.Error(t, err) + assert.Contains(t, err.Error(), "verifier.presets") + assert.Contains(t, err.Error(), "`req`", + "the message has to name the key that works, or it sends the reader back to the rulebook") +} + +func TestZkpReqPresetCheckLeavesOtherFormatsAlone(t *testing.T) { + scope := conformingScope() + scope.Format = "mso_mdoc" + scope.Req = nil + + assert.NoError(t, checkZkpReqPresetsAskForSomething(presetCfg(scope))) +} + +func TestZkpReqPresetWithItemsPasses(t *testing.T) { + assert.NoError(t, checkZkpReqPresetsAskForSomething(presetCfg(conformingScope()))) +} diff --git a/pkg/mdoc/device_auth_bytes.go b/pkg/mdoc/device_auth_bytes.go index 496946fee..365abbeee 100644 --- a/pkg/mdoc/device_auth_bytes.go +++ b/pkg/mdoc/device_auth_bytes.go @@ -35,6 +35,23 @@ const encodedCBORTag = 24 // the two agreed with each other and disagreed with the specification. Both // wallet SDKs already emit the conformant shape, which is why they passed // proximity interop while this did not. +// BuildDeviceAuthenticationBytes is buildDeviceAuthenticationBytes for +// callers outside this package. +// +// Exported for the nym_auth verifier, which hashes this structure to +// derive the circuits' e2 input (nym-auth-circuits SPEC section 3) and +// must produce the same bytes a wallet signed over. Reusing the builder +// rather than re-deriving the shape is the point: this build and its +// verifier once shared the same WRONG shape and agreed with each other +// while disagreeing with ISO 18013-5, and a second implementation is +// how that happens again. +// +// Pass nil deviceNameSpaces for the empty DeviceNameSpaces the circuits +// specify. +func BuildDeviceAuthenticationBytes(sessionTranscript []byte, docType string, deviceNameSpaces []byte) ([]byte, error) { + return buildDeviceAuthenticationBytes(sessionTranscript, docType, deviceNameSpaces) +} + func buildDeviceAuthenticationBytes(sessionTranscript []byte, docType string, deviceNameSpaces []byte) ([]byte, error) { encoder, err := NewCBOREncoder() if err != nil { diff --git a/pkg/mdoc/verifier.go b/pkg/mdoc/verifier.go index 408324848..5aae6c8f8 100644 --- a/pkg/mdoc/verifier.go +++ b/pkg/mdoc/verifier.go @@ -338,12 +338,45 @@ func (v *Verifier) verifyCertificateChain(chain []*x509.Certificate) error { // This delegates all trust decisions to go-trust which can use mdociaca, ETSI TSL, // OpenID Federation, or other registries for dynamic trust anchor resolution. func (v *Verifier) verifyCertificateChainWithContext(ctx context.Context, chain []*x509.Certificate, docType string) error { + if err := VerifyIssuerChain(ctx, v.trustEvaluator, chain, docType, v.issuerURL, v.clock()); err != nil { + return err + } + + // TODO: Check revocation status if not skipped + if !v.skipRevocationCheck { + // Revocation checking would go here (CRL/OCSP) + } + + return nil +} + +// VerifyIssuerChain checks an issuer certificate chain's validity dates +// and asks go-trust whether the issuer is trusted for this docType. +// +// Exported so that a presentation format which carries the chain +// WITHOUT carrying a credential - a circuit_zk nym_auth proof, where +// the circuit proves "signed by Q" and the chain is what says Q +// belongs to a trusted issuer - is judged by exactly this, and not by +// a second implementation that could come to disagree about what +// "trusted issuer" means. +// +// issuerURL overrides the identifier taken from the certificate when a +// deployment configures one; otherwise it comes from the DS +// certificate's SANs. docType is the REQUEST's, never the response's: +// it is what selects the issuer policy, so letting a response choose +// it would let a holder pick the policy it is judged by. +func VerifyIssuerChain(ctx context.Context, evaluator trust.TrustEvaluator, chain []*x509.Certificate, docType, issuerURL string, now time.Time) error { if len(chain) == 0 { return errors.New("empty certificate chain") } + if evaluator == nil { + // Fail closed. An evaluator-less verifier cannot say whether + // an issuer is trusted, and "cannot say" must not read as + // "yes". + return errors.New("no trust evaluator configured, so no issuer can be trusted") + } dsCert := chain[0] - now := v.clock() // Check certificate validity period if now.Before(dsCert.NotBefore) { @@ -355,13 +388,13 @@ func (v *Verifier) verifyCertificateChainWithContext(ctx context.Context, chain // Extract issuer identifier for trust evaluation. // Priority: configured IssuerURL > certificate URI SAN > certificate Organization. - issuerID := v.issuerURL + issuerID := issuerURL if issuerID == "" { issuerID = extractMDocIssuerID(dsCert) } // Delegate trust decision to TrustEvaluator (go-trust) - decision, err := v.trustEvaluator.Evaluate(ctx, &trust.EvaluationRequest{ + decision, err := evaluator.Evaluate(ctx, &trust.EvaluationRequest{ SubjectID: issuerID, KeyType: trust.KeyTypeX5C, Key: chain, @@ -375,12 +408,6 @@ func (v *Verifier) verifyCertificateChainWithContext(ctx context.Context, chain if !decision.Trusted { return fmt.Errorf("issuer not trusted: %s", decision.Reason) } - - // TODO: Check revocation status if not skipped - if !v.skipRevocationCheck { - // Revocation checking would go here (CRL/OCSP) - } - return nil } diff --git a/pkg/model/config.go b/pkg/model/config.go index 1055177b8..9b6329df8 100644 --- a/pkg/model/config.go +++ b/pkg/model/config.go @@ -849,8 +849,17 @@ type PresetDefinition struct { // VerificationPresetScope defines optional overrides for a credential query within a preset. type VerificationPresetScope struct { // Claims lists specific claims to request. If empty, all VCTM claims are used. + // + // Ignored entirely when the effective format is "zkp_req". Such a + // query discloses no issuer-signed attribute at all - every value it + // yields is the output of a function named in Req - so neither these + // claims nor the VCTM fall-back are attached to it. Listing claims on + // a zkp_req credential is a configuration mistake rather than an + // instruction, and is dropped rather than honoured. Claims []VerificationPresetClaim `yaml:"claims,omitempty" validate:"omitempty,dive"` // ExcludeClaims lists claims to exclude from the DCQL query. + // + // Also ignored for "zkp_req", which carries no claims to exclude. ExcludeClaims []VerificationPresetClaim `yaml:"exclude_claims,omitempty" validate:"omitempty,dive"` // Validations are optional rules applied server-side after claims extraction Validations []openid4vp.ClaimValidation `yaml:"validations,omitempty" validate:"omitempty,dive"` @@ -864,6 +873,51 @@ type VerificationPresetScope struct { // DCQL query has no other way to say which proof system/circuit a // verifier accepts. See openid4vp.ZKSystemTypeSpec's own doc comment. ZKSystemType []openid4vp.ZKSystemTypeSpec `yaml:"zk_system_type,omitempty" validate:"omitempty,dive"` + + // Req is the zkp_req format's list of requested calculations - + // required whenever Format is "zkp_req", since such a query + // discloses nothing and every value comes from a function named here. + // + // A zkp_req preset needs TWO things to be published: a non-empty + // req here, and verifier.preferred_vp_formats.zkp_req advertising + // the format (an empty mapping will do - see that field). Without + // either, the preset is omitted and the reason is logged, rather than + // a button being offered that asks a wallet for a proof the request + // declares no support for. + // A worked example is in the "req entry" section below, which renders + // as prose rather than as a table cell - an indented one here is + // flattened into a single run-on line an operator cannot copy. + // + // Passed through to the wire as configured. Nothing judges what it + // MEANS unless Validate names a profile: this verifier is how wallets + // are tested, so a request that breaks a rulebook on purpose has to + // be sendable, and the specifications underneath are still moving. + // + // One transport restriction applies regardless, because it is not a + // judgement about the request. The picker builds the query in the + // browser and posts it back, where every number is a float64 - so a + // numeric param the browser cannot carry exactly (an integer past + // 2^53, or past what the shortest round-tripping decimal denotes; a + // decimal with more digits than a float64 holds) would reach the + // wallet CHANGED. A param is an input to a proof, so a changed one + // yields a different pseudonym, and the only symptom is two systems + // disagreeing about who someone is. + // + // Such a credential is omitted from the preset and the reason is + // logged, rather than published and quietly altered. Quote the value + // as a string if the exact digits are what you mean. + Req []openid4vp.ReqItem `yaml:"req,omitempty" validate:"omitempty,dive"` + + // Validate names a profile to check this credential's request against + // AT STARTUP. Empty means no checking, which is the default. + // + // Startup rather than request time, and named rather than a strict + // flag. An operator who wants to be told their preset violates the + // nym_auth rulebook asks for it by name and is told while they are + // still editing configuration; traffic is never validated, because a + // zkp_req request that is not a nym_auth request is not invalid and + // this server is not the one to decide. + Validate string `yaml:"validate,omitempty" validate:"omitempty,oneof=nym_auth" doc_example:"\"nym_auth\""` } // VerificationPresetClaim defines a claim path to request within a credential. @@ -905,7 +959,16 @@ type Verifier struct { AccessCertificate *AccessCertificate `yaml:"access_certificate,omitempty"` // RegistrationCertificate points at a Relying Party registration certificate (WRPRC, ETSI TS 119 475) issued to this verifier by a national Registrar, to be presented to wallets in the OpenID4VP verifier_info parameter. vc does not issue these. RegistrationCertificate *RegistrationCertificate `yaml:"registration_certificate,omitempty"` - // PreferredVPFormats specifies informational VP formats and algorithms supported by wallets + // PreferredVPFormats specifies informational VP formats and algorithms supported by wallets. + // + // Required for a zkp_req preset. The circuit filter - which proof + // systems and circuits this Verifier will accept a proof from - lives + // at preferred_vp_formats.zkp_req and nowhere else, so a preset + // using that format is NOT PUBLISHED unless it is set. An empty + // `zkp_req: {}` is a complete answer and means "any circuit"; no + // default is supplied, because those identifiers are + // deployment-specific and guessing one would pin this Verifier to a + // single implementation. PreferredVPFormats *openid4vp.VPFormatsSupported `yaml:"preferred_vp_formats,omitempty"` // SupportedWallets holds supported wallet configurations SupportedWallets map[string]string `yaml:"supported_wallets" validate:"omitempty"` @@ -939,6 +1002,12 @@ type Verifier struct { // verification. Only consulted by builds with the "zknative" Go build // tag (see pkg/mdoc/zk_native_cgo.go) - ignored by the default build. ZkCircuits ZkCircuitsConfig `yaml:"zk_circuits,omitempty"` + + // NymAuth configures verification of nym_auth pseudonym proofs + // (the "zkp_req" presentation format). Off unless enable is + // set, and only consulted by builds with the "zknative" Go build + // tag - the default build cannot check a proof at all. + NymAuth NymAuthVerifier `yaml:"nym_auth,omitempty"` // ZkKeyCache configures the on-disk Vega verifier-key store. Verifier // only: these keys exist to verify a presented proof, and the issuer // never touches one. diff --git a/pkg/model/nymauth_verifier.go b/pkg/model/nymauth_verifier.go new file mode 100644 index 000000000..5f5caea7f --- /dev/null +++ b/pkg/model/nymauth_verifier.go @@ -0,0 +1,188 @@ +package model + +// NymAuthVerifier configures verification of nym_auth pseudonym proofs. +// +// Separate from zk_circuits, which is the mdoc ZK path: that one +// resolves a circuit by the zkSystemId a presented document names, +// while this one selects a circuit by the pseudonym CONFIGURATION the +// verifier asked for. They are different questions and sharing one +// block would make the answer to each depend on the other. +type NymAuthVerifier struct { + // Enable turns nym_auth verification on. Off by default: a + // deployment that does not verify pseudonym proofs is unaffected + // by any of this existing. + Enable bool `yaml:"enable" doc_example:"true"` + + // VerifierID is the verifier_id this Verifier is known by, and + // SHA-256 of it is verifier_input - the value that scopes every + // pseudonym to this Verifier. + // + // CONFIGURED, and never derived from client_id. Rulebook v0.4 + // defines exactly two forms and the whole string is hashed, + // prefix included: + // + // x509_san_dns: + // semantic_id: + // + // Nothing is stripped and nothing is normalised, case included. A + // value that is neither form is refused at startup: the BARE DNS + // name was the correct value until 2026-10-20, so a configuration + // written before then still carries one, and hashing it produces + // a pseudonym no other implementation reports - a wrong answer + // about a person rather than an error anyone sees. + // + // See pkg/nymauth.CheckVerifierID. + VerifierID string `yaml:"verifier_id,omitempty" validate:"required_if=Enable true" doc_example:"\"x509_san_dns:verifier.example\""` + + // Circuits is where the circuit builds this Verifier will load + // come from. + Circuits NymAuthCircuits `yaml:"circuits,omitempty"` + + // ProofTimeSkewSeconds and ProofTimeMaxAgeSeconds bound how far a + // presentation's proof_time may be from this verifier's clock. + // + // T is the PROVER'S: the circuit binds all 20 bytes of it into + // its public inputs, so only the prover knows which second it + // proved at. This window is what makes accepting its value safe - + // without one a holder could replay a proof made long ago, + // against a credential since revoked, or one made with a clock + // set forward. + // + // Outside the window is REFUSED, never clamped: verifying against + // a T the prover did not use cannot succeed, and would report the + // wrong reason when it failed. + // + // Zero means the shared recommendation - 60 seconds of skew into + // the future, 600 seconds of age. They are recommendations and + // not protocol constants: the tolerance is this verifier's to + // set, and a deployment that has not thought about it should + // behave the way other implementations assume. + ProofTimeSkewSeconds int `yaml:"proof_time_skew_seconds,omitempty" doc_example:"60"` + ProofTimeMaxAgeSeconds int `yaml:"proof_time_max_age_seconds,omitempty" doc_example:"600"` +} + +// NymAuthCircuits configures which circuit builds may be loaded, and +// where their bytes come from. +type NymAuthCircuits struct { + // Allow is the operator's allowlist. NOTHING outside it is ever + // loaded. + // + // It is the only statement of which configuration a circuit + // implements. The pseudonym's domain separator is baked into the + // circuit build - it is a code constant in the prover, not a + // parameter - so ssc and psc are different artifacts with + // different ids, and nothing in the artifact itself tells this + // Verifier which one it has. Until the native library exposes a + // configuration accessor, the entry below is that statement, and + // an entry naming the wrong configuration would verify proofs + // against a pseudonym function nobody asked for. + // No struct-tag validation on the entries: the validator recurses + // into this subtree whether or not Enable is set, so a `required` + // here would stop a deployment that has nym_auth switched OFF and + // a half-written entry left behind. Every field is checked in + // circuits.NewAllowlist instead, which runs only when enabled - + // and checking in one place rather than two is what stops the two + // from disagreeing. + Allow []NymAuthCircuit `yaml:"allow,omitempty"` + + // Sources are zk-circuits catalog mirrors, used only by an + // allowlist entry that names a catalog id instead of a local file. + // + // Optional, and empty is the ordinary case today: the published + // catalog carries mdoc Longfellow entries only, with no nym_auth + // descriptors and none of the four parameters this ABI needs. An + // entry therefore supplies its own artifact. + // No struct-tag validation, for the same reason the entries above + // have none: the validator recurses into this subtree whether or + // not Enable is set, so an `httpurl` tag here stops a deployment + // that has nym_auth switched OFF and a half-written source left + // behind. Checked in circuits.NewAllowlist instead, which runs + // only when the feature is enabled. + Sources []string `yaml:"sources,omitempty"` +} + +// NymAuthCircuit is one allowed circuit build. +// +// Everything the native loader checks is here, because the loader +// checks the artifact against its own entry and refuses anything else +// before parsing it. An entry that does not match the artifact is a +// circuit that will not load, which is the right failure: the +// alternative is loading whatever was served. +type NymAuthCircuit struct { + // SystemID is the proof system this build belongs to, named by + // the catalog alias a presentation states in zkp_req's + // "system_id" - "urn:circuits:longfellow:nym_auth:v1" or + // "urn:circuits:vega:nym_auth:v1". + // + // First-class and REQUIRED, beside Config, because the two + // together are what selects a build. One verifier may allow both + // systems for the same pseudonym configuration - that is the + // point of naming it - and an entry that did not say which system + // it is would be an artifact handed to whichever circuit library + // happened to be asked for it. + // + // It does not change the pseudonym. The domain separator is built + // from Config alone ("nym_auth/v1/" + config), so the same subject + // under the same configuration has the same pseudonym whichever + // system proved it; the system decides only how the proof is + // checked. That is what makes it safe for the holder to choose + // between the systems a Verifier offers. + SystemID string `yaml:"system_id" doc_example:"\"urn:circuits:longfellow:nym_auth:v1\""` + + // Config is the pseudonym configuration this build implements - + // "known-offender-matching-ssc" or "known-offender-matching-psc". + // + // First-class, because it is how a request selects a build. The + // two produce DIFFERENT pseudonyms from the same inputs (the + // domain separator is "nym_auth/v1/" + config), so verifying + // against the wrong one yields a value that matches nothing and + // reads as an innocent subject. + Config string `yaml:"config" doc_example:"\"known-offender-matching-ssc\""` + + // HashCircuitID and SigCircuitID are the descriptor's circuit ids, + // hex as the catalog writes them. + // + // LONGFELLOW ONLY, with the two block_enc parameters below: they + // describe a Ligero build, and its loader refuses an artifact that + // does not match them. Required for a Longfellow entry and refused + // on a Vega one, which has no counterpart - Vega derives its + // verifier key from the R1CS shapes, so the artifact digest is the + // circuit's whole identity. + HashCircuitID string `yaml:"hash_circuit_id,omitempty" doc_example:"\"5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729\""` + SigCircuitID string `yaml:"sig_circuit_id,omitempty" doc_example:"\"00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb\""` + + // BlockEncHash and BlockEncSig are the Ligero block_enc parameters + // of the two circuits. Longfellow only, as above. + BlockEncHash uint32 `yaml:"block_enc_hash,omitempty" doc_example:"6983"` + BlockEncSig uint32 `yaml:"block_enc_sig,omitempty" doc_example:"4679"` + + // ArtifactPath is a local file holding the DECOMPRESSED circuit + // artifact. Mutually exclusive with ArtifactID. + ArtifactPath string `yaml:"artifact_path,omitempty" doc_example:"\"/etc/vc/circuits/nym_auth-ssc.bin\""` + + // ArtifactID names a circuit in the configured catalog sources, + // for a deployment whose catalog publishes nym_auth descriptors. + // Mutually exclusive with ArtifactPath. + ArtifactID string `yaml:"artifact_id,omitempty"` + + // ArtifactSHA256 and ArtifactBytes are over the DECOMPRESSED + // artifact, which is what the native loader hashes. + // + // Both, not either. The length is not redundant: it is checked + // before the bytes are read, so an artifact that is the wrong size + // is refused without hashing however much of it was served. + // The example values here are the REAL ones for the nym_auth + // build, taken from zk-cred-longfellow's src/nymauth/profile.rs. + // They are not decoration: gen_config_docs copies them verbatim + // into docs/CONFIGURATION.md, and an operator configuring this + // reads them there. + // + // This digest was previously a placeholder made by keeping the + // real value's first eight and last four hex characters and + // zeroing the middle, which made it survive exactly the check + // someone would perform on it. Never write a plausible-looking + // fake here; if a real value cannot be published, leave the + // example out. + ArtifactSHA256 string `yaml:"artifact_sha256" doc_example:"\"92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f\""` + ArtifactBytes int64 `yaml:"artifact_bytes" doc_example:"1062842"` +} diff --git a/pkg/model/nymauth_verifier_doc_example_test.go b/pkg/model/nymauth_verifier_doc_example_test.go new file mode 100644 index 000000000..98298b737 --- /dev/null +++ b/pkg/model/nymauth_verifier_doc_example_test.go @@ -0,0 +1,28 @@ +package model + +import ( + "reflect" + "strconv" + "testing" + + "github.com/SUNET/vc/pkg/nymauth" + "github.com/stretchr/testify/require" +) + +// The doc_example is what an operator copies into their configuration, +// so an example the loader would refuse is a configuration reference +// that teaches the wrong value. For verifier_id that is worse than a +// typo: the BARE DNS name was the correct value until 2026-10-20 and +// still reads as plausible, and hashing it yields a pseudonym no other +// implementation reports - a wrong answer about a person rather than an +// error anyone sees. +func TestVerifierIDDocExampleIsItselfAValidVerifierID(t *testing.T) { + field, found := reflect.TypeOf(NymAuthVerifier{}).FieldByName("VerifierID") + require.True(t, found, "VerifierID is gone; this guard is reading the wrong field") + + example, err := strconv.Unquote(field.Tag.Get("doc_example")) + require.NoError(t, err, "doc_example is a quoted YAML scalar") + + require.NoError(t, nymauth.CheckVerifierID(example), + "the configuration reference's own example would be refused at startup") +} diff --git a/pkg/nymauth/circuits/circuits.go b/pkg/nymauth/circuits/circuits.go new file mode 100644 index 000000000..68485e374 --- /dev/null +++ b/pkg/nymauth/circuits/circuits.go @@ -0,0 +1,375 @@ +// Package circuits resolves which nym_auth circuit build a verifier +// may load, and produces its bytes. +// +// Everything here is cgo-free and has no opinion about proofs: it +// decides WHICH artifact, checks that the artifact is the one the +// operator allowed, and hands over the bytes. Loading them into a +// verifier handle needs the native library and lives behind the +// zknative build tag. +// +// The split is deliberate. The ways to get this wrong - an entry +// naming the wrong system or configuration, an artifact that is not +// the one allowed, two entries claiming one of each - are all decidable +// without a circuit, and a package that needs a 1 MB artifact and a C +// toolchain to test is one that gets tested less. +package circuits + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "os" + "sort" + "sync" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth" +) + +// ErrNoSuchCircuit is returned when nothing in the allowlist implements +// the proof system and configuration asked for. +// +// A distinct error because it is an ordinary operator state - a +// Verifier that allows ssc being asked for psc, or one that allows only +// Longfellow being handed a Vega presentation - and not a fault. +var ErrNoSuchCircuit = errors.New("nymauth: no allowed circuit implements this proof system and pseudonym configuration") + +// Entry is one resolved allowlist entry: an allowed build, and how to +// get its bytes. +type Entry struct { + SystemID string + Config string + HashCircuitID string + SigCircuitID string + BlockEncHash uint32 + BlockEncSig uint32 + + // Path is the local artifact, when the entry names one. + Path string + // ID is the catalog circuit id, when the entry names one instead. + ID string + + SHA256 string + Bytes int64 +} + +// circuitKey is what selects a build: the proof system a presentation +// states, and the pseudonym configuration this Verifier asked for. +// +// Both, because neither alone identifies an artifact. One verifier may +// allow Longfellow and Vega for the same configuration - that is what +// lets a wallet choose between them - and the two are different +// artifacts with different parameters. +type circuitKey struct { + systemID string + config string +} + +func (k circuitKey) String() string { return k.systemID + " " + k.config } + +// Allowlist is the set of builds a Verifier may load, by proof system +// and configuration. +type Allowlist struct { + byKey map[circuitKey]Entry + + mu sync.Mutex + loaded map[circuitKey][]byte +} + +// NewAllowlist checks a configured allowlist and returns it resolved. +// +// At construction, so a deployment whose allowlist cannot be honoured +// fails at startup rather than at the first presentation - where the +// operator would see a refused proof and have no reason to suspect +// the configuration. +func NewAllowlist(cfg model.NymAuthCircuits) (*Allowlist, error) { + list := &Allowlist{byKey: make(map[circuitKey]Entry, len(cfg.Allow))} + + // The sources, here rather than as a struct tag, so a deployment + // with nym_auth off is not stopped by a source nothing will read. + for i, source := range cfg.Sources { + where := fmt.Sprintf("verifier.nym_auth.circuits.sources[%d]", i) + parsed, err := url.Parse(source) + if err != nil { + return nil, fmt.Errorf("%s: %q is not a URL: %w", where, source, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + // A catalog is fetched over the network; anything else is + // a value that will fail at the first fetch, with nothing + // pointing back at the configuration. + return nil, fmt.Errorf("%s: %q is not an http(s) URL", where, source) + } + if parsed.Host == "" { + return nil, fmt.Errorf("%s: %q has no host", where, source) + } + } + + for i, entry := range cfg.Allow { + where := fmt.Sprintf("verifier.nym_auth.circuits.allow[%d]", i) + + if err := nymauth.CheckSystemID(entry.SystemID); err != nil { + // The system decides which circuit library checks the + // proof. An entry that does not name one this build knows + // is an artifact with no verifier to hand it to. + return nil, fmt.Errorf("%s: %w", where, err) + } + + switch entry.Config { + case nymauth.ConfigKnownOffenderMatchingSSC, nymauth.ConfigKnownOffenderMatchingPSC: + default: + // Refused rather than passed through. The configuration + // decides the pseudonym's domain separator, so an + // unrecognised one is a build this code cannot reason + // about at all. + return nil, fmt.Errorf("%s: %q is not a known-offender-matching configuration", where, entry.Config) + } + + key := circuitKey{systemID: entry.SystemID, config: entry.Config} + if _, clash := list.byKey[key]; clash { + // Two builds for one system and configuration. Refused + // rather than resolved by order: both claim to implement + // the same pseudonym function for the same prover, only + // one can be right, and a verifier picking by position + // would verify against whichever the operator happened to + // write first. + // + // Two entries that differ only in system_id are NOT a + // clash - that is a verifier offering both proof systems, + // which is the whole reason the key has two parts. + return nil, fmt.Errorf("%s: a second circuit is allowed for system %q and configuration %q", where, entry.SystemID, entry.Config) + } + + // The four circuit parameters are LONGFELLOW'S, not nym_auth's. + // They describe a Ligero build - two circuit ids and the two + // block_enc parameters - and its loader refuses an artifact + // that does not match them. Vega has no counterpart: it + // derives its verifier key from the R1CS shapes themselves, + // so the artifact digest IS the circuit's identity and there + // is nothing else to pin. + // + // So they are required for one system and refused for the + // other. Refused rather than ignored, because a Vega entry + // carrying them is a Longfellow entry someone copied and + // edited, and ignoring them would let it start while naming + // parameters nothing will ever check. + if err := checkCircuitParams(entry, where); err != nil { + return nil, err + } + if _, err := hex.DecodeString(entry.ArtifactSHA256); err != nil || len(entry.ArtifactSHA256) != 2*sha256.Size { + return nil, fmt.Errorf("%s: artifact_sha256 is not a 64-character hex SHA-256", where) + } + if entry.ArtifactBytes <= 0 { + return nil, fmt.Errorf("%s: artifact_bytes must be positive", where) + } + if (entry.ArtifactPath == "") == (entry.ArtifactID == "") { + return nil, fmt.Errorf("%s: name exactly one of artifact_path and artifact_id", where) + } + if entry.ArtifactID != "" && len(cfg.Sources) == 0 { + // An id with nowhere to fetch it from. Refused rather + // than deferred: the catalog does not publish nym_auth + // descriptors today, so this is far more likely to be a + // configuration written against a catalog that does not + // exist than a source list forgotten. + return nil, fmt.Errorf("%s: names artifact_id %q but no verifier.nym_auth.circuits.sources are configured", where, entry.ArtifactID) + } + + list.byKey[key] = Entry{ + SystemID: entry.SystemID, + Config: entry.Config, + HashCircuitID: entry.HashCircuitID, + SigCircuitID: entry.SigCircuitID, + BlockEncHash: entry.BlockEncHash, + BlockEncSig: entry.BlockEncSig, + Path: entry.ArtifactPath, + ID: entry.ArtifactID, + SHA256: entry.ArtifactSHA256, + Bytes: entry.ArtifactBytes, + } + } + return list, nil +} + +// checkCircuitParams applies the per-system rule for the four +// Longfellow parameters. +func checkCircuitParams(entry model.NymAuthCircuit, where string) error { + switch entry.SystemID { + case nymauth.SystemLongfellowNymAuth: + if err := checkCircuitID(entry.HashCircuitID, where, "hash_circuit_id"); err != nil { + return err + } + if err := checkCircuitID(entry.SigCircuitID, where, "sig_circuit_id"); err != nil { + return err + } + if entry.HashCircuitID == entry.SigCircuitID { + return fmt.Errorf("%s: the hash and signature circuit ids are the same, which no real build has", where) + } + // Both Ligero parameters, and both non-zero. Dropping the + // struct tags (they ran whether or not the feature was + // enabled) moved every field's check here, and a zero + // block_enc is not a default - it is a parameter the loader + // compares against its allowed build and refuses, after the + // artifact has been read and hashed, with a status code that + // says "bad circuit" and not "you left a number out". + if entry.BlockEncHash == 0 { + return fmt.Errorf("%s: block_enc_hash is zero; it is the hash circuit's Ligero parameter and the loader checks it", where) + } + if entry.BlockEncSig == 0 { + return fmt.Errorf("%s: block_enc_sig is zero; it is the signature circuit's Ligero parameter and the loader checks it", where) + } + return nil + + case nymauth.SystemVegaNymAuth: + for _, named := range []struct { + what string + set bool + }{ + {"hash_circuit_id", entry.HashCircuitID != ""}, + {"sig_circuit_id", entry.SigCircuitID != ""}, + {"block_enc_hash", entry.BlockEncHash != 0}, + {"block_enc_sig", entry.BlockEncSig != 0}, + } { + if named.set { + return fmt.Errorf("%s: %s is a Longfellow parameter and this entry is %s; a Vega circuit is identified by its shapes digest alone, so nothing would ever check it", + where, named.what, entry.SystemID) + } + } + return nil + + default: + // Unreachable: CheckSystemID ran first. Here so a system + // added to that list without a rule here is a refusal rather + // than an entry whose parameters nothing checks. + return fmt.Errorf("%s: no circuit-parameter rule for system %q", where, entry.SystemID) + } +} + +func checkCircuitID(value, where, what string) error { + if _, err := hex.DecodeString(value); err != nil { + return fmt.Errorf("%s: %s is not hex", where, what) + } + if value == "" { + return fmt.Errorf("%s: %s is empty", where, what) + } + return nil +} + +// Preload reads and checks every LOCAL artifact. +// +// At startup, so a missing file, a wrong length or a wrong digest is a +// refusal to start rather than a refused proof later - which is what +// the configuration check promises and what NewAllowlist alone does +// not do, since it only sees the entry and never the bytes. +// +// Catalog ids are skipped deliberately: nothing resolves one yet, and +// refusing to start over an unresolvable id would be refusing over a +// feature that has not arrived. They are refused at load instead, with +// a message saying so. +func (a *Allowlist) Preload(ctx context.Context) error { + for key, entry := range a.byKey { + if entry.Path == "" { + continue + } + if _, err := a.Artifact(ctx, key.systemID, key.config); err != nil { + return err + } + } + return nil +} + +// Entry returns the allowed build for a proof system and configuration. +func (a *Allowlist) Entry(systemID, config string) (Entry, error) { + entry, ok := a.byKey[circuitKey{systemID: systemID, config: config}] + if !ok { + return Entry{}, fmt.Errorf("%w: %q %q", ErrNoSuchCircuit, systemID, config) + } + return entry, nil +} + +// Allowed names every allowed build, as " ", sorted +// so a startup log reads the same way twice. +func (a *Allowlist) Allowed() []string { + out := make([]string, 0, len(a.byKey)) + for key := range a.byKey { + out = append(out, key.String()) + } + sort.Strings(out) + return out +} + +// Artifact returns the decompressed circuit bytes for a +// configuration, checked against the allowlist entry and cached. +// +// Cached because a circuit is on the order of a megabyte and is read +// per presentation otherwise; the bytes are immutable once checked, so +// the cache cannot serve anything the first read did not verify. +// +// The checks are the operator's own statement, re-made here rather +// than left to the native loader. The loader does check - it refuses +// an artifact whose length, SHA-256 or parameters do not match its +// entry - but it refuses with a status code, and an operator reading +// "bad circuit" has no way to tell a corrupt download from a wrong +// digest from a stale path. +func (a *Allowlist) Artifact(ctx context.Context, systemID, config string) ([]byte, error) { + entry, err := a.Entry(systemID, config) + if err != nil { + return nil, err + } + key := circuitKey{systemID: systemID, config: config} + + a.mu.Lock() + defer a.mu.Unlock() + if cached, ok := a.loaded[key]; ok { + return cached, nil + } + + raw, err := a.read(ctx, entry) + if err != nil { + return nil, err + } + if int64(len(raw)) != entry.Bytes { + return nil, fmt.Errorf("nymauth: the circuit artifact for %s is %d bytes; the allowlist says %d", + key, len(raw), entry.Bytes) + } + sum := sha256.Sum256(raw) + if got := hex.EncodeToString(sum[:]); got != entry.SHA256 { + return nil, fmt.Errorf("nymauth: the circuit artifact for %s hashes to %s; the allowlist says %s", + key, got, entry.SHA256) + } + + if a.loaded == nil { + a.loaded = map[circuitKey][]byte{} + } + a.loaded[key] = raw + return raw, nil +} + +// read produces the artifact's bytes, without checking them. +func (a *Allowlist) read(_ context.Context, entry Entry) ([]byte, error) { + if entry.Path == "" { + // A catalog id. Nothing resolves one yet, and saying so is + // better than returning bytes from somewhere unintended: the + // published catalog carries no nym_auth descriptors, so there + // is nothing to fetch and no shape to fetch it in. + return nil, fmt.Errorf("nymauth: circuit %q %q is configured as catalog id %q, which this build cannot resolve yet; use artifact_path", + entry.SystemID, entry.Config, entry.ID) + } + + file, err := os.Open(entry.Path) + if err != nil { + return nil, fmt.Errorf("nymauth: circuit artifact for %q %q: %w", entry.SystemID, entry.Config, err) + } + defer file.Close() + + // Bounded by the allowlist's own declared length, plus one byte so + // an artifact that is too LONG is detected rather than silently + // truncated to the expected size and then hashed. + raw, err := io.ReadAll(io.LimitReader(file, entry.Bytes+1)) + if err != nil { + return nil, fmt.Errorf("nymauth: circuit artifact for %q %q: %w", entry.SystemID, entry.Config, err) + } + return raw, nil +} diff --git a/pkg/nymauth/circuits/circuits_test.go b/pkg/nymauth/circuits/circuits_test.go new file mode 100644 index 000000000..bb474ec25 --- /dev/null +++ b/pkg/nymauth/circuits/circuits_test.go @@ -0,0 +1,337 @@ +package circuits + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/nymauth" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// artifact writes a circuit artifact and returns its path, digest and +// length. +func artifact(t *testing.T, content string) (path, digest string, size int64) { + t.Helper() + path = filepath.Join(t.TempDir(), "circuit.bin") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + sum := sha256.Sum256([]byte(content)) + return path, hex.EncodeToString(sum[:]), int64(len(content)) +} + +func entryFor(t *testing.T, systemID, config, content string) model.NymAuthCircuit { + t.Helper() + path, digest, size := artifact(t, content) + entry := model.NymAuthCircuit{ + SystemID: systemID, + Config: config, + ArtifactPath: path, + ArtifactSHA256: digest, + ArtifactBytes: size, + } + if systemID == nymauth.SystemLongfellowNymAuth { + // The four Ligero parameters belong to Longfellow alone; a + // Vega entry carrying them is refused. + entry.HashCircuitID = "5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729" + entry.SigCircuitID = "00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb" + entry.BlockEncHash = 6983 + entry.BlockEncSig = 4679 + } + return entry +} + +func TestAllowlistResolvesAndCaches(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + require.NoError(t, err) + + assert.Equal(t, []string{nymauth.SystemLongfellowNymAuth + " " + nymauth.ConfigKnownOffenderMatchingSSC}, list.Allowed()) + + got, err := list.Artifact(t.Context(), nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + require.NoError(t, err) + assert.Equal(t, "a circuit", string(got)) + + // Served from the cache afterwards: removing the file must not + // change the answer, because the bytes were checked once and + // cannot change. + require.NoError(t, os.Remove(entry.ArtifactPath)) + again, err := list.Artifact(t.Context(), nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + require.NoError(t, err) + assert.Equal(t, got, again) +} + +// Nothing outside the allowlist is ever loaded, and asking for a +// configuration that is not on it is an ordinary operator state +// rather than a fault. +func TestAllowlistRefusesAnUnknownConfiguration(t *testing.T) { + list, err := NewAllowlist(model.NymAuthCircuits{ + Allow: []model.NymAuthCircuit{entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit")}, + }) + require.NoError(t, err) + + _, err = list.Entry(nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingPSC) + assert.ErrorIs(t, err, ErrNoSuchCircuit) + + _, err = list.Artifact(t.Context(), nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingPSC) + assert.ErrorIs(t, err, ErrNoSuchCircuit) +} + +// The artifact must be the one the operator allowed. The native +// loader checks too, but it answers with a status code; an operator +// reading "bad circuit" cannot tell a stale path from a wrong digest. +func TestAllowlistRefusesAnArtifactThatIsNotTheAllowedOne(t *testing.T) { + t.Run("the wrong digest", func(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + entry.ArtifactSHA256 = hex.EncodeToString(make([]byte, sha256.Size)) + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + require.NoError(t, err) + + _, err = list.Artifact(t.Context(), entry.SystemID, entry.Config) + assert.ErrorContains(t, err, "hashes to") + }) + + t.Run("shorter than the allowlist says", func(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + entry.ArtifactBytes++ + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + require.NoError(t, err) + + _, err = list.Artifact(t.Context(), entry.SystemID, entry.Config) + assert.ErrorContains(t, err, "the allowlist says") + }) + + t.Run("longer than the allowlist says", func(t *testing.T) { + // Detected rather than truncated to the declared length and + // then hashed, which would make a longer artifact whose + // prefix matches indistinguishable from the real one. + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit and then some") + entry.ArtifactBytes = int64(len("a circuit")) + sum := sha256.Sum256([]byte("a circuit")) + entry.ArtifactSHA256 = hex.EncodeToString(sum[:]) + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + require.NoError(t, err) + + _, err = list.Artifact(t.Context(), entry.SystemID, entry.Config) + assert.ErrorContains(t, err, "the allowlist says") + }) + + t.Run("a path that is not there", func(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + entry.ArtifactPath = filepath.Join(t.TempDir(), "absent.bin") + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + require.NoError(t, err) + + _, err = list.Artifact(t.Context(), entry.SystemID, entry.Config) + assert.ErrorContains(t, err, "circuit artifact") + }) +} + +// A system and a configuration together are a build, so two builds +// for one pair is a question with two answers. Refused at startup +// rather than resolved by order, which would verify against whichever +// the operator happened to write first. +func TestAllowlistRefusesTwoBuildsForOneSystemAndConfiguration(t *testing.T) { + first := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + second := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "another circuit") + + _, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{first, second}}) + assert.ErrorContains(t, err, "a second circuit is allowed for system") +} + +// Both configurations may be allowed at once, which is what a +// deployment verifying ssc and psc needs. +func TestAllowlistTakesBothConfigurations(t *testing.T) { + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{ + entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the ssc circuit"), + entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingPSC, "the psc circuit"), + }}) + require.NoError(t, err) + assert.Len(t, list.Allowed(), 2) + + ssc, err := list.Artifact(t.Context(), nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + require.NoError(t, err) + psc, err := list.Artifact(t.Context(), nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingPSC) + require.NoError(t, err) + assert.NotEqual(t, ssc, psc, "two configurations are two different builds") +} + +// An entry this code cannot reason about is refused at startup. +func TestAllowlistRefusesAMalformedEntry(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*model.NymAuthCircuit) + want string + }{ + {"an unknown configuration", func(e *model.NymAuthCircuit) { e.Config = "something-else" }, "not a known-offender-matching configuration"}, + {"an unknown system", func(e *model.NymAuthCircuit) { e.SystemID = "urn:circuits:nobody:nym_auth:v1" }, "is not a nym_auth system_id"}, + {"no system at all", func(e *model.NymAuthCircuit) { e.SystemID = "" }, "no system_id"}, + {"a non-hex circuit id", func(e *model.NymAuthCircuit) { e.HashCircuitID = "not-hex" }, "hash_circuit_id is not hex"}, + {"one circuit id used twice", func(e *model.NymAuthCircuit) { e.SigCircuitID = e.HashCircuitID }, "the same, which no real build has"}, + {"no block_enc_hash", func(e *model.NymAuthCircuit) { e.BlockEncHash = 0 }, "block_enc_hash is zero"}, + {"no block_enc_sig", func(e *model.NymAuthCircuit) { e.BlockEncSig = 0 }, "block_enc_sig is zero"}, + {"a digest that is not a SHA-256", func(e *model.NymAuthCircuit) { e.ArtifactSHA256 = "abcd" }, "64-character hex SHA-256"}, + {"no declared length", func(e *model.NymAuthCircuit) { e.ArtifactBytes = 0 }, "must be positive"}, + {"neither a path nor an id", func(e *model.NymAuthCircuit) { e.ArtifactPath = "" }, "exactly one of artifact_path and artifact_id"}, + {"both a path and an id", func(e *model.NymAuthCircuit) { e.ArtifactID = "urn:circuits:x" }, "exactly one of artifact_path and artifact_id"}, + } { + t.Run(tc.name, func(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + tc.mutate(&entry) + _, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + assert.ErrorContains(t, err, tc.want) + }) + } +} + +// A catalog id with no sources is refused at startup, and a catalog +// id with sources is refused at load - honestly, because nothing +// resolves one yet and returning bytes from somewhere unintended +// would be worse than saying so. +func TestAllowlistOnCatalogIDs(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + entry.ArtifactPath = "" + entry.ArtifactID = "urn:circuits:longfellow:nym_auth:v1" + + _, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + assert.ErrorContains(t, err, "no verifier.nym_auth.circuits.sources are configured") + + list, err := NewAllowlist(model.NymAuthCircuits{ + Sources: []string{"https://zk-circuits.fly.dev"}, + Allow: []model.NymAuthCircuit{entry}, + }) + require.NoError(t, err) + + _, err = list.Artifact(t.Context(), entry.SystemID, entry.Config) + assert.ErrorContains(t, err, "cannot resolve yet") +} + +// The point of keying on both: one verifier may allow Longfellow AND +// Vega for the same pseudonym configuration, which is what lets a +// wallet choose between the two systems a Verifier offers. Before the +// key had two parts this was a startup refusal. +func TestAllowlistTakesTwoProofSystemsForOneConfiguration(t *testing.T) { + longfellow := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the longfellow circuit") + vega := entryFor(t, nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the vega circuit") + + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{longfellow, vega}}) + require.NoError(t, err) + assert.Equal(t, []string{ + nymauth.SystemLongfellowNymAuth + " " + nymauth.ConfigKnownOffenderMatchingSSC, + nymauth.SystemVegaNymAuth + " " + nymauth.ConfigKnownOffenderMatchingSSC, + }, list.Allowed()) + + // Each resolves to ITS OWN artifact. One configuration, two + // builds, and the system is the only thing telling them apart - + // so a key that dropped it would serve one circuit's bytes to the + // other system's verifier. + fromLongfellow, err := list.Artifact(t.Context(), nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + require.NoError(t, err) + assert.Equal(t, "the longfellow circuit", string(fromLongfellow)) + + fromVega, err := list.Artifact(t.Context(), nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + require.NoError(t, err) + assert.Equal(t, "the vega circuit", string(fromVega)) +} + +// Allowing one system says nothing about the other. A presentation +// naming a system this verifier did not allow is an ordinary operator +// state, not a fault - and must not fall back to the system that IS +// allowed, which would check the proof with the wrong library. +func TestAllowlistDoesNotServeOneSystemForAnother(t *testing.T) { + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{ + entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the longfellow circuit"), + }}) + require.NoError(t, err) + + _, err = list.Entry(nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + assert.ErrorIs(t, err, ErrNoSuchCircuit) + + _, err = list.Artifact(t.Context(), nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + assert.ErrorIs(t, err, ErrNoSuchCircuit) +} + +// Preload reads every local artifact, across systems as well as +// configurations - so a deployment allowing both and shipping only one +// of the two files fails at startup rather than at the first +// presentation that happens to pick the missing one. +func TestPreloadChecksEveryAllowedSystem(t *testing.T) { + longfellow := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the longfellow circuit") + vega := entryFor(t, nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the vega circuit") + + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{longfellow, vega}}) + require.NoError(t, err) + require.NoError(t, list.Preload(t.Context())) + + // Now take away only the Vega artifact. Preload must notice: with + // a one-part key the Vega entry would have replaced or been + // replaced by the Longfellow one and only a single file would + // ever have been read. + require.NoError(t, os.Remove(vega.ArtifactPath)) + + fresh, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{longfellow, vega}}) + require.NoError(t, err) + err = fresh.Preload(t.Context()) + require.Error(t, err) + assert.ErrorContains(t, err, nymauth.SystemVegaNymAuth) +} + +// The four Ligero parameters describe a Longfellow build. Vega derives +// its verifier key from the shapes, so its artifact digest is the +// circuit's whole identity and there is nothing else to pin - and an +// entry that names them anyway is a copied Longfellow entry, refused +// rather than started with parameters nothing will check. +func TestAllowlistAppliesCircuitParametersPerSystem(t *testing.T) { + t.Run("Longfellow needs them", func(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*model.NymAuthCircuit) + want string + }{ + {"no hash_circuit_id", func(e *model.NymAuthCircuit) { e.HashCircuitID = "" }, "hash_circuit_id is empty"}, + {"no block_enc_sig", func(e *model.NymAuthCircuit) { e.BlockEncSig = 0 }, "block_enc_sig is zero"}, + } { + t.Run(tc.name, func(t *testing.T) { + entry := entryFor(t, nymauth.SystemLongfellowNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + tc.mutate(&entry) + _, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + assert.ErrorContains(t, err, tc.want) + }) + } + }) + + t.Run("Vega refuses them", func(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*model.NymAuthCircuit) + want string + }{ + {"a hash_circuit_id", func(e *model.NymAuthCircuit) { e.HashCircuitID = "5f8c75ae" }, "hash_circuit_id is a Longfellow parameter"}, + {"a sig_circuit_id", func(e *model.NymAuthCircuit) { e.SigCircuitID = "00513797" }, "sig_circuit_id is a Longfellow parameter"}, + {"a block_enc_hash", func(e *model.NymAuthCircuit) { e.BlockEncHash = 6983 }, "block_enc_hash is a Longfellow parameter"}, + {"a block_enc_sig", func(e *model.NymAuthCircuit) { e.BlockEncSig = 4679 }, "block_enc_sig is a Longfellow parameter"}, + } { + t.Run(tc.name, func(t *testing.T) { + entry := entryFor(t, nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "a circuit") + tc.mutate(&entry) + _, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + assert.ErrorContains(t, err, tc.want) + }) + } + }) + + t.Run("a Vega entry with none of them is accepted", func(t *testing.T) { + entry := entryFor(t, nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC, "the vega shapes") + require.Empty(t, entry.HashCircuitID, "the fixture must not supply Longfellow parameters for Vega") + list, err := NewAllowlist(model.NymAuthCircuits{Allow: []model.NymAuthCircuit{entry}}) + require.NoError(t, err) + + got, err := list.Artifact(t.Context(), nymauth.SystemVegaNymAuth, nymauth.ConfigKnownOffenderMatchingSSC) + require.NoError(t, err) + assert.Equal(t, "the vega shapes", string(got)) + }) +} diff --git a/pkg/nymauth/pseudonym.go b/pkg/nymauth/pseudonym.go index 98fe66676..e0b972239 100644 --- a/pkg/nymauth/pseudonym.go +++ b/pkg/nymauth/pseudonym.go @@ -41,7 +41,7 @@ const SeedBytes = 16 // after it and produce a different pseudonym. var nullInput = make([]byte, 32) -// Pseudonym computes pseudonym-v1-sha256: +// Pseudonym computes pseudonym: // // pseudonym = SHA256(DST || seed || prover_input || verifier_input) // DST = "nym_auth/v1/", UTF-8 @@ -100,64 +100,108 @@ const ( // checkVerifierID refuses an identifier no conforming Verifier could have // reported. // -// The hashed input is the BARE verifier_id - "verifier.example" for an -// x509_san_dns Verifier, "semantic_id:" for a semantic one - -// never the prefixed Client Identifier and never client_id as it appears -// in the request. verifier_id_type is agreed out of band and is not -// hashed. -// -// "Bare" is about the Client Identifier Prefix, not about colons. A -// semantic verifier_id carries "semantic_id:" as part of its own value -// and is hashed whole; refusing it would leave semantic deployments -// unable to derive verifier_input at all. -// -// WHICH RULEBOOK: nym_auth_rulebook PR #7, "verifier_input: SHA-256 over -// the bare verifier_id, never the prefixed client_id" (stacked on #5). -// Pinned explicitly because the published v0.3 on that repo's main says -// the opposite - its matching and account-recovery formulas hash the -// full prefixed identifier - so a reader comparing this against main -// will find a contradiction rather than a bug. #7 supersedes it, states -// the rule with worked digests, and marks the prefixed form MUST NOT: -// -// SHA256("verifier.example") = be6a6400...4a41 (MUST) -// SHA256("x509_san_dns:verifier.example") = 6eae85a0...6bab (MUST NOT) -// -// #7 also records that the nym_auth circuits SPEC section 3 still shows -// the prefixed form and that its reference values need regenerating; the -// prefix is not bound inside the circuit, which takes verifier_input as -// a 32-byte digest public input, so a conforming verifier passing the -// bare digest verifies against current circuit builds. -// -// So a PREFIXED value is refused here, which is the opposite of what this -// function did when it was written against the earlier reading. The -// reason for refusing rather than stripping is unchanged: hashing -// whatever it is given succeeds and returns a 32-byte pseudonym +// The hashed input is the verifier_id EXACTLY AS PUBLISHED, prefix and +// all: "x509_san_dns:verifier.example" for an x509_san_dns Verifier, +// "semantic_id:" for a semantic one. Nothing is stripped +// and nothing is normalised - not case, not a trailing dot. +// +// WHICH RULEBOOK: v0.4, and the archive circuits agree with it. This is +// the REVERSE of what this function did until 2026-10-20, when it +// hashed the bare DNS name and refused the prefixed form. Both readings +// have been written down at different times, so the worked digests are +// here to settle which one this code implements: +// +// SHA256("x509_san_dns:verifier.example") = 6eae85a0...6bab (MUST) +// SHA256("verifier.example") = be6a6400...4a41 (MUST NOT) +// +// The archive's own reference values are the tie-breaker: its +// pseudonym.json carries client_id "x509_san_dns:verifier.example" with +// verifier_input 6eae85a0..., which is the prefixed digest. +// +// What is refused is a value that names no Verifier: an empty one, or a +// marker with nothing after it. Those hash cleanly - every wrong value +// here does - and a 32-byte digest from a meaningless input is // indistinguishable from a correct one, so a remapping loop comparing -// against a Verifier's real pseudonym would simply never match, and the +// against a Verifier's real pseudonym would simply never match and the // operator would conclude the subject is not the one they are looking // for. A wrong answer, not an error. // -// Stripping the prefix would be the tempting repair and is worse: it -// would accept a caller that is confused about which value it holds, and -// a caller confused about that is one whose OTHER inputs deserve a second -// look. +// CheckVerifierID is checkVerifierID for callers outside this package - +// the verifier side, which derives verifier_input from the same value +// and must refuse the same inputs. One rule, one implementation: a +// second copy is how the issuer and the verifier end up hashing +// different things and blaming each other's pseudonyms. +func CheckVerifierID(verifierID string) error { return checkVerifierID(verifierID) } + func checkVerifierID(verifierID string) error { if verifierID == "" { return fmt.Errorf("nymauth: no verifier_id") } - if strings.HasPrefix(verifierID, VerifierIDX509SANDNS) { - return fmt.Errorf("nymauth: verifier_id %q carries the Client Identifier prefix %q; "+ - "the hashed input is the bare verifier_id, and hashing the prefixed form yields a pseudonym no Verifier reports", - verifierID, VerifierIDX509SANDNS) + + // Rulebook v0.4 defines exactly TWO forms. Anything else is + // refused rather than hashed. + // + // This is the half that went missing when the rule reversed on + // 2026-10-20. Before, a bare DNS name was the correct value and + // the prefixed form was refused; after, the prefixed form is + // correct - and nothing was left checking the shape at all, so a + // configuration still carrying the old bare value started + // cleanly and produced pseudonyms no Verifier reports. Found by + // sirosid-dev starting a gdc image with + // "sirosid-gdc-vc-verifier.fly.dev" configured. + // + // Fail closed, and no normalisation: the rulebook names two + // forms, so a third is a value nobody agreed the meaning of. + for _, prefix := range []string{VerifierIDX509SANDNS, VerifierIDSemantic} { + if verifierID == prefix { + // The marker with nothing after it names no Verifier. It + // hashes cleanly, like every other wrong value here, so + // it is refused rather than turned into a pseudonym + // nobody reports. + return fmt.Errorf("nymauth: verifier_id %q is the %s prefix with no identifier after it", + verifierID, strings.TrimSuffix(prefix, ":")) + } + if strings.HasPrefix(verifierID, prefix) { + return nil + } + } + + // A value that looks like a host is the likely mistake, and the + // likely fix is one prefix - so say it, rather than leaving the + // operator to infer it from the list of forms. + hint := "" + if looksLikeDNSName(verifierID) { + hint = fmt.Sprintf("; it looks like a DNS name, so the verifier_id is probably %q", + VerifierIDX509SANDNS+verifierID) + } + return fmt.Errorf("nymauth: verifier_id %q starts with neither %q nor %q, and rulebook v0.4 defines no other form%s", + verifierID, VerifierIDX509SANDNS, VerifierIDSemantic, hint) +} + +// looksLikeDNSName reports whether a value is plausibly a hostname +// someone forgot to prefix: dot-separated labels of letters, digits +// and hyphens, and nothing else. +// +// Only used to add a hint to a refusal, so a false negative costs a +// sentence and a false positive costs nothing - it is never a reason +// to accept a value. +func looksLikeDNSName(value string) bool { + if value == "" || strings.ContainsAny(value, ":/ ") || !strings.Contains(value, ".") { + return false } - if verifierID == VerifierIDSemantic { - // The semantic form with nothing after the colon names no - // Verifier. It hashes cleanly, like every other wrong value - // here, so it is refused rather than turned into a pseudonym - // nobody reports. - return fmt.Errorf("nymauth: verifier_id %q is the semantic form with no identifier after it", verifierID) + for _, label := range strings.Split(value, ".") { + if label == "" { + return false + } + for _, r := range label { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + default: + return false + } + } } - return nil + return true } // KnownOffenderMatching computes the pseudonym a Verifier identified by @@ -167,7 +211,7 @@ func checkVerifierID(verifierID string) error { // // prover_input is null; verifier_input is SHA256(verifier_id). // -// verifierID is the BARE identifier - "verifier.example" - without the +// verifierID is the published identifier - "x509_san_dns:verifier.example" - including the // Client Identifier prefix and without verifier_id_type, which is agreed // out of band and not hashed. See checkVerifierID. func KnownOffenderMatching(config string, seed []byte, verifierID string) ([]byte, error) { diff --git a/pkg/nymauth/pseudonym_test.go b/pkg/nymauth/pseudonym_test.go index 503db670f..10b287df0 100644 --- a/pkg/nymauth/pseudonym_test.go +++ b/pkg/nymauth/pseudonym_test.go @@ -35,11 +35,11 @@ func TestPseudonymIsTheSpecifiedConstruction(t *testing.T) { // under two configurations is unrelated - which is what stops an account // recovery pseudonym being usable for offender matching. func TestConfigurationSeparatesPseudonyms(t *testing.T) { - a, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example") + a, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) - b, err := KnownOffenderMatching(ConfigKnownOffenderMatchingPSC, testSeed, "rp.example") + b, err := KnownOffenderMatching(ConfigKnownOffenderMatchingPSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) - c, err := AccountRecovery(testSeed, "rp.example", "account-1") + c, err := AccountRecovery(testSeed, "x509_san_dns:rp.example", "account-1") require.NoError(t, err) assert.NotEqual(t, a, b) @@ -50,13 +50,13 @@ func TestConfigurationSeparatesPseudonyms(t *testing.T) { // Known-offender matching only works if one Verifier always sees the same // pseudonym for one person, and different Verifiers never do. func TestKnownOffenderMatchingPerVerifier(t *testing.T) { - first, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example") + first, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) - again, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example") + again, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) assert.Equal(t, first, again) - other, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "other.example") + other, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:other.example") require.NoError(t, err) assert.NotEqual(t, first, other) @@ -64,7 +64,7 @@ func TestKnownOffenderMatchingPerVerifier(t *testing.T) { // (This used to assert that the rulebook's two PREFIXED forms of one // Relying Party were unrelated; the prefix is no longer hashed, so // what is left is the plain property that different ids differ.) - nearby, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example.semantic") + nearby, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example.semantic") require.NoError(t, err) assert.NotEqual(t, first, nearby) } @@ -72,9 +72,9 @@ func TestKnownOffenderMatchingPerVerifier(t *testing.T) { // Account recovery is per verifier AND per context, which is what makes // it per account rather than per person. func TestAccountRecoveryIsPerContext(t *testing.T) { - one, err := AccountRecovery(testSeed, "rp.example", "account-1") + one, err := AccountRecovery(testSeed, "x509_san_dns:rp.example", "account-1") require.NoError(t, err) - two, err := AccountRecovery(testSeed, "rp.example", "account-2") + two, err := AccountRecovery(testSeed, "x509_san_dns:rp.example", "account-2") require.NoError(t, err) assert.NotEqual(t, one, two) } @@ -94,17 +94,17 @@ func TestInputValidation(t *testing.T) { _, err = Pseudonym("c", testSeed, good, good[:31]) assert.Error(t, err) - _, err = KnownOffenderMatching(ConfigAccountRecoverySSC, testSeed, "rp.example") + _, err = KnownOffenderMatching(ConfigAccountRecoverySSC, testSeed, "x509_san_dns:rp.example") assert.Error(t, err, "account recovery has a different verifier_input and must not come through here") _, err = KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "") assert.Error(t, err) // The rulebook bounds verifier_context at 1-64 bytes. - _, err = AccountRecovery(testSeed, "rp.example", "") + _, err = AccountRecovery(testSeed, "x509_san_dns:rp.example", "") assert.Error(t, err) - _, err = AccountRecovery(testSeed, "rp.example", string(bytes.Repeat([]byte("c"), 65))) + _, err = AccountRecovery(testSeed, "x509_san_dns:rp.example", string(bytes.Repeat([]byte("c"), 65))) assert.Error(t, err) - _, err = AccountRecovery(testSeed, "rp.example", string(bytes.Repeat([]byte("c"), 64))) + _, err = AccountRecovery(testSeed, "x509_san_dns:rp.example", string(bytes.Repeat([]byte("c"), 64))) assert.NoError(t, err) _, err = UserManagedXsite(testSeed, "") @@ -137,7 +137,7 @@ func TestSeedLengthIsFixedByTheRulebook(t *testing.T) { assert.Error(t, err, "a %d-byte seed must be refused", n) } - _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, bytes.Repeat([]byte{7}, 32), "rp.example") + _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, bytes.Repeat([]byte{7}, 32), "x509_san_dns:rp.example") assert.Error(t, err, "the configuration helpers must refuse it too, not just Pseudonym") _, err = Pseudonym("c", bytes.Repeat([]byte{7}, SeedBytes), good, good) @@ -160,21 +160,32 @@ func TestSeedLengthIsFixedByTheRulebook(t *testing.T) { // correct pseudonym that no Verifier will ever report, so a remapping // loop would simply never match and an operator would conclude the // subject is innocent. -func TestVerifierInputIsTheBareVerifierID(t *testing.T) { +func TestVerifierInputIsThePrefixedVerifierID(t *testing.T) { // The rulebook's worked digests, recomputed here rather than // copied as opaque constants: if either moves, this says which. bare := sha256.Sum256([]byte("verifier.example")) prefixed := sha256.Sum256([]byte("x509_san_dns:verifier.example")) - require.Equal(t, "be6a6400", hex.EncodeToString(bare[:])[:8], - "the rulebook's MUST digest for the bare verifier_id") require.Equal(t, "6eae85a0", hex.EncodeToString(prefixed[:])[:8], - "and its MUST NOT digest for the prefixed Client Identifier") + "rulebook v0.4's MUST digest for the prefixed verifier_id, and the archive's own verifier_input") + require.Equal(t, "be6a6400", hex.EncodeToString(bare[:])[:8], + "and the bare DNS name, which v0.4 says is NOT a verifier_id") for _, bad := range []string{ - "x509_san_dns:rp.example", // the form the rulebook marks MUST NOT + // A marker with nothing after it names no Verifier. "x509_san_dns:", - "semantic_id:", // the semantic form naming nobody + "semantic_id:", "", + // And rulebook v0.4 defines exactly two forms, so a value + // starting with neither is refused rather than hashed. The + // bare DNS name is the one that matters in practice: it was + // the CORRECT value until 2026-10-20, so any configuration + // written before then still carries it. + "rp.example", + "verifier.example", + "sirosid-gdc-vc-verifier.fly.dev", + "https://rp.example/path", + "x509_hash:abcd", + "X509_SAN_DNS:rp.example", } { _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, bad) assert.Errorf(t, err, "verifier_id %q is not what the rulebook hashes", bad) @@ -187,27 +198,25 @@ func TestVerifierInputIsTheBareVerifierID(t *testing.T) { // the guard is about the Client Identifier Prefix, not about // punctuation. // - // "semantic_id:rp.example" is in this list on purpose. The rulebook - // says that string is part of the `semantic` verifier_id VALUE and - // not a Client Identifier Prefix, so it is hashed whole. Refusing it - // would leave every semantic deployment unable to derive - // verifier_input, and the failure would look like a bad pseudonym - // rather than a rejected identifier. + // The two forms rulebook v0.4 defines, hashed exactly as + // published. What follows the marker is opaque: the rulebook + // constrains the form, not the identifier inside it. for _, good := range []string{ - "rp.example", - "verifier.example", + "x509_san_dns:rp.example", + "x509_san_dns:verifier.example", + "x509_san_dns:a.b.c.d.e", "semantic_id:rp.example", - "https://rp.example/path", - "x509_hash:abcd", + "semantic_id:age-over-18-check", + "semantic_id:anything at all, even with spaces", } { _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, good) - assert.NoErrorf(t, err, "%q is a bare verifier_id", good) + assert.NoErrorf(t, err, "%q is one of the two defined forms", good) } // And a semantic verifier_id is hashed WHOLE: the digest is over - // "semantic_id:rp.example", not over "rp.example". + // "semantic_id:rp.example", not over "x509_san_dns:rp.example". semantic := sha256.Sum256([]byte("semantic_id:rp.example")) - identifierOnly := sha256.Sum256([]byte("rp.example")) + identifierOnly := sha256.Sum256([]byte("x509_san_dns:rp.example")) require.NotEqual(t, semantic, identifierOnly) gotSemantic, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "semantic_id:rp.example") require.NoError(t, err) @@ -215,13 +224,20 @@ func TestVerifierInputIsTheBareVerifierID(t *testing.T) { require.NoError(t, err) assert.Equal(t, wantSemantic, gotSemantic) - // And the pseudonym really is computed over the bare form: the same - // Relying Party written the prefixed way is refused, so there is no - // pair of inputs that both succeed and disagree. - fromBare, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "verifier.example") + // The pseudonym really is derived from the PREFIXED string, with + // nothing added and nothing removed. + fromPrefixed, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:verifier.example") require.NoError(t, err) - want, err := Pseudonym(ConfigKnownOffenderMatchingSSC, testSeed, nullInput, bare[:]) + want, err := Pseudonym(ConfigKnownOffenderMatchingSSC, testSeed, nullInput, prefixed[:]) require.NoError(t, err) - assert.Equal(t, want, fromBare, - "verifier_input must be SHA256 of the bare id, with nothing prepended") + assert.Equal(t, want, fromPrefixed, + "verifier_input must be SHA256 of the verifier_id exactly as published") + + // And the bare DNS name is not a verifier_id at all, so it never + // reaches a digest - which is what stops a configuration written + // before the reversal from silently producing wrong pseudonyms. + _, err = KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "verifier.example") + require.Error(t, err) + assert.Contains(t, err.Error(), "x509_san_dns:verifier.example", + "the refusal should name the value the operator probably meant") } diff --git a/pkg/nymauth/systems.go b/pkg/nymauth/systems.go new file mode 100644 index 000000000..013087992 --- /dev/null +++ b/pkg/nymauth/systems.go @@ -0,0 +1,46 @@ +package nymauth + +import "fmt" + +// Proof systems a nym_auth circuit build can belong to, named by the +// system_id a presentation states. +// +// These are the catalog ALIASES, not the catalog ids and not the +// proofSystem names: the zk-circuits catalog gives each nym_auth mdoc +// entry an alias of this form, and that is what travels on the wire in +// zkp_req's "system_id" and in a Verifier's advertised +// system_id_values. The catalog's own id (nymauth-vega-mdoc-v1) and +// its proofSystem (vega-v1) are different strings for different jobs. +// +// The SD-JWT entries carry no alias yet, so there is nothing to name +// here for them - see +// https://github.com/SUNET/vc/issues/852. +const ( + SystemLongfellowNymAuth = "urn:circuits:longfellow:nym_auth:v1" + SystemVegaNymAuth = "urn:circuits:vega:nym_auth:v1" +) + +// Systems names every proof system this package knows a system_id for, +// in a stable order. +func Systems() []string { + return []string{SystemLongfellowNymAuth, SystemVegaNymAuth} +} + +// CheckSystemID refuses a system_id that is not one of the above. +// +// Refused rather than passed through, for the same reason an unknown +// configuration is: a system_id selects which circuit library checks +// the proof, and a value this build cannot map to one is a statement +// about a verifier that does not exist here. Letting it through would +// defer the failure to the first presentation, where an operator sees +// a refused proof and no reason to suspect the configuration. +func CheckSystemID(systemID string) error { + switch systemID { + case SystemLongfellowNymAuth, SystemVegaNymAuth: + return nil + case "": + return fmt.Errorf("nymauth: no system_id; it names the proof system a circuit belongs to, one of %v", Systems()) + default: + return fmt.Errorf("nymauth: %q is not a nym_auth system_id; known ones are %v", systemID, Systems()) + } +} diff --git a/pkg/nymauth/verifier/agecheck.go b/pkg/nymauth/verifier/agecheck.go new file mode 100644 index 000000000..05f87551c --- /dev/null +++ b/pkg/nymauth/verifier/agecheck.go @@ -0,0 +1,210 @@ +package verifier + +import ( + "fmt" + "time" +) + +// The bounds that DISABLE an age check, from nym-auth-circuits SPEC +// section 3. They are sentinel dates rather than absent values, because +// the circuit takes both bounds every time. +const ( + // NoLowerBound disables the earliest-birth-date bound, which is to + // say it sets no maximum age. + NoLowerBound = "0000-00-00" + // NoUpperBound disables the cutoff-birth-date bound, which is to say + // it sets no minimum age. + NoUpperBound = "9999-12-31" +) + +// MinimumRangeDays is the shortest span the rulebook allows between E +// and C when both are set. +// +// A range narrows what a proof reveals about a birth date to the window +// it answers for. A one-day window answers "were you born on this exact +// day", which is not an age check at all - it is a birth-date oracle the +// Verifier can walk. The rulebook sets the floor at a year. +// +// Counted INCLUSIVELY, as the rulebook counts it and as the startup +// profile check already did (pkg/openid4vp: le - ge + 1). Both ends are +// satisfying birth dates, so 2001-01-01..2001-12-31 is 365 days and not +// 364. The two counts disagreeing meant a preset could pass startup +// validation and then be refused here. +const MinimumRangeDays = 365 + +// AgeBounds are the circuits' E and C: the earliest and cutoff birth +// dates a proof is made against, as YYYY-MM-DD. +type AgeBounds struct { + // Earliest is E. NoLowerBound disables it. + Earliest string + // Cutoff is C. NoUpperBound disables it. + Cutoff string +} + +// AgeCheck is the rulebook's age_check parameters, as configured on the +// Verifier. Each bound is a YYYY-MM-DD date or empty for absent. +// +// Exactly the four comparisons the rulebook defines, and no others: a +// request cannot express "between these two dates" except by setting a +// lower and an upper bound, which is what Bounds does. +type AgeCheck struct { + // GE is "born on or after this date" - an inclusive lower bound. + GE string + // GT is "born after this date". One day later than GE. + GT string + // LE is "born on or before this date" - an inclusive upper bound. + LE string + // LT is "born before this date". One day earlier than LE. + LT string +} + +// Bounds maps an age_check to the circuits' (E, C). +// +// The mapping is from SPEC section 3, and the ±1 day on the exclusive +// forms is the whole of it: the circuit's comparisons are INCLUSIVE, so +// "gt d" has to be expressed as "ge d+1" and "lt d" as "le d-1". +// Passing the exclusive date through unchanged would quietly widen the +// window by a day at each end - which, for a check that decides whether +// someone is old enough, is a wrong answer for everyone born on the +// boundary. +func (a AgeCheck) Bounds() (AgeBounds, error) { + if a.GE != "" && a.GT != "" { + return AgeBounds{}, fmt.Errorf("nymauth: age_check sets both ge and gt; they are two spellings of one lower bound") + } + if a.LE != "" && a.LT != "" { + return AgeBounds{}, fmt.Errorf("nymauth: age_check sets both le and lt; they are two spellings of one upper bound") + } + + bounds := AgeBounds{Earliest: NoLowerBound, Cutoff: NoUpperBound} + + switch { + case a.GE != "": + if _, err := parseDate(a.GE, "ge"); err != nil { + return AgeBounds{}, err + } + bounds.Earliest = a.GE + case a.GT != "": + shifted, err := shiftDate(a.GT, 1, "gt") + if err != nil { + return AgeBounds{}, err + } + bounds.Earliest = shifted + } + + switch { + case a.LE != "": + if _, err := parseDate(a.LE, "le"); err != nil { + return AgeBounds{}, err + } + bounds.Cutoff = a.LE + case a.LT != "": + shifted, err := shiftDate(a.LT, -1, "lt") + if err != nil { + return AgeBounds{}, err + } + bounds.Cutoff = shifted + } + + if err := bounds.checkRange(); err != nil { + return AgeBounds{}, err + } + return bounds, nil +} + +// checkRange enforces the rulebook's minimum span between two SET +// bounds. +// +// Only when both are set: one bound alone is an ordinary age check and +// reveals nothing but which side of a date the subject falls on. It is +// the PAIR that narrows a birth date to a window, and a narrow enough +// window is an identifier. +func (b AgeBounds) checkRange() error { + if b.Earliest == NoLowerBound || b.Cutoff == NoUpperBound { + return nil + } + earliest, err := parseDate(b.Earliest, "the earliest birth date") + if err != nil { + return err + } + cutoff, err := parseDate(b.Cutoff, "the cutoff birth date") + if err != nil { + return err + } + if !cutoff.After(earliest) { + return fmt.Errorf("nymauth: the cutoff birth date %s is not after the earliest %s, so no birth date satisfies the request", + b.Cutoff, b.Earliest) + } + if days := int(cutoff.Sub(earliest).Hours()/24) + 1; days < MinimumRangeDays { + return fmt.Errorf("nymauth: the age range %s..%s spans %d days; the rulebook requires at least %d, because a narrower window identifies a birth date rather than checking an age", + b.Earliest, b.Cutoff, days, MinimumRangeDays) + } + return nil +} + +func parseDate(value, what string) (time.Time, error) { + parsed, err := time.Parse(time.DateOnly, value) + if err != nil { + return time.Time{}, fmt.Errorf("nymauth: %s %q is not a YYYY-MM-DD date: %w", what, value, err) + } + return parsed, nil +} + +// shiftDate moves a date by whole days, in UTC. +// +// AddDate rather than adding 24 hours: a day is not 24 hours in every +// zone, and a DST transition would move the date by two days or none. +// UTC throughout, so the result cannot depend on where the verifier +// runs. +func shiftDate(value string, days int, what string) (string, error) { + parsed, err := parseDate(value, what) + if err != nil { + return "", err + } + shifted := parsed.UTC().AddDate(0, 0, days) + + // The shift can leave the format. gt 9999-12-31 becomes + // "10000-01-01" and lt 0000-01-01 becomes "-0001-12-31", both 11 + // bytes where the circuits read exactly 10, and both were returned + // without error: checkRange only looks at a PAIR of set bounds, so + // a single shifted bound was never examined again. + // + // Refused here rather than clamped to the sentinel. Clamping would + // turn "born strictly after the last representable day" - a request + // nobody can satisfy - into "no lower bound", which everybody + // satisfies. That is the opposite answer, written silently. + if year := shifted.Year(); year < 0 || year > 9999 { + return "", fmt.Errorf("nymauth: %s %s shifts to year %d, which the circuits' 10-byte date cannot express", + what, value, year) + } + return shifted.Format(time.DateOnly), nil +} + +// checkAgeCheckValues validates a contribution built by hand rather +// than through AgeCheck.Bounds(). +// +// Bounds() parses every date it is given, but a CircuitInput is a plain +// map and anything may construct one - the generic layer checks only +// that the declared inputs are filled. So the dates are checked here, +// where "earliest" being a date is known, rather than at the native +// boundary, which reports it as a generic invalid input. +// +// The sentinels are the one exception, because they are not dates: +// they are how "no bound" is spelled. +func checkAgeCheckValues(values map[string][]byte) error { + for input, sentinel := range map[string]string{ + InputEarliest: NoLowerBound, + InputCutoff: NoUpperBound, + } { + value := string(values[input]) + if value == sentinel { + continue + } + if _, err := parseDate(value, input); err != nil { + return err + } + } + return AgeBounds{ + Earliest: string(values[InputEarliest]), + Cutoff: string(values[InputCutoff]), + }.checkRange() +} diff --git a/pkg/nymauth/verifier/agecheck_test.go b/pkg/nymauth/verifier/agecheck_test.go new file mode 100644 index 000000000..7b1b0f70f --- /dev/null +++ b/pkg/nymauth/verifier/agecheck_test.go @@ -0,0 +1,160 @@ +package verifier + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The circuits compare INCLUSIVELY, so the rulebook's exclusive forms +// have to be shifted by a day. Passing them through unchanged widens the +// window by a day at each end - a wrong answer for everyone born on the +// boundary, which is exactly the population an age check is about. +func TestAgeCheckMapsToInclusiveBounds(t *testing.T) { + for _, tc := range []struct { + name string + check AgeCheck + want AgeBounds + }{ + { + name: "ge passes through", + check: AgeCheck{GE: "2008-10-07"}, + want: AgeBounds{Earliest: "2008-10-07", Cutoff: NoUpperBound}, + }, + { + name: "gt is one day later", + check: AgeCheck{GT: "2008-10-07"}, + want: AgeBounds{Earliest: "2008-10-08", Cutoff: NoUpperBound}, + }, + { + name: "le passes through", + check: AgeCheck{LE: "2008-10-07"}, + want: AgeBounds{Earliest: NoLowerBound, Cutoff: "2008-10-07"}, + }, + { + name: "lt is one day earlier", + check: AgeCheck{LT: "2008-10-07"}, + want: AgeBounds{Earliest: NoLowerBound, Cutoff: "2008-10-06"}, + }, + { + name: "absent bounds disable both ends", + check: AgeCheck{}, + want: AgeBounds{Earliest: NoLowerBound, Cutoff: NoUpperBound}, + }, + { + name: "both ends, far enough apart", + check: AgeCheck{GE: "2000-01-01", LE: "2008-10-07"}, + want: AgeBounds{Earliest: "2000-01-01", Cutoff: "2008-10-07"}, + }, + { + // The shift must cross a month boundary correctly, which a + // naive day-of-month increment would not. + name: "gt at the end of a month", + check: AgeCheck{GT: "2008-01-31"}, + want: AgeBounds{Earliest: "2008-02-01", Cutoff: NoUpperBound}, + }, + { + // And a leap day, which is the case a 24-hour arithmetic + // would also get wrong. + name: "lt just after a leap day", + check: AgeCheck{LT: "2008-03-01"}, + want: AgeBounds{Earliest: NoLowerBound, Cutoff: "2008-02-29"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.check.Bounds() + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// A narrow window is a birth-date oracle rather than an age check: the +// Verifier can walk it. The rulebook floors the span at a year, and the +// floor applies only when BOTH bounds are set - one alone reveals +// nothing but which side of a date the subject falls on. +func TestAgeCheckRefusesANarrowRange(t *testing.T) { + _, err := AgeCheck{GE: "2008-01-01", LE: "2008-01-02"}.Bounds() + require.Error(t, err, "a two-day window identifies a birth date") + assert.Contains(t, err.Error(), "at least 365") + + // One day short, to pin the boundary rather than the order of + // magnitude. Inclusive: 2001 is not a leap year, so this window + // holds 364 satisfying birth dates. + _, err = AgeCheck{GE: "2001-01-01", LE: "2001-12-30"}.Bounds() + require.Error(t, err, "364 days is still short") + + // And exactly the floor passes. The same window the startup + // profile check accepts (le - ge + 1 = 365) has to be accepted + // here, or a preset passes startup and is refused at use. + _, err = AgeCheck{GE: "2001-01-01", LE: "2001-12-31"}.Bounds() + require.NoError(t, err, "365 inclusive days is the documented minimum, so it must be allowed") + + // A single bound is never a range, however it is written. + for _, check := range []AgeCheck{{GE: "2008-01-01"}, {LE: "2008-01-01"}, {GT: "2008-01-01"}, {LT: "2008-01-01"}} { + _, err := check.Bounds() + assert.NoErrorf(t, err, "%+v is one bound, not a window", check) + } +} + +// An empty or reversed window has no satisfying birth date at all, which +// is a configuration mistake rather than a request. +func TestAgeCheckRefusesAnImpossibleRange(t *testing.T) { + _, err := AgeCheck{GE: "2008-10-07", LE: "2000-01-01"}.Bounds() + require.Error(t, err) + assert.Contains(t, err.Error(), "no birth date satisfies") + + _, err = AgeCheck{GE: "2008-10-07", LE: "2008-10-07"}.Bounds() + require.Error(t, err, "a single-day window is not a range") +} + +// Two spellings of one bound is a configuration that cannot be honoured +// both ways, so it is refused rather than resolved by precedence. +func TestAgeCheckRefusesTwoSpellingsOfOneBound(t *testing.T) { + _, err := AgeCheck{GE: "2000-01-01", GT: "2000-01-01"}.Bounds() + require.Error(t, err) + assert.Contains(t, err.Error(), "lower bound") + + _, err = AgeCheck{LE: "2008-01-01", LT: "2008-01-01"}.Bounds() + require.Error(t, err) + assert.Contains(t, err.Error(), "upper bound") +} + +// A malformed date must not reach the circuit as a sentinel or a zero. +func TestAgeCheckRefusesAMalformedDate(t *testing.T) { + for _, check := range []AgeCheck{ + {GE: "2008-13-01"}, {GT: "not a date"}, {LE: "2008-02-30"}, {LT: "08-01-01"}, + } { + _, err := check.Bounds() + assert.Errorf(t, err, "%+v carries a date no circuit can use", check) + } +} + +// gt and lt shift the bound by a day, and the shift can leave the +// format the circuits read. +// +// Both of these returned successfully before: "10000-01-01" and +// "-0001-12-31", eleven bytes where exactly ten are read. checkRange +// only examines a PAIR of set bounds, so a lone shifted bound was never +// looked at again and the policy failed at the native boundary instead, +// as a malformed input rather than as the unsatisfiable request it is. +func TestAgeCheckRefusesAShiftOutOfTheDateFormat(t *testing.T) { + _, err := AgeCheck{GT: "9999-12-31"}.Bounds() + require.Error(t, err, "there is no day after the last representable one") + assert.Contains(t, err.Error(), "10000") + + _, err = AgeCheck{LT: "0000-01-01"}.Bounds() + require.Error(t, err, "there is no day before the first representable one") + assert.Contains(t, err.Error(), "cannot express") + + // The days either side of those edges still work, so the guard + // refuses the shift that leaves the format and nothing more. + bounds, err := AgeCheck{GT: "9999-12-30"}.Bounds() + require.NoError(t, err) + assert.Equal(t, "9999-12-31", bounds.Earliest) + + bounds, err = AgeCheck{LT: "0000-01-02"}.Bounds() + require.NoError(t, err) + assert.Equal(t, "0000-01-01", bounds.Cutoff) +} diff --git a/pkg/nymauth/verifier/backend.go b/pkg/nymauth/verifier/backend.go new file mode 100644 index 000000000..37889b83e --- /dev/null +++ b/pkg/nymauth/verifier/backend.go @@ -0,0 +1,57 @@ +package verifier + +import ( + "crypto/subtle" + "fmt" +) + +// Backend is one proof system's nym_auth verifier. +// +// Both systems answer the same question - does this proof hold for the +// statement this Verifier named, and what pseudonym does it reveal - +// so the dispatch holds one of these and does not care which system is +// behind it. What differs is everything below the interface: how the +// circuit is loaded, and what the native ABI takes. +// +// The returned pseudonym is the ONLY one a caller may act on. The +// value in the holder's response is a claim until a proof has been +// checked against it, and that happens here. +type Backend interface { + // Verify checks proof against req and the pseudonym the holder + // claimed, and returns the pseudonym this verifier may use. + Verify(req Request, claimed [32]byte, proof []byte) ([32]byte, error) + + // Close releases the loaded circuit. + Close() error +} + +// Compile-time proof that both systems satisfy it, in every build. The +// stub pair carries the same methods, so a build without the native +// libraries fails here too if one of them drifts. +var ( + _ Backend = (*NativeVerifier)(nil) + _ Backend = (*VegaVerifier)(nil) +) + +// ErrPseudonymMismatch is a proof that verifies for a different +// pseudonym than the holder claimed. +// +// Separate from a proof that does not verify at all, because it is a +// different fact: the proof is sound, and the response's results do +// not match what it proves. +var ErrPseudonymMismatch = fmt.Errorf("nymauth: the proof is valid but reveals a different pseudonym than the response claimed") + +// checkClaimed compares a revealed pseudonym with the claimed one in +// constant time. +// +// Needed by a backend whose ABI REVEALS the pseudonym rather than +// taking the claimed value and comparing internally - Vega does the +// former, Longfellow the latter. Constant time because the comparison +// is between a secret-derived identifier and a value an attacker +// supplies, and a byte-at-a-time answer would let one be searched for. +func checkClaimed(revealed, claimed [32]byte) error { + if subtle.ConstantTimeCompare(revealed[:], claimed[:]) != 1 { + return ErrPseudonymMismatch + } + return nil +} diff --git a/pkg/nymauth/verifier/challenge.go b/pkg/nymauth/verifier/challenge.go new file mode 100644 index 000000000..36e0d9664 --- /dev/null +++ b/pkg/nymauth/verifier/challenge.go @@ -0,0 +1,192 @@ +// Package verifier builds the nym_auth circuit verifier's request and +// checks its policy inputs. +// +// It holds the parts that need no native library: the challenge message +// the circuits bind a presentation to, the age bounds a request asks +// for, and the rules about which of these a Verifier may take from the +// holder (none of them). The native proof check is behind the zknative +// build tag - see the zknative/stub pair in pkg/mdoc for the shape. +// +// Every value here is the VERIFIER'S. T comes from its own clock, the +// age bounds from its own policy, e2 from its own session, and +// verifier_input from its own identity. Taking any of them from the +// holder's response would let the holder choose what it is proving. +package verifier + +import ( + "crypto/sha256" + "fmt" + + "github.com/SUNET/vc/pkg/mdoc" + + "github.com/fxamacker/cbor/v2" +) + +// coseProtectedES256 is the COSE protected header the circuits fix for +// the device signature: {1: -7}, ES256. Serialised, because that is what +// the Sig_structure carries. +var coseProtectedES256 = []byte{0xa1, 0x01, 0x26} + +// MinNonceChars is the shortest request nonce the challenge accepts. +// +// The nonce is what makes a proof THIS session's rather than a replay of +// an earlier one, so its length is the entropy of that binding. The +// shared vectors fix the floor at 16 characters; both native bindings +// refuse a shorter one, and so does this, because a verifier that +// accepted one would build a challenge no prover will match and read the +// resulting failure as a bad proof. +const MinNonceChars = 16 + +// jwkThumbprintBytes is the length of a reader public key JWK +// thumbprint, when the request advertised an encryption key. SHA-256, so +// exactly 32 bytes; any other length is a different value and refused +// rather than hashed. +const jwkThumbprintBytes = 32 + +// MdocSessionTranscript builds the SessionTranscript the circuits bind +// to, for a presentation delivered through the Digital Credentials API. +// +// SessionTranscript = cbor([null, null, +// ["OpenID4VPDCAPIHandover", bstr(SHA-256(cbor([origin, nonce, thumbprint])))]]) +// +// From the verifier's OWN session - the origin it served the page from +// and the nonce it issued - never from anything the response carries. A +// transcript derived from the holder's values would be a proof that the +// holder chose what to prove. +func MdocSessionTranscript(origin, nonce string, jwkThumbprint []byte) ([]byte, error) { + if origin == "" { + return nil, fmt.Errorf("nymauth: the challenge needs the calling origin, which is what binds the proof to the page that asked for it") + } + if nonce == "" { + return nil, fmt.Errorf("nymauth: the challenge needs the request nonce, which is what makes it this session's proof") + } + if len([]rune(nonce)) < MinNonceChars { + return nil, fmt.Errorf("nymauth: the request nonce is %d characters; the shared vectors require at least %d, and a shorter one weakens the only thing binding this proof to this session", + len([]rune(nonce)), MinNonceChars) + } + if jwkThumbprint != nil && len(jwkThumbprint) != jwkThumbprintBytes { + return nil, fmt.Errorf("nymauth: the reader key thumbprint is %d bytes; a SHA-256 thumbprint is %d, and hashing another length would produce a transcript no wallet reproduces", + len(jwkThumbprint), jwkThumbprintBytes) + } + transcript, err := mdoc.BuildOID4VPDCAPISessionTranscript(origin, nonce, jwkThumbprint) + if err != nil { + return nil, fmt.Errorf("nymauth: session transcript: %w", err) + } + return transcript, nil +} + +// MdocChallenge returns the bytes an mdoc device signature is computed +// over, for a SessionTranscript the caller already holds. +// +// nym-auth-circuits SPEC section 3: +// +// DeviceAuthenticationBytes = #6.24(bstr(cbor(["DeviceAuthentication", +// SessionTranscript, docType, #6.24(bstr(h'A0'))]))) +// message = cbor(["Signature1", bstr(h'A10126'), bstr(h''), +// bstr(DeviceAuthenticationBytes)]) +// +// The transcript and DeviceAuthenticationBytes come from pkg/mdoc rather +// than being re-derived here, because this has to be byte-identical to +// what the wallet signed and a second implementation of a CBOR shape is +// how two of them drift apart. +func MdocChallenge(sessionTranscript []byte, docType string) ([]byte, error) { + if err := checkSessionTranscript(sessionTranscript); err != nil { + return nil, err + } + if docType == "" { + return nil, fmt.Errorf("nymauth: the challenge needs the docType, which is what binds the proof to this credential type") + } + + // nil deviceNameSpaces is the empty DeviceNameSpaces the circuits + // specify - #6.24(bstr(h'A0')). + deviceAuth, err := mdoc.BuildDeviceAuthenticationBytes(sessionTranscript, docType, nil) + if err != nil { + return nil, fmt.Errorf("nymauth: device authentication: %w", err) + } + + encoder, err := mdoc.NewCBOREncoder() + if err != nil { + return nil, fmt.Errorf("nymauth: %w", err) + } + // Sig_structure for a COSE_Sign1 with detached payload: the external + // AAD is empty and the payload is the DeviceAuthenticationBytes. + message, err := encoder.Marshal([]any{ + "Signature1", + coseProtectedES256, + []byte{}, + deviceAuth, + }) + if err != nil { + return nil, fmt.Errorf("nymauth: encoding the signature structure: %w", err) + } + return message, nil +} + +// MdocChallengeDigest is SHA-256 of MdocChallenge: the circuits' e2. +func MdocChallengeDigest(sessionTranscript []byte, docType string) ([32]byte, error) { + message, err := MdocChallenge(sessionTranscript, docType) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(message), nil +} + +// MdocE2 derives the transcript from this session and returns its +// challenge digest - the two steps the native bindings call +// nymauth_derive_challenge and nymauth_challenge_digest. +func MdocE2(origin, nonce, docType string, jwkThumbprint []byte) ([32]byte, error) { + transcript, err := MdocSessionTranscript(origin, nonce, jwkThumbprint) + if err != nil { + return [32]byte{}, err + } + return MdocChallengeDigest(transcript, docType) +} + +// checkSessionTranscript refuses a transcript that is not exactly one +// three-item CBOR array. +// +// Each of these is in the shared reject vectors, and each would +// otherwise hash cleanly into a challenge no prover reproduces - which +// surfaces as a failed proof rather than as the malformed input it is: +// +// - empty; +// - a CBOR item that is not a 3-item array; +// - a valid array with TRAILING bytes, which a lenient decode ignores; +// - a truncated item. +func checkSessionTranscript(sessionTranscript []byte) error { + if len(sessionTranscript) == 0 { + return fmt.Errorf("nymauth: the challenge needs a session transcript") + } + var items []cbor.RawMessage + if err := cbor.Unmarshal(sessionTranscript, &items); err != nil { + // Covers truncation and trailing bytes: fxamacker reports both + // rather than decoding a prefix. + return fmt.Errorf("nymauth: the session transcript is not a single CBOR array: %w", err) + } + if len(items) != sessionTranscriptItems { + return fmt.Errorf("nymauth: the session transcript has %d items; ISO 18013-5 fixes it at %d", + len(items), sessionTranscriptItems) + } + + // And the CONTENTS, which the decode above does not look at. + // + // A cbor.RawMessage is bytes: fxamacker checks the shape of each + // item but not what is inside it, so a text string holding + // invalid UTF-8 decodes into one without complaint. That is not a + // curiosity - it is the same failure as every other case here. An + // ill-formed transcript hashes perfectly cleanly into a challenge + // no prover reproduces, and the resulting mismatch is reported as + // a bad proof rather than as the malformed input it is. + // + // Decoding into `any` forces each item to be interpreted, which + // is what applies the UTF-8 check. + var contents []any + if err := cbor.Unmarshal(sessionTranscript, &contents); err != nil { + return fmt.Errorf("nymauth: the session transcript is not well-formed CBOR: %w", err) + } + return nil +} + +// sessionTranscriptItems is the fixed length of a SessionTranscript +// array: DeviceEngagementBytes, EReaderKeyBytes, Handover. +const sessionTranscriptItems = 3 diff --git a/pkg/nymauth/verifier/challenge_vectors_test.go b/pkg/nymauth/verifier/challenge_vectors_test.go new file mode 100644 index 000000000..e2cfd89ca --- /dev/null +++ b/pkg/nymauth/verifier/challenge_vectors_test.go @@ -0,0 +1,198 @@ +package verifier + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// challengeVectors is test-vectors/nymauth/challenge-vectors.json, shared +// byte-for-byte between zk-cred-longfellow and zk-cred-vega. +type challengeVectors struct { + Version int `json:"version"` + MinNonceChars int `json:"min_nonce_chars"` + Derive []struct { + Name string `json:"name"` + Origin string `json:"origin"` + Nonce string `json:"nonce"` + DocType string `json:"doc_type"` + JWKThumbprintHex *string `json:"jwk_thumbprint_hex"` + SessionTranscriptHex string `json:"session_transcript_hex"` + ChallengeDigestHex string `json:"challenge_digest_hex"` + } `json:"derive"` + Digest []struct { + Name string `json:"name"` + SessionTranscriptHex string `json:"session_transcript_hex"` + DocType string `json:"doc_type"` + ChallengeDigestHex string `json:"challenge_digest_hex"` + } `json:"digest"` + Rejects []struct { + Name string `json:"name"` + Op string `json:"op"` + Origin string `json:"origin"` + Nonce string `json:"nonce"` + DocType string `json:"doc_type"` + JWKThumbprintHex *string `json:"jwk_thumbprint_hex"` + SessionTranscriptHex string `json:"session_transcript_hex"` + } `json:"rejects"` +} + +// The vendored copy must be the one both provers use. +// +// These vectors are what tells us this verifier and the two native +// provers agree about what a presentation is bound to. A local edit - +// or a stale copy after the upstream file moves - would turn that +// cross-implementation check into a self-consistency check that passes +// while the verifier and the provers disagree. +const challengeVectorsSHA256 = "697d016b6232249dce9f96b2a20c370bc1f5cd9941ffe59a784e4bb827baad41" + +func loadChallengeVectors(t *testing.T) challengeVectors { + t.Helper() + raw, err := os.ReadFile("testdata/challenge-vectors.json") + require.NoError(t, err) + + sum := sha256.Sum256(raw) + require.Equal(t, challengeVectorsSHA256, hex.EncodeToString(sum[:]), + "the vendored vectors differ from the shared file; re-vendor rather than edit, or this stops checking agreement with the provers") + + var v challengeVectors + require.NoError(t, json.Unmarshal(raw, &v)) + require.NotEmpty(t, v.Derive) + require.NotEmpty(t, v.Digest) + require.NotEmpty(t, v.Rejects) + return v +} + +// The transcript and e2 this verifier computes must be the bytes the +// provers compute. Not "a valid CBOR encoding" - the same bytes. +func TestChallengeMatchesTheSharedVectors(t *testing.T) { + vectors := loadChallengeVectors(t) + + require.Equal(t, MinNonceChars, vectors.MinNonceChars, + "the nonce floor is part of the shared contract, not a local choice") + + for _, tc := range vectors.Derive { + t.Run("derive: "+tc.Name, func(t *testing.T) { + var thumbprint []byte + if tc.JWKThumbprintHex != nil { + decoded, err := hex.DecodeString(*tc.JWKThumbprintHex) + require.NoError(t, err) + thumbprint = decoded + } + + transcript, err := MdocSessionTranscript(tc.Origin, tc.Nonce, thumbprint) + require.NoError(t, err) + assert.Equal(t, tc.SessionTranscriptHex, hex.EncodeToString(transcript), + "the session transcript must be the bytes the provers bind to") + + e2, err := MdocE2(tc.Origin, tc.Nonce, tc.DocType, thumbprint) + require.NoError(t, err) + assert.Equal(t, tc.ChallengeDigestHex, hex.EncodeToString(e2[:]), + "e2 must be the digest the provers prove against") + }) + } + + for _, tc := range vectors.Digest { + t.Run("digest: "+tc.Name, func(t *testing.T) { + transcript, err := hex.DecodeString(tc.SessionTranscriptHex) + require.NoError(t, err) + + digest, err := MdocChallengeDigest(transcript, tc.DocType) + require.NoError(t, err) + assert.Equal(t, tc.ChallengeDigestHex, hex.EncodeToString(digest[:])) + }) + } +} + +// There is NO minimum length on the session transcript here. +// +// Vega accepts a short one - the shared digest vector's transcript is 12 +// bytes, from the Kotlin fixture - while Longfellow's core API requires +// its transcript_seed to be 32..65536 bytes. That floor is LONGFELLOW'S, +// not this package's, and real DC API SessionTranscripts are over 50 +// bytes, so it only bites on fixtures. Adding the floor here would look +// like hardening and would instead refuse an input one of the two +// backends accepts, before either backend is consulted. +func TestNoMinimumTranscriptLength(t *testing.T) { + vectors := loadChallengeVectors(t) + + shortest := 0 + for _, tc := range vectors.Digest { + transcript, err := hex.DecodeString(tc.SessionTranscriptHex) + require.NoError(t, err) + if shortest == 0 || len(transcript) < shortest { + shortest = len(transcript) + } + _, err = MdocChallengeDigest(transcript, tc.DocType) + require.NoErrorf(t, err, "a %d-byte transcript is accepted by Vega and must not be refused here", len(transcript)) + } + assert.Less(t, shortest, 32, + "the vectors must still exercise a transcript shorter than Longfellow's own floor, or this guard proves nothing") +} + +// Every input the provers refuse, this verifier must refuse too. +// +// Accepting one would build a challenge no prover reproduces, and the +// resulting failure reads as a bad proof rather than as the malformed +// request it is - which is the hardest kind of mismatch to find, because +// the holder is blamed for it. +func TestChallengeRefusesWhatTheProversRefuse(t *testing.T) { + vectors := loadChallengeVectors(t) + + for _, tc := range vectors.Rejects { + t.Run(tc.Op+": "+tc.Name, func(t *testing.T) { + switch tc.Op { + case "derive": + var thumbprint []byte + if tc.JWKThumbprintHex != nil { + decoded, err := hex.DecodeString(*tc.JWKThumbprintHex) + require.NoError(t, err) + thumbprint = decoded + } + _, err := MdocE2(tc.Origin, tc.Nonce, tc.DocType, thumbprint) + assert.Error(t, err, "the provers refuse this input") + case "digest": + transcript, err := hex.DecodeString(tc.SessionTranscriptHex) + require.NoError(t, err) + _, err = MdocChallengeDigest(transcript, tc.DocType) + assert.Error(t, err, "the provers refuse this transcript") + default: + t.Fatalf("unknown reject op %q; this test is not covering it", tc.Op) + } + }) + } +} + +// A transcript whose CONTENTS are ill-formed is refused, not only one +// whose shape is. +// +// cbor.RawMessage is bytes - the shape decode never looks inside an +// item - so a text string holding invalid UTF-8 passed every check +// and hashed cleanly into a challenge no prover reproduces. The +// mismatch then surfaces as a bad proof rather than as the malformed +// input it is, which is the failure this whole function exists to +// prevent. +func TestChallengeRefusesAnIllFormedTranscript(t *testing.T) { + for name, encoded := range map[string]string{ + // [null, null, ""] + "invalid UTF-8 in a text string": "83f6f663fffefd", + // [null, null, true] with true spelled as the two-byte simple + // value 0xf8 0x15 rather than the canonical 0xf5. + "a non-canonical simple value": "83f6f6f815", + // [null, null, simple(0)] spelled with two bytes. + "a two-byte simple value under 24": "83f6f6f800", + } { + t.Run(name, func(t *testing.T) { + raw, err := hex.DecodeString(encoded) + require.NoError(t, err) + + _, err = MdocChallengeDigest(raw, "eu.europa.ec.eudi.nym_auth.1") + assert.Error(t, err, "an ill-formed transcript must be refused, not hashed") + }) + } +} diff --git a/pkg/nymauth/verifier/functions.go b/pkg/nymauth/verifier/functions.go new file mode 100644 index 000000000..dd28ea337 --- /dev/null +++ b/pkg/nymauth/verifier/functions.go @@ -0,0 +1,186 @@ +package verifier + +import ( + "fmt" + "maps" + "slices" + "sync" +) + +// FunctionAgeCheck is the rulebook's age predicate, and the first +// registered function. Its parameters map to the circuits' inclusive +// (E, C) - see AgeCheck. +const FunctionAgeCheck = "age_check" + +// Circuit input names FunctionAgeCheck fills. +const ( + InputEarliest = "earliest" + InputCutoff = "cutoff" +) + +// CircuitInput is one predicate's contribution to a circuit request: +// which function it is, and the circuit inputs it fills. +// +// Opaque to everything generic. The predicate's own mapping builds it - +// AgeCheck.Bounds().CircuitInput() for the age check - and the generic +// layer only ever asks whether the function is known, whether the +// selected circuit supports it, and whether it filled the inputs that +// function is declared to fill. A predicate over some claim nobody has +// written yet needs no change here. +type CircuitInput struct { + // FunctionID is the predicate function, e.g. FunctionAgeCheck. + FunctionID string + + // Values are the circuit inputs this predicate fills, keyed by the + // names its Function declares. + Values map[string][]byte +} + +// Function describes a predicate function this verifier can express. +// +// Predicates in circuit_zk apply to ANY claim, and the set of functions +// is OPEN-ENDED - unlike EC TS12, where the vocabulary is closed. So +// the generic layer is a registry rather than a list of fields: adding +// a function is registering one, and nothing in Request or Check has to +// learn about it. +// +// Which claim a predicate applies to is NOT here. It comes from the +// request's own source path when the presentation is matched, so a +// function is reusable across claims by construction. +type Function struct { + // ID is the function identifier the circuit and the catalog use. + ID string + + // Inputs are the circuit input names this function fills. Exactly + // these, no more and no fewer: a missing one would leave a circuit + // input unset, and an extra one is a value no circuit reads, which + // is a request that means something other than it appears to. + Inputs []string + + // Check, when set, validates this function's VALUES - the shapes + // only it knows. + // + // The generic layer checks that a contribution fills exactly the + // declared inputs and nothing more; it cannot check that + // "earliest" is a date, because it does not know that any input + // is. Without this, a CircuitInput built by hand rather than + // through AgeCheck.Bounds() carried whatever it liked into the + // circuit request, and the first thing to notice was the native + // boundary, reporting it as a generic invalid input. + // + // Per function and optional, for the same reason + // MinimumSelectivity is: a rule invented for every predicate would + // be a rule nobody specified. + Check func(values map[string][]byte) error + + // MinimumSelectivity, when non-empty, describes a privacy floor + // this function imposes on its own parameters - the age check's + // 365-day minimum range, for instance. + // + // Declared PER FUNCTION and enforced by the function's own mapping, + // not by the generic layer. A floor invented for every predicate + // would be a rule nobody specified: a range over birth dates + // narrows to an identifier, while a predicate over a document + // number has no analogue. + MinimumSelectivity string +} + +var ( + functionsMu sync.RWMutex + functions = map[string]Function{ + FunctionAgeCheck: { + ID: FunctionAgeCheck, + Inputs: []string{InputEarliest, InputCutoff}, + Check: checkAgeCheckValues, + MinimumSelectivity: "the range between a set earliest and cutoff birth date is at least 365 days", + }, + } +) + +// Register adds a predicate function. Re-registering an id replaces it, +// which is what a deployment extending the vocabulary wants; the +// registry is read far more often than written. +func Register(f Function) error { + if f.ID == "" { + return fmt.Errorf("nymauth: a predicate function needs an id") + } + if len(f.Inputs) == 0 { + return fmt.Errorf("nymauth: function %q fills no circuit inputs", f.ID) + } + functionsMu.Lock() + defer functionsMu.Unlock() + functions[f.ID] = Function{ + ID: f.ID, + Inputs: slices.Clone(f.Inputs), + Check: f.Check, + MinimumSelectivity: f.MinimumSelectivity, + } + return nil +} + +// LookupFunction returns a registered predicate function. +// +// Inputs is CLONED, as Register clones it on the way in. Returning the +// stored slice let a caller rewrite the registry by assigning to +// f.Inputs[0] - without calling Register, without the mutex, and +// racing every concurrent check - which would change what later +// requests are validated against. +func LookupFunction(id string) (Function, bool) { + functionsMu.RLock() + defer functionsMu.RUnlock() + f, ok := functions[id] + if !ok { + return Function{}, false + } + f.Inputs = slices.Clone(f.Inputs) + return f, true +} + +// RegisteredFunctions names every registered function, sorted. +func RegisteredFunctions() []string { + functionsMu.RLock() + defer functionsMu.RUnlock() + return slices.Sorted(maps.Keys(functions)) +} + +// check refuses an input that does not fill exactly what its function +// declares. +func (i CircuitInput) check() error { + f, ok := LookupFunction(i.FunctionID) + if !ok { + return fmt.Errorf("nymauth: %q is not a registered predicate function; registered: %v", + i.FunctionID, RegisteredFunctions()) + } + for _, name := range f.Inputs { + if len(i.Values[name]) == 0 { + return fmt.Errorf("nymauth: function %q leaves circuit input %q unset", i.FunctionID, name) + } + } + if f.Check != nil { + if err := f.Check(i.Values); err != nil { + return fmt.Errorf("nymauth: function %q: %w", i.FunctionID, err) + } + } + for name := range i.Values { + if !slices.Contains(f.Inputs, name) { + // A value no circuit reads. Refused rather than ignored: + // the request means something other than it appears to, and + // whoever wrote it believes the extra value is doing + // something. + return fmt.Errorf("nymauth: function %q sets %q, which it does not fill", i.FunctionID, name) + } + } + return nil +} + +// CircuitInput renders these bounds as the age check's contribution to +// a circuit request. +func (b AgeBounds) CircuitInput() CircuitInput { + return CircuitInput{ + FunctionID: FunctionAgeCheck, + Values: map[string][]byte{ + InputEarliest: []byte(b.Earliest), + InputCutoff: []byte(b.Cutoff), + }, + } +} diff --git a/pkg/nymauth/verifier/functions_test.go b/pkg/nymauth/verifier/functions_test.go new file mode 100644 index 000000000..8412bb009 --- /dev/null +++ b/pkg/nymauth/verifier/functions_test.go @@ -0,0 +1,172 @@ +package verifier + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Check() must be function-AGNOSTIC: a predicate it has never heard of, +// registered by a deployment, goes through exactly like the age check. +// +// Predicates in circuit_zk apply to any claim and the set of functions +// is open-ended, so a verifier that knows the vocabulary is a verifier +// that has to be changed for every new predicate. This drives a dummy +// function end to end to prove it does not. +func TestCheckIsFunctionAgnostic(t *testing.T) { + const dummy = "test.document_number_prefix" + require.NoError(t, Register(Function{ID: dummy, Inputs: []string{"prefix"}})) + + r := testRequest(t) + r.Inputs = []CircuitInput{{FunctionID: dummy, Values: map[string][]byte{"prefix": []byte("SE")}}} + r.SupportedFunctions = []string{dummy} + + require.NoError(t, r.Check(), + "a registered function the circuit supports needs no change to Request or Check") + + // Alongside the age check, since a request may carry several. + bounds, err := AgeCheck{GE: "2000-01-01"}.Bounds() + require.NoError(t, err) + r.Inputs = append(r.Inputs, bounds.CircuitInput()) + r.SupportedFunctions = append(r.SupportedFunctions, FunctionAgeCheck) + require.NoError(t, r.Check()) +} + +// Fail closed, in both directions: a function nobody registered, and a +// registered function the selected circuit does not support. +// +// Passing either would hand the circuit inputs it does not read, and +// the proof would verify against a statement nobody checked. +func TestUnsupportedFunctionsAreRefused(t *testing.T) { + const dummy = "test.unsupported" + require.NoError(t, Register(Function{ID: dummy, Inputs: []string{"value"}})) + + t.Run("a function nobody registered", func(t *testing.T) { + r := testRequest(t) + r.Inputs = []CircuitInput{{FunctionID: "test.never_registered", Values: map[string][]byte{"value": []byte("x")}}} + r.SupportedFunctions = []string{"test.never_registered"} + err := r.Check() + require.Error(t, err, "the circuit claiming to support it does not make it expressible here") + assert.Contains(t, err.Error(), "not a registered predicate function") + }) + + t.Run("a registered function this circuit does not support", func(t *testing.T) { + r := testRequest(t) + r.Inputs = []CircuitInput{{FunctionID: dummy, Values: map[string][]byte{"value": []byte("x")}}} + r.SupportedFunctions = []string{FunctionAgeCheck} + err := r.Check() + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support") + }) + + t.Run("no supported functions at all", func(t *testing.T) { + r := testRequest(t) + r.SupportedFunctions = nil + require.Error(t, r.Check(), + "an empty capability list means none, not all; the alternative fails open") + }) + + t.Run("a value the function does not fill", func(t *testing.T) { + r := testRequest(t) + r.Inputs = []CircuitInput{{FunctionID: dummy, Values: map[string][]byte{ + "value": []byte("x"), "smuggled": []byte("y"), + }}} + r.SupportedFunctions = []string{dummy} + err := r.Check() + require.Error(t, err, "a value no circuit reads makes the request mean something other than it appears to") + assert.Contains(t, err.Error(), "does not fill") + }) + + t.Run("one function contributing twice", func(t *testing.T) { + bounds, err := AgeCheck{GE: "2000-01-01"}.Bounds() + require.NoError(t, err) + r := testRequest(t) + r.Inputs = []CircuitInput{bounds.CircuitInput(), bounds.CircuitInput()} + require.Error(t, r.Check(), "the second would silently fill the same circuit inputs") + }) +} + +// The age check keeps its own privacy floor, declared on the function +// rather than invented for every predicate. +func TestAgeCheckDeclaresItsMinimumSelectivity(t *testing.T) { + f, ok := LookupFunction(FunctionAgeCheck) + require.True(t, ok) + assert.Contains(t, f.MinimumSelectivity, "365", + "the rulebook's rule belongs to this function; a generic one would be a rule nobody specified") + assert.Equal(t, []string{InputEarliest, InputCutoff}, f.Inputs) + + // And it is still enforced where it is specified: in the mapping. + _, err := AgeCheck{GE: "2008-01-01", LE: "2008-01-02"}.Bounds() + assert.ErrorContains(t, err, "at least 365") +} + +// Registration refuses what cannot be used. +func TestRegisterRefusesAnUnusableFunction(t *testing.T) { + assert.Error(t, Register(Function{Inputs: []string{"x"}}), "a function needs an id") + assert.Error(t, Register(Function{ID: "test.no_inputs"}), "a function that fills nothing cannot contribute") +} + +// A looked-up function must not be a handle on the registry. +// +// Inputs was the stored slice, so assigning to f.Inputs[0] rewrote +// what every later request is validated against - without calling +// Register, without the mutex, and racing concurrent checks. +func TestLookupFunctionDoesNotShareTheRegistrysInputs(t *testing.T) { + const id = "test.lookup_aliasing" + require.NoError(t, Register(Function{ID: id, Inputs: []string{"value"}})) + + f, ok := LookupFunction(id) + require.True(t, ok) + require.Equal(t, []string{"value"}, f.Inputs) + + f.Inputs[0] = "something-else" + + again, ok := LookupFunction(id) + require.True(t, ok) + assert.Equal(t, []string{"value"}, again.Inputs, + "a caller writing to the returned slice must not change the registry") +} + +// A contribution built by hand must be checked as thoroughly as one +// built through the predicate's own mapping. +// +// The generic layer checks only that the declared inputs are filled - +// it cannot know that "earliest" is a date. A CircuitInput is a plain +// map, so a request assembled directly carried whatever it liked into +// the circuit, and the first thing to notice was the native boundary, +// reporting it as a generic invalid input. +func TestCircuitInputChecksTheFunctionsOwnValues(t *testing.T) { + ageInput := func(earliest, cutoff string) CircuitInput { + return CircuitInput{FunctionID: FunctionAgeCheck, Values: map[string][]byte{ + InputEarliest: []byte(earliest), + InputCutoff: []byte(cutoff), + }} + } + + t.Run("a non-date paired with a sentinel", func(t *testing.T) { + // checkRange returns early when either bound is a sentinel, so + // nothing looked at the other one. + assert.Error(t, ageInput("not-a-date", NoUpperBound).check()) + assert.Error(t, ageInput(NoLowerBound, "2008-13-01").check()) + }) + + t.Run("a window narrower than the rulebook allows", func(t *testing.T) { + assert.ErrorContains(t, ageInput("2001-01-01", "2001-06-01").check(), "at least 365") + }) + + t.Run("the sentinels themselves are not dates and are fine", func(t *testing.T) { + assert.NoError(t, ageInput(NoLowerBound, NoUpperBound).check()) + }) + + t.Run("a real window is accepted", func(t *testing.T) { + assert.NoError(t, ageInput(NoLowerBound, "2008-10-07").check()) + assert.NoError(t, ageInput("2001-01-01", "2001-12-31").check()) + }) + + t.Run("a function with no Check is unaffected", func(t *testing.T) { + const id = "test.no_value_check" + require.NoError(t, Register(Function{ID: id, Inputs: []string{"value"}})) + assert.NoError(t, CircuitInput{FunctionID: id, Values: map[string][]byte{"value": []byte("anything")}}.check()) + }) +} diff --git a/pkg/nymauth/verifier/native.go b/pkg/nymauth/verifier/native.go new file mode 100644 index 000000000..198dccfd5 --- /dev/null +++ b/pkg/nymauth/verifier/native.go @@ -0,0 +1,137 @@ +package verifier + +import ( + "errors" + "fmt" + "math/big" +) + +// ErrNativeNotBuilt is what every native entry point returns in a build +// without the "zknative" tag, which is every default build of this repo +// (CGO_ENABLED=0, fully static). See native_cgo.go and native_stub.go. +var ErrNativeNotBuilt = errors.New("nymauth: this binary was built without the zknative tag, so no nym_auth proof can be checked") + +// CircuitParams are the catalog descriptor's parameters for a nym_auth +// circuit build, passed to the native loader alongside the artifact. +// +// The loader checks the artifact against its own allowlist entry - its +// length, its SHA-256 and these parameters - and refuses anything else +// before parsing it. They are not advisory. +type CircuitParams struct { + BlockEncHash uint32 + BlockEncSig uint32 + // HashCircuitID and SigCircuitID are the descriptor's circuit ids, + // hex as the catalog writes them. + HashCircuitID string + SigCircuitID string +} + +// coordinateBytes is the width of a P-256 coordinate, which is how the +// circuits take Q: big-endian, fixed width, never minimally encoded. +const coordinateBytes = 32 + +// Bounds on the transcript seed THIS C ABI takes, from +// zk_cred_longfellow_nymauth.h. +// +// Here and not in Request.Check, because they are this backend's and +// not the rulebook's: the shared digest vector's transcript is 12 +// bytes and derives a correct e2, so a request carrying it is valid +// and simply cannot be verified by this library. Enforced at the +// flattening, where the length can be named, rather than at the +// native boundary, which reports it as a generic invalid input. +const ( + MinTranscriptBytes = 32 + MaxTranscriptBytes = 65536 +) + +// NativeRequest is Request flattened to exactly what the nym_auth C ABI +// takes (NymAuthCRequest in zk_cred_longfellow_nymauth.h). +// +// Separate from Request, and derived rather than built, for two +// reasons. The mapping is testable in a build without the native +// library, which is where most of the ways to get it wrong live; and +// the ABI's shape is the ABI's, not the request's - today it has one +// slot per age bound and no general predicate vector, and Request is +// deliberately not written that way (see functions.go). +type NativeRequest struct { + IssuerKeyX, IssuerKeyY [coordinateBytes]byte + Time string + Earliest, Cutoff string + ChallengeDigest [32]byte + VerifierInput [32]byte + TranscriptSeed []byte +} + +// Native flattens a checked request for the native verifier. +// +// It refuses any predicate the ABI has no slot for. The C request +// carries an age window and nothing else, so a function the circuits +// would evaluate but this ABI cannot carry has to fail here: passing +// the request anyway would check the holder's proof against a statement +// missing one of the verifier's conditions, and report it as verified. +func (r Request) Native() (NativeRequest, error) { + if err := r.Check(); err != nil { + return NativeRequest{}, err + } + + timeStr, err := r.TimeString() + if err != nil { + return NativeRequest{}, err + } + + e2, err := r.E2() + if err != nil { + return NativeRequest{}, err + } + + if n := len(r.SessionTranscript); n < MinTranscriptBytes || n > MaxTranscriptBytes { + return NativeRequest{}, fmt.Errorf("nymauth: the transcript seed is %d bytes; this circuit library takes %d to %d", + n, MinTranscriptBytes, MaxTranscriptBytes) + } + + earliest, cutoff := NoLowerBound, NoUpperBound + for _, input := range r.Inputs { + if input.FunctionID != FunctionAgeCheck { + return NativeRequest{}, fmt.Errorf( + "nymauth: function %q has no slot in the nym_auth C ABI, which carries an age window and nothing else; verifying without it would check a statement missing one of this request's conditions", + input.FunctionID) + } + earliest = string(input.Values[InputEarliest]) + cutoff = string(input.Values[InputCutoff]) + } + + native := NativeRequest{ + Time: timeStr, + Earliest: earliest, + Cutoff: cutoff, + ChallengeDigest: e2, + VerifierInput: r.VerifierInput, + TranscriptSeed: r.SessionTranscript, + } + if err := fillCoordinate(native.IssuerKeyX[:], r.IssuerKey.X); err != nil { + return NativeRequest{}, fmt.Errorf("nymauth: issuer key x: %w", err) + } + if err := fillCoordinate(native.IssuerKeyY[:], r.IssuerKey.Y); err != nil { + return NativeRequest{}, fmt.Errorf("nymauth: issuer key y: %w", err) + } + return native, nil +} + +// fillCoordinate writes v big-endian, left-padded, into dst. +// +// FillBytes rather than Bytes: a coordinate whose leading byte is zero +// encodes one byte shorter, and the circuits read a fixed 32. Passing +// the short form would shift every byte of Q by one and verify against +// a different key - which fails, but as a bad proof rather than as the +// encoding mistake it is. FillBytes panics on a value too wide for dst, +// so the width is checked first. +func fillCoordinate(dst []byte, v *big.Int) error { + if v.Sign() < 0 { + return fmt.Errorf("negative") + } + if byteLen := (v.BitLen() + 7) / 8; byteLen > len(dst) { + return fmt.Errorf("%d bytes wide; a P-256 coordinate is at most %d", byteLen, len(dst)) + } + v.FillBytes(dst) + return nil +} diff --git a/pkg/nymauth/verifier/native_cgo.go b/pkg/nymauth/verifier/native_cgo.go new file mode 100644 index 000000000..6c87ca8be --- /dev/null +++ b/pkg/nymauth/verifier/native_cgo.go @@ -0,0 +1,71 @@ +//go:build zknative + +package verifier + +import ( + "fmt" + + "github.com/SUNET/vc/pkg/nymauth/verifier/zknative" +) + +// NativeVerifier checks nym_auth proofs through zk-cred-longfellow's C +// ABI. Real in a build with the "zknative" tag; see native_stub.go for +// what every other build gets, and pkg/nymauth/verifier/zknative for +// the binding itself. +type NativeVerifier struct { + v *zknative.Verifier +} + +// NewNativeVerifier loads a decompressed nym_auth circuit artifact. +// +// Loading is the expensive step, so keep the result and reuse it across +// verifications rather than loading per proof. Safe for concurrent use. +func NewNativeVerifier(circuit []byte, params CircuitParams) (*NativeVerifier, error) { + v, err := zknative.NewVerifier(circuit, zknative.Params{ + BlockEncHash: params.BlockEncHash, + BlockEncSig: params.BlockEncSig, + HashCircuitID: params.HashCircuitID, + SigCircuitID: params.SigCircuitID, + }) + if err != nil { + return nil, err + } + return &NativeVerifier{v: v}, nil +} + +// Close releases the circuit handle. +func (n *NativeVerifier) Close() error { + if n == nil { + return nil + } + return n.v.Close() +} + +// Verify checks proof against req and the pseudonym the holder claimed, +// and returns the pseudonym the caller may use. +// +// Callers must use the RETURNED pseudonym and never the one they passed +// in, even though the two are equal whenever this returns nil. The +// claimed value arrived in the holder's response; it becomes an +// identifier this verifier may act on only once a proof has been +// checked against it, and the return value is the only place that has +// happened. +func (n *NativeVerifier) Verify(req Request, claimed [32]byte, proof []byte) ([32]byte, error) { + if n == nil || n.v == nil { + return [32]byte{}, fmt.Errorf("nymauth: the native verifier is closed or nil") + } + native, err := req.Native() + if err != nil { + return [32]byte{}, err + } + return n.v.Verify(zknative.Request{ + IssuerKeyX: native.IssuerKeyX[:], + IssuerKeyY: native.IssuerKeyY[:], + Time: native.Time, + Earliest: native.Earliest, + Cutoff: native.Cutoff, + ChallengeDigest: native.ChallengeDigest[:], + VerifierInput: native.VerifierInput[:], + TranscriptSeed: native.TranscriptSeed, + }, claimed[:], proof) +} diff --git a/pkg/nymauth/verifier/native_proof_zknative_test.go b/pkg/nymauth/verifier/native_proof_zknative_test.go new file mode 100644 index 000000000..8ecd5f17e --- /dev/null +++ b/pkg/nymauth/verifier/native_proof_zknative_test.go @@ -0,0 +1,273 @@ +//go:build zknative + +package verifier + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/hex" + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The circuits archive's own nym_auth fixture: a real proof, the +// circuit it was made against, and the request values that produced it. +// Staged by `make zk-native-lib` beside the library (see that target), +// rather than vendored here, so the fixture and the library it belongs +// to can never drift apart. +const nymauthVectorsDir = "../../../third_party/zk-cred-longfellow/test-vectors/nymauth" + +// The archive circuit's catalog parameters. Hardcoded because the +// archive ships the artifact but not its descriptor - and safely so: +// the loader checks the artifact against its own allowlist entry and +// refuses it outright if these do not match, so a stale constant here +// is a loud load failure, never a verification that passes against the +// wrong circuit. +var archiveParams = CircuitParams{ + BlockEncHash: 6983, + BlockEncSig: 4679, + HashCircuitID: "5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729", + SigCircuitID: "00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb", +} + +type archiveVerifier struct { + ClientID string `json:"client_id"` + Origin string `json:"origin"` + Nonce string `json:"nonce"` + VerifierInput string `json:"verifier_input"` + Pseudonym string `json:"pseudonym"` +} + +type archiveFixture struct { + issuerKeyX, issuerKeyY string + docType string + time string + earliest, cutoff string + verifiers []archiveVerifier + circuit, proof []byte +} + +func loadArchiveFixture(t *testing.T) archiveFixture { + t.Helper() + + read := func(name string) []byte { + raw, err := os.ReadFile(filepath.Join(nymauthVectorsDir, name)) + require.NoErrorf(t, err, "the nym_auth vectors are not staged; run `make zk-native-lib`") + return raw + } + + var verifierJSON struct { + IssuerKey string `json:"issuer_key"` + Mdoc struct { + DocType string `json:"doc_type"` + Time string `json:"time"` + Earliest string `json:"earliest"` + Cutoff string `json:"cutoff"` + } `json:"mdoc"` + } + require.NoError(t, json.Unmarshal(read("verifier.json"), &verifierJSON)) + + var pseudonymJSON struct { + Verifiers []archiveVerifier `json:"verifiers"` + } + require.NoError(t, json.Unmarshal(read("pseudonym.json"), &pseudonymJSON)) + require.GreaterOrEqual(t, len(pseudonymJSON.Verifiers), 2) + + decoder, err := zstd.NewReader(nil) + require.NoError(t, err) + defer decoder.Close() + circuit, err := decoder.DecodeAll(read("mdoc-circuits.zst"), nil) + require.NoError(t, err) + + // "x,y", hex, as the archive writes Q. + const sep = "," + i := len(verifierJSON.IssuerKey) / 2 + require.Equal(t, sep, verifierJSON.IssuerKey[i:i+1], "issuer_key is two hex coordinates separated by a comma") + + return archiveFixture{ + issuerKeyX: verifierJSON.IssuerKey[:i], + issuerKeyY: verifierJSON.IssuerKey[i+1:], + docType: verifierJSON.Mdoc.DocType, + time: verifierJSON.Mdoc.Time, + earliest: verifierJSON.Mdoc.Earliest, + cutoff: verifierJSON.Mdoc.Cutoff, + verifiers: pseudonymJSON.Verifiers, + circuit: circuit, + proof: read("mdoc-proof.bin"), + } +} + +// request builds the verifier-side Request for one of the archive's +// verifiers. +// +// verifier_input is read from the fixture AND derived from the +// fixture's own client_id, and the two must agree. +// +// It used to be read only, with a comment saying why: the archive +// hashes the PREFIXED Client Identifier, this package refused to derive +// that, and the prediction recorded here was that a deployment whose +// provers follow the archive would produce pseudonyms this verifier +// never reproduces. v0.4 settled it the archive's way, so the +// workaround becomes the cross-check it was standing in for - end to +// end, from the published identifier to the pseudonym in the proof. +func (f archiveFixture) request(t *testing.T, v archiveVerifier) Request { + t.Helper() + + key := archiveIssuerKey(t, f.issuerKeyX, f.issuerKeyY) + + now, err := time.Parse(TimeLayout, f.time) + require.NoError(t, err) + + transcript, err := MdocSessionTranscript(v.Origin, v.Nonce, nil) + require.NoError(t, err) + + var verifierInput [32]byte + raw, err := hex.DecodeString(v.VerifierInput) + require.NoError(t, err) + require.Len(t, raw, 32) + copy(verifierInput[:], raw) + + derived, err := VerifierInput(v.ClientID) + require.NoErrorf(t, err, "the archive's client_id %q is not a verifier_id this package will derive from", v.ClientID) + require.Equalf(t, hex.EncodeToString(raw), hex.EncodeToString(derived[:]), + "this package derives a different verifier_input from client_id %q than the archive published, so its pseudonyms would match nobody's", v.ClientID) + + return Request{ + IssuerKey: key, + Now: now, + // The archive's own circuit inputs, rather than an AgeCheck + // rendered into them: the fixture states the window the proof + // was made against, and AgeCheck's mapping is agecheck_test's + // subject. + Inputs: []CircuitInput{{ + FunctionID: FunctionAgeCheck, + Values: map[string][]byte{ + InputEarliest: []byte(f.earliest), + InputCutoff: []byte(f.cutoff), + }, + }}, + SupportedFunctions: []string{FunctionAgeCheck}, + SessionTranscript: transcript, + DocType: f.docType, + VerifierInput: verifierInput, + } +} + +// archiveIssuerKey rebuilds Q from the archive's two hex coordinates, +// and checks it is a point on P-256 - through crypto/ecdh, which +// validates, rather than by assembling the struct and trusting it. +func archiveIssuerKey(t *testing.T, xHex, yHex string) *ecdsa.PublicKey { + t.Helper() + x, ok := new(big.Int).SetString(xHex, 16) + require.Truef(t, ok, "issuer key x is not hex: %q", xHex) + y, ok := new(big.Int).SetString(yHex, 16) + require.Truef(t, ok, "issuer key y is not hex: %q", yHex) + + sec1 := make([]byte, 1+2*coordinateBytes) + sec1[0] = 4 + x.FillBytes(sec1[1 : 1+coordinateBytes]) + y.FillBytes(sec1[1+coordinateBytes:]) + _, err := ecdh.P256().NewPublicKey(sec1) + require.NoError(t, err, "the archive issuer key must be a point on P-256") + + return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} +} + +func pseudonymBytes(t *testing.T, s string) [32]byte { + t.Helper() + raw, err := hex.DecodeString(s) + require.NoError(t, err) + require.Len(t, raw, 32) + var out [32]byte + copy(out[:], raw) + return out +} + +// The archive's proof verifies, and the pseudonym it reports is the +// archive's. This is the test that says the binding is wired to the +// real cryptographic check rather than to a status code that happens to +// be zero. +func TestArchiveProofVerifies(t *testing.T) { + f := loadArchiveFixture(t) + + v, err := NewNativeVerifier(f.circuit, archiveParams) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, v.Close()) }) + + claimed := pseudonymBytes(t, f.verifiers[0].Pseudonym) + got, err := v.Verify(f.request(t, f.verifiers[0]), claimed, f.proof) + require.NoError(t, err) + assert.Equal(t, claimed, got) +} + +// Everything the request binds the proof to has to be load-bearing. If +// any of these verified, the proof would be checked against a statement +// other than the one the verifier asked for. +func TestArchiveProofIsRefusedAgainstAnythingElse(t *testing.T) { + f := loadArchiveFixture(t) + + v, err := NewNativeVerifier(f.circuit, archiveParams) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, v.Close()) }) + + first, second := f.verifiers[0], f.verifiers[1] + firstPseudonym := pseudonymBytes(t, first.Pseudonym) + + t.Run("another verifier's request and pseudonym", func(t *testing.T) { + _, err := v.Verify(f.request(t, second), pseudonymBytes(t, second.Pseudonym), f.proof) + assert.Error(t, err, "a pseudonym is pairwise; one verifier's proof must not verify for another") + }) + + t.Run("another verifier's pseudonym", func(t *testing.T) { + _, err := v.Verify(f.request(t, first), pseudonymBytes(t, second.Pseudonym), f.proof) + assert.Error(t, err) + }) + + t.Run("a tampered proof", func(t *testing.T) { + tampered := append([]byte{}, f.proof...) + tampered[len(tampered)/2] ^= 0x01 + _, err := v.Verify(f.request(t, first), firstPseudonym, tampered) + assert.Error(t, err) + }) + + t.Run("a widened age window", func(t *testing.T) { + r := f.request(t, first) + r.Inputs[0].Values[InputCutoff] = []byte("2030-01-01") + _, err := v.Verify(r, firstPseudonym, f.proof) + assert.Error(t, err, "the proof is bound to the window it was made against") + }) + + t.Run("another verification time", func(t *testing.T) { + r := f.request(t, first) + r.Now = r.Now.AddDate(1, 0, 0) + _, err := v.Verify(r, firstPseudonym, f.proof) + assert.Error(t, err) + }) +} + +// A circuit the loader's allowlist does not recognise is refused before +// it is parsed, so a wrong artifact cannot become a verifier at all. +func TestNativeVerifierRefusesAnUnknownCircuit(t *testing.T) { + f := loadArchiveFixture(t) + + tampered := append([]byte{}, f.circuit...) + tampered[len(tampered)/2] ^= 0x01 + v, err := NewNativeVerifier(tampered, archiveParams) + assert.Error(t, err) + assert.Nil(t, v) + + wrongParams := archiveParams + wrongParams.BlockEncHash++ + v, err = NewNativeVerifier(f.circuit, wrongParams) + assert.Error(t, err) + assert.Nil(t, v) +} diff --git a/pkg/nymauth/verifier/native_stub.go b/pkg/nymauth/verifier/native_stub.go new file mode 100644 index 000000000..f8ea24b85 --- /dev/null +++ b/pkg/nymauth/verifier/native_stub.go @@ -0,0 +1,32 @@ +//go:build !zknative + +package verifier + +// This file is what every default build of this repo gets: no cgo, no +// zk-cred-longfellow, and so no way to check a nym_auth proof. See +// native_cgo.go for the real implementation, opt-in via `-tags +// zknative` (the same shape as pkg/mdoc's zk_native_stub.go / +// zk_native_cgo.go pair, and pkg/pki's PKCS#11 support). +// +// These stubs REFUSE rather than return a verified-looking zero value. +// Everything upstream of the call - the trust chain, the request's own +// checks, the circuit selection - is real and has already run, so a +// stub that returned a nil error would be handing that work back as a +// successful verification of a proof nothing looked at. + +// NativeVerifier is the no-native-library build's stand-in. It holds +// nothing, because a build without the tag can never construct one. +type NativeVerifier struct{} + +// NewNativeVerifier always fails in this build. +func NewNativeVerifier(circuit []byte, params CircuitParams) (*NativeVerifier, error) { + return nil, ErrNativeNotBuilt +} + +// Close is a no-op in this build. +func (n *NativeVerifier) Close() error { return nil } + +// Verify always fails in this build, and returns no pseudonym. +func (n *NativeVerifier) Verify(req Request, claimed [32]byte, proof []byte) ([32]byte, error) { + return [32]byte{}, ErrNativeNotBuilt +} diff --git a/pkg/nymauth/verifier/native_stub_test.go b/pkg/nymauth/verifier/native_stub_test.go new file mode 100644 index 000000000..44b0a4793 --- /dev/null +++ b/pkg/nymauth/verifier/native_stub_test.go @@ -0,0 +1,25 @@ +//go:build !zknative + +package verifier + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Without the native library there is no way to check a proof, and the +// stubs must say so rather than return a zero value that reads as a +// verified one. Everything upstream - the trust chain, the request's +// own checks - is real and has already run by the time either is +// called, so a nil error here would hand that work back as a successful +// verification of a proof nothing looked at. +func TestNativeStubsRefuse(t *testing.T) { + v, err := NewNativeVerifier([]byte("circuit"), CircuitParams{}) + assert.ErrorIs(t, err, ErrNativeNotBuilt) + assert.Nil(t, v) + + pseudonym, err := (&NativeVerifier{}).Verify(testRequest(t), [32]byte{1}, []byte("proof")) + assert.ErrorIs(t, err, ErrNativeNotBuilt) + assert.Equal(t, [32]byte{}, pseudonym, "a refused verification returns no pseudonym") +} diff --git a/pkg/nymauth/verifier/native_test.go b/pkg/nymauth/verifier/native_test.go new file mode 100644 index 000000000..b2a73fae2 --- /dev/null +++ b/pkg/nymauth/verifier/native_test.go @@ -0,0 +1,115 @@ +package verifier + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The flattened request is what the circuits are actually checked +// against, so every field of it has to be the request's - not a +// default, not a truncation, and not a silently dropped condition. +func TestNativeRequestCarriesTheRequest(t *testing.T) { + r := testRequest(t) + native, err := r.Native() + require.NoError(t, err) + + assert.Equal(t, + "7eea0f8e073961c3e82e9562e0f79edaa66dbfc17eaf0ac0596a987733516dd9", + hex.EncodeToString(native.ChallengeDigest[:]), + "challenge-vectors.json, verifier 1") + assert.Equal(t, r.SessionTranscript, native.TranscriptSeed) + assert.Equal(t, r.VerifierInput, native.VerifierInput) + assert.Len(t, native.Time, 20, "the circuits take T as 20 ASCII bytes") + + // The fixture asks for a birth date at or after 2000-01-01, so the + // window is bounded below and open above. + assert.Equal(t, "2000-01-01", native.Earliest) + assert.Equal(t, NoUpperBound, native.Cutoff) +} + +// A request with no predicate is a pure pseudonym proof, and the ABI +// spells "no bound" with its sentinels rather than with an empty +// string, which would be a date the circuits cannot read. +func TestNativeRequestWithoutAPredicateDisablesBothBounds(t *testing.T) { + r := testRequest(t) + r.Inputs = nil + native, err := r.Native() + require.NoError(t, err) + assert.Equal(t, NoLowerBound, native.Earliest) + assert.Equal(t, NoUpperBound, native.Cutoff) +} + +// Q is big-endian and FIXED width. A coordinate with leading zero bytes +// encodes shorter, and writing that short form would shift every byte +// of the key. +func TestNativeRequestPadsTheIssuerCoordinates(t *testing.T) { + r := testRequest(t) + r.IssuerKey.X = big.NewInt(1) + native, err := r.Native() + require.NoError(t, err) + assert.Equal(t, + "0000000000000000000000000000000000000000000000000000000000000001", + hex.EncodeToString(native.IssuerKeyX[:])) +} + +// A coordinate too wide for the fixed field would panic inside +// FillBytes, so it is refused with its width named. +func TestNativeRequestRefusesAnOverWideCoordinate(t *testing.T) { + r := testRequest(t) + r.IssuerKey.X = new(big.Int).Lsh(big.NewInt(1), 256) + _, err := r.Native() + assert.ErrorContains(t, err, "33 bytes wide") +} + +// The C request has one age window and no general predicate vector. +// A function it cannot carry has to stop the verification: passing the +// request anyway would check the proof against a statement missing one +// of this verifier's conditions and report that as verified. +func TestNativeRequestRefusesAPredicateTheABICannotCarry(t *testing.T) { + const other = "test.native_has_no_slot" + require.NoError(t, Register(Function{ID: other, Inputs: []string{"value"}})) + + r := testRequest(t) + r.Inputs = []CircuitInput{{FunctionID: other, Values: map[string][]byte{"value": []byte("x")}}} + r.SupportedFunctions = []string{other} + + _, err := r.Native() + assert.ErrorContains(t, err, "no slot in the nym_auth C ABI") +} + +// Native checks the request itself, so a caller cannot reach the +// native boundary by skipping Check. +func TestNativeRequestChecksTheRequest(t *testing.T) { + r := testRequest(t) + r.IssuerKey = nil + _, err := r.Native() + assert.ErrorContains(t, err, "never from the proof") +} + +// The transcript-seed bound is THIS library's, so it is enforced where +// the request is flattened for it - not in Request.Check, which must +// keep accepting a request some other circuit can verify. +func TestNativeRequestEnforcesTheLibrarysTranscriptBound(t *testing.T) { + short := testRequest(t) + short.SessionTranscript = []byte{0x83, 0xf6, 0xf6, 0x68, 'h', 'a', 'n', 'd', 'o', 'v', 'e', 'r'} + require.NoError(t, short.Check(), "a short transcript is a valid request") + _, err := short.Native() + assert.ErrorContains(t, err, "this circuit library takes 32 to 65536") + + // A WELL-FORMED transcript that is simply too long: [null, null, + // ]. Padding with zero bytes would be refused + // by the shape check first and would not exercise this bound at + // all. + payload := make([]byte, MaxTranscriptBytes) + long := testRequest(t) + long.SessionTranscript = append([]byte{0x83, 0xf6, 0xf6, 0x5a, + byte(len(payload) >> 24), byte(len(payload) >> 16), byte(len(payload) >> 8), byte(len(payload)), + }, payload...) + require.NoError(t, long.Check(), "it is well-formed, just too long for this library") + _, err = long.Native() + assert.ErrorContains(t, err, "this circuit library takes 32 to 65536") +} diff --git a/pkg/nymauth/verifier/native_vega_cgo.go b/pkg/nymauth/verifier/native_vega_cgo.go new file mode 100644 index 000000000..3c430ec37 --- /dev/null +++ b/pkg/nymauth/verifier/native_vega_cgo.go @@ -0,0 +1,79 @@ +//go:build zknative + +package verifier + +import ( + "fmt" + + "github.com/SUNET/vc/pkg/nymauth/verifier/zknative_vega" +) + +// VegaVerifier checks nym_auth proofs through zk-cred-vega's C ABI. +// Real in a build with the "zknative" tag; see native_vega_stub.go for +// what every other build gets. +type VegaVerifier struct { + v *zknative_vega.Verifier +} + +// NewVegaVerifier derives the verifier keys from a decompressed R1CS +// shapes artifact. +// +// Deriving is the expensive step here, and expensive in a different way +// from Longfellow's: Vega has no precomputed verifier key to load, it +// builds roughly 12.5 MB of one from the shapes every time. Keep the +// result and reuse it across verifications. Safe for concurrent use. +func NewVegaVerifier(shapes []byte) (*VegaVerifier, error) { + v, err := zknative_vega.NewVerifier(shapes) + if err != nil { + return nil, err + } + return &VegaVerifier{v: v}, nil +} + +// Close releases the keys. +func (n *VegaVerifier) Close() error { + if n == nil || n.v == nil { + return nil + } + return n.v.Close() +} + +// Verify checks proof against req and the pseudonym the holder +// claimed, and returns the pseudonym the caller may use. +// +// The Vega ABI REVEALS the pseudonym instead of taking the claimed one +// and comparing inside the circuit, which is the one place the two +// systems differ at this level. So the comparison happens here, in +// constant time, and a proof that is sound for a different pseudonym +// is ErrPseudonymMismatch rather than a verification failure - the +// same distinction Longfellow's ABI draws internally. +func (n *VegaVerifier) Verify(req Request, claimed [32]byte, proof []byte) ([32]byte, error) { + if n == nil || n.v == nil { + return [32]byte{}, fmt.Errorf("nymauth: the Vega verifier is closed or nil") + } + native, err := req.Native() + if err != nil { + return [32]byte{}, err + } + + // TranscriptSeed is deliberately not passed on. It is Longfellow's + // alone: the Vega statement binds the transcript through e2, which + // req.Native() has already derived, and this ABI has no slot for + // the transcript itself. + revealed, err := n.v.Verify(zknative_vega.Statement{ + IssuerKeyX: native.IssuerKeyX[:], + IssuerKeyY: native.IssuerKeyY[:], + Time: native.Time, + Earliest: native.Earliest, + Cutoff: native.Cutoff, + ChallengeDigest: native.ChallengeDigest[:], + VerifierInput: native.VerifierInput[:], + }, proof) + if err != nil { + return [32]byte{}, err + } + if err := checkClaimed(revealed, claimed); err != nil { + return [32]byte{}, err + } + return revealed, nil +} diff --git a/pkg/nymauth/verifier/native_vega_proof_zknative_test.go b/pkg/nymauth/verifier/native_vega_proof_zknative_test.go new file mode 100644 index 000000000..1ce16ab86 --- /dev/null +++ b/pkg/nymauth/verifier/native_vega_proof_zknative_test.go @@ -0,0 +1,221 @@ +//go:build zknative + +package verifier + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// zk-cred-vega's own nym_auth fixture: the published mdoc circuit's +// R1CS shapes, a reference proof made against them, and the statement +// that produced it. Staged by `make zk-native-lib-vega` beside the +// library rather than vendored here, so the fixture and the library it +// belongs to cannot drift apart - the same arrangement, and the same +// reason, as the Longfellow vectors in native_proof_zknative_test.go. +const vegaVectorsDir = "../../../third_party/zk-cred-vega/test-vectors/nymauth" + +type vegaFixture struct { + issuerKeyX, issuerKeyY string + docType string + time string + earliest, cutoff string + challengeDigest string + verifiers []archiveVerifier + shapes, proof []byte +} + +func loadVegaFixture(t *testing.T) vegaFixture { + t.Helper() + + read := func(name string) []byte { + path := filepath.Join(vegaVectorsDir, name) + raw, err := os.ReadFile(path) + require.NoErrorf(t, err, "the Vega nym_auth vectors are not staged (%s); run `make zk-native-lib-vega`", path) + return raw + } + + var verifierJSON struct { + IssuerKey string `json:"issuer_key"` + Mdoc struct { + DocType string `json:"doc_type"` + Time string `json:"time"` + Earliest string `json:"earliest"` + Cutoff string `json:"cutoff"` + ChallengeDigest string `json:"challenge_digest"` + } `json:"mdoc"` + } + require.NoError(t, json.Unmarshal(read("verifier.json"), &verifierJSON)) + + var pseudonymJSON struct { + Verifiers []archiveVerifier `json:"verifiers"` + } + require.NoError(t, json.Unmarshal(read("pseudonym.json"), &pseudonymJSON)) + require.GreaterOrEqual(t, len(pseudonymJSON.Verifiers), 2, + "the fixture must carry a second verifier, which is what makes the scoping test honest") + + decoder, err := zstd.NewReader(nil) + require.NoError(t, err) + defer decoder.Close() + // The shapes are published compressed; the allowlist pins, and this + // ABI takes, the DECOMPRESSED bytes. + shapes, err := decoder.DecodeAll(read("shapes.zst"), nil) + require.NoError(t, err, "decompressing the circuit shapes") + + // "x,y", hex, as the fixture writes Q. + const sep = "," + i := len(verifierJSON.IssuerKey) / 2 + require.Equal(t, sep, verifierJSON.IssuerKey[i:i+1], "issuer_key is two hex coordinates separated by a comma") + + return vegaFixture{ + issuerKeyX: verifierJSON.IssuerKey[:i], + issuerKeyY: verifierJSON.IssuerKey[i+1:], + docType: verifierJSON.Mdoc.DocType, + time: verifierJSON.Mdoc.Time, + earliest: verifierJSON.Mdoc.Earliest, + cutoff: verifierJSON.Mdoc.Cutoff, + challengeDigest: verifierJSON.Mdoc.ChallengeDigest, + verifiers: pseudonymJSON.Verifiers, + shapes: shapes, + proof: read("reference-proof.bin"), + } +} + +// request builds the verifier-side Request for one of the fixture's +// verifiers, exactly as the Longfellow fixture's does - including +// taking verifier_input from the fixture rather than deriving it, for +// the reason spelled out on archiveFixture.request. +func (f vegaFixture) request(t *testing.T, v archiveVerifier) Request { + t.Helper() + + key := archiveIssuerKey(t, f.issuerKeyX, f.issuerKeyY) + + now, err := time.Parse(TimeLayout, f.time) + require.NoError(t, err) + + transcript, err := MdocSessionTranscript(v.Origin, v.Nonce, nil) + require.NoError(t, err) + + var verifierInput [32]byte + raw, err := hex.DecodeString(v.VerifierInput) + require.NoError(t, err) + require.Len(t, raw, 32) + copy(verifierInput[:], raw) + + return Request{ + IssuerKey: key, + Now: now, + Inputs: []CircuitInput{{ + FunctionID: FunctionAgeCheck, + Values: map[string][]byte{ + InputEarliest: []byte(f.earliest), + InputCutoff: []byte(f.cutoff), + }, + }}, + SupportedFunctions: []string{FunctionAgeCheck}, + SessionTranscript: transcript, + DocType: f.docType, + VerifierInput: verifierInput, + } +} + +// This repo derives the challenge digest the proof is bound to, from +// the verifier's own origin and nonce. The fixture states what that +// digest must come out as. +// +// Checked separately from the proof so the two failures are +// distinguishable: a wrong e2 and a bad proof both end in "the proof +// did not verify", and only one of them is this repo's bug. +func TestVegaFixtureChallengeDigestMatchesOurDerivation(t *testing.T) { + f := loadVegaFixture(t) + v := f.verifiers[0] + + e2, err := MdocE2(v.Origin, v.Nonce, f.docType, nil) + require.NoError(t, err) + assert.Equal(t, f.challengeDigest, hex.EncodeToString(e2[:]), + "our e2 must be the one the fixture's proof was bound to") +} + +// A REAL Vega nym_auth proof, verified through this repo's binding. +// +// This is the test the Vega backend exists to pass. Everything around +// it is plumbing that can be wrong in ways only a real proof catches - +// a coordinate the wrong way round, a statement field in the wrong +// slot, a digest passed where its hex was meant. +func TestVegaVerifierAcceptsAReferenceProof(t *testing.T) { + f := loadVegaFixture(t) + v := f.verifiers[0] + + backend, err := NewVegaVerifier(f.shapes) + require.NoError(t, err, "deriving the verifier keys from the published shapes") + t.Cleanup(func() { assert.NoError(t, backend.Close()) }) + + want := pseudonymBytes(t, v.Pseudonym) + got, err := backend.Verify(f.request(t, v), want, f.proof) + require.NoError(t, err, "the reference proof must verify") + assert.Equal(t, want, got, "the revealed pseudonym must be the fixture's") +} + +// The pseudonym is scoped to the verifier: the same proof under +// another verifier's input must not hand back that verifier's +// pseudonym. If it did, one verifier could compute another's and the +// scoping that makes these identifiers pairwise would be decorative. +func TestVegaVerifierDoesNotProduceAnotherVerifiersPseudonym(t *testing.T) { + f := loadVegaFixture(t) + mine, theirs := f.verifiers[0], f.verifiers[1] + require.NotEqual(t, mine.Pseudonym, theirs.Pseudonym, "the two verifiers must differ in the fixture") + + backend, err := NewVegaVerifier(f.shapes) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, backend.Close()) }) + + // My proof, their statement and their claimed pseudonym. + got, err := backend.Verify(f.request(t, theirs), pseudonymBytes(t, theirs.Pseudonym), f.proof) + require.Error(t, err, "a proof made for one verifier must not verify for another") + assert.Equal(t, [32]byte{}, got, "no pseudonym may be returned on a refusal") +} + +// A claimed pseudonym the proof does not reveal is a DIFFERENT fact +// from a proof that does not verify, and is reported as one: the proof +// is sound, and the response's results disagree with it. +func TestVegaVerifierRefusesAMismatchedClaim(t *testing.T) { + f := loadVegaFixture(t) + v := f.verifiers[0] + + backend, err := NewVegaVerifier(f.shapes) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, backend.Close()) }) + + claimed := pseudonymBytes(t, v.Pseudonym) + claimed[0] ^= 0xff + + got, err := backend.Verify(f.request(t, v), claimed, f.proof) + require.ErrorIs(t, err, ErrPseudonymMismatch) + assert.Equal(t, [32]byte{}, got, "no pseudonym may be returned on a refusal") +} + +// A tampered proof is refused - the premise every test above depends +// on. Without it they would pass against bytes nothing checked. +func TestVegaVerifierRefusesATamperedProof(t *testing.T) { + f := loadVegaFixture(t) + v := f.verifiers[0] + + backend, err := NewVegaVerifier(f.shapes) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, backend.Close()) }) + + tampered := append([]byte(nil), f.proof...) + tampered[len(tampered)/2] ^= 0x01 + + got, err := backend.Verify(f.request(t, v), pseudonymBytes(t, v.Pseudonym), tampered) + require.Error(t, err, "a proof with a flipped bit must not verify") + assert.Equal(t, [32]byte{}, got) +} diff --git a/pkg/nymauth/verifier/native_vega_stub.go b/pkg/nymauth/verifier/native_vega_stub.go new file mode 100644 index 000000000..81a90179f --- /dev/null +++ b/pkg/nymauth/verifier/native_vega_stub.go @@ -0,0 +1,22 @@ +//go:build !zknative + +package verifier + +// The Vega half of native_stub.go - see that file for why these refuse +// rather than return a verified-looking zero value. + +// VegaVerifier is the no-native-library build's stand-in. +type VegaVerifier struct{} + +// NewVegaVerifier always fails in this build. +func NewVegaVerifier(shapes []byte) (*VegaVerifier, error) { + return nil, ErrNativeNotBuilt +} + +// Close is a no-op in this build. +func (n *VegaVerifier) Close() error { return nil } + +// Verify always fails in this build, and returns no pseudonym. +func (n *VegaVerifier) Verify(req Request, claimed [32]byte, proof []byte) ([32]byte, error) { + return [32]byte{}, ErrNativeNotBuilt +} diff --git a/pkg/nymauth/verifier/native_zknative_test.go b/pkg/nymauth/verifier/native_zknative_test.go new file mode 100644 index 000000000..3c99c0155 --- /dev/null +++ b/pkg/nymauth/verifier/native_zknative_test.go @@ -0,0 +1,130 @@ +//go:build zknative + +package verifier + +import ( + "encoding/hex" + "testing" + + "github.com/SUNET/vc/pkg/nymauth/verifier/zknative" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The pure-Go challenge derivation in challenge.go exists so a verifier +// can build a request without linking the native library. That is only +// safe while it produces the same bytes the library does - and the +// vectors alone cannot show it, because they would still pass if both +// this package and the vectors were stale against the library. +// +// So this runs both implementations over the same inputs and compares +// them directly. It is the test that would catch the library changing +// the transcript shape under a vendored vectors file. +func TestGoChallengeDerivationMatchesTheLibrary(t *testing.T) { + vectors := loadChallengeVectors(t) + + for _, tc := range vectors.Derive { + t.Run(tc.Name, func(t *testing.T) { + var thumbprint []byte + if tc.JWKThumbprintHex != nil { + var err error + thumbprint, err = hex.DecodeString(*tc.JWKThumbprintHex) + require.NoError(t, err) + } + + goTranscript, err := MdocSessionTranscript(tc.Origin, tc.Nonce, thumbprint) + require.NoError(t, err) + goDigest, err := MdocChallengeDigest(goTranscript, tc.DocType) + require.NoError(t, err) + + cTranscript, cDigest, err := zknative.DeriveChallenge(tc.Origin, tc.Nonce, tc.DocType, thumbprint) + require.NoError(t, err) + + assert.Equal(t, hex.EncodeToString(cTranscript), hex.EncodeToString(goTranscript), + "the session transcript this package builds must be the one the library builds") + assert.Equal(t, hex.EncodeToString(cDigest[:]), hex.EncodeToString(goDigest[:]), + "e2 must be the library's e2") + }) + } +} + +// Same check for a transcript already in hand, which is the path a +// verifier takes when it holds the transcript from its own session. +func TestGoChallengeDigestMatchesTheLibrary(t *testing.T) { + vectors := loadChallengeVectors(t) + + for _, tc := range vectors.Digest { + t.Run(tc.Name, func(t *testing.T) { + transcript, err := hex.DecodeString(tc.SessionTranscriptHex) + require.NoError(t, err) + + goDigest, err := MdocChallengeDigest(transcript, tc.DocType) + require.NoError(t, err) + cDigest, err := zknative.ChallengeDigest(transcript, tc.DocType) + require.NoError(t, err) + + assert.Equal(t, hex.EncodeToString(cDigest[:]), hex.EncodeToString(goDigest[:])) + }) + } +} + +// verifier_input is SHA-256 of the verifier_id with its prefix, which is +// what rulebook v0.4 and the circuits archive agree on. The library +// hashes whatever it is given, so this pins the agreed value and leaves +// the refusal of anything that is neither defined form - this package's +// rule, not the library's - to request_test.go. +// +// Pinned in full rather than by prefix. The two candidate digests differ only +// after the first byte in no particular way, but the value they stand +// for is a pseudonym: hashing the wrong one produces pseudonyms no other +// implementation reports, which reads as an innocent subject rather than +// as an error. +func TestGoVerifierInputMatchesTheLibrary(t *testing.T) { + const verifierID = "x509_san_dns:verifier.example" + + goInput, err := VerifierInput(verifierID) + require.NoError(t, err) + cInput, err := zknative.VerifierInput(verifierID) + require.NoError(t, err) + + assert.Equal(t, hex.EncodeToString(cInput[:]), hex.EncodeToString(goInput[:])) + assert.Equal(t, "6eae85a0b2dfcd7d8b716291e54a004da798f93a26a248c265becd33a04a6bab", + hex.EncodeToString(goInput[:]), + "the circuits archive's verifier_input for client_id %q", verifierID) +} + +// Both bindings refuse the same malformed challenge inputs. A verifier +// that accepted one the library refuses would build a challenge no +// prover reproduces and read the resulting failure as a bad proof. +func TestGoAndLibraryRefuseTheSameChallengeInputs(t *testing.T) { + vectors := loadChallengeVectors(t) + + for _, tc := range vectors.Rejects { + t.Run(tc.Name, func(t *testing.T) { + var thumbprint []byte + if tc.JWKThumbprintHex != nil { + var err error + thumbprint, err = hex.DecodeString(*tc.JWKThumbprintHex) + require.NoError(t, err) + } + + switch tc.Op { + case "derive": + _, goErr := MdocE2(tc.Origin, tc.Nonce, tc.DocType, thumbprint) + _, _, cErr := zknative.DeriveChallenge(tc.Origin, tc.Nonce, tc.DocType, thumbprint) + assert.Error(t, goErr, "this package must refuse it") + assert.Error(t, cErr, "the library must refuse it too") + case "digest": + transcript, err := hex.DecodeString(tc.SessionTranscriptHex) + require.NoError(t, err) + _, goErr := MdocChallengeDigest(transcript, tc.DocType) + _, cErr := zknative.ChallengeDigest(transcript, tc.DocType) + assert.Error(t, goErr, "this package must refuse it") + assert.Error(t, cErr, "the library must refuse it too") + default: + t.Fatalf("unknown reject op %q", tc.Op) + } + }) + } +} diff --git a/pkg/nymauth/verifier/request.go b/pkg/nymauth/verifier/request.go new file mode 100644 index 000000000..94ffc98fd --- /dev/null +++ b/pkg/nymauth/verifier/request.go @@ -0,0 +1,189 @@ +package verifier + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "fmt" + "slices" + "time" + + "github.com/SUNET/vc/pkg/nymauth" +) + +// Request is what a nym_auth circuit is verified against. +// +// Every field is the VERIFIER'S: Q comes from the issuer chain the +// verifier resolved and trusted, T from its own clock, E and C from its +// own policy, E2 and VerifierInput from its own session and identity. +// None of them is read from the holder's response, and the pseudonym is +// an OUTPUT. A verifier that took any of these from the response would +// be checking a statement the holder chose. +type Request struct { + // IssuerKey is Q, the P-256 key the credential was signed with, + // resolved through the ordinary trust path (go-trust/PDP) and never + // taken from the proof. + IssuerKey *ecdsa.PublicKey + + // Now is T, the verifier's own clock reading. The circuits take it + // as 20 ASCII bytes, YYYY-MM-DDThh:mm:ssZ. + Now time.Time + + // Inputs are the predicate contributions to this request, each + // naming the function it comes from. + // + // A LIST keyed by function id rather than a field per predicate, + // because predicates in circuit_zk apply to any claim and the set + // of functions is open-ended. The age check is the first registered + // one; a second needs no change here. See functions.go. + Inputs []CircuitInput + + // SupportedFunctions are the function ids the selected circuit + // build supports, from the catalog's capabilities and the + // operator's allowlist. + // + // Data-driven on purpose: which circuit supports which predicate is + // not knowledge this package should carry, and new circuits are + // created elsewhere. Empty means none, so a request carrying a + // predicate is refused rather than passed to a circuit that cannot + // evaluate it - fail closed, because the alternative is a proof + // that verifies against a statement nobody checked. + SupportedFunctions []string + + // SessionTranscript is the ISO 18013-5 SessionTranscript this + // presentation is bound to, built from the verifier's own origin + // and nonce. The circuits take it whole, as the transcript seed. + // + // Checked here for SHAPE only - one three-item CBOR array, which + // the e2 derivation enforces. Length limits belong to a BACKEND: + // Longfellow's C ABI takes 32 to 65536 bytes, and the shared + // digest vector's 12-byte transcript derives a correct e2 and must + // stay usable here. A backend bound applied in this package would + // refuse a request some other circuit accepts. + // + // e2 is DERIVED from it (see E2) rather than carried beside it. + // The circuits take both, and a request that carried them as two + // independent fields could name one session in the seed and + // another in the digest - a proof that verifies against a + // transcript nobody in this session built. + SessionTranscript []byte + + // DocType is the credential's mdoc document type, which the + // device-authentication message binds the proof to. Not a circuit + // input of its own: it is what makes e2 this credential type's. + DocType string + + // VerifierInput is SHA-256 of the BARE verifier_id - never the + // prefixed Client Identifier, never client_id as it appears in the + // request. See VerifierInput. + VerifierInput [32]byte +} + +// TimeLayout is how the circuits take T for an mdoc: 20 ASCII bytes. +const TimeLayout = "2006-01-02T15:04:05Z" + +// E2 is the challenge digest this request binds the proof to: SHA-256 +// of the device-authentication message over SessionTranscript and +// DocType. +// +// Derived, never a field. See SessionTranscript. +func (r Request) E2() ([32]byte, error) { + return MdocChallengeDigest(r.SessionTranscript, r.DocType) +} + +// VerifierInput returns SHA-256 of the bare verifier_id. +// +// The published identifier including its prefix - +// "x509_san_dns:verifier.example" or "semantic_id:" - per +// rulebook v0.4. Rulebook v0.4 defines exactly those two forms, and +// CheckVerifierID refuses anything else rather than hashing it: a +// value nobody agreed the meaning of produces a pseudonym no other +// implementation reports, which matches nothing and reads as an +// innocent subject. +func VerifierInput(verifierID string) ([32]byte, error) { + if err := nymauth.CheckVerifierID(verifierID); err != nil { + return [32]byte{}, err + } + return sha256.Sum256([]byte(verifierID)), nil +} + +// Check refuses a request a circuit cannot be verified against, or that +// would be verified against the wrong thing. +func (r Request) Check() error { + if r.IssuerKey == nil { + return fmt.Errorf("nymauth: no issuer key; Q must come from the resolved issuer chain, never from the proof") + } + if r.IssuerKey.Curve == nil { + // Checked before the comparison below, which would otherwise + // dereference it to name the curve in the error: a zero + // ecdsa.PublicKey fails the P-256 test and then panics while + // explaining why. A caller handed an incomplete key needs an + // error, not a crash. + return fmt.Errorf("nymauth: the issuer key names no curve") + } + if r.IssuerKey.Curve != elliptic.P256() { + return fmt.Errorf("nymauth: the issuer key is on %s; the circuits are built for P-256", r.IssuerKey.Curve.Params().Name) + } + if r.IssuerKey.X == nil || r.IssuerKey.Y == nil { + return fmt.Errorf("nymauth: the issuer key has no coordinates") + } + if r.Now.IsZero() { + return fmt.Errorf("nymauth: no verification time; T is the verifier's own clock reading, and a zero value would check validity against the year 1") + } + if err := r.checkInputs(); err != nil { + return err + } + if _, err := r.E2(); err != nil { + // Derivation is the only check of the transcript's shape and + // of the docType, so a request is not checked until it has + // run. + return err + } + if r.VerifierInput == ([32]byte{}) { + return fmt.Errorf("nymauth: verifier_input is zero; the pseudonym would not be scoped to this verifier") + } + return nil +} + +// checkInputs refuses predicates this request cannot honour. +// +// Generic: it asks the registry what a function fills and the request +// what the circuit supports. It knows nothing about ages, dates or +// claims, which is what lets a new predicate be added by registering +// it. +func (r Request) checkInputs() error { + seen := make(map[string]bool, len(r.Inputs)) + for _, input := range r.Inputs { + if seen[input.FunctionID] { + // Two contributions from one function would fill the same + // circuit inputs twice, and the second would silently win. + return fmt.Errorf("nymauth: function %q contributes twice", input.FunctionID) + } + seen[input.FunctionID] = true + + if err := input.check(); err != nil { + return err + } + if !slices.Contains(r.SupportedFunctions, input.FunctionID) { + // Fail closed. Passing it anyway would hand the circuit + // inputs it does not read, and the proof would verify + // against a statement nobody checked. + return fmt.Errorf("nymauth: the selected circuit does not support function %q; it supports %v", + input.FunctionID, r.SupportedFunctions) + } + } + return nil +} + +// TimeString renders T the way the circuits take it. +// +// In UTC, and refusing a year the layout cannot express. The layout +// writes a literal "Z", so a non-UTC time would render its own wall +// clock under a UTC marker - a different instant, asserted as this one. +func (r Request) TimeString() (string, error) { + t := r.Now.UTC() + if year := t.Year(); year < 1 || year > 9999 { + return "", fmt.Errorf("nymauth: year %d cannot be written in the circuits' 20-byte time format", year) + } + return t.Format(TimeLayout), nil +} diff --git a/pkg/nymauth/verifier/request_test.go b/pkg/nymauth/verifier/request_test.go new file mode 100644 index 000000000..3d1d2e9be --- /dev/null +++ b/pkg/nymauth/verifier/request_test.go @@ -0,0 +1,206 @@ +package verifier + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testRequest(t *testing.T) Request { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + bounds, err := AgeCheck{GE: "2000-01-01"}.Bounds() + require.NoError(t, err) + + input, err := VerifierInput("x509_san_dns:verifier.example") + require.NoError(t, err) + + transcript, err := MdocSessionTranscript("https://verifier.example", "pOA0oqEAhLnqug2r7Ve4tQ", nil) + require.NoError(t, err) + + return Request{ + IssuerKey: &key.PublicKey, + Now: time.Now(), + Inputs: []CircuitInput{bounds.CircuitInput()}, + SupportedFunctions: []string{FunctionAgeCheck}, + SessionTranscript: transcript, + DocType: testDocType, + VerifierInput: input, + } +} + +// testDocType is the docType the shared challenge vectors use, so a +// request built from the fixture derives a digest those vectors pin. +const testDocType = "eu.europa.ec.eudi.nym_auth.1" + +// A transcript shorter than any one backend's floor is still a valid +// request here. +// +// The shared digest vector's transcript is 12 bytes and derives a +// correct e2. Longfellow's C ABI takes 32 to 65536, which is +// Longfellow's bound and is enforced where the request is flattened +// for it - applying it in this package would refuse a request another +// circuit accepts. +func TestRequestAcceptsAShortSharedVectorTranscript(t *testing.T) { + vectors := loadChallengeVectors(t) + transcript, err := hex.DecodeString(vectors.Digest[0].SessionTranscriptHex) + require.NoError(t, err) + require.Less(t, len(transcript), 32, "the point of this test is that it is short") + + r := testRequest(t) + r.SessionTranscript = transcript + r.DocType = vectors.Digest[0].DocType + require.NoError(t, r.Check()) + + got, err := r.E2() + require.NoError(t, err) + assert.Equal(t, vectors.Digest[0].ChallengeDigestHex, hex.EncodeToString(got[:])) +} + +// e2 is DERIVED from the request's own transcript and docType, and the +// value it derives is the one the shared vectors pin. A request that +// carried e2 as a field could name one session in the seed and another +// in the digest; this is what makes that unrepresentable. +func TestRequestDerivesThePinnedChallengeDigest(t *testing.T) { + r := testRequest(t) + got, err := r.E2() + require.NoError(t, err) + assert.Equal(t, + "7eea0f8e073961c3e82e9562e0f79edaa66dbfc17eaf0ac0596a987733516dd9", + hex.EncodeToString(got[:]), + "challenge-vectors.json, verifier 1") +} + +// verifier_input is SHA-256 of the verifier_id exactly as published, +// prefix included. The archive's own reference value is what proves +// it - not our own output. +func TestVerifierInputIsThePublishedIDsDigest(t *testing.T) { + got, err := VerifierInput("x509_san_dns:verifier.example") + require.NoError(t, err) + assert.Equal(t, "6eae85a0", hex.EncodeToString(got[:])[:8], + "rulebook v0.4's MUST digest, and the archive's verifier_input for this client_id") + + // The PREFIXED form is what v0.4 hashes, and it is what the + // archive's own reference pseudonyms are computed from. The bare + // DNS name derives too - it is a different verifier_id, not an + // invalid one - and the two give different digests, which is the + // point of hashing exactly as published. + prefixed, err := VerifierInput("x509_san_dns:verifier.example") + require.NoError(t, err, "the prefixed form is a verifier_id under v0.4") + assert.Equal(t, "6eae85a0", hex.EncodeToString(prefixed[:])[:8], + "v0.4's digest, and the archive's verifier_input") + + // "semantic_id:" is NOT one of those. The rulebook says it is part + // of the `semantic` verifier_id value and not a Client Identifier + // Prefix, so the whole string is the identifier and the whole + // string is hashed. + semantic, err := VerifierInput("semantic_id:verifier.example") + require.NoError(t, err, "a semantic verifier_id must be usable") + want := sha256.Sum256([]byte("semantic_id:verifier.example")) + assert.Equal(t, hex.EncodeToString(want[:]), hex.EncodeToString(semantic[:])) + + _, err = VerifierInput("") + assert.Error(t, err) +} + +// Every input the circuit is checked against is the verifier's own, so +// a request missing one is refused rather than defaulted - a default +// here would be a value nobody chose, checked as if someone had. +func TestRequestRefusesWhatItCannotCheckAgainst(t *testing.T) { + require.NoError(t, testRequest(t).Check(), "the fixture itself must be valid") + + t.Run("no issuer key", func(t *testing.T) { + r := testRequest(t) + r.IssuerKey = nil + assert.ErrorContains(t, r.Check(), "never from the proof") + }) + + t.Run("an issuer key on the wrong curve", func(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + r := testRequest(t) + r.IssuerKey = &key.PublicKey + assert.ErrorContains(t, r.Check(), "P-256") + }) + + t.Run("no verification time", func(t *testing.T) { + r := testRequest(t) + r.Now = time.Time{} + assert.ErrorContains(t, r.Check(), "year 1") + }) + + t.Run("an input that leaves a circuit input unset", func(t *testing.T) { + r := testRequest(t) + r.Inputs = []CircuitInput{{FunctionID: FunctionAgeCheck, Values: map[string][]byte{InputEarliest: []byte("2000-01-01")}}} + assert.ErrorContains(t, r.Check(), "unset") + }) + + t.Run("a function the circuit does not support", func(t *testing.T) { + r := testRequest(t) + r.SupportedFunctions = nil + assert.ErrorContains(t, r.Check(), "does not support") + }) + + t.Run("no transcript seed", func(t *testing.T) { + r := testRequest(t) + r.SessionTranscript = nil + assert.ErrorContains(t, r.Check(), "needs a session transcript") + }) + + t.Run("an issuer key naming no curve", func(t *testing.T) { + // A zero ecdsa.PublicKey. The curve comparison below it used + // to dereference the nil curve to name it in the error, so an + // incomplete key crashed the caller instead of being refused. + r := testRequest(t) + r.IssuerKey = &ecdsa.PublicKey{} + assert.ErrorContains(t, r.Check(), "names no curve") + }) + + t.Run("a transcript seed that is not one CBOR array", func(t *testing.T) { + // Trailing bytes: long enough to clear the length bound, so + // what refuses it is the shape check in the derivation. + r := testRequest(t) + r.SessionTranscript = append(append([]byte{}, r.SessionTranscript...), 0x00) + assert.ErrorContains(t, r.Check(), "not a single CBOR array") + }) + + t.Run("no docType", func(t *testing.T) { + r := testRequest(t) + r.DocType = "" + assert.ErrorContains(t, r.Check(), "binds the proof to this credential type") + }) + + t.Run("zero verifier_input", func(t *testing.T) { + r := testRequest(t) + r.VerifierInput = [32]byte{} + assert.ErrorContains(t, r.Check(), "scoped to this verifier") + }) +} + +// T is 20 ASCII bytes in UTC. The layout writes a literal "Z", so a +// non-UTC time would render its own wall clock under a UTC marker - a +// different instant, asserted as this one. +func TestTimeStringIsUTCAndBounded(t *testing.T) { + r := testRequest(t) + + zone := time.FixedZone("UTC+2", 2*60*60) + r.Now = time.Date(2026, 10, 7, 14, 0, 0, 0, zone) + got, err := r.TimeString() + require.NoError(t, err) + assert.Equal(t, "2026-10-07T12:00:00Z", got, "the same instant, written in UTC") + assert.Len(t, got, 20, "the circuits take exactly 20 ASCII bytes") + + r.Now = time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC) + _, err = r.TimeString() + assert.ErrorContains(t, err, "20-byte time format", + "a year the layout cannot express would silently render as four other digits") +} diff --git a/pkg/nymauth/verifier/testdata/SOURCE b/pkg/nymauth/verifier/testdata/SOURCE new file mode 100644 index 000000000..5a88291f9 --- /dev/null +++ b/pkg/nymauth/verifier/testdata/SOURCE @@ -0,0 +1,21 @@ +challenge-vectors.json is vendored verbatim from + + https://github.com/sirosfoundation/zk-cred-vega + test-vectors/nymauth/challenge-vectors.json + +commit e2c57a30 ("nymauth: refuse non-canonical simple values, and oversized +Go arguments before they are copied", sirosfoundation/zk-cred-vega#13) + + sha256 697d016b6232249dce9f96b2a20c370bc1f5cd9941ffe59a784e4bb827baad41 + +zk-cred-longfellow ships the same file byte for byte; either repository is a +valid source, and they must not diverge. + +These vectors are what tells this verifier and the two native provers that +they agree about what a presentation is bound to. Vendored rather than +fetched so the test suite does not depend on the network, and the digest is +pinned in challenge_vectors_test.go. + +Re-vendor rather than edit. A local change turns a cross-implementation check +into a self-consistency one that passes while the verifier and the provers +disagree - which is the single failure this file exists to prevent. diff --git a/pkg/nymauth/verifier/testdata/challenge-vectors.json b/pkg/nymauth/verifier/testdata/challenge-vectors.json new file mode 100644 index 000000000..2bf799375 --- /dev/null +++ b/pkg/nymauth/verifier/testdata/challenge-vectors.json @@ -0,0 +1,141 @@ +{ + "description": "Shared test vectors of the nym_auth mdoc challenge (SPEC.md section 3): the SessionTranscript (the transcript seed) and e2, the SHA-256 of the device-authentication message. Byte-identical in zk-cred-vega and zk-cred-longfellow. min_nonce_chars is the shortest nonce derive accepts.", + "version": 1, + "min_nonce_chars": 16, + "derive": [ + { + "name": "circuits archive, verifier 1 (credential/pseudonym.json mdoc.challenge_digest)", + "origin": "https://verifier.example", + "nonce": "pOA0oqEAhLnqug2r7Ve4tQ", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": null, + "session_transcript_hex": "83f6f682764f70656e4944345650444341504948616e646f766572582019fb903434402eb496b9a26aefcd28e9c84f0cf27ca6521f71c3f678e080075e", + "challenge_digest_hex": "7eea0f8e073961c3e82e9562e0f79edaa66dbfc17eaf0ac0596a987733516dd9" + }, + { + "name": "circuits archive, verifier 2", + "origin": "https://other-verifier.example", + "nonce": "6YfsJcysIReLorHGvKlsFQ", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": null, + "session_transcript_hex": "83f6f682764f70656e4944345650444341504948616e646f76657258200524632d721bfc7b18198a85f7520ac74ffa3cbf1616aedc08ecc7c0e5ce3245", + "challenge_digest_hex": "eadaee812dc8e402826de50dc020634bc92bbac24bf25fbbd479c98cec9e1784" + }, + { + "name": "encrypted response, JWK thumbprint 0x11*32", + "origin": "https://verifier.example", + "nonce": "pOA0oqEAhLnqug2r7Ve4tQ", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": "1111111111111111111111111111111111111111111111111111111111111111", + "session_transcript_hex": "83f6f682764f70656e4944345650444341504948616e646f766572582098a74db4c57a62c35763573aa80ce0583c9556e27bddca7c7172eaf38285fd4c", + "challenge_digest_hex": "59481f3b8d5ddc29777522b268170f5bc4cba969e5344de22d33857677550724" + }, + { + "name": "another docType", + "origin": "https://wallet.example:8443", + "nonce": "0123456789abcdefghijkl", + "doc_type": "org.iso.18013.5.1.mDL", + "jwk_thumbprint_hex": null, + "session_transcript_hex": "83f6f682764f70656e4944345650444341504948616e646f76657258207c22b0ab8bbff263e3a840c86d73bcb3240a4dd392845c16ccad3693414f84c7", + "challenge_digest_hex": "e8f15c878ec7b47b1af7b17a0b9eef1d7709a4eb2ae5033f198b362e97197d8e" + } + ], + "digest": [ + { + "name": "siros-sdk-kotlin CatalogCircuitZkProverTest fixture: [null, null, \"handover\"] (red-team RT-6 e2)", + "session_transcript_hex": "83f6f66868616e646f766572", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "challenge_digest_hex": "205f22807094a72eb2846bacf4218375f7b6e51f2bfd49713a857f713b64d1a0" + } + ], + "rejects": [ + { + "name": "empty origin", + "op": "derive", + "origin": "", + "nonce": "pOA0oqEAhLnqug2r7Ve4tQ", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": null + }, + { + "name": "empty nonce", + "op": "derive", + "origin": "https://verifier.example", + "nonce": "", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": null + }, + { + "name": "15-character nonce", + "op": "derive", + "origin": "https://verifier.example", + "nonce": "abcdefghijklmno", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": null + }, + { + "name": "empty docType", + "op": "derive", + "origin": "https://verifier.example", + "nonce": "pOA0oqEAhLnqug2r7Ve4tQ", + "doc_type": "", + "jwk_thumbprint_hex": null + }, + { + "name": "31-byte JWK thumbprint", + "op": "derive", + "origin": "https://verifier.example", + "nonce": "pOA0oqEAhLnqug2r7Ve4tQ", + "doc_type": "eu.europa.ec.eudi.nym_auth.1", + "jwk_thumbprint_hex": "11111111111111111111111111111111111111111111111111111111111111" + }, + { + "name": "empty transcript", + "op": "digest", + "session_transcript_hex": "", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "transcript that is not a 3-item array", + "op": "digest", + "session_transcript_hex": "82f6f6", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "transcript with trailing bytes", + "op": "digest", + "session_transcript_hex": "83f6f66868616e646f76657200", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "truncated transcript", + "op": "digest", + "session_transcript_hex": "83f6f66868616e646f76", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "transcript whose third item is a text string with invalid UTF-8", + "op": "digest", + "session_transcript_hex": "83f6f661ff", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "transcript whose third item is the simple value 0 in the non-canonical long form", + "op": "digest", + "session_transcript_hex": "83f6f6f800", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "transcript whose third item is the simple value 24 in the non-canonical long form", + "op": "digest", + "session_transcript_hex": "83f6f6f818", + "doc_type": "eu.europa.ec.eudi.nym_auth.1" + }, + { + "name": "empty docType for a transcript", + "op": "digest", + "session_transcript_hex": "83f6f66868616e646f766572", + "doc_type": "" + } + ] +} diff --git a/pkg/nymauth/verifier/zknative/verifier.go b/pkg/nymauth/verifier/zknative/verifier.go new file mode 100644 index 000000000..bb90ff018 --- /dev/null +++ b/pkg/nymauth/verifier/zknative/verifier.go @@ -0,0 +1,335 @@ +//go:build zknative + +// Package zknative is a cgo wrapper around zk-cred-longfellow's plain +// C-ABI nym_auth verifier (src/nymauth/c_abi.rs / +// include/zk_cred_longfellow_nymauth.h in that crate). It is built only +// when the "zknative" Go build tag is set - see +// pkg/nymauth/verifier/native_cgo.go, the sole consumer within this +// repo, and this repo's Makefile targets `zk-native-lib` and +// `test-zknative`. +// +// Same tag-gating, and the same reason, as pkg/mdoc/zknative: cgo needs +// CGO_ENABLED=1 and a linker able to find the staged shared library, +// and the default vc-verifier build (CGO_ENABLED=0, fully static) must +// keep working without either. +// +// This package deliberately does NOT import pkg/nymauth/verifier: that +// package imports this one under the build tag, and the dependency has +// to run one way. Its arguments are therefore flat bytes and strings, +// and the mapping from a verifier.Request lives in +// pkg/nymauth/verifier/native.go, where it is testable without the +// native library. +// +// Setup: `make zk-native-lib` at the repo root stages the shared +// library and both headers under third_party/zk-cred-longfellow/; then +// build or test with `-tags zknative` and LD_LIBRARY_PATH pointing at +// that lib directory (see the Makefile's `test-zknative`). +package zknative + +/* +#cgo CFLAGS: -I${SRCDIR}/../../../../third_party/zk-cred-longfellow/include +#cgo LDFLAGS: -L${SRCDIR}/../../../../third_party/zk-cred-longfellow/lib -lzk_cred_longfellow +#include +#include "zk_cred_longfellow_nymauth.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "runtime" + "sync" + "unsafe" +) + +// PseudonymBytes is the width of a nym_auth pseudonym and of every +// digest this ABI takes or returns. +const PseudonymBytes = 32 + +// Params are the catalog descriptor's circuit parameters +// (NymAuthCParams). The loader checks the artifact against its +// allowlist entry - length, SHA-256 and these - and refuses anything +// else before parsing it. +type Params struct { + BlockEncHash uint32 + BlockEncSig uint32 + HashCircuitID string + SigCircuitID string +} + +// Request is NymAuthCRequest: the values both sides know. Every one of +// them is the verifier's own; see pkg/nymauth/verifier's Request for +// why that matters and where each comes from. +type Request struct { + IssuerKeyX, IssuerKeyY []byte + Time string + Earliest, Cutoff string + ChallengeDigest []byte + VerifierInput []byte + TranscriptSeed []byte +} + +// Verifier wraps a loaded NymAuthVerifier handle. Loading is the +// expensive step (about 0.15 s), so construct one per circuit build and +// reuse it; the underlying Rust type holds no interior mutability, so +// concurrent Verify calls through one handle are fine. +type Verifier struct { + handle *C.NymAuthVerifier + // closeOnce makes "freed exactly once" a guarantee rather than a + // doc comment: two concurrent Close calls on a nil-check/assign + // pattern can both pass the check and both free the handle, which + // is a cgo double free. Same reasoning as pkg/mdoc/zknative. + closeOnce sync.Once +} + +// NewVerifier loads a decompressed nym_auth circuit artifact +// (signature circuit then hash circuit, LFC2 - zstd-decompress first if +// it came from the circuit catalog). +func NewVerifier(circuit []byte, params Params) (*Verifier, error) { + if len(circuit) == 0 { + return nil, errors.New("zknative: circuit must not be empty") + } + + var a arena + defer a.free() + + // params is passed BY POINTER, so its NymAuthBytes fields must not + // hold Go pointers - cgo forbids passing a pointer to Go memory + // that itself contains Go pointers. circuit is passed BY VALUE, so + // a pointer into the Go slice is fine there, with KeepAlive. + cParams := C.NymAuthCParams{ + block_enc_hash: C.uint32_t(params.BlockEncHash), + block_enc_sig: C.uint32_t(params.BlockEncSig), + hash_circuit_id: a.str(params.HashCircuitID), + sig_circuit_id: a.str(params.SigCircuitID), + } + + var errOut *C.char + handle := C.nymauth_verifier_new(goBytes(circuit), &cParams, &errOut) + runtime.KeepAlive(circuit) + if handle == nil { + return nil, fmt.Errorf("zknative: nymauth_verifier_new: %s", takeErrorString(errOut)) + } + // Not expected alongside a valid handle, but takeErrorString frees + // the owned string and is a no-op on nil - cheap insurance against + // leaking native memory if that ever changes. + _ = takeErrorString(errOut) + return &Verifier{handle: handle}, nil +} + +// Close releases the circuit handle. Safe to call more than once and +// concurrently; not safe to call while a Verify on the same Verifier is +// in flight. +func (v *Verifier) Close() error { + if v == nil { + return nil + } + v.closeOnce.Do(func() { + if v.handle != nil { + C.nymauth_verifier_free(v.handle) + v.handle = nil + } + }) + return nil +} + +// Verify checks proof against the verifier's own request and the +// pseudonym the holder claims, and returns the pseudonym the caller may +// use. +// +// The returned value is the ONLY one a caller may act on. It is equal +// to claimed on success, which is exactly the point: a pseudonym is a +// value the holder sent, and it means nothing until a proof has been +// checked against it. Reading it from the response instead would be +// reading an identifier the holder chose. +func (v *Verifier) Verify(req Request, claimed []byte, proof []byte) ([PseudonymBytes]byte, error) { + var zero [PseudonymBytes]byte + if v == nil || v.handle == nil { + return zero, errors.New("zknative: verifier is closed or nil") + } + if len(claimed) != PseudonymBytes { + return zero, fmt.Errorf("zknative: the claimed pseudonym is %d bytes, not %d", len(claimed), PseudonymBytes) + } + if len(proof) == 0 { + return zero, errors.New("zknative: proof must not be empty") + } + + var a arena + defer a.free() + + // Passed BY POINTER - every field must be C memory. See NewVerifier. + cReq := C.NymAuthCRequest{ + issuer_key_x: a.bytes(req.IssuerKeyX), + issuer_key_y: a.bytes(req.IssuerKeyY), + time: a.str(req.Time), + earliest: a.str(req.Earliest), + cutoff: a.str(req.Cutoff), + challenge_digest: a.bytes(req.ChallengeDigest), + verifier_input: a.bytes(req.VerifierInput), + transcript_seed: a.bytes(req.TranscriptSeed), + } + + var errOut *C.char + status := C.nymauth_verify(v.handle, &cReq, goBytes(claimed), goBytes(proof), &errOut) + // claimed and proof are passed by value as NymAuthBytes carrying + // pointers into their Go backing arrays; keep both alive across the + // (synchronous) call regardless of what the compiler can prove + // about the raw pointers. + runtime.KeepAlive(claimed) + runtime.KeepAlive(proof) + if status != 0 { + return zero, statusError("nymauth_verify", status, errOut) + } + _ = takeErrorString(errOut) + + var out [PseudonymBytes]byte + copy(out[:], claimed) + return out, nil +} + +// VerifierInput is nymauth_verifier_input: SHA-256 of the client id. +// Bound so the pure-Go derivation in pkg/nymauth/verifier can be +// checked against the library's, rather than only against a vector. +func VerifierInput(clientID string) ([PseudonymBytes]byte, error) { + var out [PseudonymBytes]byte + var a arena + defer a.free() + var errOut *C.char + status := C.nymauth_verifier_input(a.str(clientID), (*C.uint8_t)(unsafe.Pointer(&out[0])), &errOut) + if status != 0 { + return [PseudonymBytes]byte{}, statusError("nymauth_verifier_input", status, errOut) + } + _ = takeErrorString(errOut) + return out, nil +} + +// DeriveChallenge is nymauth_derive_challenge: the SessionTranscript +// this session's origin, nonce and docType produce, and its e2. A nil +// jwkThumbprint means the request advertised no encryption key. +func DeriveChallenge(origin, nonce, docType string, jwkThumbprint []byte) ([]byte, [PseudonymBytes]byte, error) { + var digest [PseudonymBytes]byte + var a arena + defer a.free() + + var transcript *C.uint8_t + var transcriptLen C.size_t + var errOut *C.char + status := C.nymauth_derive_challenge( + a.str(origin), a.str(nonce), a.str(docType), a.bytes(jwkThumbprint), + &transcript, &transcriptLen, + (*C.uint8_t)(unsafe.Pointer(&digest[0])), + &errOut, + ) + if status != 0 { + return nil, [PseudonymBytes]byte{}, statusError("nymauth_derive_challenge", status, errOut) + } + _ = takeErrorString(errOut) + defer C.nymauth_free_buffer(transcript, transcriptLen) + return C.GoBytes(unsafe.Pointer(transcript), C.int(transcriptLen)), digest, nil +} + +// ChallengeDigest is nymauth_challenge_digest: e2 for a transcript the +// caller already holds. +func ChallengeDigest(sessionTranscript []byte, docType string) ([PseudonymBytes]byte, error) { + var digest [PseudonymBytes]byte + var a arena + defer a.free() + var errOut *C.char + status := C.nymauth_challenge_digest( + a.bytes(sessionTranscript), a.str(docType), + (*C.uint8_t)(unsafe.Pointer(&digest[0])), &errOut, + ) + if status != 0 { + return [PseudonymBytes]byte{}, statusError("nymauth_challenge_digest", status, errOut) + } + _ = takeErrorString(errOut) + return digest, nil +} + +// arena owns the C copies one call needs, so the whole set is released +// together. Required wherever a pointer ends up in a struct passed to C +// by pointer; see NewVerifier. +type arena struct { + ptrs []unsafe.Pointer +} + +func (a *arena) bytes(b []byte) C.NymAuthBytes { + if len(b) == 0 { + // The header allows a NULL ptr only when len is 0, which is how + // "no value" is spelled - an absent JWK thumbprint, for one. + return C.NymAuthBytes{} + } + p := C.CBytes(b) + a.ptrs = append(a.ptrs, p) + return C.NymAuthBytes{ptr: (*C.uint8_t)(p), len: C.size_t(len(b))} +} + +// str copies a Go string as UTF-8 WITHOUT a NUL terminator, because +// every text field of this ABI is a counted NymAuthBytes rather than a +// C string. +func (a *arena) str(s string) C.NymAuthBytes { + return a.bytes([]byte(s)) +} + +func (a *arena) free() { + for _, p := range a.ptrs { + C.free(p) + } + a.ptrs = nil +} + +// goBytes points a NymAuthBytes at a Go slice's backing array. Only for +// values passed to C BY VALUE, and only with a runtime.KeepAlive of the +// slice across the call; use arena.bytes for anything that lands in a +// struct passed by pointer. +func goBytes(b []byte) C.NymAuthBytes { + if len(b) == 0 { + return C.NymAuthBytes{} + } + return C.NymAuthBytes{ptr: (*C.uint8_t)(unsafe.Pointer(&b[0])), len: C.size_t(len(b))} +} + +// statusError names what the status code means, and carries the +// library's own message when it wrote one. +// +// The codes are worth naming because -5 is a verification that ran and +// came out false, while the rest are a request or an artifact this +// process got wrong - two very different things to log, alert on, or +// show a user. +func statusError(call string, status C.int32_t, errOut *C.char) error { + return fmt.Errorf("zknative: %s: %s (status=%d): %s", call, statusMeaning(int32(status)), int32(status), takeErrorString(errOut)) +} + +func statusMeaning(status int32) string { + switch status { + case -1: + return "invalid input" + case -2: + return "bad circuit" + case -3: + return "unsupported circuit" + case -4: + return "malformed credential" + case -5: + return "the statement is not satisfied" + case -6: + return "prover failure" + case -7: + return "proof refused" + case -100: + return "an internal panic was caught at the boundary" + default: + return "unknown status" + } +} + +// takeErrorString converts and frees an owned error string written into +// an error_out out-parameter, and is a no-op on nil. +func takeErrorString(ptr *C.char) string { + if ptr == nil { + return "(no message)" + } + msg := C.GoString(ptr) + C.nymauth_free_error_string(ptr) + return msg +} diff --git a/pkg/nymauth/verifier/zknative_vega/verifier.go b/pkg/nymauth/verifier/zknative_vega/verifier.go new file mode 100644 index 000000000..ac1e0cf16 --- /dev/null +++ b/pkg/nymauth/verifier/zknative_vega/verifier.go @@ -0,0 +1,359 @@ +//go:build zknative + +// Package zknative_vega is a cgo wrapper around zk-cred-vega's plain +// C-ABI nym_auth verifier (src/nymauth_go_ffi.rs / +// include/zk_cred_vega_nymauth_go.h in that crate). It is the Vega +// counterpart of pkg/nymauth/verifier/zknative, which wraps +// zk-cred-longfellow's nym_auth ABI instead. +// +// Two packages rather than one, for the same reason the mdoc path has +// two: they are different headers and different shared libraries, with +// different load shapes. Longfellow loads a circuit artifact plus four +// Ligero parameters; Vega loads R1CS SHAPES and derives its verifier +// key from them, which is why this one takes no parameters at all. +// +// Built only when the "zknative" Go build tag is set, same convention +// as every other cgo package here: the default vc-verifier build is +// CGO_ENABLED=0 and fully static. +// +// This package deliberately does NOT import pkg/nymauth/verifier: that +// package imports this one under the build tag, and the dependency has +// to run one way. Its arguments are therefore flat bytes and strings. +// +// Setup: `make zk-native-lib-vega` at the repo root stages the shared +// library and BOTH headers under third_party/zk-cred-vega/; then build +// or test with `-tags zknative` and LD_LIBRARY_PATH pointing at that +// lib directory. +// +// # What this does not wrap +// +// The ABI also offers zk_cred_vega_nymauth_verifier_input(), which +// computes SHA-256(client_id). That is NOT this Verifier's +// verifier_input: rulebook v0.4 hashes the configured verifier_id with +// its prefix ("x509_san_dns:" or "semantic_id:"), and vc computes it in +// pkg/nymauth. Wrapping the ABI's helper would offer a second, wrong +// answer to a question that already has one, so it is left alone - as +// are prove() and derive_challenge(), which belong to a prover and to +// pkg/nymauth/verifier/challenge.go respectively. +package zknative_vega + +/* +#cgo CFLAGS: -I${SRCDIR}/../../../../third_party/zk-cred-vega/include +#cgo LDFLAGS: -L${SRCDIR}/../../../../third_party/zk-cred-vega/lib -lzk_cred_vega +#include +#include "zk_cred_vega_nymauth_go.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "runtime" + "sync" + "unsafe" +) + +// PseudonymBytes is the width of a nym_auth pseudonym and of every +// digest this ABI takes or returns. +const PseudonymBytes = 32 + +// The ABI's own argument caps (src/nymauth_go_ffi.rs). Checked here, +// before anything is copied into C memory, so an oversized argument is +// named rather than returned as a generic invalid-argument status. +const ( + MaxShapes = 64 << 20 + MaxProof = 512<<10 + 1 + maxSmall = 4096 +) + +// ErrClosed is a call on a verifier whose keys have been released. +var ErrClosed = errors.New("nymauth/vega: the verifier is closed") + +// Statement is ZkNymStatement: the values the VERIFIER names. +// +// Every one of them is this verifier's own. Time comes from the +// presentation's proof_time only after the acceptance window has +// approved it; the rest never touch the holder's response at all. +// Taking any of them from the response would let the holder choose +// what it is proving. +type Statement struct { + // IssuerKeyX and IssuerKeyY are the issuer's P-256 public key + // coordinates, 32 bytes each, big-endian. + IssuerKeyX, IssuerKeyY []byte + + // Time is T as the circuits write it: YYYY-MM-DDThh:mm:ssZ, 20 + // ASCII bytes. + Time string + + // Earliest and Cutoff are the age window's birth-date bounds, + // YYYY-MM-DD, with "0000-00-00" and "9999-12-31" disabling them. + Earliest, Cutoff string + + // ChallengeDigest is e2 and VerifierInput is SHA-256 of the + // configured verifier_id, 32 bytes each. + ChallengeDigest []byte + VerifierInput []byte +} + +// Verifier holds the keys derived from one circuit's shapes. +// +// Deriving them is the expensive step - roughly 12.5 MB of verifier key +// built from the shapes, a few hundred milliseconds - so a caller keeps +// one and reuses it rather than loading per proof. +type Verifier struct { + // mu guards keys against a Close that races a Verify. The native + // side has its own RWMutex, but a handle freed between our nil + // check and the call would be a cgo use-after-free on this side, + // which no lock over there can prevent. + mu sync.RWMutex + keys *C.ZkNymKeys +} + +// NewVerifier derives the verifier keys from DECOMPRESSED R1CS shapes. +// +// The shapes are the artifact the allowlist pinned by length and +// SHA-256, so by the time they arrive here they are the operator's +// approved bytes; the ABI refuses any shapes that are not an approved +// circuit with ZK_NYM_UNKNOWN_CIRCUIT. +func NewVerifier(shapes []byte) (*Verifier, error) { + if len(shapes) == 0 { + return nil, fmt.Errorf("nymauth/vega: no circuit shapes") + } + if len(shapes) > MaxShapes { + return nil, fmt.Errorf("nymauth/vega: the circuit shapes are %d bytes; this ABI takes at most %d", len(shapes), MaxShapes) + } + + var keys *C.ZkNymKeys + status := C.zk_cred_vega_nymauth_load_keys( + (*C.uint8_t)(unsafe.Pointer(&shapes[0])), + C.size_t(len(shapes)), + &keys, + ) + runtime.KeepAlive(shapes) + if err := statusError(status, "deriving the verifier keys from the circuit shapes"); err != nil { + return nil, err + } + if keys == nil { + // The ABI documents *keys_out as null on failure, so a null + // handle with an OK status is a contract this build cannot + // rely on. Refused rather than stored: every later Verify + // would dereference it. + return nil, fmt.Errorf("nymauth/vega: the library reported success but produced no keys") + } + return &Verifier{keys: keys}, nil +} + +// Close releases the keys. Safe to call twice. +func (v *Verifier) Close() error { + if v == nil { + return nil + } + v.mu.Lock() + defer v.mu.Unlock() + if v.keys != nil { + C.zk_cred_vega_nymauth_free_keys(v.keys) + v.keys = nil + } + return nil +} + +// Verify checks proof against the statement and returns the pseudonym +// the proof reveals. +// +// Callers must use the RETURNED pseudonym and never one that arrived in +// the holder's response. The two are equal whenever this returns nil, +// but the claimed value is the holder's until a proof has been checked +// against it, and this return is the only place that has happened. +// +// Unlike the Longfellow ABI this one takes no claimed pseudonym to +// compare against: it REVEALS the pseudonym, and comparing it with +// what the holder claimed is the caller's job. +func (v *Verifier) Verify(statement Statement, proof []byte) ([PseudonymBytes]byte, error) { + var out [PseudonymBytes]byte + + if v == nil { + return out, ErrClosed + } + if len(proof) == 0 { + return out, fmt.Errorf("nymauth/vega: no proof") + } + if len(proof) > MaxProof { + return out, fmt.Errorf("nymauth/vega: the proof is %d bytes; this ABI takes at most %d", len(proof), MaxProof) + } + if err := statement.check(); err != nil { + return out, err + } + + v.mu.RLock() + defer v.mu.RUnlock() + if v.keys == nil { + return out, ErrClosed + } + + var arena arena + defer arena.free() + c := C.ZkNymStatement{ + issuer_key_x: arena.bytes(statement.IssuerKeyX), + issuer_key_y: arena.bytes(statement.IssuerKeyY), + time: arena.str(statement.Time), + earliest: arena.str(statement.Earliest), + cutoff: arena.str(statement.Cutoff), + challenge_digest: arena.bytes(statement.ChallengeDigest), + verifier_input: arena.bytes(statement.VerifierInput), + } + proofSlice := arena.bytes(proof) + + status := C.zk_cred_vega_nymauth_verify( + v.keys, + &c, + proofSlice.ptr, + proofSlice.len, + (*C.uint8_t)(unsafe.Pointer(&out[0])), + ) + runtime.KeepAlive(v) + if err := statusError(status, "verifying the proof"); err != nil { + return [PseudonymBytes]byte{}, err + } + return out, nil +} + +// check refuses a statement the ABI would reject, while the field that +// is wrong can still be named. +func (s Statement) check() error { + for _, f := range []struct { + name string + value []byte + want int + }{ + {"issuer_key_x", s.IssuerKeyX, 32}, + {"issuer_key_y", s.IssuerKeyY, 32}, + {"challenge_digest", s.ChallengeDigest, PseudonymBytes}, + {"verifier_input", s.VerifierInput, PseudonymBytes}, + } { + if len(f.value) != f.want { + return fmt.Errorf("nymauth/vega: %s is %d bytes, not %d", f.name, len(f.value), f.want) + } + } + for _, f := range []struct{ name, value string }{ + {"time", s.Time}, + {"earliest", s.Earliest}, + {"cutoff", s.Cutoff}, + } { + if f.value == "" { + return fmt.Errorf("nymauth/vega: %s is empty", f.name) + } + if len(f.value) > maxSmall { + return fmt.Errorf("nymauth/vega: %s is %d bytes; this ABI takes at most %d", f.name, len(f.value), maxSmall) + } + } + return nil +} + +// Status is a ZK_NYM_* code of the C ABI. +type Status int32 + +// The statuses this verifier can see. The prove-only refusals +// (ZK_NYM_REFUSED_*) are included because the ABI shares one status +// enum, and a verifier that printed an unknown number would be less +// useful than one that names the code it got. +const ( + StatusOK Status = C.ZK_NYM_OK + StatusInvalidArg Status = C.ZK_NYM_INVALID_ARG + StatusInvalidInput Status = C.ZK_NYM_INVALID_INPUT + StatusInvalidShapes Status = C.ZK_NYM_INVALID_SHAPES + StatusUnknownCircuit Status = C.ZK_NYM_UNKNOWN_CIRCUIT + StatusProverFailed Status = C.ZK_NYM_PROVER_FAILED + StatusProofInvalid Status = C.ZK_NYM_PROOF_INVALID + StatusMismatch Status = C.ZK_NYM_MISMATCH + StatusInternal Status = C.ZK_NYM_INTERNAL + StatusRefusedSeedMismatch Status = C.ZK_NYM_REFUSED_SEED_MISMATCH + StatusRefusedIssuerSignature Status = C.ZK_NYM_REFUSED_ISSUER_SIGNATURE + StatusRefusedDeviceSignature Status = C.ZK_NYM_REFUSED_DEVICE_SIGNATURE + StatusRefusedCredential Status = C.ZK_NYM_REFUSED_CREDENTIAL +) + +// Error is a non-OK status, carrying the code so a caller can tell a +// refused proof from a broken circuit without parsing a string. +type Error struct { + Status Status + During string +} + +func (e *Error) Error() string { + return fmt.Sprintf("nymauth/vega: %s: %s (status %d)", e.During, e.Status.describe(), int32(e.Status)) +} + +// StatusOf returns the ABI status behind err, or StatusInternal for an +// error that did not come from the library. +func StatusOf(err error) Status { + var e *Error + if errors.As(err, &e) { + return e.Status + } + return StatusInternal +} + +func (s Status) describe() string { + switch s { + case StatusOK: + return "accepted" + case StatusInvalidArg: + return "an argument was null or past its cap" + case StatusInvalidInput: + return "a value was malformed - a range, an issuer key, or the proof's framing" + case StatusInvalidShapes: + return "the circuit shapes could not be parsed" + case StatusUnknownCircuit: + return "the circuit shapes are not an approved circuit" + case StatusProverFailed: + return "the prover failed" + case StatusProofInvalid: + return "the proof is not valid" + case StatusMismatch: + return "the proof verifies, but not for the values this verifier named" + case StatusInternal: + return "the library panicked" + case StatusRefusedSeedMismatch: + return "the secret seed does not open the credential's ssc" + case StatusRefusedIssuerSignature: + return "the issuer signature was refused" + case StatusRefusedDeviceSignature: + return "the device signature was refused" + case StatusRefusedCredential: + return "the credential was refused" + default: + return "an unrecognised status" + } +} + +func statusError(status C.int32_t, during string) error { + if Status(status) == StatusOK { + return nil + } + return &Error{Status: Status(status), During: during} +} + +// arena copies Go bytes into C memory for the duration of one call. +// +// Copied rather than pinned because cgo forbids passing a Go pointer +// that itself holds Go pointers, and ZkNymStatement is seven slices at +// once; one free list is simpler to get right than seven pin lifetimes. +type arena struct{ allocated []unsafe.Pointer } + +func (a *arena) bytes(b []byte) C.ZkNymSlice { + if len(b) == 0 { + return C.ZkNymSlice{} + } + p := C.CBytes(b) + a.allocated = append(a.allocated, p) + return C.ZkNymSlice{ptr: (*C.uint8_t)(p), len: C.size_t(len(b))} +} + +func (a *arena) str(s string) C.ZkNymSlice { return a.bytes([]byte(s)) } + +func (a *arena) free() { + for _, p := range a.allocated { + C.free(p) + } + a.allocated = nil +} diff --git a/pkg/openid4vp/dcql.go b/pkg/openid4vp/dcql.go index 6420eb06e..2550e00f6 100644 --- a/pkg/openid4vp/dcql.go +++ b/pkg/openid4vp/dcql.go @@ -4,8 +4,12 @@ import ( "bytes" "encoding/json" "fmt" + "maps" "math" + "reflect" "slices" + "strconv" + "strings" "github.com/SUNET/vc/pkg/vc20/credential" ) @@ -27,7 +31,16 @@ type CredentialQuery struct { Format string `json:"format" yaml:"format" validate:"required"` // Multiple OPTIONAL. A boolean which indicates whether multiple Credentials can be returned for this Credential Query. If omitted, the default value is false. - Multiple bool `json:"multiple,omitempty" yaml:"multiple,omitempty"` + // + // A POINTER, for the same reason RequireCryptographicHolderBinding + // below is one: with a plain bool, omitempty cannot tell an absent + // member from an explicit false, so `"multiple": false` - an + // ordinary, valid query - did not encode back to what arrived and + // was kept as Raw. That is not a harmless detour: a raw query is + // emitted from its original bytes, so augmentDCQLFromVCTM's rewrite + // of the shadow Claims was silently discarded and the request went + // out with the un-augmented paths. + Multiple *bool `json:"multiple,omitempty" yaml:"multiple,omitempty"` // Meta REQUIRED. An object defining additional properties requested by the Verifier that apply to the metadata and validity data of the Credential. The properties of this object are defined per Credential Format. Examples of those are in Appendix B.3.5 and Appendix B.2.3. If empty, no specific constraints are placed on the metadata or validity of the requested Credential. Meta MetaQuery `json:"meta" yaml:"meta" validate:"required"` @@ -43,6 +56,880 @@ type CredentialQuery struct { // ClaimSet OPTIONAL. A non-empty array containing arrays of identifiers for elements in claims that specifies which combinations of claims for the Credential are requested. The rules for selecting claims to send are defined in Section 6.4.1. ClaimSet [][]string `json:"claim_sets,omitempty" yaml:"claim_sets,omitempty" validate:"omitnil,min=1,dive,required,min=1,dive,required"` + + // Req is the zkp_req format's list of requested calculations. + // + // It replaces Claims rather than accompanying it: a zkp_req + // presentation discloses no issuer-signed attribute at all, so every + // value a Verifier wants is the output of a function named here. A + // query carrying both is one the planner rejects - which this does + // not prevent, because sending it is how a wallet gets tested. + Req []ReqItem `json:"_req,omitempty" yaml:"_req,omitempty"` + + // Extra carries members this build does not know about, flattened + // alongside the known ones. + // + // The request shape is moving - the nym_auth rulebook, OpenID4VP and + // the DC API all - and wallets of several generations will be in the + // field at once. Without somewhere to put a member this code has never + // heard of, talking to any of them means waiting for a release here. + Extra map[string]any `json:"-" yaml:"-"` + + // Raw replaces this query entirely: when set, it is emitted as this + // JSON VALUE and every other modelled member is ignored on the wire. + // + // The value, not the bytes. encoding/json compacts and HTML-escapes + // whatever a MarshalJSON returns, so an operator's whitespace is + // dropped and a & inside a string is written \u0026 - which no + // conformant wallet can tell apart from what was configured, and + // which nothing here can prevent short of re-implementing the + // request-object encoder. What IS guaranteed is that every member, + // its nesting, and every value survives unchanged, including members + // this build has never heard of and numeric literals it would + // otherwise reformat. + // + // Extra covers a shape that ADDS members. This covers one that changes + // the type of a member already modelled - the rulebook's WIP example + // makes "claims" an object where DCQL has an array, and a future + // generation could do the same to "_req" or "meta". A per-member + // override for each of those is a field this struct grows every time a + // specification moves; one raw escape hatch covers all of them and + // needs no release here. + // + // It is deliberately all-or-nothing. A half-raw query would leave an + // operator guessing which of the two sources a given member came from. + Raw json.RawMessage `json:"-" yaml:"-"` +} + +// MarshalYAML flattens Extra alongside the known members, so a query read +// from configuration can be written back out whole. +func (q CredentialQuery) MarshalYAML() (any, error) { + // Raw is tagged yaml:"-", so the bridge below would emit only the + // modelled shadow - a DIFFERENT query, missing whatever made these + // bytes raw in the first place, and missing it silently. + if q.Raw != nil { + if q.Extra != nil { + return nil, fmt.Errorf("credential query: raw and extra are both set, and raw would discard extra") + } + if !json.Valid(q.Raw) { + return nil, fmt.Errorf("credential query: raw is not valid JSON") + } + // A duplicate member has no YAML representation that keeps both. + // This path decodes the bytes to a tree, which collapses them to + // the last value - so writing the configuration back out would + // emit a DIFFERENT request, and the whole reason such a query is + // carried raw is that it must not change. Refused rather than + // quietly reduced; the JSON path still carries it. + if hasDuplicateMembers(q.Raw) { + return nil, fmt.Errorf("credential query: this query names a member more than once, which YAML cannot write without dropping one") + } + var tree any + if err := decodeExact(q.Raw, &tree); err != nil { + return nil, fmt.Errorf("credential query: %w", err) + } + // decodeExact keeps numbers as their literals; this refuses the + // ones yaml.v2 would round on the way out, rather than writing a + // different number than the one configured. + normalised, err := checkYAMLRendersNumbersExactly(tree, "credential query", nil) + if err != nil { + return nil, err + } + return normalised, nil + } + type plain CredentialQuery + encoded, err := json.Marshal(plain(q)) + if err != nil { + return nil, err + } + var m map[string]any + if err := decodeExact(encoded, &m); err != nil { + return nil, err + } + return mergeExtraYAML(m, q.Extra, declaredJSONKeys(reflect.TypeOf(CredentialQuery{})), "credential query") +} + +// UnmarshalYAML captures every member other than the modelled ones. +// +// Without it a presentation request template - which pkg/configuration +// loads from YAML into an openid4vp.DCQL - could not carry a member this +// build does not model, which is the whole purpose of Extra. +// +// The known members go through a methodless alias so yaml.v2 applies the +// struct tags and reports a wrong type itself, rather than this coercing +// one. See the note above ReqItem.UnmarshalYAML for why nothing here +// passes through JSON on the way. +func (q *CredentialQuery) UnmarshalYAML(unmarshal func(any) error) error { + if err := refuseDuplicateYAMLKeys(unmarshal, "credential query"); err != nil { + return err + } + type plain CredentialQuery + var known plain + if err := unmarshal(&known); err != nil { + // Same fallback as the JSON decode, for the same reason: a shape + // the typed model cannot hold - claims as a mapping, say - is + // kept rather than refused. Without it this type would refuse to + // read back a query it had just written, since MarshalYAML above + // emits exactly such a shape. + raw, rawErr := rawFromYAML(unmarshal) + if rawErr != nil { + return fmt.Errorf("credential query: %w", err) + } + // On the NORMALISED bytes, which is what will be emitted and + // what a wallet will read. A template is as capable of naming a + // constraint twice, or in the wrong case, as a JSON request is. + if checkErr := checkRawConstraints(raw); checkErr != nil { + return checkErr + } + fallback := CredentialQuery{Raw: raw} + if _, invalid := fallback.adoptRawShadowReporting(); len(invalid) > 0 { + // A member of the right JSON kind that the model still + // rejects is an invalid query, not an unmodelled one. See the + // JSON decode. + return fmt.Errorf("credential query: %w", err) + } + *q = fallback + return nil + } + *q = CredentialQuery(known) + + extra, err := yamlExtra(unmarshal, "credential query", + "id", "format", "multiple", "meta", "trusted_authorities", + "require_cryptographic_holder_binding", "claims", "claim_sets", "_req") + if err != nil { + return err + } + q.Extra = extra + + // The same question the JSON decode asks, and for the same reason: + // did it decode, or did it keep everything? A template carrying + // `req: []`, an item with no `id`, or `params: null` decodes into zero + // values that are then re-emitted with members added or removed - and + // those are exactly the negative requests an operator configures this + // verifier to send. + // + // Compared on SHAPE rather than value, which is the one difference + // from the JSON path. yaml.v2 coerces a scalar to the field's type - + // `id: 7` becomes "7" - for every configuration struct in this + // repository, and that policy is deliberate; treating it as loss here + // would send every template with a numeric-looking scalar down the raw + // path and quietly reverse it. Presence, absence and nesting are what + // the typed model changes, and what this catches. + original, rawErr := rawFromYAML(unmarshal) + if rawErr != nil { + return rawErr + } + reencoded, encErr := json.Marshal(*q) + if encErr != nil { + return encErr + } + if !jsonShapeEquivalent(original, reencoded) { + // The second YAML route to Raw, and the same guard. Missing it + // here was the whole of the first fix's incompleteness: a + // template with a case-aliased constraint takes THIS path, not + // the decode-failure one above. + if err := checkRawConstraints(original); err != nil { + return err + } + fallback := CredentialQuery{Raw: original} + fallback.adoptRawShadow() + *q = fallback + } + return nil +} + +// jsonShapeEquivalent reports whether two JSON documents have the same +// members, nesting and array lengths - ignoring scalar types. +// +// Scalars are not compared because yaml.v2's coercion is this +// repository's settled policy, not a loss: `id: 7` arriving as "7" is how +// every other configuration struct here behaves, and diverging for this +// one type would be worse than the coercion. What a typed decode DOES +// lose is structural - an empty array dropped by omitempty, an absent +// member becoming an empty string, a null becoming nothing at all - and +// that is what this sees. +func jsonShapeEquivalent(a, b json.RawMessage) bool { + decode := func(data []byte) (any, bool) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, false + } + return value, true + } + left, ok := decode(a) + if !ok { + return false + } + right, ok := decode(b) + if !ok { + return false + } + return sameJSONShape(left, right) +} + +func sameJSONShape(a, b any) bool { + switch left := a.(type) { + case map[string]any: + right, ok := b.(map[string]any) + if !ok || len(left) != len(right) { + return false + } + for name, value := range left { + other, present := right[name] + if !present || !sameJSONShape(value, other) { + return false + } + } + return true + case []any: + right, ok := b.([]any) + if !ok || len(left) != len(right) { + return false + } + for i := range left { + if !sameJSONShape(left[i], right[i]) { + return false + } + } + return true + case nil: + // null is a value an operator wrote, not an absence. Dropping it + // is the loss this exists to catch, so it only matches null. + return b == nil + default: + // A scalar matches any other scalar: see the doc comment. + switch b.(type) { + case map[string]any, []any, nil: + return false + } + return true + } +} + +// rawFromYAML reads a whole YAML value and returns it as JSON, keeping +// each numeric scalar's original text. +// +// Through yamlValue rather than a plain any, so a decimal does not lose +// its digits on the way in - see that type. Non-string keys are refused +// by normaliseYAML rather than coerced, since JSON has no spelling for +// them. +func rawFromYAML(unmarshal func(any) error) (json.RawMessage, error) { + var tree yamlValue + if err := unmarshal(&tree); err != nil { + return nil, err + } + normalised, err := normaliseYAML(tree.value, "credential query") + if err != nil { + return nil, err + } + return json.Marshal(normalised) +} + +// MarshalJSON flattens Extra alongside the known members. +// +// With Extra empty this is byte-identical to the struct encoding it +// replaces, which TestCredentialQueryEncodingUnchanged pins: every format +// other than zkp_req goes through here too, and none of them should be +// able to tell this method exists. +func (q CredentialQuery) MarshalJSON() ([]byte, error) { + // Returned as-is, which encoding/json then compacts and HTML-escapes + // on its way into the enclosing document. See Raw's own comment for + // why the guarantee is on the JSON value rather than the bytes. + if q.Raw != nil { + if q.Extra != nil { + return nil, fmt.Errorf("credential query: raw and extra are both set, and raw would discard extra") + } + if !json.Valid(q.Raw) { + return nil, fmt.Errorf("credential query: raw is not valid JSON") + } + return q.Raw, nil + } + type plain CredentialQuery // no methods, so no recursion + return marshalWithExtra(plain(q), q.Extra, "credential query") +} + +// UnmarshalJSON collects unknown members into Extra. +func (q *CredentialQuery) UnmarshalJSON(data []byte) error { + // A split failure is a decode failure: data that is not an object at + // all must reach the escape hatch below exactly as a member of the + // wrong kind does, or the fallback stops being total. + knownObject, extra, err := splitMembers(data, "credential query", + "id", "format", "multiple", "meta", "trusted_authorities", + "require_cryptographic_holder_binding", "claims", "claim_sets", "_req") + type plain CredentialQuery + var known plain + if err == nil { + err = decodeExact(knownObject, &known) + } + if err != nil { + // A shape the model cannot hold is kept as it stands rather than + // refused. Decoding has to be TOTAL for the escape hatch to be + // worth anything: a request object is JSON-encoded into the HA + // cache and read back by another node, so a raw query with - say - + // an object at "claims" would be written out fine and then fail to + // load anywhere but the node that built it. + // + // It also keeps the verifier's own promise. This serialiser does + // not repair a request, and refusing to read back something it + // just wrote would be a repair by other means. + if !json.Valid(data) { + return err + } + // Before anything is kept raw. A typed decode failing is exactly + // how a duplicate or case-aliased constraint used to get past + // these checks: the shadow still reported the constraint, so + // validation passed, while the emitted bytes said something else. + if checkErr := checkRawConstraints(data); checkErr != nil { + return checkErr + } + fallback := CredentialQuery{Raw: append(json.RawMessage(nil), data...)} + if _, invalid := fallback.adoptRawShadowReporting(); len(invalid) > 0 { + // A member of the right JSON kind that the model still + // rejects is an ordinary query that is simply invalid, not a + // shape this build has never seen. Sending it to Raw would + // launder a decode error into a query that validates - the + // SD-JWT and mdoc rules read the shadow, and an unreadable + // member leaves nothing there to judge. + return err + } + *q = fallback + return nil + } + *q = CredentialQuery(known) + q.Extra = extra + + // A decode that SUCCEEDED is not necessarily one that kept + // everything. `"_req": []` decodes to an empty slice and is then + // dropped by omitempty; an item missing "id" decodes to "" and comes + // back as a member that was not there. Those shapes are exactly the + // ones a Verifier sends on purpose to see what a wallet does with + // them, and the HA cache round trip would quietly repair them. + // + // So the test is not "did it decode" but "does it encode back to what + // arrived". Anything that does not is kept as it stands. + if ambiguous := ambiguousConstraintMembers(data); len(ambiguous) > 0 { + return &DCQLValidationError{ + Field: strings.Join(ambiguous, ", "), + Message: "named more than once, and JSON decoders disagree about which value wins; " + + "a request whose constraint depends on the reader is one this verifier will not sign", + } + } + if hasDuplicateMembers(data) || !q.encodesBackTo(data) { + // The same guard on the way in here. A case-aliased member is one + // of the things that makes a query fail to encode back, so this + // is the path it takes. + if err := checkRawConstraints(data); err != nil { + return err + } + *q = CredentialQuery{Raw: append(json.RawMessage(nil), data...)} + q.adoptRawShadow() + } + return nil +} + +// ambiguousConstraintMembers names the members that decide what a request +// is LIMITED TO and appear more than once. +// +// A duplicate is a parser differential: encoding/json keeps the last +// value, other decoders keep the first, and the two disagree about what +// the document says. For most members that is a curiosity this verifier +// passes through, because sending a malformed request is how a wallet +// gets tested. For the members validation reads it is not. +// +// {"format":"dc+sd-jwt","meta":{},"meta":{"vct_values":["urn:x"]}} is the +// shape: Go validates against the LAST meta, finds a constraint, signs +// and sends - while a first-wins wallet reads an unconstrained SD-JWT +// request and may return anything it holds. The query arrives at the +// ingress from a browser, so that is attacker-reachable over-disclosure, +// reached through the very escape hatch added to preserve malformed +// requests. +// +// So these are refused rather than carried, wherever they come from. A +// request whose constraint depends on who is reading it is not one to +// sign on anybody's behalf - and the negative-test cases that matter +// (the rulebook's own uniqueness rules on req ids and handles) are deeper +// in the document and still pass through untouched. +func ambiguousConstraintMembers(data []byte) []string { + members, ok := topLevelMemberNames(data) + if !ok { + return nil + } + + var ambiguous []string + for _, name := range []string{"format", "meta"} { + if members[name] > 1 { + ambiguous = append(ambiguous, name) + } + } + + // And inside meta, where the constraint itself lives. Only the last + // one can be read back, which is the same reading validation uses, so + // checking it is enough to catch a constraint that differs between + // decoders. + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err == nil { + if nested, present := envelope["meta"]; present { + if inner, ok := topLevelMemberNames(nested); ok { + for _, name := range slices.Sorted(maps.Keys(inner)) { + if inner[name] > 1 { + ambiguous = append(ambiguous, "meta."+name) + } + } + } + } + } + slices.Sort(ambiguous) + return slices.Compact(ambiguous) +} + +// aliasedConstraintMembers names the constraint members written in a case +// that is not the one the wire uses. +// +// Go's encoding/json matches field names case-insensitively, so +// {"meta":{"VCT_VALUES":["urn:x"]}} decodes into MetaQuery.VCTValues and +// the shadow reports a constraint - while the bytes this verifier signs +// carry VCT_VALUES, which no conformant wallet reads as vct_values. The +// request goes out unconstrained and validation believed otherwise. +// +// The same reasoning as the duplicate check beside it: a request whose +// constraint depends on who is reading it is one this verifier will not +// sign. Refused rather than quietly un-shadowed, because an alias is +// almost certainly a typo in a configuration, and a request that silently +// stops constraining anything is the worst way to discover that. +// +// Only the members validation reads. Everything else keeps passing +// through, because sending a shape this build has never seen is the whole +// point of the raw path. +func aliasedConstraintMembers(data []byte) []string { + var aliased []string + + check := func(prefix string, document []byte, modelled ...string) { + names, ok := topLevelMemberNames(document) + if !ok { + return + } + for name := range names { + for _, want := range modelled { + if name != want && strings.EqualFold(name, want) { + aliased = append(aliased, prefix+name) + } + } + } + } + + check("", data, "format", "meta", "claims") + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err == nil { + if nested, present := envelope["meta"]; present { + check("meta.", nested, "vct_values", "doctype_value", "type_values", "schema_id_value", "source_format") + } + } + + slices.Sort(aliased) + return slices.Compact(aliased) +} + +// topLevelMemberNames counts how often each member name appears in a JSON +// object, without descending into it. +func topLevelMemberNames(data []byte) (map[string]int, bool) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + + token, err := decoder.Token() + if err != nil { + return nil, false + } + if delim, ok := token.(json.Delim); !ok || delim != '{' { + return nil, false + } + + counts := map[string]int{} + for decoder.More() { + name, err := decoder.Token() + if err != nil { + return nil, false + } + text, ok := name.(string) + if !ok { + return nil, false + } + counts[text]++ + if _, err := scanForDuplicateMembers(decoder); err != nil { + return nil, false + } + } + return counts, true +} + +// hasDuplicateMembers reports whether any object in data names the same +// member twice, at any depth. +// +// encoding/json keeps the last one, and so does every other decoder in +// this repository, which makes a duplicate INVISIBLE to the +// encode-back-to comparison: {"id":"first","id":"last"} re-encodes as +// just "last" and compares equal to itself. The query is then sent with +// the duplicate silently removed. +// +// Two reasons that is not acceptable here. It is a changed request, and +// this serialiser does not change requests - a duplicate member is +// exactly the kind of malformed input a Verifier sends on purpose to see +// what a wallet does with it. And it is a parser differential: JSON +// decoders disagree about which value wins, so a request where that +// matters has to reach the wallet as written rather than as this build +// happens to read it. +// +// Detected rather than refused, because refusing would make the malformed +// request unsendable, which is the same loss by another route. A query +// carrying one simply goes down the raw path, where the bytes stand. +func hasDuplicateMembers(data []byte) bool { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + duplicate, err := scanForDuplicateMembers(decoder) + if err != nil { + // Malformed, which is not this function's business to report - + // the caller has already established the bytes are valid JSON, or + // is about to refuse them for being invalid. + return false + } + return duplicate +} + +// scanForDuplicateMembers consumes exactly one JSON value from decoder +// and reports whether it, or anything inside it, names a member twice. +// +// Written as a descent rather than a flat token loop because +// Decoder.Token reports member names and string values identically; only +// the structure says which is which. +func scanForDuplicateMembers(decoder *json.Decoder) (bool, error) { + token, err := decoder.Token() + if err != nil { + return false, err + } + delim, ok := token.(json.Delim) + if !ok { + return false, nil // a scalar, now consumed + } + + duplicate := false + switch delim { + case '{': + seen := map[string]bool{} + for decoder.More() { + name, err := decoder.Token() + if err != nil { + return false, err + } + text, ok := name.(string) + if !ok { + return false, fmt.Errorf("object member name is not a string") + } + if seen[text] { + duplicate = true + } + seen[text] = true + + inner, err := scanForDuplicateMembers(decoder) + if err != nil { + return false, err + } + duplicate = duplicate || inner + } + case '[': + for decoder.More() { + inner, err := scanForDuplicateMembers(decoder) + if err != nil { + return false, err + } + duplicate = duplicate || inner + } + default: + return false, fmt.Errorf("unexpected delimiter %q", delim) + } + + if _, err := decoder.Token(); err != nil { // the closing delimiter + return false, err + } + return duplicate, nil +} + +// encodesBackTo reports whether re-encoding q reproduces data, compared +// as JSON values rather than as bytes - member order and whitespace are +// not something this type promises to preserve, and treating them as loss +// would send every ordinary query down the raw path. +func (q CredentialQuery) encodesBackTo(data []byte) bool { + reencoded, err := json.Marshal(q) + if err != nil { + return false + } + return jsonEquivalent(data, reencoded) +} + +// jsonEquivalent compares two JSON documents by value. +// +// Numbers are compared as their literals (UseNumber), so a value that +// re-encoded with different precision counts as changed - which for a +// req param, an input to a proof, is exactly what it is. +func jsonEquivalent(a, b []byte) bool { + decode := func(data []byte) (any, bool) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, false + } + return value, true + } + left, ok := decode(a) + if !ok { + return false + } + right, ok := decode(b) + if !ok { + return false + } + return reflect.DeepEqual(left, right) +} + +// NewRawCredentialQuery builds a query that is sent as exactly these +// bytes, with every modelled member it can read filled in alongside. +// +// This is the supported way to use the escape hatch. Writing +// CredentialQuery{Raw: ...} by hand compiles and is wrong: Raw is +// authoritative for the WIRE, and the rest of the stack reads the struct - +// UIInteraction records ID in the authorization context's scopes and the +// response path matches a presentation back to it, matching reads +// Meta.TypeValues, Multiple and RequireCryptographicHolderBinding decide +// what a response may contain. A hand-built query is therefore sent +// correctly and then tracked under defaults nobody chose, which fails as +// a result that cannot be matched rather than as an error. +// +// TestRawIsOnlyConstructedThroughTheConstructor keeps that from being +// advice: production code may not write the literal. +// checkRawConstraints refuses a raw query whose constraint members this +// verifier and a wallet would read differently. +// +// Shared by the constructor and by both decode fallbacks. The decode +// paths used to skip it, and that was the hole: an unmodelled member - +// `"_req": {}` - made the typed decode fail, the query went raw, and the +// duplicate-meta check never ran. The shadow still reported the last +// meta's constraint, so validation passed while the request was emitted +// with BOTH, which a first-wins wallet reads as unconstrained. One +// function, three callers, no path that reaches Raw without passing here. +func checkRawConstraints(raw json.RawMessage) error { + if ambiguous := ambiguousConstraintMembers(raw); len(ambiguous) > 0 { + return fmt.Errorf( + "credential query: %s named more than once, and JSON decoders disagree about which value wins", + strings.Join(ambiguous, ", ")) + } + if aliased := aliasedConstraintMembers(raw); len(aliased) > 0 { + return fmt.Errorf( + "credential query: %s differs only in case from the member the wire uses, so this verifier and a wallet would read different constraints", + strings.Join(aliased, ", ")) + } + return nil +} + +// UnmarshalTrustedDCQL decodes a query THIS verifier built and stored, +// rather than one that arrived from a caller. +// +// The two cannot use the same decoder, and the asymmetry is deliberate on +// both sides. Wire ingress refuses a modelled member of the right JSON +// kind whose value the model rejects - `"doctype_value": 1` - because +// sending it to Raw would launder a decode error into a query that +// validates: the SD-JWT and mdoc rules read the shadow, and an +// unreadable member leaves nothing there to judge. But +// NewRawCredentialQuery accepts exactly those shapes on purpose, because +// a conformance harness has to be able to SEND a malformed request to +// see what a wallet does with it. +// +// Put together, a request the escape hatch built could be published and +// then reload nowhere - not even on the node that built it, once the +// context had been through the cache. This is the same defect +// TestMalformedReqSurvivesACacheRoundTrip fixed for req shapes, reaching +// meta by the other route. +// +// What is NOT relaxed: checkRawConstraints still runs, through +// NewRawCredentialQuery, so a duplicate or case-aliased constraint is +// still refused on the way back in. The trust here is only that this +// verifier wrote the bytes, not that they may mean two things. +func UnmarshalTrustedDCQL(data []byte) (*DCQL, error) { + var strict DCQL + strictErr := json.Unmarshal(data, &strict) + if strictErr == nil { + return &strict, nil + } + + var outer struct { + Credentials []json.RawMessage `json:"credentials"` + CredentialSets []CredentialSetQuery `json:"credential_sets,omitempty"` + } + if err := json.Unmarshal(data, &outer); err != nil { + // The outer shape is not a DCQL at all, which is a different + // problem from a query the model cannot hold. Report the + // original failure rather than this one. + return nil, strictErr + } + + rebuilt := &DCQL{ + Credentials: make([]CredentialQuery, 0, len(outer.Credentials)), + CredentialSets: outer.CredentialSets, + } + for i, raw := range outer.Credentials { + var query CredentialQuery + if err := json.Unmarshal(raw, &query); err == nil { + rebuilt.Credentials = append(rebuilt.Credentials, query) + continue + } + built, err := NewRawCredentialQuery(raw) + if err != nil { + return nil, fmt.Errorf("credential query %d: %w", i, strictErr) + } + rebuilt.Credentials = append(rebuilt.Credentials, built) + } + return rebuilt, nil +} + +func NewRawCredentialQuery(raw json.RawMessage) (CredentialQuery, error) { + if !json.Valid(raw) { + return CredentialQuery{}, fmt.Errorf("credential query: raw is not valid JSON") + } + // Refused here as well as on the decode paths. A request whose + // constraint depends on the reader is not one to sign on anybody's + // behalf, and allowing it here would also mean a query that works on + // the replica that built it and nowhere else, since the decode paths + // refuse to read it back. + if err := checkRawConstraints(raw); err != nil { + return CredentialQuery{}, err + } + query := CredentialQuery{Raw: append(json.RawMessage(nil), raw...)} + query.adoptRawShadow() + return query, nil +} + +// adoptRawShadow fills in every modelled member the raw bytes can be read +// for, and reports the names of those that are present but unreadable. +// +// Raw is authoritative for the WIRE and for nothing else. Plenty of this +// stack reads the struct: UIInteraction copies ID into the authorization +// context's scopes and the response path matches a presentation back to +// it; matching reads Meta.TypeValues; Multiple and +// RequireCryptographicHolderBinding change what a response is allowed to +// contain. A query that arrived raw and kept the zero value for those was +// sent correctly and then judged against defaults nobody chose. +// +// Derived from the struct tags rather than written as a list of members, +// because the list is what goes stale - that is exactly how this started +// with only id and format. A member added to CredentialQuery is shadowed +// by the commit that adds it. +// +// Best effort per member: one that is absent, or of a shape this build +// cannot read, is left at its zero value rather than failing the whole +// decode. A query with a member this code has never seen is the reason +// these bytes are raw in the first place. +func (q *CredentialQuery) adoptRawShadow() (unreadable []string) { + unreadable, _ = q.adoptRawShadowReporting() + return unreadable +} + +// adoptRawShadowReporting is adoptRawShadow, also reporting members that +// are the RIGHT JSON KIND for the model and still failed to decode. +// +// That distinction is the whole difference between the escape hatch and a +// bypass. "claims" as an object is a shape the model does not have - the +// rulebook's WIP example - and belongs in Raw. "claims" as an ARRAY whose +// entries the model rejects is an ordinary DCQL query that is simply +// invalid, and sending it to Raw would launder a decode error into a +// query that validates: ClaimQuery.UnmarshalJSON refuses an empty path, +// and such a query used to be refused outright. +// openItemMembers are members whose ITEMS are deliberately open, so an +// array this model cannot hold is an unmodelled shape rather than an +// ordinary invalid query. +// +// Only req. A zkp_req request carries its arguments there, the +// rulebook is moving, and the vendored conformance set includes negative +// vectors - a numeric item id, array-valued params - that exist to be +// SENT. Without this they were accepted by NewRawCredentialQuery and +// then refused on the way back in: a request object is JSON-encoded into +// the HA cache and read by another node, so such a request could be +// published and then not reload anywhere but the node that built it. +// +// The other members keep the strict rule. claims, meta and format are +// what the SD-JWT and mdoc validation reads, and laundering a decode +// error there into Raw would turn an unreadable constraint into a query +// that validates because there is nothing left to judge. +var openItemMembers = map[string]bool{"_req": true} + +func (q *CredentialQuery) adoptRawShadowReporting() (unreadable, invalid []string) { + var members map[string]json.RawMessage + if err := json.Unmarshal(q.Raw, &members); err != nil { + return nil, nil // not an object at all; nothing to shadow + } + + type plain CredentialQuery // no methods, so no recursion + shadow := plain{} + value := reflect.ValueOf(&shadow).Elem() + structure := value.Type() + for i := range structure.NumField() { + name, _, _ := strings.Cut(structure.Field(i).Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + encoded, present := members[name] + if !present { + continue + } + field := reflect.New(structure.Field(i).Type) + if err := json.Unmarshal(encoded, field.Interface()); err != nil { + unreadable = append(unreadable, name) + if jsonKindMatches(structure.Field(i).Type, encoded) && !openItemMembers[name] { + invalid = append(invalid, name) + } + continue + } + value.Field(i).Set(field.Elem()) + } + + raw := q.Raw + *q = CredentialQuery(shadow) + q.Raw = raw + slices.Sort(unreadable) + slices.Sort(invalid) + return unreadable, invalid +} + +// jsonKindMatches reports whether encoded is the JSON kind that target is +// modelled as - an array for a slice, an object for a struct or map, and +// so on. +// +// null matches everything, since unmarshalling it is a no-op whatever the +// destination, and an interface accepts any kind by definition. +func jsonKindMatches(target reflect.Type, encoded json.RawMessage) bool { + trimmed := bytes.TrimLeft(encoded, " \t\r\n") + if len(trimmed) == 0 { + return false + } + for target.Kind() == reflect.Pointer { + target = target.Elem() + } + switch target.Kind() { + case reflect.Interface: + return true + case reflect.Slice, reflect.Array: + return trimmed[0] == '[' || trimmed[0] == 'n' + case reflect.Struct, reflect.Map: + return trimmed[0] == '{' || trimmed[0] == 'n' + case reflect.String: + return trimmed[0] == '"' || trimmed[0] == 'n' + case reflect.Bool: + return trimmed[0] == 't' || trimmed[0] == 'f' || trimmed[0] == 'n' + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return trimmed[0] == '-' || trimmed[0] == 'n' || (trimmed[0] >= '0' && trimmed[0] <= '9') + } + return false } type CredentialSetQuery struct { @@ -62,6 +949,12 @@ func (q CredentialSetQuery) IsRequired() bool { return *q.Required } +// AllowsMultiple returns the effective value of Multiple, applying the +// OpenID4VP 1.0 §6.1 default (false) when the member was omitted. +func (q CredentialQuery) AllowsMultiple() bool { + return q.Multiple != nil && *q.Multiple +} + // RequiresCryptographicHolderBinding returns the effective value of // RequireCryptographicHolderBinding, applying the OpenID4VP 1.0 §6.1 // default (true) when the field was omitted. @@ -124,6 +1017,17 @@ type MetaQuery struct { // String that specifies an allowed value for the doctype of the requested Verifiable Credential. DoctypeValue string `json:"doctype_value,omitempty" yaml:"doctype_value,omitempty"` + // SchemaIDValue is the type value for a bbs_cd source format, the + // way DoctypeValue is for mso_mdoc. Exactly one of the two + // belongs in a zkp_req query's meta, decided by source_format. + SchemaIDValue string `json:"schema_id_value,omitempty" yaml:"schema_id_value,omitempty"` + + // SourceFormat is the stored attestation a zkp_req proof is made + // over: "mso_mdoc" or "bbs_cd". It decides whether proof_time is + // required in the response - every approved mso_mdoc circuit checks + // the MSO validity period, so it is required there. + SourceFormat string `json:"source_format,omitempty" yaml:"source_format,omitempty"` + // ZKSystemType for ZK-mdoc format (mso_mdoc_zk) only. // A non-empty array of ZK proof system/circuit variants the Verifier is // willing to accept (e.g. Longfellow circuits for different attribute @@ -151,6 +1055,80 @@ type VPFormatsSupported struct { // MsoMdoc is the configuration for ISO mdoc format (mso_mdoc) MsoMdoc *MsoMdocFormat `json:"mso_mdoc,omitempty" yaml:"mso_mdoc,omitempty"` + + // ZkpReq is the configuration for zero-knowledge circuit + // presentations (zkp_req): which proof systems and circuits a proof + // may come from. + ZkpReq *ZkpReqFormat `json:"zkp_req,omitempty" yaml:"zkp_req,omitempty"` + + // Extra carries format entries this build does not know about, + // flattened alongside the known ones - so a Verifier can offer a + // format identifier that postdates this code. + Extra map[string]any `json:"-" yaml:"-"` +} + +// MarshalJSON flattens Extra alongside the known format entries. +func (v VPFormatsSupported) MarshalJSON() ([]byte, error) { + type plain VPFormatsSupported + return marshalWithExtra(plain(v), v.Extra, "vp_formats_supported") +} + +// UnmarshalJSON collects unknown format entries into Extra. +func (v *VPFormatsSupported) UnmarshalJSON(data []byte) error { + knownObject, extra, err := splitMembers(data, "vp_formats_supported", "ldp_vc", "jwt_vc_json", "dc+sd-jwt", "mso_mdoc", "zkp_req") + if err != nil { + return err + } + type plain VPFormatsSupported + var known plain + if err := decodeExact(knownObject, &known); err != nil { + return err + } + *v = VPFormatsSupported(known) + v.Extra = extra + return nil +} + +// MarshalYAML flattens Extra alongside the known format entries. +// +// Needed as much as the JSON pair, and arguably more: this is the type +// verifier.preferred_vp_formats loads into, so the OPERATOR CONFIGURATION +// path is the one an unknown format identifier has to come in by. With +// JSON support alone the promise held only for metadata this build +// constructed itself, which is not where a format that postdates it +// arrives from. +func (v VPFormatsSupported) MarshalYAML() (any, error) { + type plain VPFormatsSupported + encoded, err := json.Marshal(plain(v)) + if err != nil { + return nil, err + } + var m map[string]any + if err := decodeExact(encoded, &m); err != nil { + return nil, err + } + return mergeExtraYAML(m, v.Extra, declaredJSONKeys(reflect.TypeOf(VPFormatsSupported{})), "vp_formats_supported") +} + +// UnmarshalYAML captures format entries this build does not model. +func (v *VPFormatsSupported) UnmarshalYAML(unmarshal func(any) error) error { + if err := refuseDuplicateYAMLKeys(unmarshal, "vp_formats_supported"); err != nil { + return err + } + type plain VPFormatsSupported + var known plain + if err := unmarshal(&known); err != nil { + return fmt.Errorf("vp_formats_supported: %w", err) + } + *v = VPFormatsSupported(known) + + extra, err := yamlExtra(unmarshal, "vp_formats_supported", + "ldp_vc", "jwt_vc_json", "dc+sd-jwt", "mso_mdoc", "zkp_req") + if err != nil { + return err + } + v.Extra = extra + return nil } // LDPVCFormat defines format-specific parameters for W3C VC Data Integrity (ldp_vc). @@ -268,7 +1246,14 @@ func (cq *ClaimQuery) UnmarshalJSON(data []byte) error { if bytes.Equal(bytes.TrimSpace(raw.Values), []byte("null")) { return fmt.Errorf(`claim "values" must not be null`) } - if err := json.Unmarshal(raw.Values, &values); err != nil { + // decodeExact, not json.Unmarshal: it keeps numbers as their + // literals. A plain decode makes every value a float64, so a + // legitimate 9007199254740993 came back as 9007199254740992 and + // was then rejected for being outside the safe integer range - + // which made a stored context carrying one UNREADABLE once the + // exact JSON copy became the preferred representation. Saved + // fine, failed on the way back. + if err := decodeExact(raw.Values, &values); err != nil { return fmt.Errorf(`claim "values" must be an array of strings, integers, or booleans: %w`, err) } } @@ -383,6 +1368,16 @@ func validateClaimValues(values []any, present bool) error { for i, v := range values { switch t := v.(type) { case string, bool, int, int32, int64, uint, uint32, uint64: + case json.Number: + // Judged AS the literal, with no float64 on the way. The + // float arms below stop at 2^53-1 because that is where a + // float64 stops being exact; a literal has no such limit, so + // imposing one here would be this validator inventing a rule + // the specification does not have - and rejecting the very + // values the exact-JSON copy exists to preserve. + if !isIntegralNumber(t.String()) { + return fmt.Errorf("claim values[%d] must be a string, integer, or boolean", i) + } case float32: if err := checkIntegralFloat(float64(t), i); err != nil { return err @@ -398,6 +1393,64 @@ func validateClaimValues(values []any, present bool) error { return nil } +// isIntegralNumber reports whether a JSON number literal denotes an +// integer - its VALUE, not its spelling. +// +// 10, 10.0 and 1e1 are all the integer ten, and a validator that accepts +// only the first changes what an ordinary SD-JWT or mdoc query may say. +// An earlier version of this tested the spelling and did exactly that. +// +// Decided on the digits rather than by parsing to a number, because +// parsing is what this exists to avoid: a float64 cannot hold +// 9007199254740993, and that value is the reason the exact copy exists. +// +// A literal is digits x 10^(exp - len(frac)). Shift the point right by +// that exponent: if it lands at or past the end of the digits the value +// is an integer, and if it lands inside them the digits it would cut +// have to be zeros. +func isIntegralNumber(literal string) bool { + mantissa, exponent := literal, 0 + if i := strings.IndexAny(literal, "eE"); i >= 0 { + parsed, err := strconv.Atoi(literal[i+1:]) + if err != nil { + return false + } + mantissa, exponent = literal[:i], parsed + } + mantissa = strings.TrimPrefix(strings.TrimPrefix(mantissa, "-"), "+") + + whole, fraction, _ := strings.Cut(mantissa, ".") + digits := whole + fraction + if digits == "" { + return false + } + for i := range len(digits) { + if digits[i] < '0' || digits[i] > '9' { + return false + } + } + // Zero is an integer however it is spelled, including 0e-5. + if strings.Trim(digits, "0") == "" { + return true + } + + // A nonzero value whose every significant digit falls to the right of + // the point is not an integer. Deciding that on the exponent alone + // keeps the arithmetic below away from the ends of the int range: a + // literal may carry an exponent as extreme as math.MinInt64, where + // negating the shift wraps round to a negative cut that panics the + // slice, and subtracting the fraction length wraps the other way and + // reports a fraction as integral. + if exponent <= len(fraction)-len(digits) { + return false + } + shift := exponent - len(fraction) + if shift >= 0 { + return true + } + return strings.Trim(digits[len(digits)+shift:], "0") == "" +} + func checkIntegralFloat(f float64, i int) error { const maxSafeInt = float64(1<<53 - 1) if math.IsNaN(f) || math.IsInf(f, 0) || f < -maxSafeInt || f > maxSafeInt || f != math.Trunc(f) { @@ -625,6 +1678,67 @@ func NewTrustedAuthorityOpenIDFederation(trustAnchors ...string) TrustedAuthorit // ValidateCredentialQuery validates that a CredentialQuery has the required fields // for the specified format. func ValidateCredentialQuery(query CredentialQuery) error { + // A raw query is judged by its own bytes, not by the empty struct the + // failed typed decode left behind. + // + // The escape hatch is for a member whose SHAPE this build does not + // model; it is not a way around the constraint rules, and it is not + // specific to one format. Reading format and meta back out of the raw + // bytes keeps both true at once: an SD-JWT query with no vct_values is + // still refused here even though it arrived raw, and a zkp_req one + // still falls to the permissive default below, as every format this + // build does not constrain does. + if query.Raw != nil { + if !json.Valid(query.Raw) { + return &DCQLValidationError{ + Field: "raw", + Message: "a raw credential query must be valid JSON", + } + } + // An UNREADABLE meta is judged as an EMPTY one, not waved through. + // + // "Cannot read the constraint" and "there is no constraint" are + // the same thing to everyone downstream: either way nothing here + // has established what the request is limited to. Returning nil + // on the first and refusing the second would let + // {"format":"vc+sd-jwt","meta":"not an object"} be signed and + // served with no vct_values - the very case this check exists for, + // reached by making the member harder to read rather than by + // omitting it. + // + // Substituting the zero value rather than naming the formats that + // have rules keeps the two in step by construction: a zero + // MetaQuery satisfies no constraint rule in the switch below, and + // passes the permissive default exactly where there is no rule to + // fail. A format that grows one later is covered without anything + // here being updated. + // Re-read from the BYTES rather than trusting the struct's + // shadow: that is populated by UnmarshalJSON, and a query built in + // code - CredentialQuery{Raw: ...}, which is how the escape hatch + // is meant to be used directly - carries none of it. + shadow := CredentialQuery{Raw: query.Raw} + unreadable := shadow.adoptRawShadow() + if !json.Valid(query.Raw) || !isJSONObject(query.Raw) { + // A credential query that is not an object carries no members + // at all, which is the strongest form of "the constraint + // cannot be read" rather than a separate case. + unreadable = append(unreadable, "meta") + } + query = shadow + query.Raw = nil + if slices.Contains(unreadable, "meta") { + if err := ValidateCredentialQuery(query); err != nil { + // Reported as what it is. "vct_values is required" would + // send an operator looking for a member that may well be + // in the bytes, inside the meta this could not read. + return &DCQLValidationError{ + Field: "meta", + Message: fmt.Sprintf("meta is a shape this build cannot read, so the %s constraint it would have to satisfy cannot be established: %v", formatOrDefault(query.Format), err), + } + } + return nil + } + } switch query.Format { // Not FormatJwtVCJson: UIInteraction calls this on the live ingress, and // the rest of the stack refuses that format - nothing issues it and the @@ -722,6 +1836,23 @@ func ValidateCredentialQuery(query CredentialQuery) error { return nil } +// isJSONObject reports whether data is a JSON object, which is what a +// credential query has to be before any member can be read from it. +func isJSONObject(data []byte) bool { + var object map[string]json.RawMessage + return json.Unmarshal(data, &object) == nil +} + +// formatOrDefault names a format for an error message, spelling out the +// empty one rather than printing "" - which reads as a missing format +// when it is in fact the SD-JWT default. +func formatOrDefault(format string) string { + if format == "" { + return FormatSDJWTVC + " (the default for an absent format)" + } + return format +} + // validateMsoMdocZkQuery validates the ZK-mdoc (mso_mdoc_zk)-specific // fields of a CredentialQuery, split out of ValidateCredentialQuery to // keep that function's per-format switch flat (this format alone needs a diff --git a/pkg/openid4vp/dcql_copy_test.go b/pkg/openid4vp/dcql_copy_test.go new file mode 100644 index 000000000..ee8ab6158 --- /dev/null +++ b/pkg/openid4vp/dcql_copy_test.go @@ -0,0 +1,115 @@ +package openid4vp + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// copyDCQL reconstructs each credential query field by field, so a field +// added to CredentialQuery is dropped by it until somebody remembers. +// +// That is exactly what happened: Req, Extra and Raw were added and the +// copier kept building queries without them, so every requested circuit +// function disappeared on the verifier's main template path - silently, +// because the result is still a valid query, just not the one configured. +// +// This guard derives the field set from the type rather than listing it. +// The fixture below must set every field of CredentialQuery to something +// non-zero, and the copy must equal it. Add a field and this test fails +// twice over: once because the fixture does not cover it, and again if the +// copier drops it. +func TestCopyDCQLCarriesEveryCredentialQueryField(t *testing.T) { + yes := true + path := "ssc" + + original := CredentialQuery{ + ID: "nym_attestation", + Format: FormatZkpReq, + Multiple: new(true), + Meta: MetaQuery{ + DoctypeValue: "eu.europa.ec.eudi.nym_auth.1", + VCTValues: []string{"urn:example"}, + TypeValues: [][]string{{"https://example.org/T"}}, + PPIDContext: "ctx", + ZKSystemType: []ZKSystemTypeSpec{{ID: "i", System: "s", Params: map[string]string{"n": "1"}}}, + }, + TrustedAuthorities: []TrustedAuthority{{Type: "aki", Values: []string{"v"}}}, + RequireCryptographicHolderBinding: &yes, + Claims: []ClaimQuery{{ID: "c", Path: []*string{&path}, Values: []any{"v"}}}, + ClaimSet: [][]string{{"c"}}, + Req: []ReqItem{{ + FnID: FunctionPseudonym, Return: "nym-123", + Params: map[string]any{"config": "known-offender-matching-ssc"}, + Extra: map[string]any{"source": map[string]any{"path": []any{"ssc"}}}, + }}, + Extra: map[string]any{"future_member": "kept"}, + // Raw is exclusive with the rest, so it is covered separately + // below rather than set here. + } + + // Every field must be covered by the fixture, derived from the type. + for _, field := range reflect.VisibleFields(reflect.TypeOf(original)) { + if !field.IsExported() { + continue + } + if field.Name == "Raw" { + continue // exclusive with the others; see TestCopyDCQLCarriesRaw + } + value := reflect.ValueOf(original).FieldByIndex(field.Index) + assert.False(t, value.IsZero(), + "the fixture leaves %s zero, so this test would not notice copyDCQL dropping it", field.Name) + } + + src := &DCQL{Credentials: []CredentialQuery{original}} + dst := copyDCQL(src) + + require.Len(t, dst.Credentials, 1) + assert.Equal(t, original, dst.Credentials[0], + "copyDCQL must reproduce the query it was given, member for member") + + // And it must be a COPY: mutating the original must not reach it. + src.Credentials[0].Req[0].Params["config"] = "mutated" + src.Credentials[0].Extra["future_member"] = "mutated" + assert.Equal(t, "known-offender-matching-ssc", dst.Credentials[0].Req[0].Params["config"]) + assert.Equal(t, "kept", dst.Credentials[0].Extra["future_member"]) +} + +func TestCopyDCQLCarriesRaw(t *testing.T) { + raw := json.RawMessage(`{"id":"q","format":"zkp_req","claims":{"_req":[]}}`) + src := &DCQL{Credentials: []CredentialQuery{{Raw: raw}}} + dst := copyDCQL(src) + + require.Len(t, dst.Credentials, 1) + assert.JSONEq(t, string(raw), string(dst.Credentials[0].Raw)) + + src.Credentials[0].Raw[1] = 'X' + assert.NotEqual(t, string(src.Credentials[0].Raw), string(dst.Credentials[0].Raw), + "the copy must not alias the original's bytes") +} + +// The template path is where this actually bit, so it is checked end to +// end rather than only at the copier. +func TestTemplateDCQLQueryKeepsCircuitFunctions(t *testing.T) { + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "nym_attestation", + Format: FormatZkpReq, + Meta: MetaQuery{DoctypeValue: "eu.europa.ec.eudi.nym_auth.1"}, + Req: []ReqItem{ + {FnID: FunctionPseudonym, Return: "nym-123", Params: map[string]any{"config": "known-offender-matching-ssc"}}, + {FnID: FunctionAgeCheck, Return: "age-123", Params: map[string]any{"ge": "2008-10-10"}}, + }, + }}} + + dst := copyDCQL(src) + + encoded, err := json.Marshal(dst) + require.NoError(t, err) + assert.Contains(t, string(encoded), FunctionPseudonym, + "a templated circuit_zk request that lost its functions asks the wallet for nothing") + assert.Contains(t, string(encoded), FunctionAgeCheck) + assert.Contains(t, string(encoded), "nym-123") +} diff --git a/pkg/openid4vp/dcql_encoding_test.go b/pkg/openid4vp/dcql_encoding_test.go new file mode 100644 index 000000000..5f3ec8106 --- /dev/null +++ b/pkg/openid4vp/dcql_encoding_test.go @@ -0,0 +1,349 @@ +package openid4vp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Adding MarshalJSON to CredentialQuery and VPFormatsSupported changes how +// EVERY format is encoded, not just circuit_zk - these types carry mdoc, +// SD-JWT and W3C queries too. +// +// So with no extension set, the output must be byte-identical to the +// struct encoding the method replaced. Not merely equivalent: identical, +// because these bytes are signed. A request object is a JWS over this +// JSON, so a reordered or differently-omitted member changes a signature +// that wallets and conformance fixtures have already pinned. +// +// The comparison is against encoding the same value through a type that +// has no methods, which is exactly what encoding/json did before. +func TestCredentialQueryEncodingUnchanged(t *testing.T) { + requireTrue := new(true) + requireFalse := new(false) + + for _, tc := range []struct { + name string + query CredentialQuery + }{ + {name: "empty", query: CredentialQuery{}}, + { + name: "an mdoc query", + query: CredentialQuery{ + ID: "pid", Format: FormatMsoMdoc, + Meta: MetaQuery{DoctypeValue: "eu.europa.ec.eudi.pid.1"}, + Claims: []ClaimQuery{{Path: StringPath("eu.europa.ec.eudi.pid.1", "family_name")}}, + }, + }, + { + name: "an SD-JWT query with claim sets and holder binding", + query: CredentialQuery{ + ID: "pid", Format: FormatSDJWTVC, Multiple: new(true), + Meta: MetaQuery{VCTValues: []string{"urn:eudi:pid:1"}}, + Claims: []ClaimQuery{{ID: "a", Path: StringPath("family_name")}}, + ClaimSet: [][]string{{"a"}}, + RequireCryptographicHolderBinding: requireTrue, + }, + }, + { + name: "a W3C query with trusted authorities and binding false", + query: CredentialQuery{ + ID: "dip", Format: FormatVCLDJSON, + Meta: MetaQuery{TypeValues: [][]string{{"https://example.org/Diploma"}}}, + TrustedAuthorities: []TrustedAuthority{{Type: "aki", Values: []string{"abc"}}}, + RequireCryptographicHolderBinding: requireFalse, + }, + }, + { + name: "a ZK mdoc query", + query: CredentialQuery{ + ID: "mdl", Format: FormatMsoMdocZk, + Meta: MetaQuery{ + DoctypeValue: "org.iso.18013.5.1.mDL", + ZKSystemType: []ZKSystemTypeSpec{{ID: "x", System: "y", Params: map[string]string{"n": "1"}}}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + type plain CredentialQuery // no methods: the old encoding + before, err := json.Marshal(plain(tc.query)) + require.NoError(t, err) + + after, err := json.Marshal(tc.query) + require.NoError(t, err) + + assert.Equal(t, string(before), string(after), + "these bytes are signed; a change here changes a signature") + assert.NotContains(t, string(after), `"req"`, "a non-circuit_zk query must not grow a req member") + }) + } +} + +func TestVPFormatsSupportedEncodingUnchanged(t *testing.T) { + for _, tc := range []struct { + name string + formats VPFormatsSupported + }{ + {name: "empty", formats: VPFormatsSupported{}}, + { + name: "every pre-existing format", + formats: VPFormatsSupported{ + LDPVC: &LDPVCFormat{ProofTypeValues: []string{"DataIntegrityProof"}}, + JWTVCJson: &JWTVCFormat{AlgValues: []string{"ES256"}}, + SDJWT: &SDJWTVCFormat{SDJWTAlgValues: []string{"ES256"}}, + MsoMdoc: &MsoMdocFormat{IssuerAuthAlgValues: []int{-7}}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + type plain VPFormatsSupported + before, err := json.Marshal(plain(tc.formats)) + require.NoError(t, err) + after, err := json.Marshal(tc.formats) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) + assert.NotContains(t, string(after), "circuit_zk") + }) + } +} + +// A decode of a query carrying no unknown members must leave Extra nil, +// so a request read in and written back out is unchanged - and so the +// byte-identity above is not quietly bypassed by every decoded query +// acquiring an empty map. +func TestDecodingAKnownQueryLeavesNoExtra(t *testing.T) { + const in = `{"id":"pid","format":"mso_mdoc","meta":{"doctype_value":"d"}}` + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(in), &query)) + assert.Nil(t, query.Extra) + assert.Nil(t, query.Raw) + + out, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, in, string(out)) +} + +// An ordinary query must stay MUTABLE, because something downstream +// mutates it. +// +// Multiple was a plain bool with omitempty, so `"multiple": false` - a +// valid, unremarkable SD-JWT query - did not encode back to what arrived +// and was kept as Raw. A raw query is emitted from its original bytes, so +// augmentDCQLFromVCTM's rewrite of the shadow Claims was silently +// discarded and the request went out with the un-augmented paths. Nothing +// failed; the wallet simply got a different query than the one the +// verifier had decided to send. +// +// The decode-augment-serialise cycle is the thing to assert, not the Raw +// flag on its own: a future member with the same omitempty problem would +// reintroduce exactly this, and the flag alone would not say what it +// cost. +func TestOrdinaryQueriesSurviveDecodeAugmentSerialise(t *testing.T) { + for _, body := range []string{ + `{"id":"pid","format":"dc+sd-jwt","multiple":false,"meta":{"vct_values":["urn:x"]},"claims":[{"path":["address","locality"]}]}`, + `{"id":"pid","format":"dc+sd-jwt","multiple":true,"meta":{"vct_values":["urn:x"]},"claims":[{"path":["address","locality"]}]}`, + `{"id":"pid","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"claims":[{"path":["address","locality"]}]}`, + } { + t.Run(body, func(t *testing.T) { + var q CredentialQuery + require.NoError(t, json.Unmarshal([]byte(body), &q)) + + require.Nil(t, q.Raw, + "an ordinary query must not be kept raw; a raw one ignores the edits below") + + // What augmentDCQLFromVCTM does to a query whose parent claim + // has configured children. + q.Claims = []ClaimQuery{{Path: StringPath("address")}} + + encoded, err := json.Marshal(q) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"claims":[{"path":["address"]}]`, + "the augmented claims must be what goes on the wire") + assert.NotContains(t, string(encoded), `"locality"`, + "the pre-augmentation path must not survive") + }) + } +} + +// And the member that caused it round-trips in all three states, which is +// the property a plain bool could not have: absent, explicitly false, and +// explicitly true are three different requests. +func TestMultipleRoundTripsAbsentFalseAndTrue(t *testing.T) { + for _, tc := range []struct { + body string + wantInOutput string + allows bool + }{ + {`{"id":"q","format":"dc+sd-jwt","meta":{}}`, "", false}, + {`{"id":"q","format":"dc+sd-jwt","multiple":false,"meta":{}}`, `"multiple":false`, false}, + {`{"id":"q","format":"dc+sd-jwt","multiple":true,"meta":{}}`, `"multiple":true`, true}, + } { + t.Run(tc.body, func(t *testing.T) { + var q CredentialQuery + require.NoError(t, json.Unmarshal([]byte(tc.body), &q)) + require.Nil(t, q.Raw) + assert.Equal(t, tc.allows, q.AllowsMultiple()) + + encoded, err := json.Marshal(q) + require.NoError(t, err) + if tc.wantInOutput == "" { + assert.NotContains(t, string(encoded), `"multiple"`, + "an omitted member must stay omitted, not become an explicit false") + return + } + assert.Contains(t, string(encoded), tc.wantInOutput) + }) + } +} + +// A constraint this verifier reads one way and a wallet reads another is +// not a request to sign, and reaching the Raw path must not be a way past +// that rule. +// +// The duplicate check ran only after a SUCCESSFUL typed decode. An +// unmodelled member - `"_req": {}` - made the decode fail, the query went +// raw, and the check never ran: the shadow still reported the last meta's +// constraint, so validation passed, while the request was emitted with +// BOTH metas. A first-wins wallet reads the empty one and is handed an +// unconstrained SD-JWT request. +// +// Case is the same hole by another route. encoding/json matches field +// names case-insensitively, so "VCT_VALUES" populates MetaQuery.VCTValues +// and the shadow reports a constraint - while the signed bytes carry a +// member name no conformant wallet reads. +func TestRawPathIsNotAWayPastTheConstraintChecks(t *testing.T) { + for _, tc := range []struct{ name, body, want string }{ + { + name: "duplicate meta, reached through an unmodelled member", + body: `{"id":"q","format":"dc+sd-jwt","meta":{},"meta":{"vct_values":["urn:x"]},"_req":{}}`, + want: "named more than once", + }, + { + name: "duplicate meta, reached through object-valued claims", + body: `{"id":"q","format":"dc+sd-jwt","meta":{},"meta":{"vct_values":["urn:x"]},"claims":{}}`, + want: "named more than once", + }, + { + name: "duplicate format, same route", + body: `{"id":"q","format":"dc+sd-jwt","format":"mso_mdoc","meta":{},"_req":{}}`, + want: "named more than once", + }, + { + name: "a case-aliased constraint inside meta", + body: `{"id":"q","format":"dc+sd-jwt","meta":{"VCT_VALUES":["urn:x"]}}`, + want: "differs only in case", + }, + { + name: "a case-aliased meta itself", + body: `{"id":"q","format":"dc+sd-jwt","META":{"vct_values":["urn:x"]}}`, + want: "differs only in case", + }, + { + name: "a case-aliased doctype_value", + body: `{"id":"q","format":"mso_mdoc","meta":{"DocType_Value":"org.iso.18013.5.1.mDL"}}`, + want: "differs only in case", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var q CredentialQuery + err := json.Unmarshal([]byte(tc.body), &q) + require.Error(t, err, "this request must not be readable; it decoded to %#v", q) + assert.Contains(t, err.Error(), tc.want) + + // And the explicit constructor refuses it too, so neither + // entrance can produce one. + _, cerr := NewRawCredentialQuery(json.RawMessage(tc.body)) + require.Error(t, cerr) + assert.Contains(t, cerr.Error(), tc.want) + }) + } +} + +// And the rule is narrow: an unmodelled member on its own is exactly what +// the raw path is for, and an exactly-spelled constraint beside it is +// still read and still emitted as it arrived. +func TestRawPathStillCarriesUnmodelledMembers(t *testing.T) { + for _, body := range []string{ + `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"_req":{}}`, + `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"claims":{}}`, + `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"],"future_member":1}}`, + } { + t.Run(body, func(t *testing.T) { + var q CredentialQuery + require.NoError(t, json.Unmarshal([]byte(body), &q), + "an unmodelled member is the point of the raw path") + + encoded, err := json.Marshal(q) + require.NoError(t, err) + assert.JSONEq(t, body, string(encoded), "and it goes out as it came in") + }) + } +} + +// A claim value is judged on what the number IS, not how it is spelled. +// +// 10, 10.0 and 1e1 are the same integer, and ordinary SD-JWT and mdoc +// queries use all three. An earlier version of this check tested the +// spelling and rejected the last two - a regression for every query that +// did not happen to write its integers the shortest way. +// +// Decided on the digits rather than by parsing, because parsing is what +// the exact-copy work exists to avoid: a float64 cannot hold +// 9007199254740993. +func TestClaimValuesAreJudgedByValueNotSpelling(t *testing.T) { + for _, tc := range []struct { + literal string + ok bool + }{ + {"10", true}, + {"10.0", true}, + {"10.000", true}, + {"1e1", true}, + {"1E1", true}, + {"1.5e1", true}, // 15 + {"-1.5e1", true}, // -15 + {"1e+2", true}, // 100 + {"0", true}, + {"0.0", true}, + {"0e-5", true}, // zero is an integer however it is spelled + {"9007199254740993", true}, + {"123456789012345678901234567890", true}, + {"-9007199254740993", true}, + + {"1.5", false}, + {"1e-1", false}, // 0.1 + {"1.55e1", false}, // 15.5 + {"0.001", false}, + {"1e-20", false}, + + // An exponent may sit at the very end of the int range. Both of + // these are nonzero magnitudes far below one, so neither is an + // integer - but reaching that answer by negating the shift wraps + // to a negative cut and panics the slice, and reaching it by + // subtracting the fraction length wraps the other way and calls a + // fraction integral. + {"1e-9223372036854775808", false}, + {"1.0e-9223372036854775808", false}, + } { + t.Run(tc.literal, func(t *testing.T) { + body := `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},` + + `"claims":[{"path":["a"],"values":[` + tc.literal + `]}]}` + var q CredentialQuery + err := json.Unmarshal([]byte(body), &q) + if tc.ok { + require.NoError(t, err, "%s is an integer", tc.literal) + + // And it goes out as it came in: judging the literal must + // not mean rewriting it. + encoded, mErr := json.Marshal(q) + require.NoError(t, mErr) + assert.Contains(t, string(encoded), tc.literal) + return + } + require.Error(t, err, "%s is not an integer", tc.literal) + }) + } +} diff --git a/pkg/openid4vp/dcql_raw_cache_test.go b/pkg/openid4vp/dcql_raw_cache_test.go new file mode 100644 index 000000000..306604314 --- /dev/null +++ b/pkg/openid4vp/dcql_raw_cache_test.go @@ -0,0 +1,581 @@ +package openid4vp + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A request object is JSON-encoded into the HA cache and read back by +// another node (internal/verifier/cache). So a query this model cannot +// hold has to survive that round trip, or the escape hatch works only on +// the node that built the request - which is the shape of bug that shows +// up as "it works on one replica". +func TestRawSurvivesAJSONRoundTrip(t *testing.T) { + for _, tc := range []struct{ name, query string }{ + {"the WIP shape, claims as an object", `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[{"fn_id":"age_check","id":"h"}]}}`}, + {"req as an object, as a later generation might", `{"id":"q","format":"zkp_req","_req":{"not":"an array"}}`}, + {"a member whose type the model does not expect", `{"id":"q","format":"zkp_req","meta":"not an object"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + // As the verifier built it. + built := CredentialQuery{Raw: json.RawMessage(tc.query)} + + // Into the cache. + stored, err := json.Marshal(&DCQL{Credentials: []CredentialQuery{built}}) + require.NoError(t, err) + + // And back out on another node. + var loaded DCQL + require.NoError(t, json.Unmarshal(stored, &loaded), + "a query the model cannot hold must still load; otherwise the request exists only where it was made") + require.Len(t, loaded.Credentials, 1) + + // What goes to the wallet is what was configured. + out, err := json.Marshal(loaded.Credentials[0]) + require.NoError(t, err) + assert.JSONEq(t, tc.query, string(out)) + }) + } +} + +// A query the model DOES understand still decodes typed, so the fallback +// does not quietly swallow everything. +func TestTypedQueriesStillDecodeTyped(t *testing.T) { + const in = `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"age_check","return":"h"}]}` + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(in), &query)) + + assert.Nil(t, query.Raw, "this shape is modelled, so it must not fall back to raw") + require.Len(t, query.Req, 1) + assert.Equal(t, FunctionAgeCheck, query.Req[0].FnID) +} + +// Invalid JSON is still an error. The fallback keeps shapes, not rubbish: +// bytes that are not JSON cannot be put inside a request object at all. +func TestInvalidJSONIsStillRejected(t *testing.T) { + var query CredentialQuery + require.Error(t, json.Unmarshal([]byte(`{not json`), &query)) +} + +// The escape hatch has to get PAST the ingress, not just through the +// serialiser. UIInteraction and the two VC 2.0 paths run every query they +// are handed through ValidateCredentialQuery. +// +// What it must NOT do is become a way around the format rules. A raw +// query is therefore judged by its own bytes: the format and the +// constraint are re-read from them, so the hatch covers a member whose +// shape this build does not model and nothing else. +func TestValidateJudgesARawQueryByItsOwnBytes(t *testing.T) { + for _, tc := range []struct { + name, query, wantErr string + }{ + { + name: "circuit_zk, which this build does not constrain either way", + query: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[]}}`, + }, + { + name: "an mdoc query whose doctype survived the member that did not", + query: `{"id":"q","format":"mso_mdoc","meta":{"doctype_value":"d"},"claims":{"_req":[]}}`, + }, + { + name: "an sd-jwt query with no vct_values is still an unconstrained request", + query: `{"id":"q","format":"vc+sd-jwt","claims":{"_req":[]}}`, + wantErr: "vct_values", + }, + { + name: "and an mdoc one with no doctype", + query: `{"id":"q","format":"mso_mdoc","claims":{"_req":[]}}`, + wantErr: "doctype_value", + }, + // "Cannot read the constraint" and "there is no constraint" are + // the same thing downstream, so an unreadable meta is judged as an + // empty one. Otherwise the check is dodged by making the member + // harder to read rather than by leaving it out. + { + name: "meta unreadable, on a format that has a rule", + query: `{"id":"q","format":"vc+sd-jwt","meta":"not an object"}`, + wantErr: "cannot read", + }, + { + name: "and the whole query not being an object is the same thing", + query: `[1,2,3]`, + wantErr: "cannot read", + }, + { + name: "meta unreadable, on a format that has none", + query: `{"id":"q","format":"zkp_req","meta":"not an object"}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(tc.query), &query)) + require.NotNil(t, query.Raw, "the point of this case is that it fell back to raw") + + err := ValidateCredentialQuery(query) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// Raw is authoritative for the wire and nothing else. The rest of the +// stack routes by id - UIInteraction copies it into the authorization +// context's scopes, and the response path matches a presentation back to +// it - so a raw query that kept an empty id would be sent correctly and +// then match nothing that came back. +func TestRawRetainsTheMembersTheStackRoutesBy(t *testing.T) { + const in = `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[]}}` + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(in), &query)) + + require.NotNil(t, query.Raw) + assert.Equal(t, "q", query.ID, "the response path looks a presentation up by this") + assert.Equal(t, FormatZkpReq, query.Format, "every format decision after this reads it") + + // And the wire is still the bytes that arrived, not a re-encoding of + // the two members just adopted. + out, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, in, string(out)) +} + +// Best effort, both ways: a routing member of a type this build does not +// expect is left alone rather than coerced, and does not stop the rest. +func TestRawRoutingIgnoresMembersItCannotRead(t *testing.T) { + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(`{"id":7,"format":"zkp_req","claims":{"_req":[]}}`), &query)) + require.NotNil(t, query.Raw) + assert.Empty(t, query.ID) + assert.Equal(t, FormatZkpReq, query.Format) +} + +// Not judging is not the same as not looking. Raw is emitted verbatim, so +// bytes that are not JSON would make the whole request object unencodable +// at send time, far from the configuration that caused it. +func TestValidateRejectsRawThatIsNotJSON(t *testing.T) { + err := ValidateCredentialQuery(CredentialQuery{Raw: json.RawMessage(`{not json`)}) + require.Error(t, err) + assert.Contains(t, err.Error(), "raw") +} + +// The escape hatch is meant to be used in code too, not only reached by +// a decode that failed. A query built as CredentialQuery{Raw: ...} - a +// template the verifier assembles, say - carries none of the struct's +// shadow copies of id and format, so validation has to read them from the +// bytes rather than trust the struct. +// +// Without that, an empty Format means SD-JWT to every reader, and a raw +// circuit_zk query is refused for a vct_values it was never going to +// have, on every production path that validates. +func TestValidateReadsTheFormatFromRawNotFromTheStruct(t *testing.T) { + for _, tc := range []struct { + name, query, wantErr string + }{ + { + name: "circuit_zk, built in code with no decode in sight", + query: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[]}}`, + }, + { + name: "mso_mdoc with its doctype, likewise", + query: `{"id":"q","format":"mso_mdoc","meta":{"doctype_value":"d"},"claims":{"_req":[]}}`, + }, + { + name: "and the rules still apply: sd-jwt with no vct_values", + query: `{"id":"q","format":"vc+sd-jwt","claims":{"_req":[]}}`, + wantErr: "vct_values", + }, + } { + t.Run(tc.name, func(t *testing.T) { + // Constructed directly. No UnmarshalJSON anywhere. + query := CredentialQuery{Raw: json.RawMessage(tc.query)} + require.Empty(t, query.Format, "the point of this case is that the struct carries nothing") + + err := ValidateCredentialQuery(query) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// Raw is authoritative for the WIRE and nothing else. Plenty of this +// stack reads the struct - matching reads Meta.TypeValues, Multiple and +// RequireCryptographicHolderBinding change what a response may contain - +// so a query that arrived raw and kept zero values for those was sent +// correctly and then judged against defaults nobody chose. +// +// Derived from the struct tags, so this test also fails if a new member +// is added to CredentialQuery and left out of the shadow. +func TestRawShadowsEveryReadableModelledMember(t *testing.T) { + const in = `{ + "id": "w3c", + "format": "ldp_vc", + "multiple": true, + "require_cryptographic_holder_binding": false, + "meta": {"type_values": [["https://www.w3.org/2018/credentials#VerifiableCredential","https://example.org#Diploma"]]}, + "claim_sets": [["a"]], + "claims": {"_req": [{"fn_id": "age_check"}]} + }` + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(in), &query)) + require.NotNil(t, query.Raw, "claims as an object is what sends this down the raw path") + + assert.Equal(t, "w3c", query.ID) + assert.Equal(t, FormatLdpVCDCQL, query.Format) + assert.True(t, query.AllowsMultiple(), "a response is allowed more than one credential because of this") + require.NotNil(t, query.RequireCryptographicHolderBinding) + assert.False(t, *query.RequireCryptographicHolderBinding, "an explicit false must not revert to the spec default") + require.Len(t, query.Meta.TypeValues, 1, "matching reads this; empty matches nothing") + assert.Equal(t, [][]string{{"a"}}, query.ClaimSet) + + // The member that could not be read is simply absent, not fatal. + assert.Empty(t, query.Claims) + + // And the wire is still exactly what arrived. + out, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, in, string(out)) +} + +// The shadow is derived from the tags, so every json-tagged member of +// CredentialQuery has to be reachable through it. A member added without +// being shadowed is the bug this catches on the commit that adds it. +func TestEveryModelledMemberIsShadowed(t *testing.T) { + structure := reflect.TypeOf(CredentialQuery{}) + var shadowed int + for i := range structure.NumField() { + name, _, _ := strings.Cut(structure.Field(i).Tag.Get("json"), ",") + if name == "" || name == "-" { + continue // Raw and Extra, which are not wire members + } + shadowed++ + } + // Fail closed: a walk that found nothing proves nothing. + require.NotZero(t, shadowed) + + // Every one of them set to a non-zero value in the raw bytes must + // come back out of the shadow. + raw := map[string]any{ + "id": "q", "format": "mso_mdoc", "multiple": true, + "meta": map[string]any{"doctype_value": "d"}, + "trusted_authorities": []any{map[string]any{"type": "aki", "values": []string{"x"}}}, + "require_cryptographic_holder_binding": false, + "claim_sets": [][]string{{"a"}}, + "_req": []any{map[string]any{"id": "f", "return": "h"}}, + // claims is the member that forces the raw path. + "claims": map[string]any{"_req": []any{}}, + } + encoded, err := json.Marshal(raw) + require.NoError(t, err) + + var query CredentialQuery + require.NoError(t, json.Unmarshal(encoded, &query)) + require.NotNil(t, query.Raw) + + value := reflect.ValueOf(query) + for i := range structure.NumField() { + name, _, _ := strings.Cut(structure.Field(i).Tag.Get("json"), ",") + if name == "" || name == "-" || name == "claims" { + continue + } + t.Run(name, func(t *testing.T) { + assert.Falsef(t, value.Field(i).IsZero(), + "%s is set in the raw bytes but zero on the struct, so everything reading the struct sees a default nobody chose", name) + }) + } +} + +// The constructor is what makes a hand-built raw query usable: the +// shadow has to be on the STORED query, not only on the copy validation +// happens to make, because the response path reads the stored one. +func TestNewRawCredentialQueryFillsTheShadow(t *testing.T) { + const in = `{"id":"w3c","format":"ldp_vc","multiple":true,"meta":{"type_values":[["https://example.org#Diploma"]]},"claims":{"_req":[]}}` + + query, err := NewRawCredentialQuery(json.RawMessage(in)) + require.NoError(t, err) + + assert.Equal(t, "w3c", query.ID, "the response path matches a presentation back to this") + assert.Equal(t, FormatLdpVCDCQL, query.Format) + assert.True(t, query.AllowsMultiple()) + require.Len(t, query.Meta.TypeValues, 1, "matching reads this; empty matches nothing") + + // And the wire is still exactly the bytes handed in. + out, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, in, string(out)) +} + +// It refuses bytes that are not JSON rather than deferring the failure to +// send time, where it would surface as an unencodable request object a +// long way from the call that caused it. +func TestNewRawCredentialQueryRefusesNonJSON(t *testing.T) { + _, err := NewRawCredentialQuery(json.RawMessage(`{not json`)) + require.Error(t, err) +} + +// The copy is independent: mutating the caller's slice afterwards must +// not change what the query sends. +func TestNewRawCredentialQueryCopiesItsInput(t *testing.T) { + raw := []byte(`{"id":"q","format":"zkp_req","claims":{"_req":[]}}`) + query, err := NewRawCredentialQuery(raw) + require.NoError(t, err) + + before, err := json.Marshal(query) + require.NoError(t, err) + copy(raw, []byte(`{"id":"X"`)) + after, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, string(before), string(after)) +} + +// escapedAmpersand is how encoding/json writes & inside a string. Built +// rather than spelled so this file contains no escape sequence that could +// be mistaken for the thing it describes. +var escapedAmpersand = string(rune(92)) + "u0026" + +// What Raw does and does not promise. +// +// encoding/json post-processes whatever a MarshalJSON returns on its way +// into the enclosing document: it compacts, and it HTML-escapes. So "the +// exact bytes" was never true, and the semantic comparisons everywhere +// else in this file could not have caught that. This pins the real +// guarantee from both sides, so neither half is an assumption. +func TestRawPreservesTheValueAndNotTheBytes(t *testing.T) { + // Spaced and indented the way an operator might write it, with a + // character encoding/json escapes inside a string value. + const configured = `{ + "id": "q", + "format": "zkp_req", + "meta": { "doctype_value": "a&b" }, + "claims": { "_req": [] } + }` + + query, err := NewRawCredentialQuery(json.RawMessage(configured)) + require.NoError(t, err) + encoded, err := json.Marshal(&DCQL{Credentials: []CredentialQuery{query}}) + require.NoError(t, err) + + t.Run("the bytes are not preserved, and this is the documented limit", func(t *testing.T) { + assert.NotContains(t, string(encoded), "\n\t\t", + "encoding/json compacts, so an operator's layout does not reach the wire") + assert.NotContains(t, string(encoded), "a&b", + "and it HTML-escapes, so the configured text is not what appears on the wire") + assert.Contains(t, string(encoded), "a"+escapedAmpersand+"b", + "the ampersand is written as its escape") + }) + + t.Run("every member and value is preserved, which is the promise", func(t *testing.T) { + var decoded DCQL + require.NoError(t, json.Unmarshal(encoded, &decoded)) + require.Len(t, decoded.Credentials, 1) + + out, err := json.Marshal(decoded.Credentials[0]) + require.NoError(t, err) + assert.JSONEq(t, configured, string(out), + "including claims as an object, which the typed model cannot hold") + + // And the escaping is a spelling, not a change: a wallet reading + // it gets the string that was configured. + var shape struct { + Credentials []struct { + Meta struct { + DoctypeValue string `json:"doctype_value"` + } `json:"meta"` + } `json:"credentials"` + } + require.NoError(t, json.Unmarshal(encoded, &shape)) + assert.Equal(t, "a&b", shape.Credentials[0].Meta.DoctypeValue) + }) +} + +// A duplicate member is invisible to the encode-back-to comparison: +// encoding/json keeps the last one, so {"id":"first","id":"last"} +// re-encodes as just "last" and compares equal to itself. The query would +// go out with the duplicate silently removed. +// +// Two reasons that is wrong here. It is a changed request, and a +// duplicate member is exactly the kind of malformed input a Verifier +// sends on purpose to see what a wallet does with it. And it is a parser +// differential - decoders disagree about which value wins - so a request +// where that matters has to arrive as written rather than as this build +// happens to read it. +func TestDuplicateMembersGoRawAndSurvive(t *testing.T) { + for _, tc := range []struct { + name, query string + wantRaw bool + }{ + {"a duplicate at the top level", `{"id":"first","id":"last","format":"zkp_req","meta":{"doctype_value":"d"}}`, true}, + {"a duplicate nested inside a req param", `{"id":"q","format":"zkp_req","meta":{},"_req":[{"fn_id":"f","return":"h","params":{"p":1,"p":2}}]}`, true}, + + // The same NAME in two different objects is not a duplicate. A + // scanner that missed this would push every ordinary req down the + // raw path. + {"the same name in sibling objects", `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"a","return":"h"},{"fn_id":"b","return":"h2"}]}`, false}, + {"and the same name at different depths", `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"a","return":"h","params":{"fn_id":"inner"}}]}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(tc.query), &query)) + + if !tc.wantRaw { + assert.Nil(t, query.Raw, "this query has no duplicate and must still decode typed") + return + } + require.NotNil(t, query.Raw, "a duplicate member has nowhere to live in the typed model") + + // And it reaches the wire intact, BOTH members present. + // + // Compared as text, not with JSONEq: that parses both sides, + // which collapses the duplicate on each, so it would pass + // just as happily if one member had been dropped. These + // inputs are already compact and carry nothing + // encoding/json escapes, so the bytes stand. + out, err := json.Marshal(query) + require.NoError(t, err) + assert.Equal(t, tc.query, string(out)) + }) + } +} + +// The escape hatch must not become a laundry for invalid ordinary +// queries. +// +// Making the decode total meant any query the model rejected became Raw - +// including one that is plain DCQL and simply WRONG. An SD-JWT query with +// claims:[{"path":[]}] is refused by ClaimQuery.UnmarshalJSON, and used +// to be refused outright; under a total decode it became Raw, the shadow +// left Claims empty, and validation then found nothing to object to. The +// UI path would sign and send a query that previously could not be +// decoded at all. +// +// The line is the JSON KIND. A member of a kind the model does not have +// is a shape this build has never seen, and belongs in Raw. A member of +// the right kind that the model still rejects is an ordinary query that +// is invalid, and stays refused. +func TestInvalidModelledMembersAreNotLaunderedIntoRaw(t *testing.T) { + for _, tc := range []struct { + name, query string + wantErr bool + }{ + { + name: "an empty claim path, which the model rejects", + query: `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"claims":[{"path":[]}]}`, + wantErr: true, + }, + { + name: "and the same inside an otherwise fine mdoc query", + query: `{"id":"q","format":"mso_mdoc","meta":{"doctype_value":"d"},"claims":[{"path":[]}]}`, + wantErr: true, + }, + { + // Same member, different KIND: an object where DCQL has an + // array. This is the rulebook's WIP shape and the reason Raw + // exists at all. + name: "claims as an object, which the model does not have", + query: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[]}}`, + }, + { + name: "req as an object, likewise", + query: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":{"not":"an array"}}`, + }, + { + name: "an ordinary valid query", + query: `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"claims":[{"path":["a"]}]}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var query CredentialQuery + err := json.Unmarshal([]byte(tc.query), &query) + if tc.wantErr { + require.Error(t, err, + "a query the model rejects must stay rejected, not become raw and then validate") + return + } + require.NoError(t, err) + }) + } +} + +// A duplicate is a parser differential, and for the members that decide +// what a request is LIMITED TO that is not a curiosity to pass through. +// +// {"format":"dc+sd-jwt","meta":{},"meta":{"vct_values":["urn:x"]}} is the +// shape: Go validates against the last meta, finds a constraint, and +// signs - while a first-wins wallet reads an unconstrained SD-JWT request +// and may return anything it holds. The query reaches the ingress from a +// browser, so that is attacker-reachable over-disclosure, through the +// very escape hatch added to preserve malformed requests. +// +// Refused wherever it comes from, including the constructor: a request +// whose constraint depends on who is reading it is not one to sign on +// anybody's behalf. +func TestAmbiguousConstraintMembersAreRefused(t *testing.T) { + for _, tc := range []struct{ name, query, want string }{ + { + name: "two metas, the second constraining", + query: `{"id":"q","format":"dc+sd-jwt","meta":{},"meta":{"vct_values":["urn:x"]}}`, + want: "meta", + }, + { + name: "and the other way round, which a last-wins reader finds unconstrained", + query: `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"meta":{}}`, + want: "meta", + }, + { + name: "two formats", + query: `{"id":"q","format":"zkp_req","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]}}`, + want: "format", + }, + { + name: "a duplicate of the constraint itself", + query: `{"id":"q","format":"mso_mdoc","meta":{"doctype_value":"a","doctype_value":"b"}}`, + want: "meta.doctype_value", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var query CredentialQuery + err := json.Unmarshal([]byte(tc.query), &query) + require.Error(t, err, "this must not be signed, and must not be carried as raw either") + assert.Contains(t, err.Error(), tc.want) + + // And the explicit, trusted construction path refuses it too - + // otherwise it would build a query the decode path cannot read + // back, which works on one replica and nowhere else. + _, err = NewRawCredentialQuery(json.RawMessage(tc.query)) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +// Duplicates that do NOT decide what the request is limited to still pass +// through, because sending a malformed request is how a wallet gets +// tested - and the rulebook's own uniqueness rules, on req ids and +// handles, are exactly there. +func TestDuplicatesThatDoNotDecideTheConstraintStillPass(t *testing.T) { + for _, in := range []string{ + `{"id":"first","id":"last","format":"zkp_req","meta":{"doctype_value":"d"}}`, + `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"f","return":"h","params":{"p":1,"p":2}}]}`, + `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"a","return":"h","fn_id":"b"}]}`, + } { + var query CredentialQuery + require.NoErrorf(t, json.Unmarshal([]byte(in), &query), "%s", in) + require.NotNilf(t, query.Raw, "%s", in) + + out, err := json.Marshal(query) + require.NoError(t, err) + assert.Equal(t, in, string(out), "both members have to reach the wallet") + } +} diff --git a/pkg/openid4vp/presentation_builder.go b/pkg/openid4vp/presentation_builder.go index 7c3dac862..8d63a53d1 100644 --- a/pkg/openid4vp/presentation_builder.go +++ b/pkg/openid4vp/presentation_builder.go @@ -2,8 +2,11 @@ package openid4vp import ( "context" + "encoding" + "encoding/json" "fmt" "maps" + "reflect" "slices" ) @@ -195,6 +198,385 @@ func (pb *PresentationBuilder) TemplateDCQLQuery(_ context.Context, scopes []str return copyDCQL(dcql), slices.Clone(template.GetOIDCScopes()), true } +// copyAnyMap deep-copies an opaque JSON tree. +// +// Nil in, nil out, so a copied query is indistinguishable from the +// original. +// +// This copied through the encoding, which was tidy and quietly REPAIRED +// what it copied. A raw leaf is bytes, and re-parsing bytes is not a copy +// of them: Params{"payload": json.RawMessage(`{"x":1,"x":2}`)} came back +// as {"x":2}. The original emits both members - that is what a raw leaf is +// FOR, since a Verifier sends a deliberately malformed request to see what +// a wallet does with it - so templating one silently changed the request +// under test. Member order and a number's spelling were lost the same way. +// +// So the tree is walked instead, and the containers a JSON tree is made of +// are copied structurally. Anything else still goes through the encoding, +// which keeps the original promise: a shape this build does not model is +// normalised rather than aliased, and the walk does not have to be taught +// every type a Verifier might configure. +func copyAnyMap(src map[string]any) map[string]any { + return copyAnyMapAt(src, ©Path{}) +} + +// copyPath is the walk's position: how deep it is, and which containers +// are on the route it took to get there. +// +// On the PATH, not merely visited. The same container reached twice by +// different routes is a DAG, which is legitimate and has to be copied +// both times; only a container reachable from itself is a cycle. +// +// Depth alone was not enough. It bounds stack usage, not total work, so +// a map holding ITSELF TWICE - m["a"] = m, m["b"] = m - still expanded +// into a binary tree and burned 2^65 steps before the bound could stop +// it. Identity stops it at the first repeat. +type copyPath struct { + depth int + seen map[[2]uintptr]bool +} + +// containerKey identifies a container on the path. +// +// The length is part of it because two sub-slices of one array share a +// data pointer: keying on the pointer alone would call a legitimate +// nested slice a cycle and alias it instead of copying it. A map or a +// pointer has no length and uses zero. +func containerKey(pointer uintptr, length int) [2]uintptr { + return [2]uintptr{pointer, uintptr(length)} +} + +// enter records a container as being on the active path, reporting false +// if it already is - which means the walk has come back to it. +func (p *copyPath) enter(key [2]uintptr) bool { + if p.seen[key] { + return false + } + if p.seen == nil { + p.seen = make(map[[2]uintptr]bool, 4) + } + p.seen[key] = true + p.depth++ + return true +} + +func (p *copyPath) leave(key [2]uintptr) { + delete(p.seen, key) + p.depth-- +} + +// maxCopyDepth bounds the walk below. +// +// A tree that came off the wire is shallow; a programmatically built one +// can contain a CYCLE, and the walk followed it until the stack died - +// a self-referencing field tagged json:"-" is invisible to Marshal, so +// the value serialises fine and only copying it crashes. Past this depth +// the value goes to encodeCopy, which keeps it as the bytes it marshals +// to, and reports the cycle as an error that leaves the original in +// place. The bound costs nothing in fidelity now that the fallback is +// byte-exact. +const maxCopyDepth = 64 + +func copyAnyMapAt(src map[string]any, path *copyPath) map[string]any { + if src == nil { + return nil + } + key := containerKey(reflect.ValueOf(src).Pointer(), 0) + if !path.enter(key) { + // Already on the path: this map contains itself. Nothing can be + // copied faithfully from here, and Marshal refuses such a value + // anyway, so the original is left in place. + return src + } + defer path.leave(key) + + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = copyAnyValueAt(value, path) + } + return dst +} + +// copyAnyValue deep-copies one node of an opaque JSON tree. +func copyAnyValue(src any) any { + return copyAnyValueAt(src, ©Path{}) +} + +func copyAnyValueAt(src any, path *copyPath) any { + if path.depth > maxCopyDepth { + return encodeCopy(src) + } + switch typed := src.(type) { + case nil, bool, string, float64, json.Number: + // Immutable, so sharing one is not aliasing. + return src + + case json.RawMessage: + // Bytes, copied as bytes. Everything a raw leaf exists to carry - + // a duplicate member, member order, a number written the long way - + // survives only if it is never re-parsed. + // + // Allocated with a length, because appending to a nil slice + // returns a NIL one: an empty-but-present raw leaf would come out + // nil, and the two do not mean the same thing. An empty one fails + // to marshal ("unexpected end of JSON input") while nil marshals + // as null, so flattening them turns a request that cannot be sent + // into one that quietly says null - the copy repairing what it + // copies, which is the thing this whole path exists to stop. + if typed == nil { + return src + } + return append(make(json.RawMessage, 0, len(typed)), typed...) + + case map[string]any: + return copyAnyMapAt(typed, path) + + case []any: + if typed == nil { + // A nil slice encodes as null and an empty one as [], so + // allocating here would send [] where the query said null. + return src + } + // This branch is a FAST PATH around the reflective walk, and it + // was skipping that walk's cycle guard with it: s[0] = s recursed + // here without ever recording the slice, so the guards on maps + // and on typed slices did nothing and the stack died. + key := containerKey(reflect.ValueOf(typed).Pointer(), len(typed)) + if !path.enter(key) { + return src + } + defer path.leave(key) + + dst := make([]any, len(typed)) + for i, value := range typed { + dst[i] = copyAnyValueAt(value, path) + } + return dst + + default: + return copyTypedContainerAt(src, path) + } +} + +// copyTypedContainer copies a map or slice whose element type is concrete, +// which the cases above cannot name. +// +// map[string]json.RawMessage and []json.RawMessage are the ones a caller +// is likely to build, and sending them through the encoding would re-parse +// the raw leaves inside them - collapsing a duplicate member exactly as +// copying the whole tree through the encoding used to. The container is +// copied by reflection while every element goes back through +// copyAnyValue, so a raw leaf survives at any depth inside a typed one. +// +// Anything that is not a map or a slice, or whose elements will not fit +// back into it, still goes through the encoding: a shape this build does +// not model is normalised rather than aliased. +func copyTypedContainerAt(src any, path *copyPath) any { + value := reflect.ValueOf(src) + kind := value.Kind() + + // A SCALAR is immutable, so sharing one is not aliasing - and this + // has to come first, because sending one through the encoding changes + // its TYPE. An int came back as a json.Number, which is not + // assignable to int, so the container holding it failed the + // assignability check below and was condemned to the encoding whole: + // a struct with a Count int beside a raw payload had the payload + // collapsed because of the Count. Testing the KIND rather than naming + // types also covers a named scalar, say type Count int. + switch kind { + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, + reflect.String: + return src + } + + // A value with its OWN MarshalJSON is snapshotted whole. A + // structural copy keeps the container's named type, and so keeps that + // method, while the elements inside it can come back as different + // types - a named map[string]any whose marshaller expects a + // time.Time element would find a json.RawMessage there, and + // marshalling the copied request would fail. encodeCopy keeps the + // bytes the marshaller produced, which is what the request is. + if implementsMarshaler(value.Type()) { + return encodeCopy(src) + } + + // A POINTER to a raw leaf is a raw leaf. *json.RawMessage went + // straight to the encoding, where re-parsing collapsed a duplicate + // member exactly as it did for the value form - so a template + // holding one still had its negative request repaired while the + // unpointered spelling beside it was preserved. + if kind == reflect.Pointer { + if value.IsNil() { + return src + } + key := containerKey(value.Pointer(), 0) + if !path.enter(key) { + return src // already on the path: a cycle through this pointer + } + defer path.leave(key) + + copied, ok := copyReflectedAt(value.Elem().Interface(), value.Type().Elem(), path) + if !ok { + return encodeCopy(src) + } + pointer := reflect.New(value.Type().Elem()) + pointer.Elem().Set(copied) + return pointer.Interface() + } + + // An ARRAY is a slice that cannot grow: the same elements, the same + // raw leaves, but reflect.Array is not reflect.Slice, so + // [2]json.RawMessage went to the encoding and had its duplicates + // collapsed. Arrays are values, so there is no nil to preserve. + if kind == reflect.Array { + dst := reflect.New(value.Type()).Elem() + for i := range value.Len() { + copied, ok := copyReflectedAt(value.Index(i).Interface(), value.Type().Elem(), path) + if !ok { + return encodeCopy(src) + } + dst.Index(i).Set(copied) + } + return dst.Interface() + } + + if kind != reflect.Map && kind != reflect.Slice { + return encodeCopy(src) + } + if value.IsNil() { + return src + } + + // Only a STRING key is safe to carry across. Every other key is + // copied BY VALUE into the new map, which for a pointer - or anything + // holding one - shares what it points at, and a key is part of the + // wire representation: a map[*big.Int]string key marshals through + // TextMarshaler, so mutating the original integer after the copy + // renamed the COPY's member, {"1":"v"} becoming {"999":"v"}. + // + // The values were walked carefully and the keys were handed over + // untouched; a key is as much of the request as a value is. + if kind == reflect.Map && value.Type().Key().Kind() != reflect.String { + return encodeCopy(src) + } + + length := 0 + if kind == reflect.Slice { + length = value.Len() + } + key := containerKey(value.Pointer(), length) + if !path.enter(key) { + return src // already on the path: a cycle through this container + } + defer path.leave(key) + + element := value.Type().Elem() + if kind == reflect.Map { + dst := reflect.MakeMapWithSize(value.Type(), value.Len()) + for iter := value.MapRange(); iter.Next(); { + copied, ok := copyReflectedAt(iter.Value().Interface(), element, path) + if !ok { + return encodeCopy(src) + } + dst.SetMapIndex(iter.Key(), copied) + } + return dst.Interface() + } + + dst := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + for i := range value.Len() { + copied, ok := copyReflectedAt(value.Index(i).Interface(), element, path) + if !ok { + return encodeCopy(src) + } + dst.Index(i).Set(copied) + } + return dst.Interface() +} + +// copyReflected copies one element of a typed container, reporting +// whether the result fits back into it. +// +// A nil interface element is the case worth naming: copyAnyValue returns +// it unchanged, reflect.ValueOf gives an INVALID Value for it, and +// treating that as "does not fit" sent the whole container through the +// encoding - re-parsing every raw leaf sitting beside the nil and +// collapsing any duplicate member inside them. A named container, say +// type Object map[string]any, reaches here holding exactly that mix. +func copyReflectedAt(src any, element reflect.Type, path *copyPath) (reflect.Value, bool) { + copied := reflect.ValueOf(copyAnyValueAt(src, path)) + if !copied.IsValid() { + // Nil, which every element type that can hold it has a zero for. + if element.Kind() == reflect.Interface { + return reflect.Zero(element), true + } + return reflect.Value{}, false + } + if !copied.Type().AssignableTo(element) { + return reflect.Value{}, false + } + return copied, true +} + +// A value that serialises ITSELF is snapshotted whole: its output is the +// request, and what is inside it is none of this walk's business. +// +// encoding/json dispatches to more than one interface, and recognising +// only some of them is what repeatedly let a container through. The ones +// below are the stdlib's; MarshalJSONTo is matched by NAME because it +// belongs to encoding/json/v2, which this module reaches through the +// jsonv2 GOEXPERIMENT rather than by import - naming it keeps this +// correct whether that experiment is on or off, and snapshotting a type +// json would NOT have dispatched to costs nothing, since encodeCopy +// reproduces its bytes either way. +var marshalerTypes = []reflect.Type{ + reflect.TypeOf((*json.Marshaler)(nil)).Elem(), + reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem(), + reflect.TypeOf((*encoding.TextAppender)(nil)).Elem(), +} + +var marshalerMethods = []string{"MarshalJSONTo"} + +func implementsMarshaler(t reflect.Type) bool { + pointer := reflect.PointerTo(t) + for _, marshaler := range marshalerTypes { + if t.Implements(marshaler) || pointer.Implements(marshaler) { + return true + } + } + for _, name := range marshalerMethods { + if _, found := t.MethodByName(name); found { + return true + } + if _, found := pointer.MethodByName(name); found { + return true + } + } + return false +} + +// encodeCopy copies through the encoding, normalising a shape this build +// does not model rather than aliasing it. +func encodeCopy(src any) any { + encoded, err := json.Marshal(src) + if err != nil { + // Unreachable for a tree that came off the wire or out of + // configuration; aliasing the original is better than losing it. + return src + } + // As BYTES, not re-parsed. Re-parsing them was the whole defect: a + // duplicate member, member order and a number's spelling are all lost + // by a decode, and a value that reaches here is precisely the one + // nothing else knows how to preserve. Held as a json.RawMessage the + // copy marshals to the same bytes as the original, which is all this + // copy has ever needed to guarantee. + return json.RawMessage(encoded) +} + // copyDCQL creates a deep copy of a DCQL query func copyDCQL(src *DCQL) *DCQL { if src == nil { @@ -241,10 +623,14 @@ func copyDCQL(src *DCQL) *DCQL { } } dst.Credentials[i] = CredentialQuery{ - ID: cred.ID, - Format: cred.Format, - Multiple: cred.Multiple, - Meta: meta, + ID: cred.ID, + Format: cred.Format, + + Meta: meta, + } + if cred.Multiple != nil { + v := *cred.Multiple + dst.Credentials[i].Multiple = &v } if cred.RequireCryptographicHolderBinding != nil { v := *cred.RequireCryptographicHolderBinding @@ -273,9 +659,18 @@ func copyDCQL(src *DCQL) *DCQL { pathCopy[k] = &s } } + // Through copyAnyValue, not a shallow append. A claim + // value is `any`, so it can hold a raw leaf or a + // container just as Params can, and appending copies the + // SLICE while sharing what its elements point at: a + // json.RawMessage here was the original's bytes, so + // mutating them changed the copied request. var valuesCopy []any if claim.Values != nil { - valuesCopy = append([]any{}, claim.Values...) + valuesCopy = make([]any, len(claim.Values)) + for k, v := range claim.Values { + valuesCopy[k] = copyAnyValue(v) + } } dst.Credentials[i].Claims[j] = ClaimQuery{ ID: claim.ID, @@ -285,6 +680,28 @@ func copyDCQL(src *DCQL) *DCQL { } } + // Copy the zkp_req request. Params and Extra are opaque trees + // this package passes through unchanged, so they are deep-copied + // through their JSON encoding rather than walked field by field. + if len(cred.Req) > 0 { + dst.Credentials[i].Req = make([]ReqItem, len(cred.Req)) + for j, item := range cred.Req { + dst.Credentials[i].Req[j] = ReqItem{ + FnID: item.FnID, + Return: item.Return, + Params: copyAnyMap(item.Params), + Extra: copyAnyMap(item.Extra), + } + } + } + dst.Credentials[i].Extra = copyAnyMap(cred.Extra) + if cred.Raw != nil { + // Allocated with a length: appending to a NIL slice returns a + // nil one, so an empty-but-present Raw came out absent, and + // those do not mean the same thing. + dst.Credentials[i].Raw = append(make(json.RawMessage, 0, len(cred.Raw)), cred.Raw...) + } + // Copy claim sets if len(cred.ClaimSet) > 0 { dst.Credentials[i].ClaimSet = make([][]string, len(cred.ClaimSet)) diff --git a/pkg/openid4vp/raw_constructor_guard_test.go b/pkg/openid4vp/raw_constructor_guard_test.go new file mode 100644 index 000000000..9bb5bbde3 --- /dev/null +++ b/pkg/openid4vp/raw_constructor_guard_test.go @@ -0,0 +1,244 @@ +package openid4vp + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Writing CredentialQuery{Raw: ...} by hand compiles and is wrong. +// +// Raw is authoritative for the WIRE and nothing else, and plenty of this +// stack reads the struct: UIInteraction records ID in the authorization +// context's scopes and the response path matches a presentation back to +// it; matching reads Meta.TypeValues; Multiple and +// RequireCryptographicHolderBinding decide what a response may contain. A +// hand-built raw query is therefore sent correctly and then tracked under +// defaults nobody chose - which surfaces as a result that cannot be +// matched, with nothing malformed to point at. +// +// NewRawCredentialQuery fills the shadow in. "Callers should use the +// constructor" is advice, and advice does not survive the next caller, so +// this makes it a rule: production code may not write the literal. +// +// Tests may, and do - asserting what a hand-built query does IS the +// subject of some of them. +func TestRawIsOnlyConstructedThroughTheConstructor(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + + // Where the shadow is actually populated, and so the only file that + // may build the literal. + sanctioned := filepath.Join(root, "pkg", "openid4vp", "dcql.go") + + fset := token.NewFileSet() + var inspected int + var violations []string + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + switch entry.Name() { + case "vendor", ".git", "node_modules", "testdata": + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || path == sanctioned { + return nil + } + file, parseErr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if parseErr != nil { + return nil // not ours to police + } + inspected++ + violations = append(violations, rawQueryLiteralViolations(fset, file)...) + return nil + }) + require.NoError(t, err) + + // Fail closed: a walk that inspected nothing proves nothing. + require.NotZerof(t, inspected, "walked %s and parsed no Go files; this guard is not looking at anything", root) + require.Empty(t, violations, strings.Join(violations, "\n")) +} + +// rawQueryLiteralViolations reports every CredentialQuery literal in file +// that builds Raw by hand, or that could reach Raw by position. +// +// The subtlety is that an element of a slice, array or map OMITS its type: +// in []CredentialQuery{{Raw: raw}} the inner CompositeLit has Type == nil, +// so asking it what type it builds returns nothing and the literal sails +// past. The enclosing literal is the only thing that knows, and ast.Inspect +// walks parents before children, so each literal records the element type +// its children will be missing before those children are visited. +func rawQueryLiteralViolations(fset *token.FileSet, file *ast.File) []string { + var found []string + elided := map[*ast.CompositeLit]ast.Expr{} + + ast.Inspect(file, func(n ast.Node) bool { + composite, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + typ := composite.Type + if typ == nil { + typ = elided[composite] + } + + keyType, valueType := compositeElementTypes(typ) + for _, element := range composite.Elts { + if kv, ok := element.(*ast.KeyValueExpr); ok { + noteElidedType(elided, kv.Key, keyType) + noteElidedType(elided, kv.Value, valueType) + continue + } + noteElidedType(elided, element, valueType) + } + + if !isCredentialQueryType(typ) { + // A literal whose type we could not resolve and which names + // Raw is reported anyway rather than waved through: resolving + // a struct FIELD's elided type needs go/types, and a guard + // that cannot tell must refuse, not assume. + if typ == nil { + for _, element := range composite.Elts { + kv, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "Raw" { + found = append(found, fmt.Sprintf( + "%s: this literal sets Raw but omits its type, so the guard cannot tell "+ + "whether it is a CredentialQuery - name the type explicitly, and build raw "+ + "queries with openid4vp.NewRawCredentialQuery", + fset.Position(kv.Pos()))) + } + } + } + return true + } + + for _, element := range composite.Elts { + kv, ok := element.(*ast.KeyValueExpr) + if !ok { + // An UNKEYED literal sets fields by position, so it can + // reach Raw without ever naming it - and skipping those, + // as this did at first, left the invariant open to the one + // spelling nobody reads carefully. + // + // Rejected wholesale rather than counted to Raw's + // position: that position changes whenever a field is + // added, which would make this guard quietly stop looking + // at the right thing. + found = append(found, fmt.Sprintf( + "%s: a CredentialQuery literal must name its fields - an unkeyed one can set "+ + "Raw by position, and a raw query not built by openid4vp.NewRawCredentialQuery "+ + "carries no ID, Format or Meta, so the response path cannot match it", + fset.Position(element.Pos()))) + continue + } + if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "Raw" { + found = append(found, fmt.Sprintf( + "%s: build a raw query with openid4vp.NewRawCredentialQuery, not a literal - "+ + "a hand-built one carries no ID, Format or Meta, and the response path cannot match it", + fset.Position(kv.Pos()))) + } + } + return true + }) + return found +} + +// noteElidedType records the type an element omitted, for the element to +// read when the walk reaches it. +func noteElidedType(elided map[*ast.CompositeLit]ast.Expr, element ast.Expr, elementType ast.Expr) { + inner, ok := element.(*ast.CompositeLit) + if !ok || inner.Type != nil || elementType == nil { + return + } + elided[inner] = elementType +} + +// compositeElementTypes reports the types a composite literal's keys and +// elements may omit. Only sequences and maps let an element omit its type. +func compositeElementTypes(typ ast.Expr) (key, value ast.Expr) { + switch typed := typ.(type) { + case *ast.ArrayType: // slices and arrays both + return nil, typed.Elt + case *ast.MapType: + return typed.Key, typed.Value + } + return nil, nil +} + +func isCredentialQueryType(expr ast.Expr) bool { + switch typed := expr.(type) { + case *ast.Ident: // within this package + return typed.Name == "CredentialQuery" + case *ast.SelectorExpr: // openid4vp.CredentialQuery + return typed.Sel.Name == "CredentialQuery" + case *ast.StarExpr: // []*CredentialQuery{{...}} means &CredentialQuery{...} + return isCredentialQueryType(typed.X) + } + return false +} + +// A literal that omits its element type is the spelling the guard first +// missed: []CredentialQuery{{Raw: raw}} builds exactly what +// CredentialQuery{Raw: raw} builds, and sailed straight past, because the +// inner literal names no type at all. +func TestGuardSeesLiteralsThatOmitTheirType(t *testing.T) { + for _, tc := range []struct { + name string + body string + want int + }{ + {"explicit", `_ = CredentialQuery{Raw: raw}`, 1}, + {"qualified", `_ = openid4vp.CredentialQuery{Raw: raw}`, 1}, + + {"slice", `_ = []CredentialQuery{{Raw: raw}}`, 1}, + {"slice qualified", `_ = []openid4vp.CredentialQuery{{Raw: raw}}`, 1}, + {"slice of pointers", `_ = []*CredentialQuery{{Raw: raw}}`, 1}, + {"array", `_ = [2]CredentialQuery{{Raw: raw}, {Raw: raw}}`, 2}, + {"slice of slices", `_ = [][]CredentialQuery{{{Raw: raw}}}`, 1}, + + {"map value", `_ = map[string]CredentialQuery{"a": {Raw: raw}}`, 1}, + {"map value qualified", `_ = map[string]openid4vp.CredentialQuery{"a": {Raw: raw}}`, 1}, + {"map key", `_ = map[CredentialQuery]bool{{Raw: raw}: true}`, 1}, + + // Unkeyed reaches Raw by position without naming it, and omitting + // the type hides it twice over. + {"unkeyed in a slice", `_ = []CredentialQuery{{raw}}`, 1}, + + // Fail closed: resolving a struct FIELD's elided type needs + // go/types, so a literal that sets Raw and cannot be attributed is + // reported rather than assumed innocent. + {"unresolvable field", `_ = wrapper{Inner: {Raw: raw}}`, 1}, + + // Negatives: the guard must not fire on another type that happens + // to carry a Raw field, nor on a CredentialQuery that leaves Raw + // alone. + {"another type in a slice", `_ = []somethingElse{{Raw: raw}}`, 0}, + {"another type in a map", `_ = map[string]somethingElse{"a": {Raw: raw}}`, 0}, + {"no Raw field", `_ = []CredentialQuery{{ID: "x"}}`, 0}, + {"keyed, not Raw", `_ = CredentialQuery{ID: "x"}`, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + src := "package p\nfunc f(raw []byte) {\n" + tc.body + "\n}\n" + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fixture.go", src, parser.SkipObjectResolution) + require.NoError(t, err, "fixture must parse") + + got := rawQueryLiteralViolations(fset, file) + require.Lenf(t, got, tc.want, "violations for %q: %v", tc.body, got) + }) + } +} diff --git a/pkg/openid4vp/response_parameters.go b/pkg/openid4vp/response_parameters.go index 89ca76108..bc779ec4d 100644 --- a/pkg/openid4vp/response_parameters.go +++ b/pkg/openid4vp/response_parameters.go @@ -10,6 +10,35 @@ import ( type VPResponse struct { VPToken map[string][]string `json:"vp_token,omitempty" bson:"vp_token" validate:"required"` State string `json:"state,omitempty" bson:"state" validate:"required"` + + // VPTokenRaw is every vp_token entry as it arrived, before any + // decoding. + // + // Needed because not every presentation is a string. A circuit_zk + // presentation is an OBJECT, and its validation has to run on the + // raw text: a repeated member or a repeated result handle cannot + // be seen after decoding, since Go keeps the last of a repeated + // key and discards the other silently. + // + // Not persisted (bson:"-") and not serialised: it is the input + // bytes, kept for the length of one verification. A round trip + // through storage would re-encode them, and re-encoded bytes are + // exactly the ones a duplicate check can no longer see. + VPTokenRaw map[string]json.RawMessage `json:"-" bson:"-"` + + // VPTokenEnvelope is the whole vp_token object as it arrived. + // + // The ENVELOPE and not only its entries, because a validator that + // checks the envelope - a repeated query id, the shape of the + // object itself - has to see the object. Handing it one entry + // would silently drop those checks and, worse, hand it an array + // where it expects a map. + // + // Same lifetime and same reason as VPTokenRaw: input bytes, kept + // for one verification, never persisted. A round trip through + // storage would re-encode them, and re-encoded bytes are the ones + // a duplicate check can no longer see. + VPTokenEnvelope json.RawMessage `json:"-" bson:"-"` } // UnmarshalJSON implements custom JSON unmarshaling for VPResponse. @@ -42,11 +71,14 @@ func (v *VPResponse) UnmarshalJSON(data []byte) error { } v.VPToken = make(map[string][]string) + v.VPTokenRaw = make(map[string]json.RawMessage) + v.VPTokenEnvelope = raw.VPToken // Try as object (DCQL format): {"id": "token"} or {"id": ["token1","token2"]} var obj map[string]json.RawMessage if err := json.Unmarshal(raw.VPToken, &obj); err == nil { for key, val := range obj { + v.VPTokenRaw[key] = val // Try as string first var s string if err := json.Unmarshal(val, &s); err == nil { @@ -73,6 +105,19 @@ func (v *VPResponse) UnmarshalJSON(data []byte) error { v.VPToken[key] = arr continue } + // An array of OBJECTS is a presentation format whose + // elements are not strings - circuit_zk is the first. + // It stays in VPTokenRaw only: the string map is what + // every other format reads, and putting a re-encoded + // object in it would hand that format a token it cannot + // parse while hiding the raw bytes the new one needs. + // + // Anything else still fails here. Admitting one more + // legitimate shape is not the same as admitting + // everything. + if isArrayOfObjects(val) { + continue + } return fmt.Errorf("vp_token[%q]: expected string or array of strings", key) } // JSON null unmarshals into a map WITHOUT error, leaving a nil map - @@ -80,7 +125,14 @@ func (v *VPResponse) UnmarshalJSON(data []byte) error { // above, which tests the raw bytes and sees four of them. An empty // object arrives the same way. Either is a response that presented // nothing while claiming the shape of one. - if len(v.VPToken) == 0 { + // + // Counted over the RAW entries, not the string map: a + // response carrying only object presentations - a circuit_zk + // one, where every entry is an array of objects - leaves the + // string map empty and is a perfectly good response. Counting + // the string map here refused it before anything could + // dispatch it. + if len(v.VPTokenRaw) == 0 { return fmt.Errorf("vp_token: missing or empty") } return nil @@ -152,3 +204,23 @@ func ResponseParametersFromJSON(data []byte) (*ResponseParameters, error) { } return &r, nil } + +// isArrayOfObjects reports whether raw is a non-empty JSON array whose +// every element is an object. +// +// Deliberately narrow. It admits exactly the shape a circuit_zk +// presentation array has, so a vp_token entry that is a number, a +// nested array or a mix keeps failing with the message it always had. +func isArrayOfObjects(raw json.RawMessage) bool { + var elements []json.RawMessage + if err := json.Unmarshal(raw, &elements); err != nil || len(elements) == 0 { + return false + } + for _, element := range elements { + var object map[string]json.RawMessage + if err := json.Unmarshal(element, &object); err != nil { + return false + } + } + return true +} diff --git a/pkg/openid4vp/response_parameters_raw_test.go b/pkg/openid4vp/response_parameters_raw_test.go new file mode 100644 index 000000000..d5b5ec185 --- /dev/null +++ b/pkg/openid4vp/response_parameters_raw_test.go @@ -0,0 +1,107 @@ +package openid4vp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Not every presentation is a string. A circuit_zk presentation is an +// object, and the decoder used to refuse the whole response over it. +// +// It is kept RAW and only raw: the string map is what every other +// format reads, and a re-encoded object there would hand that format a +// token it cannot parse while losing the bytes the new one needs - a +// repeated member or handle is invisible after a decode, because Go +// keeps the last of a repeated key. +func TestVPResponseKeepsObjectPresentationsRaw(t *testing.T) { + const body = `{ + "state": "s", + "vp_token": { + "nym_attestation": [{"circuit_id": "urn:c", "results": {"h": {"satisfied": true}}}], + "pid": "a.b.c" + } + }` + + var response VPResponse + require.NoError(t, json.Unmarshal([]byte(body), &response)) + + assert.Equal(t, []string{"a.b.c"}, response.VPToken["pid"], "a string presentation still decodes") + assert.NotContains(t, response.VPToken, "nym_attestation", "an object presentation is not a string token") + + raw, ok := response.VPTokenRaw["nym_attestation"] + require.True(t, ok, "it has to be reachable as raw bytes") + assert.Contains(t, string(raw), `"circuit_id"`) + + // Byte-for-byte what arrived, because a re-encoding is exactly + // what a duplicate check can no longer see. + assert.JSONEq(t, `[{"circuit_id": "urn:c", "results": {"h": {"satisfied": true}}}]`, string(raw)) + + // Every entry, not only the object one, so a caller never has to + // ask which map an entry is in. + assert.Contains(t, response.VPTokenRaw, "pid") +} + +// Admitting one more legitimate shape is not admitting everything. +func TestVPResponseStillRefusesAMalformedEntry(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"a number", `{"state":"s","vp_token":{"pid": 42}}`}, + {"an array of numbers", `{"state":"s","vp_token":{"pid": [1,2]}}`}, + {"an array of arrays", `{"state":"s","vp_token":{"pid": [[]]}}`}, + {"a mixed array", `{"state":"s","vp_token":{"pid": [{"a":1}, 2]}}`}, + {"an empty array", `{"state":"s","vp_token":{"pid": []}}`}, + {"an empty string", `{"state":"s","vp_token":{"pid": ""}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var response VPResponse + assert.Error(t, json.Unmarshal([]byte(tc.body), &response)) + }) + } +} + +// A response carrying ONLY object presentations is a perfectly good +// response, and the emptiness check must not refuse it. +// +// Counting the string map here refused a circuit_zk-only response +// before anything could dispatch it - and a test whose fixture also +// carried a string presentation would never have noticed, because +// the string map was then non-empty for the wrong reason. +func TestVPResponseAcceptsAnObjectOnlyResponse(t *testing.T) { + const body = `{ + "state": "s", + "vp_token": { + "nym_attestation": [{"circuit_id": "urn:c"}] + } + }` + + var response VPResponse + require.NoError(t, json.Unmarshal([]byte(body), &response)) + + assert.Empty(t, response.VPToken, "there are no string presentations in it") + assert.Contains(t, response.VPTokenRaw, "nym_attestation") + assert.NotEmpty(t, response.VPTokenEnvelope, "the whole envelope is kept, not only the entry") + + // And the envelope is the OBJECT, not one entry's array: a + // validator handed the entry would be given an array where it + // expects a map. + var envelope map[string]json.RawMessage + require.NoError(t, json.Unmarshal(response.VPTokenEnvelope, &envelope)) + assert.Contains(t, envelope, "nym_attestation") +} + +// A response that presented nothing is still refused, however it is +// spelled. +func TestVPResponseStillRefusesAnEmptyEnvelope(t *testing.T) { + for _, body := range []string{ + `{"state":"s","vp_token":{}}`, + `{"state":"s","vp_token":null}`, + } { + var response VPResponse + assert.Errorf(t, json.Unmarshal([]byte(body), &response), "body %s", body) + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/SOURCE b/pkg/openid4vp/testdata/zkp_req/SOURCE new file mode 100644 index 000000000..56bb5bb18 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/SOURCE @@ -0,0 +1,34 @@ +These vectors are vendored verbatim from + + https://github.com/sirosfoundation/siros-dc-matcher + crates/siros-dc-matcher-core/tests/vectors/{zkp_req,zkp_req_response,zkp_req_unsat,proof_time} + +commit 93047bc7b8a22117fbc9b4c8cd59ce7cd742e8d3, which is main: the squash +merge of https://github.com/sirosfoundation/siros-dc-matcher/pull/50. No tag +and no release, so a commit rather than a tag is the pin. + +Vendored from that pull request's head (69e6be12) and re-pinned when it +merged; the vectors are byte-identical between the two, so the merge moved the +provenance and not the bytes. + +The normative text is docs/zkp_req_request.md and docs/zkp_req_response.md in +that repository, which follow nym_auth rulebook v0.4. Where the rulebook and +those documents differ, the rulebook wins; where the archive circuits and +either differ on circuit semantics, the archive wins. + + set digest 9f482d7a3fb87cb1381d0dfb7a2fc396b6cd3c1eba07c5d76f44e47142b751ac + +computed the way the matcher's scripts/vectors_digest.sh computes it: the +sha256 of the `sha256sum` manifest of every *.json under the four directories, +LC_ALL=C sorted, paths relative to this directory, README.md excluded. Counts +at that commit: zkp_req 54, zkp_req_response 143, zkp_req_unsat 43, +proof_time 1. + +They are the shared conformance set for every implementation that speaks +zkp_req - the Rust matcher, the Kotlin and Swift SDKs, and this verifier. +Vendored rather than fetched so the test suite does not depend on the network, +and the digest is pinned in the test that loads them. + +Re-vendor rather than edit. A local change turns a cross-implementation check +into a self-consistency one that passes while the implementations disagree - +which is the single failure this file exists to prevent. diff --git a/pkg/openid4vp/testdata/zkp_req/proof_time/README.md b/pkg/openid4vp/testdata/zkp_req/proof_time/README.md new file mode 100644 index 000000000..c516840ff --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/proof_time/README.md @@ -0,0 +1,7 @@ +# proof_time vectors + +`proof_time.json`: epoch seconds to the exact 20-byte `YYYY-MM-DDThh:mm:ssZ` +form (epoch, year boundaries, leap days, 2100, 9999-12-31), the refusals of +the parser, and the verifier tolerance check (recommended skew 60 s, age +600 s; both configurable). Spec: `docs/zkp_req_response.md`, "proof_time". +Texts were computed independently of the Rust code. diff --git a/pkg/openid4vp/testdata/zkp_req/proof_time/proof_time.json b/pkg/openid4vp/testdata/zkp_req/proof_time/proof_time.json new file mode 100644 index 000000000..178337003 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/proof_time/proof_time.json @@ -0,0 +1,341 @@ +{ + "description": "proof_time: the exact 20-byte rendering of epoch seconds (UTC), refusals, and the verifier tolerance check. Recommended tolerance: skew 60, max_age 600; both are the verifier's to configure. Expected texts were computed with an independent implementation (Python datetime).", + "render": [ + { + "secs": 0, + "text": "1970-01-01T00:00:00Z" + }, + { + "secs": 1, + "text": "1970-01-01T00:00:01Z" + }, + { + "secs": 59, + "text": "1970-01-01T00:00:59Z" + }, + { + "secs": 60, + "text": "1970-01-01T00:01:00Z" + }, + { + "secs": 3599, + "text": "1970-01-01T00:59:59Z" + }, + { + "secs": 3600, + "text": "1970-01-01T01:00:00Z" + }, + { + "secs": 86399, + "text": "1970-01-01T23:59:59Z" + }, + { + "secs": 86400, + "text": "1970-01-02T00:00:00Z" + }, + { + "secs": 951782399, + "text": "2000-02-28T23:59:59Z" + }, + { + "secs": 951782400, + "text": "2000-02-29T00:00:00Z" + }, + { + "secs": 951868799, + "text": "2000-02-29T23:59:59Z" + }, + { + "secs": 951868800, + "text": "2000-03-01T00:00:00Z" + }, + { + "secs": 1709164799, + "text": "2024-02-28T23:59:59Z" + }, + { + "secs": 1709164800, + "text": "2024-02-29T00:00:00Z" + }, + { + "secs": 1709251199, + "text": "2024-02-29T23:59:59Z" + }, + { + "secs": 1709251200, + "text": "2024-03-01T00:00:00Z" + }, + { + "secs": 1760961600, + "text": "2025-10-20T12:00:00Z" + }, + { + "secs": 1767225599, + "text": "2025-12-31T23:59:59Z" + }, + { + "secs": 1767225600, + "text": "2026-01-01T00:00:00Z" + }, + { + "secs": 1798761599, + "text": "2026-12-31T23:59:59Z" + }, + { + "secs": 1798761600, + "text": "2027-01-01T00:00:00Z" + }, + { + "secs": 2147483647, + "text": "2038-01-19T03:14:07Z" + }, + { + "secs": 2147483648, + "text": "2038-01-19T03:14:08Z" + }, + { + "secs": 4107542399, + "text": "2100-02-28T23:59:59Z" + }, + { + "secs": 4107628799, + "text": "2100-03-01T23:59:59Z" + }, + { + "secs": 4294967295, + "text": "2106-02-07T06:28:15Z" + }, + { + "secs": 4294967296, + "text": "2106-02-07T06:28:16Z" + }, + { + "secs": 253402214400, + "text": "9999-12-31T00:00:00Z" + }, + { + "secs": 253402300799, + "text": "9999-12-31T23:59:59Z" + } + ], + "render_errors": [ + { + "secs": -1, + "error": "proof_time_out_of_range" + }, + { + "secs": 253402300800, + "error": "proof_time_out_of_range" + }, + { + "secs": -86400, + "error": "proof_time_out_of_range" + } + ], + "parse_ok": [ + { + "text": "2000-02-29T00:00:00Z", + "secs": 951782400 + }, + { + "text": "2024-02-29T23:59:59Z", + "secs": 1709251199 + }, + { + "text": "1970-01-01T00:00:00Z", + "secs": 0 + }, + { + "text": "9999-12-31T23:59:59Z", + "secs": 253402300799 + } + ], + "parse_errors": [ + { + "text": "2026-10-20T12:00:00.000Z", + "error": "proof_time_form" + }, + { + "text": "2026-10-20T12:00:00+00:00", + "error": "proof_time_form" + }, + { + "text": "2026-10-20T12:00:00z", + "error": "proof_time_form" + }, + { + "text": "2026-10-20t12:00:00Z", + "error": "proof_time_form" + }, + { + "text": "2026-10-20 12:00:00Z", + "error": "proof_time_form" + }, + { + "text": "2026-10-20T12:00Z", + "error": "proof_time_form" + }, + { + "text": "2026-10-20", + "error": "proof_time_form" + }, + { + "text": "", + "error": "proof_time_form" + }, + { + "text": "2026-10-20T12:00:00Z ", + "error": "proof_time_form" + }, + { + "text": " 2026-10-20T12:00:00Z", + "error": "proof_time_form" + }, + { + "text": "2026-10-20T12:00:0٠Z", + "error": "proof_time_form" + }, + { + "text": "+026-10-20T12:00:00Z", + "error": "proof_time_form" + }, + { + "text": "2026-13-20T12:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-00-20T12:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-10-00T12:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-10-32T12:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-04-31T12:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-02-29T12:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2100-02-29T00:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-10-20T24:00:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-10-20T12:60:00Z", + "error": "proof_time_not_an_instant" + }, + { + "text": "2026-10-20T23:59:60Z", + "error": "proof_time_not_an_instant" + } + ], + "tolerance": [ + { + "name": "equal_now", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2025-10-20T12:00:00Z", + "expect": "ok" + }, + { + "name": "skew_edge_inclusive", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2025-10-20T12:01:00Z", + "expect": "ok" + }, + { + "name": "one_past_skew", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2025-10-20T12:01:01Z", + "expect": "proof_time_in_future" + }, + { + "name": "age_edge_inclusive", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2025-10-20T11:50:00Z", + "expect": "ok" + }, + { + "name": "one_past_age", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2025-10-20T11:49:59Z", + "expect": "proof_time_too_old" + }, + { + "name": "custom_zero_skew_edge", + "now_secs": 1760961600, + "skew_secs": 0, + "max_age_secs": 600, + "proof_time": "2025-10-20T12:00:00Z", + "expect": "ok" + }, + { + "name": "custom_zero_skew_one_ahead", + "now_secs": 1760961600, + "skew_secs": 0, + "max_age_secs": 600, + "proof_time": "2025-10-20T12:00:01Z", + "expect": "proof_time_in_future" + }, + { + "name": "custom_wide_age", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 86400, + "proof_time": "2025-10-19T12:00:00Z", + "expect": "ok" + }, + { + "name": "custom_wide_age_past", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 86400, + "proof_time": "2025-10-19T11:59:59Z", + "expect": "proof_time_too_old" + }, + { + "name": "not_the_exact_form", + "now_secs": 1760961600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2026-10-20T12:00:00.5Z", + "expect": "proof_time_form" + }, + { + "name": "across_year_boundary", + "now_secs": 1767225600, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2025-12-31T23:55:00Z", + "expect": "ok" + }, + { + "name": "across_leap_day", + "now_secs": 1709251260, + "skew_secs": 60, + "max_age_secs": 600, + "proof_time": "2024-02-29T23:59:59Z", + "expect": "ok" + } + ] +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/README.md b/pkg/openid4vp/testdata/zkp_req/zkp_req/README.md new file mode 100644 index 000000000..8c3b1de02 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/README.md @@ -0,0 +1,53 @@ +# zkp_req planner conformance vectors + +One JSON file per case. These are the shared conformance file for every +implementation of the `zkp_req` query planner (Rust here; the Kotlin and Swift +SDKs may reuse them verbatim). Files named `ok_*` must plan; `err_*` must be +rejected. The shape is the `nym_auth` rulebook v0.4's: `_req` items +`{fn_id, return, params}`, `meta.source_format`, `vp_formats_supported.zkp_req` +with `proof_system_values` and `system_id_values`. Spec: `docs/zkp_req_request.md`. + +```json +{ + "description": "what the case shows", + "query": { }, // the Credential Query (one element of dcql_query.credentials) + "query_text": "{not json", // instead of `query`: raw text, for unparseable input + "client_metadata": { }, // optional; or "client_metadata_text" for raw text + "expect": { "plan": { } } // or { "error": "" } +} +``` + +Exactly one of `query` / `query_text`; at most one of `client_metadata` / +`client_metadata_text`. Absent client metadata means `None`. + +## `expect.plan` + +Compared for exact JSON equality with the planner's output **without `raw`**: + +- `functions`: `[{function_id, handle, params}]` in request order. `function_id` + is the item's `fn_id`, `handle` its `return`. `params` is flat: known + parameters that were present, plus every other parameter unchanged. +- `system_filter`: `{proof_systems?, system_ids?}`; a key is absent when the + verifier did not state it. +- `source_format`: `meta.source_format`. +- `meta_doctype`, `meta_schema_id`: string or `null`. + +`raw` must equal the parsed `query`, unchanged (unknown members survive). + +## `expect.error` + +A stable code, not message text: `query_not_parseable`, `req_missing_or_empty`, +`item_missing_function_id`, `item_missing_handle`, `param_wrong_type`, +`param_invalid_full_date`, `duplicate_handle`, `claims_present`, +`claim_sets_present`, `req_sets_present`, `invalid_meta`, +`invalid_client_metadata`. + +## Scope + +The planner judges structure only. `ok_satisfiability_not_judged` and +`ok_ver_large_integer_is_an_integer` pin that well-formed but unsatisfiable +requests plan; the unsatisfiability rules are a separate check with their own +vectors in `../zkp_req_unsat/`. `ok_bbs_cd_source_format` and +`ok_source_format_not_judged` pin that `bbs_cd` (held) and a reserved source +format plan. `ok_verifier_id_type_is_an_extra_param` pins that the retired +`verifier_id_type` is just an undeclared parameter. diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claim_sets.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claim_sets.json new file mode 100644 index 000000000..cf138ad7f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claim_sets.json @@ -0,0 +1,29 @@ +{ + "description": "claim_sets present.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "claim_sets": [ + [ + "a" + ] + ] + }, + "expect": { + "error": "claim_sets_present" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_array.json new file mode 100644 index 000000000..124d8f4d0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_array.json @@ -0,0 +1,31 @@ +{ + "description": "claims as an ordinary DCQL claims array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "claims": [ + { + "path": [ + "birth_date" + ] + } + ] + }, + "expect": { + "error": "claims_present" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_empty_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_empty_array.json new file mode 100644 index 000000000..000a7e0c6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_empty_array.json @@ -0,0 +1,24 @@ +{ + "description": "claims present even as an empty array: zkp_req has no claims.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2008-10-10" + } + } + ], + "claims": [] + }, + "expect": { + "error": "claims_present" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_not_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_not_object.json new file mode 100644 index 000000000..34acff7c3 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_claims_not_object.json @@ -0,0 +1,25 @@ +{ + "description": "claims that is neither array nor object.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "claims": "x" + }, + "expect": { + "error": "claims_present" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_list_wrong_type.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_list_wrong_type.json new file mode 100644 index 000000000..f336282c7 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_list_wrong_type.json @@ -0,0 +1,33 @@ +{ + "description": "system_id_values holds a non-string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "system_id_values": [ + 1 + ] + } + } + }, + "expect": { + "error": "invalid_client_metadata" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_not_parseable.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_not_parseable.json new file mode 100644 index 000000000..7342a9f0e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_not_parseable.json @@ -0,0 +1,25 @@ +{ + "description": "client_metadata is not JSON.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata_text": "{nope", + "expect": { + "error": "invalid_client_metadata" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_vp_formats_wrong_type.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_vp_formats_wrong_type.json new file mode 100644 index 000000000..dabb0f77a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_client_metadata_vp_formats_wrong_type.json @@ -0,0 +1,31 @@ +{ + "description": "vp_formats_supported.zkp_req is not an object.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": [ + "longfellow" + ] + } + }, + "expect": { + "error": "invalid_client_metadata" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_duplicate_handle.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_duplicate_handle.json new file mode 100644 index 000000000..4ccd5d272 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_duplicate_handle.json @@ -0,0 +1,31 @@ +{ + "description": "Two items return the same handle (different functions).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym-123", + "params": { + "config": "known-offender-matching-ssc" + } + }, + { + "fn_id": "age_check", + "return": "nym-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "duplicate_handle" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_function_id_not_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_function_id_not_string.json new file mode 100644 index 000000000..05eb7d0cb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_function_id_not_string.json @@ -0,0 +1,20 @@ +{ + "description": "Normative item whose id is not a string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": 7, + "return": "h" + } + ] + }, + "expect": { + "error": "item_missing_function_id" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_missing_function_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_missing_function_id.json new file mode 100644 index 000000000..dacec9600 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_missing_function_id.json @@ -0,0 +1,19 @@ +{ + "description": "Normative item with no id.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "return": "h" + } + ] + }, + "expect": { + "error": "item_missing_function_id" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_missing_handle.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_missing_handle.json new file mode 100644 index 000000000..85d82eaec --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_missing_handle.json @@ -0,0 +1,19 @@ +{ + "description": "Normative item with no return.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check" + } + ] + }, + "expect": { + "error": "item_missing_handle" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_not_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_not_object.json new file mode 100644 index 000000000..765de23cb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_not_object.json @@ -0,0 +1,17 @@ +{ + "description": "A req element that is not an object.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + "age_check" + ] + }, + "expect": { + "error": "item_missing_function_id" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_pre_v04_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_pre_v04_id.json new file mode 100644 index 000000000..aaf7ed3df --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_item_pre_v04_id.json @@ -0,0 +1,20 @@ +{ + "description": "The pre-v0.4 item member `id` is not `fn_id`.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "id": "age_check", + "return": "h" + } + ] + }, + "expect": { + "error": "item_missing_function_id" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_doctype_not_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_doctype_not_string.json new file mode 100644 index 000000000..e38ab8485 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_doctype_not_string.json @@ -0,0 +1,24 @@ +{ + "description": "meta.doctype_value is not a string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": 1 + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_missing.json new file mode 100644 index 000000000..8bd200c45 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_missing.json @@ -0,0 +1,20 @@ +{ + "description": "No meta.", + "query": { + "id": "q", + "format": "zkp_req", + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_not_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_not_object.json new file mode 100644 index 000000000..b630ca33d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_not_object.json @@ -0,0 +1,21 @@ +{ + "description": "meta is a string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": "x", + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_schema_id_not_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_schema_id_not_string.json new file mode 100644 index 000000000..a4d6bacbf --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_schema_id_not_string.json @@ -0,0 +1,23 @@ +{ + "description": "meta.schema_id_value is not a string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": 7 + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2008-10-10" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_source_format_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_source_format_missing.json new file mode 100644 index 000000000..8231da017 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_source_format_missing.json @@ -0,0 +1,22 @@ +{ + "description": "meta has a type value but no source_format (REQUIRED by rulebook v0.4).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2008-10-10" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_source_format_not_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_source_format_not_string.json new file mode 100644 index 000000000..9f386afd8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_source_format_not_string.json @@ -0,0 +1,23 @@ +{ + "description": "meta.source_format is a number.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": 1, + "doctype_value": "x" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2008-10-10" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_vct_values_not_strings.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_vct_values_not_strings.json new file mode 100644 index 000000000..78fbd7f44 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_meta_vct_values_not_strings.json @@ -0,0 +1,26 @@ +{ + "description": "meta.vct_values is not an array of strings.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "vct_values": [ + 1 + ] + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "invalid_meta" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_calendar.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_calendar.json new file mode 100644 index 000000000..06f1e2815 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_calendar.json @@ -0,0 +1,23 @@ +{ + "description": "2026-02-30 does not exist.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "le": "2026-02-30" + } + } + ] + }, + "expect": { + "error": "param_invalid_full_date" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_datetime.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_datetime.json new file mode 100644 index 000000000..77beb6d9e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_datetime.json @@ -0,0 +1,23 @@ +{ + "description": "A date-time is not a full-date.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "le": "2026-01-01T00:00:00Z" + } + } + ] + }, + "expect": { + "error": "param_invalid_full_date" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_month.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_month.json new file mode 100644 index 000000000..081772694 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_month.json @@ -0,0 +1,23 @@ +{ + "description": "Month 13.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "le": "2026-13-01" + } + } + ] + }, + "expect": { + "error": "param_invalid_full_date" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_not_leap.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_not_leap.json new file mode 100644 index 000000000..6153f147a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_not_leap.json @@ -0,0 +1,23 @@ +{ + "description": "2023-02-29 does not exist (not a leap year).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "le": "2023-02-29" + } + } + ] + }, + "expect": { + "error": "param_invalid_full_date" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_short_year.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_short_year.json new file mode 100644 index 000000000..1cd1bd6bd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_short_year.json @@ -0,0 +1,23 @@ +{ + "description": "ge has a two-digit year.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "08-10-10" + } + } + ] + }, + "expect": { + "error": "param_invalid_full_date" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_syntax.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_syntax.json new file mode 100644 index 000000000..eb20c88a4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_date_syntax.json @@ -0,0 +1,23 @@ +{ + "description": "ge is not YYYY-MM-DD.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "10/10/2008" + } + } + ] + }, + "expect": { + "error": "param_invalid_full_date" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_null.json new file mode 100644 index 000000000..4c0ea6c03 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_null.json @@ -0,0 +1,23 @@ +{ + "description": "A declared param that is JSON null is the wrong type, not absent.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "le": null + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_prf_not_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_prf_not_string.json new file mode 100644 index 000000000..b3f2e1182 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_prf_not_string.json @@ -0,0 +1,23 @@ +{ + "description": "prf is a number.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "prf": 1 + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_fraction.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_fraction.json new file mode 100644 index 000000000..02db0a96c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_fraction.json @@ -0,0 +1,23 @@ +{ + "description": "ver is a number with a fraction.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "ver": 1.5 + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_null.json new file mode 100644 index 000000000..7b95ed2dc --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_null.json @@ -0,0 +1,23 @@ +{ + "description": "ver is JSON null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "ver": null + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_string.json new file mode 100644 index 000000000..2ad8bb56f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_ver_string.json @@ -0,0 +1,23 @@ +{ + "description": "ver is the string \"1\", not an integer.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "ver": "1" + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_wrong_type_date.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_wrong_type_date.json new file mode 100644 index 000000000..6012a6058 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_wrong_type_date.json @@ -0,0 +1,23 @@ +{ + "description": "ge is a number.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": 20081010 + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_wrong_type_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_wrong_type_string.json new file mode 100644 index 000000000..a3a8d6939 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_param_wrong_type_string.json @@ -0,0 +1,23 @@ +{ + "description": "config is a number.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "config": 5 + } + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_params_not_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_params_not_object.json new file mode 100644 index 000000000..f920dcd01 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_params_not_object.json @@ -0,0 +1,23 @@ +{ + "description": "params is not an object.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": [ + "2008-10-10" + ] + } + ] + }, + "expect": { + "error": "param_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_query_not_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_query_not_object.json new file mode 100644 index 000000000..3df455376 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_query_not_object.json @@ -0,0 +1,10 @@ +{ + "description": "Valid JSON but not an object.", + "query": [ + 1, + 2 + ], + "expect": { + "error": "query_not_parseable" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_query_not_parseable.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_query_not_parseable.json new file mode 100644 index 000000000..3024ee2e5 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_query_not_parseable.json @@ -0,0 +1,7 @@ +{ + "description": "Not JSON.", + "query_text": "{not json", + "expect": { + "error": "query_not_parseable" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_empty.json new file mode 100644 index 000000000..782a35c6e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_empty.json @@ -0,0 +1,15 @@ +{ + "description": "req is an empty array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [] + }, + "expect": { + "error": "req_missing_or_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_missing.json new file mode 100644 index 000000000..523535883 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_missing.json @@ -0,0 +1,14 @@ +{ + "description": "No req and no claims._req.", + "query": { + "id": "q", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + } + }, + "expect": { + "error": "req_missing_or_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_not_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_not_array.json new file mode 100644 index 000000000..cbc46a616 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_not_array.json @@ -0,0 +1,17 @@ +{ + "description": "req is not an array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": { + "id": "age_check" + } + }, + "expect": { + "error": "req_missing_or_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_not_underscore.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_not_underscore.json new file mode 100644 index 000000000..b8c8869dd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_not_underscore.json @@ -0,0 +1,20 @@ +{ + "description": "The pre-v0.4 member name `req` is not `_req`: _req is missing.", + "query": { + "id": "q", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "d" + }, + "req": [ + { + "fn_id": "age_check", + "return": "h" + } + ] + }, + "expect": { + "error": "req_missing_or_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_sets.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_sets.json new file mode 100644 index 000000000..7421c9812 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/err_req_sets.json @@ -0,0 +1,29 @@ +{ + "description": "req_sets present.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "_req_sets": [ + [ + "a" + ] + ] + }, + "expect": { + "error": "req_sets_present" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_bbs_cd_source_format.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_bbs_cd_source_format.json new file mode 100644 index 000000000..5eaeafa72 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_bbs_cd_source_format.json @@ -0,0 +1,41 @@ +{ + "description": "bbs_cd (held, not implemented in wallets yet) plans like mso_mdoc: source_format and schema_id_value are read, hashdh-bls12381g1 is an opaque prf string. Nothing here may break when bbs_cd arrives.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "pseudonym", + "handle": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ], + "system_filter": {}, + "source_format": "bbs_cd", + "meta_doctype": null, + "meta_schema_id": "eu.europa.ec.eudi.nym_auth.bbs.1" + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_client_metadata_without_zkp_req.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_client_metadata_without_zkp_req.json new file mode 100644 index 000000000..4c09fc509 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_client_metadata_without_zkp_req.json @@ -0,0 +1,44 @@ +{ + "description": "client_metadata with no zkp_req entry: empty system filter.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "dc+sd-jwt": {} + } + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_leap_day.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_leap_day.json new file mode 100644 index 000000000..b454953b4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_leap_day.json @@ -0,0 +1,37 @@ +{ + "description": "2024-02-29 is a valid full-date.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2024-02-29" + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "h", + "params": { + "ge": "2024-02-29" + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_no_client_metadata.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_no_client_metadata.json new file mode 100644 index 000000000..6bf07bb9c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_no_client_metadata.json @@ -0,0 +1,39 @@ +{ + "description": "No client_metadata: empty system filter.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_normative_plain.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_normative_plain.json new file mode 100644 index 000000000..bd38a54d0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_normative_plain.json @@ -0,0 +1,76 @@ +{ + "description": "Rulebook example: normative req shape, plain vp_formats_supported arrays.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym-123", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "pseudonym", + "handle": "nym-123", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "function_id": "age_check", + "handle": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": { + "proof_systems": [ + "longfellow-libzk-v1" + ], + "system_ids": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + }, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_opaque_strings.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_opaque_strings.json new file mode 100644 index 000000000..b3514031e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_opaque_strings.json @@ -0,0 +1,39 @@ +{ + "description": "config and verifier_context are opaque: unknown and over-long values pass through unchanged.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "config": "no-such-config", + "verifier_context": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "pseudonym", + "handle": "h", + "params": { + "config": "no-such-config", + "verifier_context": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_params_omitted.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_params_omitted.json new file mode 100644 index 000000000..549783d98 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_params_omitted.json @@ -0,0 +1,32 @@ +{ + "description": "params is optional (rulebook: omission applies defaults).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h" + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "h", + "params": {} + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_satisfiability_not_judged.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_satisfiability_not_judged.json new file mode 100644 index 000000000..f4d079ee4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_satisfiability_not_judged.json @@ -0,0 +1,90 @@ +{ + "description": "Requests that are well-formed but unsatisfiable per the rulebook are NOT rejected by the planner (see ../zkp_req_unsat for the unsatisfiability rules): le before ge, a one-day span, no age bound, two age_check items, two pseudonym items, a pseudonym without params (no ver, prf or config), require_cryptographic_holder_binding false, and no doctype_value (vct_values only).", + "query": { + "id": "q", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "vct_values": [ + "urn:example" + ] + }, + "require_cryptographic_holder_binding": false, + "_req": [ + { + "fn_id": "age_check", + "return": "a1", + "params": { + "ge": "2013-10-09", + "le": "2008-10-10" + } + }, + { + "fn_id": "age_check", + "return": "a2", + "params": { + "ge": "2010-01-01", + "le": "2010-01-01" + } + }, + { + "fn_id": "age_check", + "return": "a3" + }, + { + "fn_id": "pseudonym", + "return": "p1" + }, + { + "fn_id": "pseudonym", + "return": "p2", + "params": { + "config": "known-offender-matching-ssc" + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "a1", + "params": { + "ge": "2013-10-09", + "le": "2008-10-10" + } + }, + { + "function_id": "age_check", + "handle": "a2", + "params": { + "ge": "2010-01-01", + "le": "2010-01-01" + } + }, + { + "function_id": "age_check", + "handle": "a3", + "params": {} + }, + { + "function_id": "pseudonym", + "handle": "p1", + "params": {} + }, + { + "function_id": "pseudonym", + "handle": "p2", + "params": { + "config": "known-offender-matching-ssc" + } + } + ], + "meta_doctype": null, + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_source_format_not_judged.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_source_format_not_judged.json new file mode 100644 index 000000000..fb5222113 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_source_format_not_judged.json @@ -0,0 +1,39 @@ +{ + "description": "A source_format this version does not define (dc+sd-jwt is reserved) still plans; it is unsatisfiable (see ../zkp_req_unsat), not malformed.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "dc+sd-jwt", + "vct_values": [ + "urn:example" + ] + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2008-10-10" + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "h", + "params": { + "ge": "2008-10-10" + } + } + ], + "system_filter": {}, + "source_format": "dc+sd-jwt", + "meta_doctype": null, + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_unknown_fields_passthrough.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_unknown_fields_passthrough.json new file mode 100644 index 000000000..413081a55 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_unknown_fields_passthrough.json @@ -0,0 +1,67 @@ +{ + "description": "Unknown members at the query, item and params level, and unknown zkp_req metadata members, do not reject; unknown params are kept in params, the rest in raw (raw == query, checked by the runner).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "h", + "params": { + "ge": "2008-10-10", + "future_param": [ + 1, + { + "a": null + } + ] + }, + "future_item_member": true + } + ], + "future_query_member": { + "x": 1 + } + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "p" + ], + "future_metadata": 1 + } + } + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "age_check", + "handle": "h", + "params": { + "ge": "2008-10-10", + "future_param": [ + 1, + { + "a": null + } + ] + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": { + "proof_systems": [ + "p" + ] + }, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_unknown_function.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_unknown_function.json new file mode 100644 index 000000000..f5496d897 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_unknown_function.json @@ -0,0 +1,39 @@ +{ + "description": "A function the table does not know is passed through with its params untouched (unsatisfiable for wallets, not malformed).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "future_fn", + "return": "h", + "params": { + "ge": "not a date", + "n": 1 + } + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "future_fn", + "handle": "h", + "params": { + "ge": "not a date", + "n": 1 + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_ver_large_integer_is_an_integer.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_ver_large_integer_is_an_integer.json new file mode 100644 index 000000000..5f12f9992 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_ver_large_integer_is_an_integer.json @@ -0,0 +1,46 @@ +{ + "description": "ver 2 and a huge integer are integers: the planner only judges the type; ver != 1 is unsatisfiable, not malformed.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "h", + "params": { + "ver": 2 + } + }, + { + "fn_id": "age_check", + "return": "a" + } + ] + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "pseudonym", + "handle": "h", + "params": { + "ver": 2 + } + }, + { + "function_id": "age_check", + "handle": "a", + "params": {} + } + ], + "system_filter": {}, + "source_format": "mso_mdoc", + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_verifier_id_type_is_an_extra_param.json b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_verifier_id_type_is_an_extra_param.json new file mode 100644 index 000000000..76ca16af1 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req/ok_verifier_id_type_is_an_extra_param.json @@ -0,0 +1,74 @@ +{ + "description": "verifier_id_type is no longer part of the rulebook request. If a verifier still sends it, it is an undeclared parameter: kept in params like any other, never interpreted, never an error.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym-123", + "params": { + "config": "known-offender-matching-ssc", + "verifier_id_type": "x509_san_dns" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "expect": { + "plan": { + "functions": [ + { + "function_id": "pseudonym", + "handle": "nym-123", + "params": { + "config": "known-offender-matching-ssc", + "verifier_id_type": "x509_san_dns" + } + }, + { + "function_id": "age_check", + "handle": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "meta_doctype": "eu.europa.ec.eudi.nym_auth.1", + "system_filter": { + "proof_systems": [ + "longfellow-libzk-v1" + ], + "system_ids": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + }, + "source_format": "mso_mdoc", + "meta_schema_id": null + } + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/README.md b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/README.md new file mode 100644 index 000000000..10be2087c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/README.md @@ -0,0 +1,57 @@ +# zkp_req response conformance vectors + +One JSON file per case. These are the shared conformance file for every +implementation that validates a `zkp_req` presentation (Rust here, as +`zkp_req_response::validate_response`; the Kotlin and Swift SDKs may reuse them +verbatim). Files named `ok_*` must be accepted; `err_*` must be refused with the +stated reason code. The normative text is +[`docs/zkp_req_response.md`](../../../../../docs/zkp_req_response.md). + +The set is generated by `scripts/gen_zkp_req_response_vectors.py` (hand-rolled +CBOR, deterministic; run it to regenerate, never edit a file by hand). + +```json +{ + "description": "what the case shows", + "query": { }, // the Credential Query the response answers (as in ../zkp_req) + "client_metadata": { }, // the request's client_metadata ({} offers nothing) + "vp_token": { }, // the vp_token object; each presentation is base64url(CBOR) + "vp_token_text": "{not json", // instead of `vp_token`: raw text (unparseable input, duplicate members) + "expect": { "valid": true } // or { "error": "" } +} +``` + +Exactly one of `vp_token` / `vp_token_text`. `query` and `client_metadata` are +the request-side inputs of the planner; every vector's request must plan. + +`vp_token` carries the presentations as unpadded base64url strings; decode them +to read the CBOR. Vectors that need malformed CBOR (indefinite lengths, +trailing bytes, truncation, duplicate keys) carry the raw bytes the same way. + +## `expect.error` + +A stable code, not message text. The first rule broken is reported, in the +order given in the spec's "Order of checks": + +`response_not_parseable`, `duplicate_member`, `query_id_missing`, +`presentations_not_array`, `presentations_empty`, +`presentations_multiple_not_allowed`, `presentation_not_string`, +`presentation_not_base64url`, `cbor_malformed`, `cbor_indefinite_length`, +`cbor_trailing_bytes`, `cbor_duplicate_key`, `presentation_not_map`, +`presentation_key_not_text`, `presentation_unknown_member`, +`presentation_member_missing`, `presentation_member_wrong_type`, +`system_id_not_offered`, `proof_empty`, `issuer_key_set_empty`, +`issuer_key_set_too_many`, `issuer_key_not_bytes`, `issuer_key_empty`, +`issuer_key_too_large`, `proof_time_missing`, `proof_time_not_tdate`, +`proof_time_form`, `result_key_not_text`, `result_handle_unexpected`, +`result_handle_missing`, `result_function_unknown`, `result_wrong_type`, +`result_not_satisfied`, `pseudonym_prf_unknown`, `pseudonym_wrong_length`. + +## Scope + +Structure and the request/response binding only. The proof is not verified; +`issuer_key_set` is checked for limits (8 elements, 4096 bytes each), +certificates are not parsed. The `proof_time` tolerance check is the verifier's +(`proof_time::check_tolerance`, vectors in `../proof_time/`). No vector depends +on how `verifier_input` is formed: results are opaque bytes of the right +length. diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_break_outside.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_break_outside.json new file mode 100644 index 000000000..c8b445ae0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_break_outside.json @@ -0,0 +1,50 @@ +{ + "description": "A stray break byte.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "_w" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_handle.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_handle.json new file mode 100644 index 000000000..b5543f11d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_handle.json @@ -0,0 +1,50 @@ +{ + "description": "results repeats a handle.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOjY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9jbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "cbor_duplicate_key" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_key_same_value_different_form.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_key_same_value_different_form.json new file mode 100644 index 000000000..259529511 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_key_same_value_different_form.json @@ -0,0 +1,50 @@ +{ + "description": "A repeated key is refused even when both values are identical.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOjbWFnZS1yYW5nZS0xMjP1bWFnZS1yYW5nZS0xMjP1Y255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + ] + }, + "expect": { + "error": "cbor_duplicate_key" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_member.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_member.json new file mode 100644 index 000000000..4a5532209 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_duplicate_member.json @@ -0,0 +1,50 @@ +{ + "description": "The presentation map repeats `proof`.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH2Vwcm9vZlggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9uaXNzdWVyX2tleV9zZXSBWEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P2pwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "cbor_duplicate_key" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_empty.json new file mode 100644 index 000000000..81d940a44 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_empty.json @@ -0,0 +1,50 @@ +{ + "description": "Zero bytes.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_impossible_length.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_impossible_length.json new file mode 100644 index 000000000..9c9780028 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_impossible_length.json @@ -0,0 +1,50 @@ +{ + "description": "An array claims 2^32 elements; refused without allocating.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "mv____8" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_array.json new file mode 100644 index 000000000..23bbb0a62 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_array.json @@ -0,0 +1,50 @@ +{ + "description": "issuer_key_set as an indefinite-length array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldJ9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4__2pwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "cbor_indefinite_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_bstr.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_bstr.json new file mode 100644 index 000000000..88991626a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_bstr.json @@ -0,0 +1,50 @@ +{ + "description": "proof as an indefinite-length byte string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mX1AAAQIDBAUGBwgJCgsMDQ4PUBAREhMUFRYXGBkaGxwdHh__bmlzc3Vlcl9rZXlfc2V0gVhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "cbor_indefinite_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_map.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_map.json new file mode 100644 index 000000000..7f76c5d14 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_map.json @@ -0,0 +1,50 @@ +{ + "description": "An indefinite-length map (0xbf ... 0xff).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "v2lzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_X_" + ] + }, + "expect": { + "error": "cbor_indefinite_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_tstr.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_tstr.json new file mode 100644 index 000000000..8e61098ee --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_indefinite_tstr.json @@ -0,0 +1,50 @@ +{ + "description": "A result handle as an indefinite-length text string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOif2NueW1g_1ggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "cbor_indefinite_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_invalid_utf8.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_invalid_utf8.json new file mode 100644 index 000000000..84824838c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_invalid_utf8.json @@ -0,0 +1,50 @@ +{ + "description": "A text-string key that is not UTF-8.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "oWL__vU" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_reserved_info.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_reserved_info.json new file mode 100644 index 000000000..0c7bed82b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_reserved_info.json @@ -0,0 +1,50 @@ +{ + "description": "Additional information 28 is reserved.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "HA" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_too_deep.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_too_deep.json new file mode 100644 index 000000000..04b214a88 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_too_deep.json @@ -0,0 +1,50 @@ +{ + "description": "Seventeen levels of nesting.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "gYGBgYGBgYGBgYGBgYGBgYEA" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_trailing_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_trailing_bytes.json new file mode 100644 index 000000000..f1064e7da --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_trailing_bytes.json @@ -0,0 +1,50 @@ +{ + "description": "One extra byte after the map.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_UA" + ] + }, + "expect": { + "error": "cbor_trailing_bytes" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_truncated.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_truncated.json new file mode 100644 index 000000000..65ac42957 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_truncated.json @@ -0,0 +1,50 @@ +{ + "description": "The map is cut off.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTE" + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_two_items.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_two_items.json new file mode 100644 index 000000000..419be35f8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_cbor_two_items.json @@ -0,0 +1,50 @@ +{ + "description": "Two concatenated presentations.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_WlaXN5c3RlbV9pZHgjdXJuOmNpcmN1aXRzOmxvbmdmZWxsb3c6bnltX2F1dGg6djFlcHJvb2ZYIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbmlzc3Vlcl9rZXlfc2V0gVhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "cbor_trailing_bytes" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_duplicate_member.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_duplicate_member.json new file mode 100644 index 000000000..2a142b56a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_duplicate_member.json @@ -0,0 +1,46 @@ +{ + "description": "The vp_token object repeats a member name (raw text: a parsed object cannot hold it).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token_text": "{\"nym_attestation\":[\"AA\"],\"nym_attestation\":[\"AA\"]}", + "expect": { + "error": "duplicate_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_empty.json new file mode 100644 index 000000000..366b05d4a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_empty.json @@ -0,0 +1,50 @@ +{ + "description": "A zero-length element.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFAanByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "issuer_key_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_not_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_not_bytes.json new file mode 100644 index 000000000..5aefcb8dd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_not_bytes.json @@ -0,0 +1,50 @@ +{ + "description": "An element is a text string (a base64 certificate, as in the pre-v0.4 x5chain).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFkTUlJQmpwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "issuer_key_not_bytes" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_null.json new file mode 100644 index 000000000..889c471e8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_null.json @@ -0,0 +1,50 @@ +{ + "description": "An element is CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIH2anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "issuer_key_not_bytes" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_second_not_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_second_not_bytes.json new file mode 100644 index 000000000..600d2baea --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_second_not_bytes.json @@ -0,0 +1,50 @@ +{ + "description": "Elements are checked in order: the second is bad.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIJYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_AWpwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "issuer_key_not_bytes" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_set_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_set_empty.json new file mode 100644 index 000000000..0322a24b0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_set_empty.json @@ -0,0 +1,50 @@ +{ + "description": "issuer_key_set is an empty array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIBqcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "issuer_key_set_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_set_too_many.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_set_too_many.json new file mode 100644 index 000000000..091d65cb6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_set_too_many.json @@ -0,0 +1,50 @@ +{ + "description": "Nine certificates.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIlYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "issuer_key_set_too_many" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_too_large.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_too_large.json new file mode 100644 index 000000000..297191b11 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_issuer_key_too_large.json @@ -0,0 +1,50 @@ +{ + "description": "An element of 4097 bytes.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFZEAEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMGpwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "issuer_key_too_large" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_issuer_key_set.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_issuer_key_set.json new file mode 100644 index 000000000..35a51a200 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_issuer_key_set.json @@ -0,0 +1,50 @@ +{ + "description": "`issuer_key_set` absent.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH2pwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "presentation_member_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_proof.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_proof.json new file mode 100644 index 000000000..13cd9eca2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_proof.json @@ -0,0 +1,50 @@ +{ + "description": "`proof` absent.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxbmlzc3Vlcl9rZXlfc2V0gVhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "presentation_member_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_results.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_results.json new file mode 100644 index 000000000..714fe8ae0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_results.json @@ -0,0 +1,50 @@ +{ + "description": "`results` absent.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBa" + ] + }, + "expect": { + "error": "presentation_member_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_system_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_system_id.json new file mode 100644 index 000000000..e83003f66 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_member_missing_system_id.json @@ -0,0 +1,50 @@ +{ + "description": "`system_id` absent.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGVwcm9vZlggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9uaXNzdWVyX2tleV9zZXSBWEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P2pwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "presentation_member_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_multiple_false.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_multiple_false.json new file mode 100644 index 000000000..e74931f8b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_multiple_false.json @@ -0,0 +1,52 @@ +{ + "description": "Two presentations with multiple: false.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "multiple": false + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U", + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentations_multiple_not_allowed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_multiple_not_allowed.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_multiple_not_allowed.json new file mode 100644 index 000000000..a6170114c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_multiple_not_allowed.json @@ -0,0 +1,51 @@ +{ + "description": "Two presentations without multiple: true.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U", + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentations_multiple_not_allowed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_not_json.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_not_json.json new file mode 100644 index 000000000..590ecd17e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_not_json.json @@ -0,0 +1,46 @@ +{ + "description": "vp_token is not JSON.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token_text": "{not json", + "expect": { + "error": "response_not_parseable" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_not_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_not_object.json new file mode 100644 index 000000000..849dd2b40 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_not_object.json @@ -0,0 +1,46 @@ +{ + "description": "vp_token is a JSON array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token_text": "[]", + "expect": { + "error": "response_not_parseable" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_issuer_key_set.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_issuer_key_set.json new file mode 100644 index 000000000..a9c80b63b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_issuer_key_set.json @@ -0,0 +1,50 @@ +{ + "description": "issuer_key_set is CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldPZqcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_presentation.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_presentation.json new file mode 100644 index 000000000..814dbc11c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_presentation.json @@ -0,0 +1,50 @@ +{ + "description": "An element is JSON null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + null + ] + }, + "expect": { + "error": "presentation_not_string" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_proof.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_proof.json new file mode 100644 index 000000000..a370dee32 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_proof.json @@ -0,0 +1,50 @@ +{ + "description": "proof is CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29m9m5pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_query_entry.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_query_entry.json new file mode 100644 index 000000000..830efde3c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_query_entry.json @@ -0,0 +1,48 @@ +{ + "description": "The entry is null: a type error, not absence.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": null + }, + "expect": { + "error": "presentations_not_array" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_results.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_results.json new file mode 100644 index 000000000..06f9ffb0e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_results.json @@ -0,0 +1,50 @@ +{ + "description": "results is CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHP2" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_system_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_system_id.json new file mode 100644 index 000000000..b4862e97b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_system_id.json @@ -0,0 +1,50 @@ +{ + "description": "system_id is CBOR null: wrong type, not absent.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWT2ZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_vp_token.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_vp_token.json new file mode 100644 index 000000000..3ab65c175 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_null_vp_token.json @@ -0,0 +1,46 @@ +{ + "description": "vp_token is JSON null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token_text": "null", + "expect": { + "error": "response_not_parseable" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_envelope_before_presentation.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_envelope_before_presentation.json new file mode 100644 index 000000000..c32f3a7bb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_envelope_before_presentation.json @@ -0,0 +1,51 @@ +{ + "description": "Two presentations, the first malformed: the count is refused before either is read.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "", + "" + ] + }, + "expect": { + "error": "presentations_multiple_not_allowed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_first_presentation_first.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_first_presentation_first.json new file mode 100644 index 000000000..de6d9ae60 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_first_presentation_first.json @@ -0,0 +1,52 @@ +{ + "description": "Presentation 0 is malformed and presentation 1 is also bad: presentation 0 is reported.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "multiple": true + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "", + 5 + ] + }, + "expect": { + "error": "cbor_malformed" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_handles_before_values.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_handles_before_values.json new file mode 100644 index 000000000..4d084acf3 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_handles_before_values.json @@ -0,0 +1,50 @@ +{ + "description": "A missing handle and a bad value: handles first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhY255bWNiYWQ" + ] + }, + "expect": { + "error": "result_handle_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_issuer_key_count_before_element.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_issuer_key_count_before_element.json new file mode 100644 index 000000000..b2df1a51c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_issuer_key_count_before_element.json @@ -0,0 +1,50 @@ +{ + "description": "Nine elements, the first not bytes: the count is checked first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIlheFhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "issuer_key_set_too_many" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_issuer_key_set_before_proof_time.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_issuer_key_set_before_proof_time.json new file mode 100644 index 000000000..fd14072bd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_issuer_key_set_before_proof_time.json @@ -0,0 +1,50 @@ +{ + "description": "An empty issuer_key_set and a missing proof_time: key set first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIBncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "issuer_key_set_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_missing_before_wrong_type.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_missing_before_wrong_type.json new file mode 100644 index 000000000..e5235d010 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_missing_before_wrong_type.json @@ -0,0 +1,50 @@ +{ + "description": "A missing member and a wrong type: missing first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWT2ZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBa" + ] + }, + "expect": { + "error": "presentation_member_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_offer_before_proof_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_offer_before_proof_empty.json new file mode 100644 index 000000000..19975d564 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_offer_before_proof_empty.json @@ -0,0 +1,50 @@ +{ + "description": "An unoffered system_id and an empty proof: offer first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWRpdXJuOm90aGVyZXByb29mQG5pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "system_id_not_offered" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_proof_empty_before_issuer_key_set.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_proof_empty_before_issuer_key_set.json new file mode 100644 index 000000000..b2c9a84d8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_proof_empty_before_issuer_key_set.json @@ -0,0 +1,50 @@ +{ + "description": "An empty proof and an empty issuer_key_set: proof first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mQG5pc3N1ZXJfa2V5X3NldIBqcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "proof_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_proof_time_before_handles.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_proof_time_before_handles.json new file mode 100644 index 000000000..c2fdc742c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_proof_time_before_handles.json @@ -0,0 +1,50 @@ +{ + "description": "A missing proof_time and a missing handle: proof_time first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_Z3Jlc3VsdHOhbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "proof_time_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_unexpected_before_missing_handle.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_unexpected_before_missing_handle.json new file mode 100644 index 000000000..6e50d9f72 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_unexpected_before_missing_handle.json @@ -0,0 +1,50 @@ +{ + "description": "An extra handle and a missing handle: unexpected first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY3p6evVtYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "result_handle_unexpected" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_unknown_member_before_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_unknown_member_before_missing.json new file mode 100644 index 000000000..f1529ff84 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_unknown_member_before_missing.json @@ -0,0 +1,50 @@ +{ + "description": "An unknown member and a missing member: unknown first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxbmlzc3Vlcl9rZXlfc2V0gVhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9WN6enr2" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_values_in_request_order.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_values_in_request_order.json new file mode 100644 index 000000000..3e597fe7b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_values_in_request_order.json @@ -0,0 +1,50 @@ +{ + "description": "Both values are bad: the first request item (the pseudonym) is reported.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bWF4bWFnZS1yYW5nZS0xMjP0" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_values_in_request_order_reversed.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_values_in_request_order_reversed.json new file mode 100644 index 000000000..90a9473d2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_values_in_request_order_reversed.json @@ -0,0 +1,50 @@ +{ + "description": "The same bad results with the age item first in the request: the age item is reported.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + }, + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bWF4bWFnZS1yYW5nZS0xMjP0" + ] + }, + "expect": { + "error": "result_not_satisfied" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_wrong_type_before_offer.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_wrong_type_before_offer.json new file mode 100644 index 000000000..4f872809f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_order_wrong_type_before_offer.json @@ -0,0 +1,50 @@ +{ + "description": "A wrong-typed proof and an unoffered system_id: type first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWRpdXJuOm90aGVyZXByb29mYXhuaXNzdWVyX2tleV9zZXSBWEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P2pwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_false.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_false.json new file mode 100644 index 000000000..ada37b4b3 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_false.json @@ -0,0 +1,50 @@ +{ + "description": "false is never a valid result: an unsatisfiable predicate is a decline.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_Q" + ] + }, + "expect": { + "error": "result_not_satisfied" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_null.json new file mode 100644 index 000000000..e65bf86a3 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_null.json @@ -0,0 +1,50 @@ +{ + "description": "A predicate result of CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_Y" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_object.json new file mode 100644 index 000000000..0577b8a39 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_object.json @@ -0,0 +1,50 @@ +{ + "description": "The pre-v0.4 shape {\"satisfied\": true}.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM6Fpc2F0aXNmaWVk9Q" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_text.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_text.json new file mode 100644 index 000000000..09bd68f85 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_text.json @@ -0,0 +1,50 @@ +{ + "description": "A predicate result of the text \"true\".", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM2R0cnVl" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_uint.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_uint.json new file mode 100644 index 000000000..aae49724c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_predicate_uint.json @@ -0,0 +1,50 @@ +{ + "description": "A predicate result of 1.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyMwE" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_array.json new file mode 100644 index 000000000..f36f2dc41 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_array.json @@ -0,0 +1,50 @@ +{ + "description": "The presentation is a CBOR array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "gXgjdXJuOmNpcmN1aXRzOmxvbmdmZWxsb3c6bnltX2F1dGg6djE" + ] + }, + "expect": { + "error": "presentation_not_map" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_key_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_key_bytes.json new file mode 100644 index 000000000..965c73776 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_key_bytes.json @@ -0,0 +1,50 @@ +{ + "description": "A presentation key is a byte string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "oUlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYx" + ] + }, + "expect": { + "error": "presentation_key_not_text" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_key_uint.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_key_uint.json new file mode 100644 index 000000000..0884ebeb0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_key_uint.json @@ -0,0 +1,50 @@ +{ + "description": "A presentation key is an integer.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "oQF4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYx" + ] + }, + "expect": { + "error": "presentation_key_not_text" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_noncanonical_final_char.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_noncanonical_final_char.json new file mode 100644 index 000000000..9f7c25c35 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_noncanonical_final_char.json @@ -0,0 +1,50 @@ +{ + "description": "Non-zero trailing bits in the last character.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_V" + ] + }, + "expect": { + "error": "presentation_not_base64url" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_not_string.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_not_string.json new file mode 100644 index 000000000..b9d32bd18 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_not_string.json @@ -0,0 +1,50 @@ +{ + "description": "An element is a number.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + 1 + ] + }, + "expect": { + "error": "presentation_not_string" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_null.json new file mode 100644 index 000000000..c123b504b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_null.json @@ -0,0 +1,50 @@ +{ + "description": "The presentation is CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "9g" + ] + }, + "expect": { + "error": "presentation_not_map" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_object.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_object.json new file mode 100644 index 000000000..203ff0c39 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_object.json @@ -0,0 +1,52 @@ +{ + "description": "An element is a JSON object (the pre-v0.4 JSON presentation).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + { + "circuit_id": "urn:circuits:longfellow:nym_auth:v1" + } + ] + }, + "expect": { + "error": "presentation_not_string" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_padded.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_padded.json new file mode 100644 index 000000000..f62b189e6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_padded.json @@ -0,0 +1,50 @@ +{ + "description": "Padding is not allowed.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U=" + ] + }, + "expect": { + "error": "presentation_not_base64url" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_standard_alphabet.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_standard_alphabet.json new file mode 100644 index 000000000..fe3cbb39f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_standard_alphabet.json @@ -0,0 +1,50 @@ +{ + "description": "Standard alphabet: '+' or '/' instead of '-' or '_'.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW+faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentation_not_base64url" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_text.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_text.json new file mode 100644 index 000000000..283ed2c3a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_text.json @@ -0,0 +1,50 @@ +{ + "description": "The presentation is a text string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "YXg" + ] + }, + "expect": { + "error": "presentation_not_map" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_whitespace.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_whitespace.json new file mode 100644 index 000000000..b6cf93422 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentation_whitespace.json @@ -0,0 +1,50 @@ +{ + "description": "Embedded whitespace.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0 ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentation_not_base64url" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentations_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentations_empty.json new file mode 100644 index 000000000..2248b354d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentations_empty.json @@ -0,0 +1,48 @@ +{ + "description": "An empty array is a decline in disguise.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [] + }, + "expect": { + "error": "presentations_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentations_not_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentations_not_array.json new file mode 100644 index 000000000..a0a420795 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_presentations_not_array.json @@ -0,0 +1,48 @@ +{ + "description": "The entry is an object.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": {} + }, + "expect": { + "error": "presentations_not_array" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_empty.json new file mode 100644 index 000000000..e4a8efdfe --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_empty.json @@ -0,0 +1,50 @@ +{ + "description": "proof is a zero-length byte string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mQG5pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_empty" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_century_not_leap.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_century_not_leap.json new file mode 100644 index 000000000..b7c745d7d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_century_not_leap.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: 29 February 2100 (not a leap year).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIxMDAtMDItMjlUMDA6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_date_only.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_date_only.json new file mode 100644 index 000000000..a0e8b483c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_date_only.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a date without a time.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAajIwMjYtMTAtMjBncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_empty.json new file mode 100644 index 000000000..5cf82bcf6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_empty.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: an empty string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAYGdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_epoch_tag.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_epoch_tag.json new file mode 100644 index 000000000..1ad4f246d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_epoch_tag.json @@ -0,0 +1,50 @@ +{ + "description": "Tag 1 (epoch time) is not tdate.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXBGmj2JEBncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "proof_time_not_tdate" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_fraction.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_fraction.json new file mode 100644 index 000000000..4e8500ecb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_fraction.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: fractional seconds.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAeBgyMDI2LTEwLTIwVDEyOjAwOjAwLjAwMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_hour_24.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_hour_24.json new file mode 100644 index 000000000..56b698717 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_hour_24.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: hour 24.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMjQ6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_lowercase_t.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_lowercase_t.json new file mode 100644 index 000000000..06fa2c88a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_lowercase_t.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a lowercase t.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjB0MTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_lowercase_z.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_lowercase_z.json new file mode 100644 index 000000000..9792cb047 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_lowercase_z.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a lowercase z.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDB6Z3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_missing.json new file mode 100644 index 000000000..5e0353cdd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_missing.json @@ -0,0 +1,50 @@ +{ + "description": "mso_mdoc circuits check validity: proof_time is required.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_Z3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_month_13.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_month_13.json new file mode 100644 index 000000000..b11bc5e76 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_month_13.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: month 13.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTMtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_no_seconds.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_no_seconds.json new file mode 100644 index 000000000..4263b29fc --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_no_seconds.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: no seconds (RFC 3339 requires them; 17 bytes).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAcTIwMjYtMTAtMjBUMTI6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_non_ascii_digit.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_non_ascii_digit.json new file mode 100644 index 000000000..aa2b5a6fa --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_non_ascii_digit.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a non-ASCII digit.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdTIwMjYtMTAtMjBUMTI6MDA6MNmgWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_not_leap_day.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_not_leap_day.json new file mode 100644 index 000000000..de398c49f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_not_leap_day.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: 29 February in a non-leap year.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMDItMjlUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_null.json new file mode 100644 index 000000000..57cf64736 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_null.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is CBOR null: a type error, not absence.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWX2Z3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_not_tdate" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_offset.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_offset.json new file mode 100644 index 000000000..b19e5d2f2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_offset.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a numeric offset instead of Z.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAeBkyMDI2LTEwLTIwVDEyOjAwOjAwKzAwOjAwZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_plain_text.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_plain_text.json new file mode 100644 index 000000000..18b9de087 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_plain_text.json @@ -0,0 +1,50 @@ +{ + "description": "A text string without tag 0.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWV0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "proof_time_not_tdate" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_plain_uint.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_plain_uint.json new file mode 100644 index 000000000..6ef82ff30 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_plain_uint.json @@ -0,0 +1,50 @@ +{ + "description": "An integer.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWUaaPYkQGdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "proof_time_not_tdate" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_second_60.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_second_60.json new file mode 100644 index 000000000..a62a3fce4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_second_60.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a leap second (the circuit input has none).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMjM6NTk6NjBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_short_year.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_short_year.json new file mode 100644 index 000000000..abe6f1a4d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_short_year.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a two-digit year.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAcjI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_space.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_space.json new file mode 100644 index 000000000..2f3d0ac53 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_space.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a space instead of T.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjAgMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_tag0_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_tag0_bytes.json new file mode 100644 index 000000000..4ee58a0a6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_tag0_bytes.json @@ -0,0 +1,50 @@ +{ + "description": "Tag 0 around a byte string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAVDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "proof_time_not_tdate" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_trailing_space.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_trailing_space.json new file mode 100644 index 000000000..097dc0eb0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_proof_time_trailing_space.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time is a tdate but not the exact 20-byte form: a trailing space (21 bytes).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdTIwMjYtMTAtMjBUMTI6MDA6MDBaIGdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "proof_time_form" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_hashdh_wrong_length.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_hashdh_wrong_length.json new file mode 100644 index 000000000..eb9adc1e6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_hashdh_wrong_length.json @@ -0,0 +1,42 @@ +{ + "description": "prf hashdh-bls12381g1 sets 48 bytes; 32 is refused.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:mms:bbs-cd:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4GnVybjptbXM6YmJzLWNkOm55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_Z3Jlc3VsdHOhY255bVggAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_0.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_0.json new file mode 100644 index 000000000..0a2637427 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_0.json @@ -0,0 +1,50 @@ +{ + "description": "A 0-byte pseudonym where prf sha-256 sets 32.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bUBtYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_16.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_16.json new file mode 100644 index 000000000..ebcfa834c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_16.json @@ -0,0 +1,50 @@ +{ + "description": "A 16-byte pseudonym where prf sha-256 sets 32.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVAAAAAAAAAAAAAAAAAAAAAAbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_31.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_31.json new file mode 100644 index 000000000..47e22c795 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_31.json @@ -0,0 +1,50 @@ +{ + "description": "A 31-byte pseudonym where prf sha-256 sets 32.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVgfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG1hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_33.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_33.json new file mode 100644 index 000000000..197843230 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_33.json @@ -0,0 +1,50 @@ +{ + "description": "A 33-byte pseudonym where prf sha-256 sets 32.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVghAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_48.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_48.json new file mode 100644 index 000000000..c7ebf7308 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_48.json @@ -0,0 +1,50 @@ +{ + "description": "A 48-byte pseudonym where prf sha-256 sets 32.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVgwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_64.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_64.json new file mode 100644 index 000000000..4cd9fd1e6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_length_64.json @@ -0,0 +1,50 @@ +{ + "description": "A 64-byte pseudonym where prf sha-256 sets 32.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG1hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "pseudonym_wrong_length" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_null.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_null.json new file mode 100644 index 000000000..d1391d2a4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_null.json @@ -0,0 +1,50 @@ +{ + "description": "A pseudonym of CBOR null.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bfZtYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_prf_unknown.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_prf_unknown.json new file mode 100644 index 000000000..f74d2328a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_prf_unknown.json @@ -0,0 +1,40 @@ +{ + "description": "The request's pseudonym item has no known prf, so no length can be demanded: refused.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + ] + }, + "expect": { + "error": "pseudonym_prf_unknown" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_text.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_text.json new file mode 100644 index 000000000..2ecc417a2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_text.json @@ -0,0 +1,50 @@ +{ + "description": "A pseudonym as a hex text string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bXhAMDAwMTAyMDMwNDA1MDYwNzA4MDkwYTBiMGMwZDBlMGYxMDExMTIxMzE0MTUxNjE3MTgxOTFhMWIxYzFkMWUxZm1hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_true.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_true.json new file mode 100644 index 000000000..d65d3546f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_pseudonym_true.json @@ -0,0 +1,50 @@ +{ + "description": "A pseudonym of true.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bfVtYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "result_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_query_id_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_query_id_missing.json new file mode 100644 index 000000000..468986481 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_query_id_missing.json @@ -0,0 +1,50 @@ +{ + "description": "No entry for the query id.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "other": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "query_id_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_empty.json new file mode 100644 index 000000000..1b2a8c62d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_empty.json @@ -0,0 +1,50 @@ +{ + "description": "results is an empty map (the rulebook requires at least one entry).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOg" + ] + }, + "expect": { + "error": "result_handle_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_function_unknown.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_function_unknown.json new file mode 100644 index 000000000..a28452c9c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_function_unknown.json @@ -0,0 +1,37 @@ +{ + "description": "The request has a function this validator has no result shape for.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "no-such-fn", + "return": "x" + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhYXj1" + ] + }, + "expect": { + "error": "result_function_unknown" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_handle_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_handle_missing.json new file mode 100644 index 000000000..fc8c15a1d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_handle_missing.json @@ -0,0 +1,50 @@ +{ + "description": "A returned handle has no result.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + ] + }, + "expect": { + "error": "result_handle_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_handle_unexpected.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_handle_unexpected.json new file mode 100644 index 000000000..2faccdd16 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_handle_unexpected.json @@ -0,0 +1,50 @@ +{ + "description": "A handle the request did not return.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOjY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VlZXh0cmH1" + ] + }, + "expect": { + "error": "result_handle_unexpected" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_key_not_text.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_key_not_text.json new file mode 100644 index 000000000..4a2a721b9 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_key_not_text.json @@ -0,0 +1,50 @@ +{ + "description": "A results key is an integer.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiAVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "result_key_not_text" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_of_other_function.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_of_other_function.json new file mode 100644 index 000000000..d7a91a4c7 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_result_of_other_function.json @@ -0,0 +1,50 @@ +{ + "description": "Result keyed by the function id instead of the handle.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiaXBzZXVkb255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9pYWdlX2NoZWNr9Q" + ] + }, + "expect": { + "error": "result_handle_unexpected" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_second_presentation_bad.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_second_presentation_bad.json new file mode 100644 index 000000000..46ba0f3a2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_second_presentation_bad.json @@ -0,0 +1,52 @@ +{ + "description": "Every presentation is checked, not only the first.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "multiple": true + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U", + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + ] + }, + "expect": { + "error": "result_handle_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_empty_offer_list.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_empty_offer_list.json new file mode 100644 index 000000000..6eba56be6 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_empty_offer_list.json @@ -0,0 +1,48 @@ +{ + "description": "An empty system_id_values offers nothing.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "system_id_not_offered" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_no_offer_list.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_no_offer_list.json new file mode 100644 index 000000000..6bcd912ab --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_no_offer_list.json @@ -0,0 +1,39 @@ +{ + "description": "The request stated no system_id_values: nothing was offered, fail closed.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": {}, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "system_id_not_offered" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_not_offered.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_not_offered.json new file mode 100644 index 000000000..a28aac4e0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_system_id_not_offered.json @@ -0,0 +1,50 @@ +{ + "description": "system_id is not in system_id_values.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR1dXJuOmNpcmN1aXRzOm90aGVyOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "system_id_not_offered" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_circuit_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_circuit_id.json new file mode 100644 index 000000000..4db516a18 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_circuit_id.json @@ -0,0 +1,50 @@ +{ + "description": "A member `circuit_id` outside the defined five (pre-v0.4 JSON member).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VqY2lyY3VpdF9pZGF4" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_error.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_error.json new file mode 100644 index 000000000..100c67d9f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_error.json @@ -0,0 +1,50 @@ +{ + "description": "A member `error` outside the defined five; an in-band error is no different from any other unknown member.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VlZXJyb3JheA" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_error_description.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_error_description.json new file mode 100644 index 000000000..a4e48154f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_error_description.json @@ -0,0 +1,50 @@ +{ + "description": "A member `error_description` outside the defined five; an in-band error is no different from any other unknown member.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VxZXJyb3JfZGVzY3JpcHRpb25heA" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_extra.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_extra.json new file mode 100644 index 000000000..70d3f4dc1 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_extra.json @@ -0,0 +1,50 @@ +{ + "description": "A member `extra` outside the defined five.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VlZXh0cmFheA" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_proof_system.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_proof_system.json new file mode 100644 index 000000000..0acbb68c2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_proof_system.json @@ -0,0 +1,50 @@ +{ + "description": "A member `proof_system` outside the defined five (pre-v0.4 JSON member).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VscHJvb2Zfc3lzdGVtYXg" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_x5chain.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_x5chain.json new file mode 100644 index 000000000..cab2aad6f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_unknown_member_x5chain.json @@ -0,0 +1,50 @@ +{ + "description": "A member `x5chain` outside the defined five (pre-v0.4 JSON member).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pmlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_VneDVjaGFpbmF4" + ] + }, + "expect": { + "error": "presentation_unknown_member" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_issuer_key_set_bstr.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_issuer_key_set_bstr.json new file mode 100644 index 000000000..288ad3b1c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_issuer_key_set_bstr.json @@ -0,0 +1,50 @@ +{ + "description": "issuer_key_set is a single byte string, not an array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldFhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_issuer_key_set_map.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_issuer_key_set_map.json new file mode 100644 index 000000000..d2241e035 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_issuer_key_set_map.json @@ -0,0 +1,50 @@ +{ + "description": "issuer_key_set is a map.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldKBqcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_proof_text.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_proof_text.json new file mode 100644 index 000000000..1cdfdf678 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_proof_text.json @@ -0,0 +1,50 @@ +{ + "description": "proof is a text string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mZXByb29mbmlzc3Vlcl9rZXlfc2V0gVhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9qcHJvb2ZfdGltZcB0MjAyNi0xMC0yMFQxMjowMDowMFpncmVzdWx0c6JjbnltWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH21hZ2UtcmFuZ2UtMTIz9Q" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_results_array.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_results_array.json new file mode 100644 index 000000000..55d0e8b24 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_results_array.json @@ -0,0 +1,50 @@ +{ + "description": "results is an array.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOA" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_system_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_system_id.json new file mode 100644 index 000000000..b2e0d58c8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/err_wrong_type_system_id.json @@ -0,0 +1,50 @@ +{ + "description": "system_id is a byte string.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWRYI3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "error": "presentation_member_wrong_type" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_age_check_only.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_age_check_only.json new file mode 100644 index 000000000..f5180fd12 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_age_check_only.json @@ -0,0 +1,41 @@ +{ + "description": "Only an age predicate requested.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_bbs_cd_no_proof_time.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_bbs_cd_no_proof_time.json new file mode 100644 index 000000000..d5e0d58a4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_bbs_cd_no_proof_time.json @@ -0,0 +1,42 @@ +{ + "description": "bbs_cd (held): a 48-byte pseudonym (prf hashdh-bls12381g1) and no proof_time (the encoding carries no validity period).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:mms:bbs-cd:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pGlzeXN0ZW1faWR4GnVybjptbXM6YmJzLWNkOm55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_Z3Jlc3VsdHOhY255bVgwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_bbs_cd_with_proof_time.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_bbs_cd_with_proof_time.json new file mode 100644 index 000000000..be2ed9818 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_bbs_cd_with_proof_time.json @@ -0,0 +1,42 @@ +{ + "description": "bbs_cd: proof_time is optional there; when present it is checked like any other.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:mms:bbs-cd:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4GnVybjptbXM6YmJzLWNkOm55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhY255bVgwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_issuer_key_set_limits.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_issuer_key_set_limits.json new file mode 100644 index 000000000..89dc1d86a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_issuer_key_set_limits.json @@ -0,0 +1,50 @@ +{ + "description": "Eight certificates, the last of exactly 4096 bytes: both limits inclusive.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIhYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WEQwggEKAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P1hEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9YRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_WRAAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMGpwcm9vZl90aW1lwHQyMDI2LTEwLTIwVDEyOjAwOjAwWmdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_issuer_key_set_single.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_issuer_key_set_single.json new file mode 100644 index 000000000..e1d0f8585 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_issuer_key_set_single.json @@ -0,0 +1,50 @@ +{ + "description": "One certificate (the usual set for a system whose circuit takes one issuer key).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_member_order_irrelevant.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_member_order_irrelevant.json new file mode 100644 index 000000000..c71ad9759 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_member_order_irrelevant.json @@ -0,0 +1,50 @@ +{ + "description": "Presentation members in any order (CBOR map order carries no meaning).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWdyZXN1bHRzomNueW1YIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fbWFnZS1yYW5nZS0xMjP1anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBabmlzc3Vlcl9rZXlfc2V0gVhEMIIBCgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9lcHJvb2ZYIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4faXN5c3RlbV9pZHgjdXJuOmNpcmN1aXRzOmxvbmdmZWxsb3c6bnltX2F1dGg6djE" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_normative_both.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_normative_both.json new file mode 100644 index 000000000..951223bab --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_normative_both.json @@ -0,0 +1,50 @@ +{ + "description": "Rulebook example: a pseudonym (32 bytes) and an age predicate, proof_time in the exact 20-byte form.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_other_query_ids_ignored.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_other_query_ids_ignored.json new file mode 100644 index 000000000..7654da7b1 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_other_query_ids_ignored.json @@ -0,0 +1,53 @@ +{ + "description": "Entries for other credential queries are not looked at.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ], + "other": [ + "garbage" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_century_non_leap.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_century_non_leap.json new file mode 100644 index 000000000..77bcd86fb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_century_non_leap.json @@ -0,0 +1,50 @@ +{ + "description": "2100 is not a leap year: 2100-02-28 is valid.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIxMDAtMDItMjhUMDA6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_epoch.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_epoch.json new file mode 100644 index 000000000..3f816cde4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_epoch.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time at the Unix epoch.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDE5NzAtMDEtMDFUMDA6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_leap_day.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_leap_day.json new file mode 100644 index 000000000..7144eb4d8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_leap_day.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time on a leap day, last second.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjQtMDItMjlUMjM6NTk6NTlaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_max.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_max.json new file mode 100644 index 000000000..aa351f1a1 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_max.json @@ -0,0 +1,50 @@ +{ + "description": "Largest renderable proof_time.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDk5OTktMTItMzFUMjM6NTk6NTlaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_year_end.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_year_end.json new file mode 100644 index 000000000..26e446c2a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_proof_time_year_end.json @@ -0,0 +1,50 @@ +{ + "description": "proof_time across a year boundary.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTItMzFUMjM6NTk6NTlaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_pseudonym_only.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_pseudonym_only.json new file mode 100644 index 000000000..6012ca04f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_pseudonym_only.json @@ -0,0 +1,42 @@ +{ + "description": "Only a pseudonym requested.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOhY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_results_order_irrelevant.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_results_order_irrelevant.json new file mode 100644 index 000000000..017279c76 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_results_order_irrelevant.json @@ -0,0 +1,50 @@ +{ + "description": "Result keys in any order.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOibWFnZS1yYW5nZS0xMjP1Y255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_two_presentations.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_two_presentations.json new file mode 100644 index 000000000..841a7248f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_response/ok_two_presentations.json @@ -0,0 +1,52 @@ +{ + "description": "multiple: true allows more than one presentation; each is checked.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age-range-123", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "multiple": true + }, + "client_metadata": { + "vp_formats_supported": { + "zkp_req": { + "proof_system_values": [ + "longfellow-libzk-v1" + ], + "system_id_values": [ + "urn:circuits:longfellow:nym_auth:v1" + ] + } + } + }, + "vp_token": { + "nym_attestation": [ + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U", + "pWlzeXN0ZW1faWR4I3VybjpjaXJjdWl0czpsb25nZmVsbG93Om55bV9hdXRoOnYxZXByb29mWCAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eH25pc3N1ZXJfa2V5X3NldIFYRDCCAQoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_anByb29mX3RpbWXAdDIwMjYtMTAtMjBUMTI6MDA6MDBaZ3Jlc3VsdHOiY255bVggAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh9tYWdlLXJhbmdlLTEyM_U" + ] + }, + "expect": { + "valid": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/README.md b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/README.md new file mode 100644 index 000000000..9f222de0f --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/README.md @@ -0,0 +1,12 @@ +# zkp_req unsatisfiable-request vectors + +The rulebook v0.4 rules by which a well-formed `zkp_req` request is +unsatisfiable, decided from the request alone (`zkp_req::unsatisfiable`). +Spec: `docs/zkp_req_request.md`. Every query here plans; `expect` is +`{"satisfiable": true}` (file `ok_*`) or `{"error": ""}` (file `err_*`), +the first rule broken. A request that passes is not thereby served: which +configurations a system supports is its registry entry's say, not these rules. + +```json +{ "description": "...", "query": { }, "expect": { "satisfiable": true } } +``` diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_inverted.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_inverted.json new file mode 100644 index 000000000..17521a17c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_inverted.json @@ -0,0 +1,24 @@ +{ + "description": "le before ge.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2013-10-09", + "le": "2008-10-10" + } + } + ] + }, + "expect": { + "error": "age_check_inverted" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_no_bound.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_no_bound.json new file mode 100644 index 000000000..0634fb36c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_no_bound.json @@ -0,0 +1,21 @@ +{ + "description": "At least one of ge and le MUST be present.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": {} + } + ] + }, + "expect": { + "error": "age_check_no_bound" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_repeated.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_repeated.json new file mode 100644 index 000000000..420c4ca1d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_repeated.json @@ -0,0 +1,32 @@ +{ + "description": "Two age_check items.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "a", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + }, + { + "fn_id": "age_check", + "return": "b", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "age_check_repeated" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_single_day.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_single_day.json new file mode 100644 index 000000000..896152b02 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_single_day.json @@ -0,0 +1,24 @@ +{ + "description": "ge == le is a one-day range: an exact match, refused.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2010-01-01", + "le": "2010-01-01" + } + } + ] + }, + "expect": { + "error": "age_check_span_too_short" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_span_364_days.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_span_364_days.json new file mode 100644 index 000000000..8ebb01bd0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_span_364_days.json @@ -0,0 +1,24 @@ +{ + "description": "2008-10-10..2009-10-08 is 364 days inclusive: below the 365-day minimum.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2009-10-08" + } + } + ] + }, + "expect": { + "error": "age_check_span_too_short" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_span_364_days_across_non_leap_february.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_span_364_days_across_non_leap_february.json new file mode 100644 index 000000000..2ef0d8d9a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_age_check_span_364_days_across_non_leap_february.json @@ -0,0 +1,24 @@ +{ + "description": "2008-03-01..2009-02-27 is 364 days inclusive (no leap day inside): below the minimum.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-03-01", + "le": "2009-02-27" + } + } + ] + }, + "expect": { + "error": "age_check_span_too_short" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_bbs_cd_config_psc.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_bbs_cd_config_psc.json new file mode 100644 index 000000000..518e3cccb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_bbs_cd_config_psc.json @@ -0,0 +1,25 @@ +{ + "description": "bbs_cd defines only known-offender-matching-ssc.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-psc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ] + }, + "expect": { + "error": "pseudonym_prf_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_65_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_65_bytes.json new file mode 100644 index 000000000..c77d83b00 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_65_bytes.json @@ -0,0 +1,26 @@ +{ + "description": "65 bytes.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + }, + "expect": { + "error": "verifier_context_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_empty.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_empty.json new file mode 100644 index 000000000..3cb4b3117 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_empty.json @@ -0,0 +1,26 @@ +{ + "description": "Empty verifier_context.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "" + } + } + ] + }, + "expect": { + "error": "verifier_context_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_missing_for_account_recovery.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_missing_for_account_recovery.json new file mode 100644 index 000000000..bea32cb7c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_missing_for_account_recovery.json @@ -0,0 +1,25 @@ +{ + "description": "account-recovery-ssc without verifier_context.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "verifier_context_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_multibyte_over_64.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_multibyte_over_64.json new file mode 100644 index 000000000..ace47fdc9 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_multibyte_over_64.json @@ -0,0 +1,26 @@ +{ + "description": "22 three-byte characters = 66 bytes (the limit is bytes, not characters).", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac" + } + } + ] + }, + "expect": { + "error": "verifier_context_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_on_known_offender.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_on_known_offender.json new file mode 100644 index 000000000..fc8759797 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_context_on_known_offender.json @@ -0,0 +1,26 @@ +{ + "description": "verifier_context MUST NOT be present for other configurations.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "x" + } + } + ] + }, + "expect": { + "error": "verifier_context_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_function_old_name.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_function_old_name.json new file mode 100644 index 000000000..171903121 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_function_old_name.json @@ -0,0 +1,23 @@ +{ + "description": "pseudonym-v1-sha256 is the pre-v0.4 name; the function is now `pseudonym`.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym-v1-sha256", + "return": "x", + "params": { + "config": "known-offender-matching-ssc" + } + } + ] + }, + "expect": { + "error": "function_unknown" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_function_unknown.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_function_unknown.json new file mode 100644 index 000000000..dca3bfaa8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_function_unknown.json @@ -0,0 +1,20 @@ +{ + "description": "A function the rulebook does not define.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "no-such-fn", + "return": "x" + } + ] + }, + "expect": { + "error": "function_unknown" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_holder_binding_waived.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_holder_binding_waived.json new file mode 100644 index 000000000..15355fb5c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_holder_binding_waived.json @@ -0,0 +1,25 @@ +{ + "description": "require_cryptographic_holder_binding false.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "require_cryptographic_holder_binding": false + }, + "expect": { + "error": "holder_binding_waived" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_bbs_cd_without_schema_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_bbs_cd_without_schema_id.json new file mode 100644 index 000000000..1860ef38d --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_bbs_cd_without_schema_id.json @@ -0,0 +1,24 @@ +{ + "description": "bbs_cd needs schema_id_value.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "doctype_value": "d" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "meta_type_value_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_mso_mdoc_with_schema_id.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_mso_mdoc_with_schema_id.json new file mode 100644 index 000000000..234cb14f8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_mso_mdoc_with_schema_id.json @@ -0,0 +1,24 @@ +{ + "description": "mso_mdoc takes exactly one type value, doctype_value; schema_id_value is the wrong one.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "schema_id_value": "s" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "meta_type_value_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_mso_mdoc_without_doctype.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_mso_mdoc_without_doctype.json new file mode 100644 index 000000000..ea2112d86 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_mso_mdoc_without_doctype.json @@ -0,0 +1,23 @@ +{ + "description": "mso_mdoc needs doctype_value.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "meta_type_value_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_two_type_values.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_two_type_values.json new file mode 100644 index 000000000..800ee58fd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_two_type_values.json @@ -0,0 +1,25 @@ +{ + "description": "Both doctype_value and schema_id_value.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "d", + "schema_id_value": "s" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "meta_type_value_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_vct_values.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_vct_values.json new file mode 100644 index 000000000..a8f460767 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_meta_vct_values.json @@ -0,0 +1,27 @@ +{ + "description": "vct_values belongs to dc+sd-jwt.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1", + "vct_values": [ + "x" + ] + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "meta_type_value_invalid" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_config_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_config_missing.json new file mode 100644 index 000000000..f7f40f2e8 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_config_missing.json @@ -0,0 +1,24 @@ +{ + "description": "config is REQUIRED.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_config_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_config_unknown.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_config_unknown.json new file mode 100644 index 000000000..78426150e --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_config_unknown.json @@ -0,0 +1,25 @@ +{ + "description": "A configuration the rulebook does not define.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "no-such-config", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_config_unknown" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_hashdh_on_mso_mdoc.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_hashdh_on_mso_mdoc.json new file mode 100644 index 000000000..2cabc0aab --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_hashdh_on_mso_mdoc.json @@ -0,0 +1,25 @@ +{ + "description": "hashdh-bls12381g1 is a bbs_cd prf.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + } + ] + }, + "expect": { + "error": "pseudonym_prf_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_missing.json new file mode 100644 index 000000000..fb35f8b2c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_missing.json @@ -0,0 +1,24 @@ +{ + "description": "prf is REQUIRED.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1 + } + } + ] + }, + "expect": { + "error": "pseudonym_prf_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_sha256_on_bbs_cd.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_sha256_on_bbs_cd.json new file mode 100644 index 000000000..9f98dd5dd --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_sha256_on_bbs_cd.json @@ -0,0 +1,25 @@ +{ + "description": "bbs_cd defines only hashdh-bls12381g1.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_prf_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_unknown.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_unknown.json new file mode 100644 index 000000000..e86e5b1d4 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_prf_unknown.json @@ -0,0 +1,25 @@ +{ + "description": "mso_mdoc defines only sha-256.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "md5" + } + } + ] + }, + "expect": { + "error": "pseudonym_prf_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_repeated.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_repeated.json new file mode 100644 index 000000000..5a6aba510 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_repeated.json @@ -0,0 +1,34 @@ +{ + "description": "Two pseudonym items.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "a", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "pseudonym", + "return": "b", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_repeated" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_0.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_0.json new file mode 100644 index 000000000..30658d458 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_0.json @@ -0,0 +1,25 @@ +{ + "description": "ver 0.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 0, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_ver_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_2.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_2.json new file mode 100644 index 000000000..42bd60438 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_2.json @@ -0,0 +1,25 @@ +{ + "description": "ver must be 1 in this version.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 2, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_ver_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_missing.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_missing.json new file mode 100644 index 000000000..a2fb4b3f2 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_missing.json @@ -0,0 +1,24 @@ +{ + "description": "ver is REQUIRED.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_ver_missing" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_negative.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_negative.json new file mode 100644 index 000000000..c957f3613 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_pseudonym_ver_negative.json @@ -0,0 +1,25 @@ +{ + "description": "ver -1.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": -1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "error": "pseudonym_ver_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_source_format_unknown.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_source_format_unknown.json new file mode 100644 index 000000000..303b57feb --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_source_format_unknown.json @@ -0,0 +1,24 @@ +{ + "description": "An unknown source_format.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "jwt_vc", + "doctype_value": "d" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "source_format_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_source_format_unsupported.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_source_format_unsupported.json new file mode 100644 index 000000000..cc95c34f0 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/err_source_format_unsupported.json @@ -0,0 +1,26 @@ +{ + "description": "dc+sd-jwt is reserved, not defined in this version.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "dc+sd-jwt", + "vct_values": [ + "urn:x" + ] + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "error": "source_format_unsupported" + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_account_recovery_with_context.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_account_recovery_with_context.json new file mode 100644 index 000000000..fa350e2b7 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_account_recovery_with_context.json @@ -0,0 +1,26 @@ +{ + "description": "account-recovery-ssc REQUIRES verifier_context, 1 to 64 UTF-8 bytes.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "acct-42" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_age_only.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_age_only.json new file mode 100644 index 000000000..a0bf6b4fa --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_age_only.json @@ -0,0 +1,23 @@ +{ + "description": "An age_check alone, with one bound.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_bbs_cd.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_bbs_cd.json new file mode 100644 index 000000000..2e0e8569a --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_bbs_cd.json @@ -0,0 +1,33 @@ +{ + "description": "bbs_cd (held): known-offender-matching-ssc with prf hashdh-bls12381g1 is satisfiable by the rules.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "bbs_cd", + "schema_id_value": "eu.europa.ec.eudi.nym_auth.bbs.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "hashdh-bls12381g1" + } + }, + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_context_64_bytes.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_context_64_bytes.json new file mode 100644 index 000000000..470b13aaf --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_context_64_bytes.json @@ -0,0 +1,26 @@ +{ + "description": "verifier_context of exactly 64 bytes.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_context_multibyte_within_64.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_context_multibyte_within_64.json new file mode 100644 index 000000000..aa1964c45 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_context_multibyte_within_64.json @@ -0,0 +1,26 @@ +{ + "description": "21 three-byte characters = 63 bytes.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "account-recovery-ssc", + "ver": 1, + "prf": "sha-256", + "verifier_context": "\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac\u20ac" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_holder_binding_true.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_holder_binding_true.json new file mode 100644 index 000000000..7c9651276 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_holder_binding_true.json @@ -0,0 +1,25 @@ +{ + "description": "require_cryptographic_holder_binding true is the default and allowed.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ], + "require_cryptographic_holder_binding": true + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_normative.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_normative.json new file mode 100644 index 000000000..3c579d229 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_normative.json @@ -0,0 +1,33 @@ +{ + "description": "Rulebook example: pseudonym (known-offender-matching-ssc, ver 1, prf sha-256) and a one-year age range.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": "sha-256" + } + }, + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2013-10-09" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_psc_and_xsite_configs_are_rulebook_defined.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_psc_and_xsite_configs_are_rulebook_defined.json new file mode 100644 index 000000000..2495d1588 --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_psc_and_xsite_configs_are_rulebook_defined.json @@ -0,0 +1,25 @@ +{ + "description": "known-offender-matching-psc is a defined configuration: the request is satisfiable by the rules. Whether a system supports it is the registry entry (capabilities), not this check.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "pseudonym", + "return": "nym", + "params": { + "config": "known-offender-matching-psc", + "ver": 1, + "prf": "sha-256" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_span_exactly_365_days.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_span_exactly_365_days.json new file mode 100644 index 000000000..d5697f99c --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_span_exactly_365_days.json @@ -0,0 +1,24 @@ +{ + "description": "2008-10-10..2009-10-09 is 365 days inclusive: the minimum.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2008-10-10", + "le": "2009-10-09" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_span_over_leap_day.json b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_span_over_leap_day.json new file mode 100644 index 000000000..f7499ed9b --- /dev/null +++ b/pkg/openid4vp/testdata/zkp_req/zkp_req_unsat/ok_span_over_leap_day.json @@ -0,0 +1,24 @@ +{ + "description": "2007-03-01..2008-02-29 is 366 days inclusive (spans a leap day): accepted.", + "query": { + "id": "nym_attestation", + "format": "zkp_req", + "meta": { + "source_format": "mso_mdoc", + "doctype_value": "eu.europa.ec.eudi.nym_auth.1" + }, + "_req": [ + { + "fn_id": "age_check", + "return": "age", + "params": { + "ge": "2007-03-01", + "le": "2008-02-29" + } + } + ] + }, + "expect": { + "satisfiable": true + } +} diff --git a/pkg/openid4vp/zkp_req.go b/pkg/openid4vp/zkp_req.go new file mode 100644 index 000000000..d01cda246 --- /dev/null +++ b/pkg/openid4vp/zkp_req.go @@ -0,0 +1,1009 @@ +package openid4vp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "maps" + "math" + "math/big" + "reflect" + "slices" + "strconv" + "strings" + "time" + + yamlv2 "gopkg.in/yaml.v2" +) + +// FormatZkpReq is the DCQL format identifier for a zero-knowledge +// circuit presentation, as profiled by the EUDI nym_auth rulebook. +// +// Distinct from FormatMsoMdocZk, which is multipaz's ZK presentation of an +// mdoc: that one returns a DeviceResponse-shaped vp_token and discloses +// attributes. A zkp_req query discloses nothing. Every value a Verifier +// wants is the OUTPUT of a function it asks for in Req, computed inside +// the circuit and returned under a handle. +const FormatZkpReq = "zkp_req" + +// IsZkpReqFormat reports whether format is the zkp_req identifier. +func IsZkpReqFormat(format string) bool { return format == FormatZkpReq } + +// Function identifiers the nym_auth rulebook registers. They are not an +// enumeration this package enforces - a Verifier may ask for a function +// this build has never heard of, and the Wallet decides whether it can. +const ( + // FunctionPseudonym derives a pseudonym from a seed commitment's + // opening. Rulebook section "Pseudonym generation function". + FunctionPseudonym = "pseudonym" + // FunctionAgeCheck proves a date predicate over birth_date. + FunctionAgeCheck = "age_check" +) + +// The stored attestation a zkp_req proof is made over. +// +// mso_mdoc is the ISO/IEC 18013-5 mdoc and is what the default +// profile stores. bbs_cd is defined and held: its presentations +// validate by the same rules, and nothing more is claimed for it. +const ( + SourceFormatMsoMdoc = "mso_mdoc" + SourceFormatBbsCd = "bbs_cd" +) + +// The pseudonym functions' `prf` parameter, which fixes the width of +// the pseudonym a result carries. +// +// The width is the prf's, not the function's: a pseudonym with no +// agreed width is one this verifier cannot check, so an item naming +// no known prf is refused rather than measured against a guess. +const ( + // PseudonymVer1 is the only pseudonym `ver` v0.4 defines. The + // parameter is REQUIRED, so there is no "unversioned" request. + PseudonymVer1 = 1 + + PrfSHA256 = "sha-256" + PrfHashDHBLS12381G1 = "hashdh-bls12381g1" +) + +// ReqItem is one requested calculation inside a zkp_req credential +// query: a function, a handle its result is returned under, and the +// function's parameters. +// +// A zkp_req preset needs two things to be published - a non-empty req, +// and verifier.preferred_vp_formats.zkp_req advertising the format: +// +// ```yaml +// verifier: +// preferred_vp_formats: +// zkp_req: {} # or a circuit filter +// presets: +// "Pseudonym": +// credentials: +// nym_auth: +// format: zkp_req +// validate: nym_auth # optional +// # `req` is the PRESET key; the wire member it becomes is +// # `_req`. They differ on purpose - the preset is this +// # repository's configuration surface, not the wire. +// req: +// - fn_id: pseudonym +// return: nym-123 +// params: +// config: known-offender-matching-ssc +// ver: 1 +// prf: sha-256 +// ``` +// +// Params is an open map and is serialised exactly as given. Nothing here +// coerces a value, drops an empty one, or repairs a malformed one, and +// that is deliberate twice over. The rulebook and the OpenID4VP and DC API +// specifications under it are all still moving, and several generations of +// wallet will be in the field at once - so a Verifier has to be able to +// send a shape this build has never been taught. And this Verifier is the +// tool we test wallets WITH: a builder that quietly fixed a bad request +// could not produce the bad requests a negative test needs. +type ReqItem struct { + // FnID is the function identifier, "fn_id" on the wire. Named for + // the wire member rather than Function, since the wire is what an + // operator configures and reads back. + FnID string `json:"fn_id" yaml:"fn_id"` + + // Return is the handle the result is keyed by in the response's + // results map. The rulebook requires it to be unique within the + // credential query; nothing here enforces that, because a duplicate + // is a thing a wallet should be tested against. + Return string `json:"return" yaml:"return"` + + // Params are the function's parameters, passed through unchanged. + Params map[string]any `json:"params,omitempty" yaml:"params,omitempty"` + + // Extra carries members this build does not know about, flattened + // alongside the three above rather than nested. It is how a request + // shape that arrives after this code was written can still be sent. + Extra map[string]any `json:"-" yaml:"-"` +} + +// MarshalJSON flattens Extra alongside the known members. +func (r ReqItem) MarshalJSON() ([]byte, error) { + type plain ReqItem // no methods, so no recursion + return marshalWithExtra(plain(r), r.Extra, "_req item") +} + +// UnmarshalJSON collects unknown members into Extra. +func (r *ReqItem) UnmarshalJSON(data []byte) error { + knownObject, extra, err := splitMembers(data, "_req item", "fn_id", "return", "params") + if err != nil { + return err + } + type plain ReqItem + var known plain + if err := decodeExact(knownObject, &known); err != nil { + return err + } + *r = ReqItem(known) + r.Extra = extra + return nil +} + +// ZkpReqFormat is the zkp_req entry of client_metadata's +// vp_formats_supported: which proof systems and circuits the Verifier will +// accept a proof from. +// +// Entirely an open map, with the two known arrays as a convenience. The +// rulebook's own example wraps the same information in a +// {"fn_id":..., "input": base64(json)} envelope, and the matcher accepts +// both - so an operator talking to a wallet generation that wants the +// envelope has to be able to send it without waiting for a release here. +type ZkpReqFormat struct { + // omitzero, not omitempty, on both arrays below. + // + // The matcher's contract distinguishes an EMPTY list - a filter that + // nothing matches - from an ABSENT one, which is no filter at all. + // omitempty cannot tell them apart, so an explicit + // {"system_id_values": []} was serialised as {} and the constraint + // silently disappeared: the wallet saw an unfiltered request where + // the operator had asked for one that matches nothing. + // + // omitzero omits only the zero value, which for a slice is nil, so + // absent stays absent and empty survives. MarshalYAML below already + // made this distinction by hand (it tests != nil), so only the JSON + // path was losing it. + + // ProofSystemValues lists acceptable proof systems. + ProofSystemValues []string `json:"proof_system_values,omitzero" yaml:"proof_system_values,omitempty" doc_example:"[\"longfellow-libzk-v1\"]"` + + // SystemIDValues lists acceptable circuit identifiers, from the + // circuit catalog. + SystemIDValues []string `json:"system_id_values,omitzero" yaml:"system_id_values,omitempty" doc_example:"[\"urn:circuits:longfellow:nym_auth:v1\"]"` + + // Extra carries any other member, flattened alongside the two above - + // including the envelope form's fn_id and input. + Extra map[string]any `json:"-" yaml:"-"` +} + +// MarshalJSON flattens Extra alongside the known members. +func (c ZkpReqFormat) MarshalJSON() ([]byte, error) { + type plain ZkpReqFormat + return marshalWithExtra(plain(c), c.Extra, "zkp_req client metadata") +} + +// nullFilterError reports a modelled filter array this type cannot hold. +// +// Neither spelling survives the round trip. A null ARRAY decodes to a nil +// slice, which omitzero then omits, so an explicitly supplied filter +// becomes NO filter and the wallet sees an unfiltered request. A null +// ELEMENT decodes to the zero string, so a filter naming nothing becomes +// one naming the empty string. The model has nowhere to keep the +// difference, so this refuses rather than repairs - as the duplicate and +// overflow checks beside it do, and for the same reason: a changed +// request that looks perfectly well formed is the worst thing this +// serialiser can emit. +func nullFilterError(what, name string, index int) error { + if index < 0 { + return fmt.Errorf("%s: %q is null, which cannot be told from an absent member once decoded; "+ + "omit it for no filter at all, or give [] for a filter that matches nothing", what, name) + } + return fmt.Errorf("%s: %q[%d] is null, which would be read back as an empty string; "+ + "remove the element, or write the value you mean", what, name, index) +} + +// refuseNullFiltersJSON refuses a null modelled array, or one holding a +// null element, in JSON. +func refuseNullFiltersJSON(data []byte, what string, names ...string) error { + var members map[string]json.RawMessage + if err := decodeExact(data, &members); err != nil { + return nil // not an object; the typed decode reports it + } + for _, name := range names { + raw, present := members[name] + if !present { + continue + } + if string(bytes.TrimSpace(raw)) == "null" { + return nullFilterError(what, name, -1) + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + continue // wrong kind; the typed decode reports it + } + for i, item := range items { + if string(bytes.TrimSpace(item)) == "null" { + return nullFilterError(what, name, i) + } + } + } + return nil +} + +// refuseNullFiltersYAML is the same refusal for the YAML path, which +// decodes the same two spellings into the same two wrong answers. +func refuseNullFiltersYAML(unmarshal func(any) error, what string, names ...string) error { + var mapping map[string]any + if err := unmarshal(&mapping); err != nil { + return nil // not a mapping; the typed decode reports it + } + for _, name := range names { + value, present := mapping[name] + if !present { + continue + } + if value == nil { + return nullFilterError(what, name, -1) + } + items, ok := value.([]any) + if !ok { + continue + } + for i, item := range items { + if item == nil { + return nullFilterError(what, name, i) + } + } + } + return nil +} + +// UnmarshalJSON collects unknown members into Extra. +func (c *ZkpReqFormat) UnmarshalJSON(data []byte) error { + if err := refuseNullFiltersJSON(data, "zkp_req client metadata", + "proof_system_values", "system_id_values"); err != nil { + return err + } + knownObject, extra, err := splitMembers(data, "zkp_req client metadata", "proof_system_values", "system_id_values") + if err != nil { + return err + } + type plain ZkpReqFormat + var known plain + if err := decodeExact(knownObject, &known); err != nil { + return err + } + *c = ZkpReqFormat(known) + c.Extra = extra + return nil +} + +// marshalWithExtra serialises known and merges extra at the top level. +// +// A member in extra that collides with one the struct models is an error +// rather than a silent winner. The check is against every member the type +// DECLARES, not against the ones that happened to survive omitempty: a +// typed field left empty is omitted from the encoding, so checking the +// output would let extra supply a modelled key whenever its field is +// unset - quietly, and only sometimes, which is the worst shape a rule +// can have. Changing the type of a modelled member is what Raw is for. +func marshalWithExtra(known any, extra map[string]any, what string) ([]byte, error) { + encoded, err := json.Marshal(known) + if err != nil { + return nil, err + } + if len(extra) == 0 { + return encoded, nil + } + + reserved := declaredJSONKeys(reflect.TypeOf(known)) + + var merged map[string]json.RawMessage + if err := json.Unmarshal(encoded, &merged); err != nil { + return nil, err + } + for _, key := range slices.Sorted(maps.Keys(extra)) { + if reserved[key] { + return nil, fmt.Errorf("%s: %q is a member this type models, so it cannot be set through extra; use raw to change its type", what, key) + } + if _, taken := merged[key]; taken { + return nil, fmt.Errorf("%s: %q is set both as a known member and in extra", what, key) + } + value, err := json.Marshal(extra[key]) + if err != nil { + return nil, fmt.Errorf("%s: extra member %q: %w", what, key, err) + } + merged[key] = value + } + return json.Marshal(merged) +} + +// declaredJSONKeys returns every JSON member name a struct type declares, +// including ones omitempty may drop and ones tagged "-". +// +// Derived from the type rather than listed by hand. A list would be +// correct until the next field is added, and the failure it produces then +// is an extension silently overriding a modelled member. +func declaredJSONKeys(t reflect.Type) map[string]bool { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + keys := map[string]bool{} + if t.Kind() != reflect.Struct { + return keys + } + for _, field := range reflect.VisibleFields(t) { + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + switch name { + case "", "-": + // "-" is a field deliberately kept off the wire (Extra, Raw); + // an empty tag means the field name is the member name. + if name == "" && field.IsExported() { + keys[field.Name] = true + } + continue + } + keys[name] = true + } + return keys +} + +// decodeExact decodes data into v preserving numbers exactly. +// +// Plain Unmarshal turns every JSON number reaching an `any` into a +// float64, so 9007199254740993 is re-emitted as ...992. These structures +// are mostly `any` by design - a function's params are passed through +// untouched - so without this the one guarantee this package makes about +// params would be false for any integer past 2^53. +func decodeExact(data []byte, v any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + return decoder.Decode(v) +} + +// splitMembers separates data's top-level members into the ones named in +// known, re-encoded as an object for the struct decode, and every other +// member, which survives as an extension and is written back unchanged. +// +// Both halves have to agree on what a member NAME is, and they did not. +// encoding/json matches a struct field case-insensitively, so +// "CIRCUIT_ID_VALUES" bound to SystemIDValues; the extension half matched +// exactly, so the very same member was ALSO kept as unknown. Re-encoding +// then wrote both, and an extension nobody modelled silently rewrote a +// modelled member: {"system_id_values":[],"CIRCUIT_ID_VALUES":["urn:x"]} +// came back with BOTH arrays naming urn:x - a filter that matches nothing +// turned into one that permits that circuit. +// +// Selecting the known members by exact name BEFORE the decode settles it +// for every type that carries an extension bag: the struct only ever sees +// members spelled the way it declares them, and a differently cased name +// is an unknown member like any other. The members are carried across as +// raw bytes, so a number still reaches the struct exactly as written. +func splitMembers(data []byte, what string, known ...string) ([]byte, map[string]any, error) { + // Before the map decode below, which keeps the LAST of a repeated + // member and silently drops the rest - and the extension values are + // decoded into any, so a duplicate nested inside one does not survive + // either. + // + // Refused rather than quietly reduced, as the YAML side already does: + // {"system_id_values":[],"system_id_values":["urn:x"]} re-encoded + // as just ["urn:x"], turning a filter that matches NOTHING into one + // that permits that circuit. The credential query was the only bag + // that survived this, and only because its decode failure routes the + // bytes to Raw, where they are kept verbatim. + if hasDuplicateMembers(data) { + return nil, nil, fmt.Errorf("%s: this names a member more than once, which cannot survive a decode", what) + } + + var all map[string]json.RawMessage + if err := decodeExact(data, &all); err != nil { + return nil, nil, err + } + + selected := make(map[string]json.RawMessage, len(known)) + for _, name := range known { + if raw, ok := all[name]; ok { + selected[name] = raw + delete(all, name) + } + } + knownObject, err := json.Marshal(selected) + if err != nil { + return nil, nil, err + } + if len(all) == 0 { + return knownObject, nil, nil + } + + extra := make(map[string]any, len(all)) + for name, raw := range all { + var value any + if err := decodeExact(raw, &value); err != nil { + return nil, nil, err + } + extra[name] = value + } + return knownObject, extra, nil +} + +// YAML support for the extension bags. +// +// Extra is tagged yaml:"-", so without these an operator configuring +// .verifier.preferred_vp_formats.zkp_req or a presentation request +// template could not use it at all - the members would be read and +// silently dropped, which is the worst way for an escape hatch to fail. +// pkg/configuration loads both of those from YAML. +// +// Known members are decoded into a methodless alias, so yaml.v2 applies +// the struct tags and reports a wrong type itself. Unknown members are +// collected separately and normalised. Nothing goes through JSON on the +// way, which matters: yaml.v2 materialises every nested mapping as +// map[any]any, and what encoding/json does with that has changed between +// releases - it currently coerces an integer key to a string and drops a +// sequence key silently, neither of which should be how a verifier's +// configuration is interpreted. + +// yamlValue decodes a YAML value while keeping each numeric scalar's +// ORIGINAL TEXT. +// +// yaml.v2 resolves a non-integer scalar to a float64 and discards the +// lexeme (vendor/gopkg.in/yaml.v2/resolve.go), so a configured +// 1.00000000000000001 is already 1 by the time any check here could look +// at it - and a check downstream then sees a value that is perfectly +// stable, because the damage is upstream of it. Integers survive yaml.v2 +// intact; it is the decimals that do not. +// +// A req param is an input to a proof, so this is the one place in the +// configuration where that matters: the wallet hashes what it is given, +// and a parameter that quietly became a different number yields a +// different pseudonym. +// +// The lexeme is recoverable because yaml.v2 will decode any scalar into a +// string field. When it is also a valid JSON number it is carried as a +// json.Number, which encodes back out as exactly those digits. When it is +// not - YAML's 0x10, 1_000, .inf have no JSON spelling - the resolved +// value is kept, since inventing one would be worse than the rounding. +type yamlValue struct{ value any } + +func (v *yamlValue) UnmarshalYAML(unmarshal func(any) error) error { + var typed any + if err := unmarshal(&typed); err != nil { + return err + } + switch resolved := typed.(type) { + case float64: + // .inf and .nan are YAML numbers with no JSON spelling at all. + // Left alone they reach json.Marshal, which refuses them - so the + // whole request object becomes unencodable at SEND time, a long + // way from the configuration that caused it. + if math.IsInf(resolved, 0) || math.IsNaN(resolved) { + return &unrepresentableScalarError{message: fmt.Sprintf( + "%v %s: it has no JSON spelling at all, so it cannot be sent as a proof input", resolved, unrepresentableScalarMarker)} + } + var lexeme string + if err := unmarshal(&lexeme); err == nil && isJSONNumber(lexeme) { + v.value = json.Number(lexeme) + return nil + } + case int, int64, uint64: + var lexeme string + if err := unmarshal(&lexeme); err == nil && isJSONNumber(lexeme) { + v.value = json.Number(lexeme) + return nil + } + case time.Time: + // Does not happen on yaml.v2, which sets a timestamp-resolved + // scalar as a string when the destination is an interface - + // deliberately, for backward compatibility, and with a + // "TODO(v3) Drop this" beside it (decode.go, the + // yaml_TIMESTAMP_TAG branch). That TODO is the reason this case + // exists. + // + // If it ever does happen, the damage is specific and severe: the + // rulebook's age_check takes full-dates, so `ge: 2008-10-10` + // would be emitted as "2008-10-10T00:00:00Z" - a value the + // matcher rejects as not a full-date, from a template that reads + // as perfectly correct. The lexeme is recoverable the same way a + // number's is, so recovering it costs nothing and removes the + // dependency on a compatibility hack someone else has already + // scheduled for removal. + var lexeme string + if err := unmarshal(&lexeme); err == nil { + v.value = lexeme + return nil + } + case string: + // yaml.v2 resolves a numeric literal it cannot hold - 1e400 - + // into a STRING, and nothing in the value it hands back says + // whether the operator wrote 1e400 or "1e400". So a number + // quietly becomes a string, which for a proof input is a changed + // request that no check downstream can see: a string always looks + // perfectly stable. + // + // Refused rather than guessed. A value at this magnitude is not + // usable as a proof input whichever of the two was meant, so a + // refusal costs nothing real, and a silent type change costs a + // pseudonym that does not match. + if outOfRangeNumericSpelling(resolved) { + return &unrepresentableScalarError{message: fmt.Sprintf( + "%q %s: yaml.v2 resolves a literal outside its range to a string, so it cannot tell %s from %q and neither can this", + resolved, unrepresentableScalarMarker, resolved, resolved)} + } + // Errors from the nested decodes are RETURNED, not swallowed. They + // are this type's own refusals - a scalar with no JSON number to + // become - and a fallback here would discard the refusal and keep + // exactly the value it exists to reject. (A shape yaml.v2 can put in + // a map[any]any it can also put in a map[any]yamlValue, since + // yamlValue accepts anything, so there is no structural error to fall + // back from either.) + case map[any]any: + var mapping map[any]yamlValue + if err := unmarshal(&mapping); err != nil { + return err + } + out := make(map[any]any, len(mapping)) + for key, item := range mapping { + out[key] = item.value + } + v.value = out + return nil + case []any: + var sequence []yamlValue + if err := unmarshal(&sequence); err != nil { + return err + } + out := make([]any, len(sequence)) + for i, item := range sequence { + out[i] = item.value + } + v.value = out + return nil + } + v.value = typed + return nil +} + +// outOfRangeNumericSpelling reports whether a value yaml.v2 handed back +// as a string is in fact a JSON number spelling that overflows a float64 - +// which is how yaml.v2 reports one it cannot resolve. +func outOfRangeNumericSpelling(value string) bool { + if !json.Valid([]byte(value)) { + return false // not a number spelling at all + } + var numeric *strconv.NumError + _, err := strconv.ParseFloat(value, 64) + return errors.As(err, &numeric) && errors.Is(numeric.Err, strconv.ErrRange) +} + +// isJSONNumber reports whether literal is spelled the way JSON spells a +// number. YAML admits several that JSON does not. +func isJSONNumber(literal string) bool { + if _, err := strconv.ParseFloat(literal, 64); err != nil { + return false + } + return json.Valid([]byte(literal)) +} + +// MarshalYAML flattens Extra alongside the known members. +func (r ReqItem) MarshalYAML() (any, error) { + m := map[string]any{"id": r.FnID, "return": r.Return} + if r.Params != nil { + m["params"] = r.Params + } + return mergeExtraYAML(m, r.Extra, declaredJSONKeys(reflect.TypeOf(ReqItem{})), "_req item") +} + +// UnmarshalYAML captures every member other than the modelled ones. +func (r *ReqItem) UnmarshalYAML(unmarshal func(any) error) error { + if err := refuseDuplicateYAMLKeys(unmarshal, "_req item"); err != nil { + return err + } + type plain ReqItem // no methods, so yaml.v2 uses the struct tags + var known plain + if err := unmarshal(&known); err != nil { + return fmt.Errorf("req item: %w", err) + } + *r = ReqItem(known) + + // Params is decoded a second time, keeping each numeric scalar's + // original text. See yamlValue: yaml.v2 resolves a non-integer scalar + // to a float64 before anything here runs, so the lexeme is the only + // place the configured precision still exists. + var lexemes struct { + Params yamlValue `yaml:"params"` + } + if err := unmarshal(&lexemes); err != nil { + return err + } + if lexemes.Params.value != nil { + if params, ok := lexemes.Params.value.(map[any]any); ok { + r.Params = nil + for key, item := range params { + name, ok := key.(string) + if !ok { + return fmt.Errorf("req item params: %v is not a string key, and has no JSON spelling", key) + } + if r.Params == nil { + r.Params = make(map[string]any, len(params)) + } + r.Params[name] = item + } + } + } + + // Params is an open tree, so it needs the same walk Extra gets. The + // struct tag puts it in a map[string]any, but yaml.v2 leaves every + // NESTED mapping inside it as map[any]any - and this item is emitted + // as JSON, where a key with no JSON spelling is coerced to its + // rendering or, for a sequence key, dropped without a word. A function + // parameter is the one place a configuration must survive unchanged: + // it is an input to a proof, so a changed one yields a different + // pseudonym and the failure appears nowhere near here. + // + // (A nested tree whose keys are all strings encodes correctly through + // encoding/json on this toolchain either way. The walk is here so that + // staying correct is this code's decision rather than that package's.) + params, err := normaliseYAML(r.Params, "req item params") + if err != nil { + return err + } + if params != nil { + normalised, ok := params.(map[string]any) + if !ok { + return fmt.Errorf("req item params: expected a mapping, got %T", params) + } + r.Params = normalised + } + + extra, err := yamlExtra(unmarshal, "_req item", "fn_id", "return", "params") + if err != nil { + return err + } + r.Extra = extra + return nil +} + +// MarshalYAML flattens Extra alongside the known members. +func (c ZkpReqFormat) MarshalYAML() (any, error) { + m := map[string]any{} + if c.ProofSystemValues != nil { + m["proof_system_values"] = c.ProofSystemValues + } + if c.SystemIDValues != nil { + m["system_id_values"] = c.SystemIDValues + } + return mergeExtraYAML(m, c.Extra, declaredJSONKeys(reflect.TypeOf(ZkpReqFormat{})), "zkp_req client metadata") +} + +// UnmarshalYAML captures every member other than the modelled ones - +// including the rulebook example's fn_id and input, which is the shape an +// operator is most likely to need and the one this could not express. +func (c *ZkpReqFormat) UnmarshalYAML(unmarshal func(any) error) error { + if err := refuseDuplicateYAMLKeys(unmarshal, "zkp_req client metadata"); err != nil { + return err + } + if err := refuseNullFiltersYAML(unmarshal, "zkp_req client metadata", + "proof_system_values", "system_id_values"); err != nil { + return err + } + type plain ZkpReqFormat + var known plain + if err := unmarshal(&known); err != nil { + return fmt.Errorf("zkp_req client metadata: %w", err) + } + *c = ZkpReqFormat(known) + + extra, err := yamlExtra(unmarshal, "zkp_req client metadata", + "proof_system_values", "system_id_values") + if err != nil { + return err + } + c.Extra = extra + return nil +} + +// refuseDuplicateYAMLKeys reports a mapping key repeated at any depth. +// +// yaml.v2's non-strict Unmarshal keeps the LAST of a repeated key, and +// the configuration loaders use it, so +// +// circuit_id_values: [] +// circuit_id_values: [urn:unexpected] +// +// loaded cleanly and emitted only the second - a filter matching NOTHING +// replaced by one that permits that circuit, with nothing malformed to +// point at. A repeated nested parameter went the same way, and a proof +// input quietly taking its last spelling is the same defect one level +// down. +// +// MapSlice is the one decode target that keeps repeats, and yaml.v2 uses +// it for nested mappings too once the top level asks for it, so a single +// decode and one walk cover every depth. The JSON side refuses the same +// shape in splitMembers; this is that guard for the other format. +func refuseDuplicateYAMLKeys(unmarshal func(any) error, what string) error { + var document yamlv2.MapSlice + if err := unmarshal(&document); err != nil { + return nil // not a mapping; the typed decode reports it + } + return walkYAMLDuplicates(document, what, nil) +} + +func walkYAMLDuplicates(value any, what string, path []string) error { + switch typed := value.(type) { + case yamlv2.MapSlice: + seen := make(map[string]bool, len(typed)) + for _, item := range typed { + key := fmt.Sprint(item.Key) + if seen[key] { + where := key + if len(path) > 0 { + where = strings.Join(append(path, key), ".") + } + return fmt.Errorf("%s: %s is named more than once, and YAML keeps only the last", what, where) + } + seen[key] = true + if err := walkYAMLDuplicates(item.Value, what, append(path, key)); err != nil { + return err + } + } + case []any: + for i, item := range typed { + if err := walkYAMLDuplicates(item, what, append(path, strconv.Itoa(i))); err != nil { + return err + } + } + } + return nil +} + +// yamlExtra decodes the same node a second time as a mapping and returns +// every member not named in known, normalised. +func yamlExtra(unmarshal func(any) error, what string, known ...string) (map[string]any, error) { + // Through yamlValue, not a plain map[string]any: yaml.v2 resolves a + // decimal scalar to a float64 and discards the lexeme, so an unknown + // member carrying 1.00000000000000001 would arrive here as 1 and + // every check downstream would see a value that is perfectly stable. + // + // An extension member is as much a proof input as a modelled one - + // more so, since the shapes this bag exists for are the ones a newer + // rulebook defines and this build does not understand. Preserving + // params and rounding these would be a distinction with no basis. + var tree yamlValue + if err := unmarshal(&tree); err != nil { + // EVERY error, not just yamlValue's own refusals. + // + // This used to swallow anything else on the reasoning that the + // typed decode above had already reported it. That is true of a + // node which is not a mapping - and nothing else. A structural + // fault the typed decode simply IGNORES, such as a sequence used + // as a map key under an unknown member, reaches here as a real + // error, and discarding it discarded the whole extension bag + // with it: a metadata block carrying fn_id, input and one + // malformed sibling loaded with NO extensions at all, silently, + // and the verifier sent different metadata than was configured. + // + // The not-a-mapping case needs no special handling here: it is + // refused by the typed decode before this function is called, + // and if it ever did arrive the type assertion below returns it + // as "no extensions" without an error. + return nil, fmt.Errorf("%s: %w", what, err) + } + all, ok := tree.value.(map[any]any) + if !ok { + return nil, nil // not a mapping; as above + } + for _, name := range known { + delete(all, name) + } + if len(all) == 0 { + return nil, nil + } + normalised, err := normaliseYAML(all, what) + if err != nil { + return nil, err + } + return normalised.(map[string]any), nil +} + +// unrepresentableScalarError is yamlValue's own refusal: a scalar that +// cannot be carried as the number it was written as. +// +// A distinct type rather than a message to match on, because the caller +// has to tell this apart from a structural mismatch - which is not its to +// report, the typed decode having already said what is wrong with the +// shape - and matching on wording would break the first time the wording +// improved. +type unrepresentableScalarError struct{ message string } + +func (e *unrepresentableScalarError) Error() string { return e.message } + +// isUnrepresentableScalar reports whether err is such a refusal, +// including through whatever yaml.v2 wraps it in. +func isUnrepresentableScalar(err error) bool { + var refusal *unrepresentableScalarError + if errors.As(err, &refusal) { + return true + } + // yaml.v2 collects errors from nested UnmarshalYAML calls into a + // TypeError that keeps only their messages, so the chain does not + // always survive. The marker is this package's own wording, emitted + // nowhere else. + return strings.Contains(err.Error(), unrepresentableScalarMarker) +} + +const unrepresentableScalarMarker = "cannot be carried as a number" + +// normaliseYAML converts yaml.v2's map[any]any into map[string]any, all +// the way down, refusing a key that is not a string. +// +// Refusing rather than rendering: a non-string key has no JSON spelling, +// and the alternatives are both silent - encoding/json coerces an integer +// key and drops a sequence key entirely. A verifier that quietly discards +// part of its own configuration is worse than one that will not start. +func normaliseYAML(value any, what string) (any, error) { + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for k, v := range typed { + converted, err := normaliseYAML(v, what) + if err != nil { + return nil, err + } + out[k] = converted + } + return out, nil + case map[any]any: + out := make(map[string]any, len(typed)) + for k, v := range typed { + key, ok := k.(string) + if !ok { + return nil, fmt.Errorf("%s: %v is not a string key, and has no JSON spelling", what, k) + } + converted, err := normaliseYAML(v, what) + if err != nil { + return nil, err + } + out[key] = converted + } + return out, nil + case []any: + out := make([]any, len(typed)) + for i, v := range typed { + converted, err := normaliseYAML(v, what) + if err != nil { + return nil, err + } + out[i] = converted + } + return out, nil + default: + return value, nil + } +} + +// mergeExtraYAML adds extra to m, refusing a member the type models. +// +// Against the DECLARED set, not against what m happens to hold. Checking +// m would only catch the collision when the field was populated, so an +// empty one would let extra take the member over - quietly, and only +// sometimes. That is the same mistake the JSON side had, found in review; +// it was reproduced here within the hour, writing this function, which is +// why both now go through declaredJSONKeys instead of inspecting output. +func mergeExtraYAML(m map[string]any, extra map[string]any, reserved map[string]bool, what string) (any, error) { + for _, key := range slices.Sorted(maps.Keys(extra)) { + if reserved[key] { + return nil, fmt.Errorf("%s: %q is a member this type models, so it cannot be set through extra; use raw to change its type", what, key) + } + m[key] = extra[key] + } + normalised, err := checkYAMLRendersNumbersExactly(m, what, nil) + if err != nil { + return nil, err + } + return normalised, nil +} + +// checkYAMLRendersNumbersExactly refuses to emit a number yaml.v2 would +// round on its way out. +// +// The YAML marshal path bridges through JSON, so an exactly-preserved +// param arrives here as a json.Number - and yaml.v2 encodes one by trying +// Int64 and falling back to Float64 (vendor/gopkg.in/yaml.v2/encode.go). +// An integer beyond int64, or a decimal with more digits than a float64 +// holds, is therefore RENDERED DIFFERENTLY than it was configured. +// +// An error rather than a silent rounding, and rather than a repair. +// These numbers are inputs to a proof: a changed one yields a different +// pseudonym, and the symptom is two systems disagreeing about who someone +// is with nothing malformed to point at. Every other path keeps them +// exact - the YAML decode keeps the lexeme, JSON keeps the literal - so +// this is the one place where the honest answer is to stop. +func checkYAMLRendersNumbersExactly(value any, what string, path []string) (any, error) { + // Normalised first, so the walk below sees one shape of container + // whatever the caller assembled. Without this it descended only + // map[string]any and []any, and a TYPED container - a + // map[string]json.Number, a []json.Number - was skipped: yaml.v2 then + // rounded what was inside it with nothing said. + // + // CredentialQuery.MarshalYAML happened to be safe, because it bridges + // through JSON before calling this. ReqItem.MarshalYAML builds its + // map directly and was not. Relying on which caller normalises is the + // kind of correctness that holds until someone adds a third caller. + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + // Before the normalisation below, which would drop one silently. A + // raw leaf is embedded verbatim by Marshal, so a duplicate inside one + // surfaces here whatever container it sat in and however deep. + if hasDuplicateMembers(encoded) { + return nil, fmt.Errorf("%s: this names a member more than once, which YAML cannot write without dropping one", what) + } + var normalised any + if err := decodeExact(encoded, &normalised); err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + if err := walkYAMLNumbers(normalised, what, path); err != nil { + return nil, err + } + // The NORMALISED tree, not the caller's. The check above ran on this + // shape while the caller went on to hand yaml.v2 its own containers, + // where a json.RawMessage is a []byte - written out as a sequence of + // byte integers rather than the object it encodes. + return normalised, nil +} + +func walkYAMLNumbers(value any, what string, path []string) error { + switch typed := value.(type) { + case json.Number: + if yamlRendersNumberExactly(typed.String()) { + return nil + } + where := "a number" + if len(path) > 0 { + where = strings.Join(path, ".") + } + return fmt.Errorf("%s: %s is %s, which YAML cannot write without changing it; "+ + "a proof input has to survive the round trip, so quote it as a string if the exact digits are what you mean", + what, where, typed.String()) + case map[string]any: + for _, key := range slices.Sorted(maps.Keys(typed)) { + if err := walkYAMLNumbers(typed[key], what, append(path, key)); err != nil { + return err + } + } + case []any: + for i, item := range typed { + if err := walkYAMLNumbers(item, what, append(path, strconv.Itoa(i))); err != nil { + return err + } + } + } + return nil +} + +// yamlRendersNumberExactly reports whether yaml.v2 writes literal back as +// the same number: exactly for an integer it can hold in an int64, and +// otherwise via a float64, whose shortest rendering has to denote the +// same value. +func yamlRendersNumberExactly(literal string) bool { + if _, err := strconv.ParseInt(literal, 10, 64); err == nil { + return true + } + exact, ok := new(big.Rat).SetString(literal) + if !ok { + return false + } + asFloat, err := strconv.ParseFloat(literal, 64) + if err != nil || math.IsInf(asFloat, 0) || math.IsNaN(asFloat) { + return false + } + rendered, ok := new(big.Rat).SetString(strconv.FormatFloat(asFloat, 'g', -1, 64)) + return ok && rendered.Cmp(exact) == 0 +} diff --git a/pkg/openid4vp/zkp_req_cbor.go b/pkg/openid4vp/zkp_req_cbor.go new file mode 100644 index 000000000..b49360207 --- /dev/null +++ b/pkg/openid4vp/zkp_req_cbor.go @@ -0,0 +1,243 @@ +package openid4vp + +import ( + "errors" + "reflect" + "sync" + + "github.com/fxamacker/cbor/v2" +) + +// The CBOR reader for a ZkpPresentation, strict in the four ways rule +// 3 names - and only those. +// +// It is NOT a canonicality check: integers and lengths in non-shortest +// form are accepted on purpose. A prover that encodes a length the +// long way has produced a different encoding of the same value, and +// refusing it would refuse a presentation the rulebook permits. +// +// What IS refused is anything that would let two readers disagree +// about what they read: an indefinite length (which a streaming reader +// and a buffering one chunk differently), a repeated map key (which a +// map-typed decode silently collapses, so whichever value survives is +// the wallet's choice and not this verifier's), bytes after the item, +// and the ordinary malformed cases. +var ( + zkpCBORModeOnce sync.Once + zkpCBORMode cbor.DecMode + zkpCBORStrict cbor.DecMode +) + +func initZkpCBORModes() { + // The lenient-on-indefinite mode. Everything else is strict, so a + // failure here is malformed, a duplicate key, or bad UTF-8. + lenient, err := cbor.DecOptions{ + DupMapKey: cbor.DupMapKeyEnforcedAPF, + IndefLength: cbor.IndefLengthAllowed, + MaxNestedLevels: maxCBORNestingLevels, + UTF8: cbor.UTF8RejectInvalid, + DefaultMapType: reflect.TypeOf(map[any]any(nil)), + }.DecMode() + if err != nil { + panic("openid4vp: zkp_req CBOR mode: " + err.Error()) + } + zkpCBORMode = lenient + + // The same, refusing indefinite lengths. Running both is what + // separates "indefinite length" from "malformed" without matching + // on a library's message text. + strict, err := cbor.DecOptions{ + DupMapKey: cbor.DupMapKeyEnforcedAPF, + IndefLength: cbor.IndefLengthForbidden, + MaxNestedLevels: maxCBORNestingLevels, + UTF8: cbor.UTF8RejectInvalid, + DefaultMapType: reflect.TypeOf(map[any]any(nil)), + }.DecMode() + if err != nil { + panic("openid4vp: zkp_req strict CBOR mode: " + err.Error()) + } + zkpCBORStrict = strict +} + +func zkpCBOR() (cbor.DecMode, cbor.DecMode) { + zkpCBORModeOnce.Do(initZkpCBORModes) + return zkpCBORMode, zkpCBORStrict +} + +// decodeZkpPresentationCBOR reads the presentation map, with each +// member left raw so its own type error can be reported precisely. +func decodeZkpPresentationCBOR(data []byte) (map[string]cbor.RawMessage, error) { + lenient, strict := zkpCBOR() + + // Indefinite length first: a presentation that is indefinite AND + // malformed is reported as indefinite, which is the more specific + // statement about what the prover did. + var probe any + if _, err := strict.UnmarshalFirst(data, &probe); err != nil { + if _, lenientErr := lenient.UnmarshalFirst(data, &probe); lenientErr == nil { + return nil, refuse(ReasonCBORIndefiniteLength, "the presentation uses an indefinite length") + } + } + + // Into RawMessage, not `any`: a RawMessage keeps every value as + // bytes, so no tag is ever interpreted here. Decoding into `any` + // turns tag 0 into a time.Time and fails on a date that is merely + // WRONG - reporting an unparseable presentation where the right + // answer is proof_time_form, three rules later. + var whole cbor.RawMessage + rest, err := lenient.UnmarshalFirst(data, &whole) + if err != nil { + return nil, classifyCBORError(err) + } + if len(rest) > 0 { + return nil, refuse(ReasonCBORTrailingBytes, "%d bytes after the presentation", len(rest)) + } + // Duplicate keys and key types, at every level, now that the item + // is known to be one well-formed value. + if err := refuseDuplicateCBORKeys(lenient, whole, 0); err != nil { + return nil, err + } + + // The shape: a map whose keys are all text. + // + // The major type BEFORE the decode, because CBOR null decodes + // into a map as nil without error - which would arrive below as a + // map with no members and be reported as a missing one. + if !isCBORMap(whole) { + return nil, refuse(ReasonPresentationNotMap, "the presentation is not a map") + } + var raw map[any]cbor.RawMessage + if err := lenient.Unmarshal(whole, &raw); err != nil { + return nil, refuse(ReasonPresentationNotMap, "the presentation is not a map") + } + members := make(map[string]cbor.RawMessage, len(raw)) + for key, value := range raw { + name, isText := key.(string) + if !isText { + return nil, refuse(ReasonPresentationKeyNotText, "a presentation key is %T, not a text string", key) + } + members[name] = value + } + return members, nil +} + +// cborResultsMap reads the results map, keeping each value raw and +// insisting every key is text. +func cborResultsMap(raw cbor.RawMessage) (map[string]cbor.RawMessage, error) { + if isCBORNull(raw) { + return nil, refuse(ReasonPresentationMemberWrongType, "\"results\" is null") + } + lenient, _ := zkpCBOR() + var byAny map[any]cbor.RawMessage + if err := lenient.Unmarshal(raw, &byAny); err != nil { + var badKey *cbor.InvalidMapKeyTypeError + if errors.As(err, &badKey) { + return nil, refuse(ReasonResultKeyNotText, "%v", err) + } + return nil, refuse(ReasonPresentationMemberWrongType, "\"results\" is not a map") + } + results := make(map[string]cbor.RawMessage, len(byAny)) + for key, value := range byAny { + name, isText := key.(string) + if !isText { + return nil, refuse(ReasonResultKeyNotText, "a results key is %T, not a text string", key) + } + results[name] = value + } + return results, nil +} + +// decodeProofTimeTDate reads proof_time as the tag-0 text string it +// has to be, and returns the text unchanged. +// +// The TEXT, not a time.Time: the circuit takes those 20 bytes as a +// public input, so what matters is the spelling the prover used and +// not the instant it denotes. +func decodeProofTimeTDate(raw cbor.RawMessage) (string, error) { + if isCBORNull(raw) { + return "", refuse(ReasonProofTimeNotTDate, "proof_time is null") + } + lenient, _ := zkpCBOR() + var tag cbor.RawTag + if err := lenient.Unmarshal(raw, &tag); err != nil { + return "", refuse(ReasonProofTimeNotTDate, "proof_time is not a tagged value") + } + if tag.Number != 0 { + return "", refuse(ReasonProofTimeNotTDate, "proof_time carries tag %d, not tag 0", tag.Number) + } + var text string + if err := lenient.Unmarshal(tag.Content, &text); err != nil { + return "", refuse(ReasonProofTimeNotTDate, "the tag-0 content is not a text string") + } + return text, nil +} + +// classifyCBORError maps a decode failure to the reason code the +// shared vocabulary uses for it. +func classifyCBORError(err error) error { + var dup *cbor.DupMapKeyError + if errors.As(err, &dup) { + return refuse(ReasonCBORDuplicateKey, "%v", err) + } + var badTag *cbor.InadmissibleTagContentTypeError + if errors.As(err, &badTag) { + // The reader validates a tag's content type while checking + // well-formedness, so a tag 0 around anything but a text + // string fails here rather than at rule 8. proof_time is the + // only tagged member a ZkpPresentation has, and the error + // names tag 0 itself, so this is that rule's refusal arriving + // early rather than a malformed item. + return refuse(ReasonProofTimeNotTDate, "%v", err) + } + return refuse(ReasonCBORMalformed, "%v", err) +} + +// refuseDuplicateCBORKeys walks the item, refusing a map that repeats +// a key or carries a non-text key, at ANY level. +// +// Done by hand because the values stay raw: decoding the whole item +// into `any` would enforce duplicates for free but would also +// interpret tags, which is exactly what must not happen before +// proof_time is reached. +// +// Keys are compared structurally - the decoder refuses a repeat even +// when the two values are identical - because a reader that collapsed +// them would be choosing which one to believe. +func refuseDuplicateCBORKeys(mode cbor.DecMode, raw cbor.RawMessage, depth int) error { + if depth > maxCBORNestingLevels { + return refuse(ReasonCBORMalformed, "nesting deeper than %d levels", maxCBORNestingLevels) + } + switch { + case isCBORMap(raw): + var asMap map[any]cbor.RawMessage + if err := mode.Unmarshal(raw, &asMap); err != nil { + var badKey *cbor.InvalidMapKeyTypeError + if errors.As(err, &badKey) { + // An unhashable key - an array or a map used as one. + return refuse(ReasonPresentationKeyNotText, "%v", err) + } + return classifyCBORError(err) + } + for _, value := range asMap { + if err := refuseDuplicateCBORKeys(mode, value, depth+1); err != nil { + return err + } + } + case isCBORArray(raw): + var items []cbor.RawMessage + if err := mode.Unmarshal(raw, &items); err != nil { + return classifyCBORError(err) + } + for _, item := range items { + if err := refuseDuplicateCBORKeys(mode, item, depth+1); err != nil { + return err + } + } + } + return nil +} + +// isCBORMap and isCBORArray read the major type off the head byte, +// without decoding the item. +func isCBORMap(raw cbor.RawMessage) bool { return len(raw) > 0 && raw[0]>>5 == 5 } +func isCBORArray(raw cbor.RawMessage) bool { return len(raw) > 0 && raw[0]>>5 == 4 } diff --git a/pkg/openid4vp/zkp_req_json.go b/pkg/openid4vp/zkp_req_json.go new file mode 100644 index 000000000..f6c3647b5 --- /dev/null +++ b/pkg/openid4vp/zkp_req_json.go @@ -0,0 +1,139 @@ +package openid4vp + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" +) + +// A repeated JSON member name has to be judged on the RAW TEXT. +// +// A decoded object cannot represent one: every JSON decoder keeps one +// of the values and discards the other silently. So a vp_token +// carrying two entries for one query id arrives at a map as one entry, +// and whichever the decoder kept is the wallet's choice rather than +// anything this verifier decided. +// +// Unlike the pre-v0.4 shape there is nothing to skip: the results now +// live inside the CBOR presentation, where a repeated key is +// cbor_duplicate_key, so the whole JSON envelope is scanned. +func refuseDuplicateJSONMembers(raw []byte) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := scanForDuplicates(decoder); err != nil { + var refusal *ZkpReqResponseError + if errors.As(err, &refusal) { + return refusal + } + // A scan that cannot run is not a duplicate; it is an + // unparseable response, and the caller's decode reports that + // with the right code. + return nil + } + return nil +} + +func scanForDuplicates(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, isDelim := token.(json.Delim) + if !isDelim { + return nil + } + + switch delim { + case '{': + seen := map[string]bool{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return io.ErrUnexpectedEOF + } + if seen[key] { + return refuse(ReasonDuplicateMember, "member %q appears more than once", key) + } + seen[key] = true + if err := scanForDuplicates(decoder); err != nil { + return err + } + } + case '[': + for decoder.More() { + if err := scanForDuplicates(decoder); err != nil { + return err + } + } + } + // The matching closing delimiter. + _, err = decoder.Token() + return err +} + +// decodeCanonicalBase64URL decodes unpadded base64url and refuses +// anything that is not the canonical spelling of those bytes. +// +// Decoding is not the same as the wire contract. Go's decoder skips CR +// and LF and accepts a final character whose unused low bits are set, +// so a value with a newline in it and a value differing only in those +// bits both decode - two spellings of one presentation, from a +// contract that says there is one. Re-encoding and comparing catches +// every non-canonical spelling at once, rather than a list of rules +// that has to stay complete. +func decodeCanonicalBase64URL(encoded string) ([]byte, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("not unpadded base64url: %w", err) + } + if base64.RawURLEncoding.EncodeToString(raw) != encoded { + return nil, errors.New("not canonical unpadded base64url") + } + return raw, nil +} + +// isJSONArray reports whether raw is a JSON array, by its first +// non-space byte. Shape, not contents. +// +// Before the decode, because Go's decoder accepts JSON null into a +// slice without error and leaves it nil - which would arrive as an +// empty array and be reported as the wrong refusal. +func isJSONArray(raw json.RawMessage) bool { + trimmed := bytes.TrimLeft(raw, " \t\r\n") + return len(trimmed) > 0 && trimmed[0] == '[' +} + +// strictDecode decodes exactly one JSON value and refuses trailing +// content, and refuses JSON null as a value of the wrong type. +// +// Go's decoder treats null as "leave the target alone": into a string +// it is a no-op, into a slice or map it leaves nil, and NEITHER +// returns an error. Every member of a zkp_req response has a type and +// none of them may be null, so one place to be right about it. +func strictDecode(raw []byte, into any) error { + if isJSONNull(raw) { + return errors.New("the value is null") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(into); err != nil { + return err + } + var trailing json.RawMessage + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("trailing content after the JSON value") + } + return nil +} + +// isJSONNull reports whether raw is the JSON literal null. +func isJSONNull(raw []byte) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} diff --git a/pkg/openid4vp/zkp_req_profile.go b/pkg/openid4vp/zkp_req_profile.go new file mode 100644 index 000000000..1e13c5bbb --- /dev/null +++ b/pkg/openid4vp/zkp_req_profile.go @@ -0,0 +1,367 @@ +package openid4vp + +import ( + "encoding/json" + "fmt" + "maps" + "math" + "slices" + "strings" + "time" +) + +// ProfileNymAuth validates a zkp_req request against the EUDI nym_auth +// rulebook. +// +// It is one profile among possible others, which is why it is named +// rather than being a "strict" flag. A zkp_req request that is not a +// nym_auth request is not invalid - the format is general, the rulebook +// is one use of it - and a boolean would say otherwise. +const ProfileNymAuth = "nym_auth" + +// ValidateProfile checks query against the named profile. +// +// Nothing calls this on the request path. It is for CONFIGURATION: an +// operator who wants to be told that a preset violates the rulebook asks +// for it by name, at startup, where the answer is useful. Traffic stays +// unvalidated on purpose - this verifier is how wallets are tested, and +// the specifications under it are still moving. +func ValidateProfile(profile string, query CredentialQuery) error { + switch profile { + case "": + return nil + case ProfileNymAuth: + return validateNymAuth(query) + default: + return fmt.Errorf("unknown validation profile %q", profile) + } +} + +// nymAuthDoctype is the credential the rulebook defines. +// +// Pinned rather than "any non-empty doctype": a profile named after the +// rulebook that accepts an unrelated document is not checking the thing +// it is named for. A later revision of the rulebook will mint a new +// identifier and this will need updating with it - which is the right +// cost, since the alternative is a check that passes on anything. +const nymAuthDoctype = "eu.europa.ec.eudi.nym_auth.1" + +// nym_auth pseudonym configurations, and whether each takes a +// verifier_context. +// +// Whether a configuration derives verifier_input is no longer a property +// of the REQUEST. The rulebook took verifier_id_type out of it +// (nym_auth_rulebook#5): the wallet decides how to identify the verifier, +// from its own ordered list of identity kinds and the trust framework's +// answer, so there is nothing for a request to say and nothing here to +// check. known-offender-matching-ssc and account-recovery-ssc still +// derive one; the circuit knows that, and the wallet does. +// +// The rulebook's -psc configurations are deliberately ABSENT. They are +// well-defined there, but a psc is a commitment the HOLDER already knows, +// and nothing yet specifies how a Verifier conveys one to a wallet - +// which means a preset naming one cannot be sent, whatever this profile +// says about it. Accepting it here would report a request as valid that +// no wallet can be asked for. See nymAuthPendingConfigs. +var nymAuthConfigs = map[string]struct{ usesContext bool }{ + "known-offender-matching-ssc": {}, + "account-recovery-ssc": {usesContext: true}, + "user-managed-pseudonym-xsite-ssc": {}, +} + +// nymAuthPendingConfigs are rulebook configurations this build cannot +// request yet, named separately so an operator gets told WHY rather than +// "not a configuration the rulebook defines", which would be false. +var nymAuthPendingConfigs = map[string]string{ + "known-offender-matching-psc": "a psc is a commitment the holder already knows, and there is no specified mechanism yet for conveying one to a wallet", +} + +func validateNymAuth(query CredentialQuery) error { + if query.Raw != nil { + return fmt.Errorf("a raw query cannot be checked against the %s profile; the profile reads members a raw query does not model", ProfileNymAuth) + } + if !IsZkpReqFormat(query.Format) { + return fmt.Errorf("format is %q, and the %s profile describes zkp_req requests", query.Format, ProfileNymAuth) + } + if err := validateNymAuthMeta(query.Meta); err != nil { + return err + } + // The shared matcher vectors list an explicit false among the + // well-formed-but-rulebook-unsatisfiable requests + // (ok_satisfiability_not_judged.json): the planner passes it and the + // rulebook does not. Absent is fine - OpenID4VP 1.0 6.1 defaults it to + // true - so only an explicit false is refused. + if query.RequireCryptographicHolderBinding != nil && !*query.RequireCryptographicHolderBinding { + return fmt.Errorf("require_cryptographic_holder_binding is explicitly false, which the %s rulebook does not allow", ProfileNymAuth) + } + if len(query.Claims) > 0 || len(query.ClaimSet) > 0 { + return fmt.Errorf("a zkp_req query discloses nothing, so it carries no claims: every value comes from req") + } + if len(query.Req) == 0 { + return fmt.Errorf("req is required and must not be empty") + } + + handles := map[string]bool{} + counts := map[string]int{} + for i, item := range query.Req { + where := fmt.Sprintf("req[%d]", i) + if item.FnID == "" { + return fmt.Errorf("%s: id is required", where) + } + if item.Return == "" { + return fmt.Errorf("%s: return is required", where) + } + if handles[item.Return] { + return fmt.Errorf("%s: return %q is used more than once; a handle identifies one result", where, item.Return) + } + handles[item.Return] = true + counts[item.FnID]++ + + switch item.FnID { + case FunctionPseudonym: + if err := validateNymAuthPseudonym(where, query.Meta.SourceFormat, item); err != nil { + return err + } + case FunctionAgeCheck: + if err := validateNymAuthAgeCheck(where, item); err != nil { + return err + } + default: + return fmt.Errorf("%s: %q is not a function the %s rulebook registers", where, item.FnID, ProfileNymAuth) + } + } + + // "A req MUST NOT contain more than one pseudonym item", and + // the same for age_check. + for _, function := range slices.Sorted(maps.Keys(counts)) { + if counts[function] > 1 { + return fmt.Errorf("req carries %d %s items; the rulebook allows one", counts[function], function) + } + } + return nil +} + +// nymAuthBBSSchemaID is the type value of the blind-BBS nym_auth +// attestation, the way nymAuthDoctype is for the mdoc one. +const nymAuthBBSSchemaID = "eu.europa.ec.eudi.nym_auth.bbs.1" + +// validateNymAuthMeta checks the pair that has to agree: which stored +// attestation the proof is made over, and the type value naming it. +// +// They are one check and not two because each source format has exactly +// one type value member - doctype_value for mso_mdoc, schema_id_value +// for bbs_cd - and carrying the other one, or carrying vct_values, which +// belongs to dc+sd-jwt, is what the matcher refuses as +// meta_type_value_invalid. +func validateNymAuthMeta(meta MetaQuery) error { + // The planner refuses a zkp_req meta with no string source_format + // (invalid_meta) before it reads anything else, so a request that + // goes out without one is answered by nobody. + switch meta.SourceFormat { + case SourceFormatMsoMdoc: + if meta.DoctypeValue != nymAuthDoctype { + if meta.DoctypeValue == "" { + return fmt.Errorf("meta.doctype_value is required for source_format %s, and the %s rulebook defines it as %q", + SourceFormatMsoMdoc, ProfileNymAuth, nymAuthDoctype) + } + return fmt.Errorf("meta.doctype_value is %q; the %s rulebook defines this credential as %q", + meta.DoctypeValue, ProfileNymAuth, nymAuthDoctype) + } + if meta.SchemaIDValue != "" { + return fmt.Errorf("meta.schema_id_value belongs to source_format %s; an %s request names its type with doctype_value", + SourceFormatBbsCd, SourceFormatMsoMdoc) + } + case SourceFormatBbsCd: + if meta.SchemaIDValue != nymAuthBBSSchemaID { + if meta.SchemaIDValue == "" { + return fmt.Errorf("meta.schema_id_value is required for source_format %s, and the %s rulebook defines it as %q", + SourceFormatBbsCd, ProfileNymAuth, nymAuthBBSSchemaID) + } + return fmt.Errorf("meta.schema_id_value is %q; the %s rulebook defines this credential as %q", + meta.SchemaIDValue, ProfileNymAuth, nymAuthBBSSchemaID) + } + if meta.DoctypeValue != "" { + return fmt.Errorf("meta.doctype_value belongs to source_format %s; a %s request names its type with schema_id_value", + SourceFormatMsoMdoc, SourceFormatBbsCd) + } + case "": + return fmt.Errorf("meta.source_format is required for a %s request, and names the stored attestation the proof is made over (%s or %s)", + FormatZkpReq, SourceFormatMsoMdoc, SourceFormatBbsCd) + default: + return fmt.Errorf("meta.source_format is %q; the %s rulebook defines %s and %s (dc+sd-jwt is reserved)", + meta.SourceFormat, ProfileNymAuth, SourceFormatMsoMdoc, SourceFormatBbsCd) + } + if len(meta.VCTValues) > 0 { + return fmt.Errorf("meta.vct_values belongs to dc+sd-jwt; a %s request names its type with doctype_value or schema_id_value", FormatZkpReq) + } + return nil +} + +func validateNymAuthPseudonym(where, sourceFormat string, item ReqItem) error { + config, _ := item.Params["config"].(string) + if config == "" { + return fmt.Errorf("%s: params.config is required", where) + } + spec, known := nymAuthConfigs[config] + if !known { + if why, pending := nymAuthPendingConfigs[config]; pending { + return fmt.Errorf("%s: %q is defined by the rulebook but cannot be requested yet: %s", where, config, why) + } + return fmt.Errorf("%s: %q is not a configuration the rulebook defines (%s)", where, config, + strings.Join(slices.Sorted(maps.Keys(nymAuthConfigs)), ", ")) + } + + // Two named members the rulebook does not allow on a request, kept + // separate from the unknown-member case: everything this profile + // supports is an -ssc configuration, so a psc - the commitment a + // HOLDER already knows - has no place in one; and the request names + // the KIND of verifier identity, which the wallet then derives, + // rather than the identity itself. + // + // Named members only. An unknown parameter stays permissive, because + // a newer rulebook adding one is the case this whole format is built + // around. + for name, why := range map[string]string{ + "psc": "every configuration this profile supports is -ssc, and a psc is the holder's own commitment", + "verifier_id": "the wallet derives the verifier's identity itself; a request never carries it", + } { + if _, present := item.Params[name]; present { + return fmt.Errorf("%s: params.%s is not a member the %s rulebook allows on a request: %s", where, name, ProfileNymAuth, why) + } + } + + // verifier_id_type is deliberately NOT checked, in either direction. + // + // It is no longer part of the request (nym_auth_rulebook#5). A + // missing one is not a violation, so this does not require it; and a + // request still carrying one has to remain sendable, so this does not + // refuse it either. A verifier testing a wallet against the legacy + // shape is a thing this format exists to allow, and wallets accept + // and ignore the member. + // + // It is named here rather than simply left out, because "the profile + // says nothing about it" is a decision and not an oversight. + + // ver and prf became REQUIRED parameters of pseudonym in v0.4, and + // the matcher refuses a request missing either as unsatisfiable + // (pseudonym_ver_missing, pseudonym_prf_missing). An operator who + // copies an older example gets a preset that starts cleanly and is + // then declined by every wallet. + ver, hasVer := item.Params["ver"] + if !hasVer { + return fmt.Errorf("%s: params.ver is required, and the %s rulebook defines %d", where, ProfileNymAuth, PseudonymVer1) + } + n, whole := wholeNumber(ver) + if !whole { + return fmt.Errorf("%s: params.ver must be an integer", where) + } + if n != PseudonymVer1 { + return fmt.Errorf("%s: params.ver is %d; the %s rulebook defines %d", where, n, ProfileNymAuth, PseudonymVer1) + } + + prf, hasPRF := item.Params["prf"] + if !hasPRF { + return fmt.Errorf("%s: params.prf is required", where) + } + name, isString := prf.(string) + if !isString { + return fmt.Errorf("%s: params.prf must be a string", where) + } + // Which prf is defined depends on BOTH the source format and the + // configuration: hashdh-bls12381g1 exists for blind-BBS, and only + // for the one configuration that uses it. + switch { + case sourceFormat == SourceFormatMsoMdoc && name == PrfSHA256: + case sourceFormat == SourceFormatBbsCd && name == PrfHashDHBLS12381G1 && config == "known-offender-matching-ssc": + default: + return fmt.Errorf("%s: params.prf %q is not defined for source_format %q with config %q", where, name, sourceFormat, config) + } + + context, hasContext := item.Params["verifier_context"] + switch { + case spec.usesContext && !hasContext: + return fmt.Errorf("%s: %q requires params.verifier_context", where, config) + case !spec.usesContext && hasContext: + return fmt.Errorf("%s: %q does not allow params.verifier_context", where, config) + } + if hasContext { + value, ok := context.(string) + if !ok { + return fmt.Errorf("%s: params.verifier_context must be a string", where) + } + if n := len(value); n < 1 || n > 64 { + return fmt.Errorf("%s: params.verifier_context is %d bytes; the rulebook allows 1 to 64", where, n) + } + } + return nil +} + +// wholeNumber reads a configured or received integer, whatever numeric +// type it arrived as: YAML gives an int, JSON a float64, and a decoder +// set to UseNumber a json.Number. A value with a fractional part is not +// an integer and is reported as such rather than truncated. +func wholeNumber(value any) (int64, bool) { + switch n := value.(type) { + case int: + return int64(n), true + case int32: + return int64(n), true + case int64: + return n, true + case uint64: + return int64(n), n <= math.MaxInt64 + case float64: + return int64(n), n == math.Trunc(n) + case json.Number: + i, err := n.Int64() + return i, err == nil + } + return 0, false +} + +// minAgeSpanDays is the rulebook's floor on an age_check range: a +// sufficiently narrow range is an exact birth date, which the predicate +// is not meant to reveal. +const minAgeSpanDays = 365 + +func validateNymAuthAgeCheck(where string, item ReqItem) error { + ge, hasGE, err := nymAuthDate(where, item.Params, "ge") + if err != nil { + return err + } + le, hasLE, err := nymAuthDate(where, item.Params, "le") + if err != nil { + return err + } + if !hasGE && !hasLE { + return fmt.Errorf("%s: at least one of params.ge and params.le is required", where) + } + if !hasGE || !hasLE { + return nil + } + if le.Before(ge) { + return fmt.Errorf("%s: params.le is before params.ge", where) + } + // Inclusive, as the rulebook counts it: le - ge + 1 days. + if days := int(le.Sub(ge).Hours()/24) + 1; days < minAgeSpanDays { + return fmt.Errorf("%s: the range spans %d days; the rulebook requires at least %d, since a narrow range is an exact date", + where, days, minAgeSpanDays) + } + return nil +} + +func nymAuthDate(where string, params map[string]any, name string) (time.Time, bool, error) { + raw, present := params[name] + if !present { + return time.Time{}, false, nil + } + s, ok := raw.(string) + if !ok { + return time.Time{}, false, fmt.Errorf("%s: params.%s must be a string", where, name) + } + parsed, err := time.Parse(time.DateOnly, s) + if err != nil { + return time.Time{}, false, fmt.Errorf("%s: params.%s is %q, which is not an RFC 3339 full-date", where, name, s) + } + return parsed, true, nil +} diff --git a/pkg/openid4vp/zkp_req_profile_test.go b/pkg/openid4vp/zkp_req_profile_test.go new file mode 100644 index 000000000..bd78c7652 --- /dev/null +++ b/pkg/openid4vp/zkp_req_profile_test.go @@ -0,0 +1,337 @@ +package openid4vp + +import ( + "encoding/json" + "maps" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func nymQuery(items ...ReqItem) CredentialQuery { + return CredentialQuery{ + ID: "nym_attestation", Format: FormatZkpReq, + Meta: MetaQuery{SourceFormat: SourceFormatMsoMdoc, DoctypeValue: "eu.europa.ec.eudi.nym_auth.1"}, + Req: items, + } +} + +// pseudonymItem fills in the two parameters v0.4 makes REQUIRED, so a +// case can name only the thing it is about. A case testing ver or prf +// overwrites them. +func pseudonymItem(params map[string]any) ReqItem { + withDefaults := map[string]any{"ver": PseudonymVer1, "prf": PrfSHA256} + maps.Copy(withDefaults, params) + return ReqItem{FnID: FunctionPseudonym, Return: "nym-1", Params: withDefaults} +} + +// The conformant request from the rulebook passes. +func TestNymAuthProfileAcceptsTheRulebookExample(t *testing.T) { + query := nymQuery( + pseudonymItem(map[string]any{"config": "known-offender-matching-ssc"}), + ReqItem{FnID: FunctionAgeCheck, Return: "age-1", Params: map[string]any{"ge": "2008-10-10", "le": "2013-10-09"}}, + ) + assert.NoError(t, ValidateProfile(ProfileNymAuth, query)) +} + +// Each rulebook MUST, violated in turn. +func TestNymAuthProfileRejectsEachViolation(t *testing.T) { + for _, tc := range []struct { + name string + query CredentialQuery + want string + }{ + { + name: "two pseudonym items", + query: nymQuery(pseudonymItem(map[string]any{"config": "user-managed-pseudonym-xsite-ssc"}), func() ReqItem { + second := pseudonymItem(map[string]any{"config": "user-managed-pseudonym-xsite-ssc"}) + second.Return = "nym-2" + return second + }()), + want: "allows one", + }, + { + name: "two age_check items", + query: nymQuery( + ReqItem{FnID: FunctionAgeCheck, Return: "a1", Params: map[string]any{"ge": "2000-01-01"}}, + ReqItem{FnID: FunctionAgeCheck, Return: "a2", Params: map[string]any{"ge": "2000-01-01"}}), + want: "allows one", + }, + { + name: "a duplicate handle", + query: nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "same", Params: map[string]any{"ge": "2000-01-01"}}, ReqItem{FnID: FunctionPseudonym, Return: "same", Params: map[string]any{"config": "user-managed-pseudonym-xsite-ssc"}}), + want: "more than once", + }, + { + name: "an unknown function", + query: nymQuery(ReqItem{FnID: "no-such-function", Return: "h"}), + want: "not a function the nym_auth rulebook registers", + }, + { + name: "an unknown pseudonym configuration", + query: nymQuery(pseudonymItem(map[string]any{"config": "made-up"})), + want: "not a configuration the rulebook defines", + }, + { + name: "no verifier_context for account recovery", + query: nymQuery(pseudonymItem(map[string]any{"config": "account-recovery-ssc"})), + want: "requires params.verifier_context", + }, + { + name: "a verifier_context where it is not allowed", + query: nymQuery(pseudonymItem(map[string]any{"config": "known-offender-matching-ssc", "verifier_context": "x"})), + want: "does not allow params.verifier_context", + }, + { + name: "a verifier_context past 64 bytes", + query: nymQuery(pseudonymItem(map[string]any{"config": "account-recovery-ssc", "verifier_context": string(make([]byte, 65))})), + want: "1 to 64", + }, + { + name: "an age_check with neither bound", + query: nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a"}), + want: "at least one of params.ge and params.le", + }, + { + name: "le before ge", + query: nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2013-10-09", "le": "2008-10-10"}}), + want: "before params.ge", + }, + { + name: "a range under 365 days", + query: nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2010-01-01", "le": "2010-06-01"}}), + want: "at least 365", + }, + { + name: "a date that is not a full-date", + query: nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2010-01-01T00:00:00Z"}}), + want: "not an RFC 3339 full-date", + }, + { + name: "no req at all", + query: nymQuery(), + want: "req is required", + }, + { + name: "no doctype", + query: CredentialQuery{Format: FormatZkpReq, Meta: MetaQuery{SourceFormat: SourceFormatMsoMdoc}, Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2000-01-01"}}}}, + want: "meta.doctype_value is required", + }, + { + name: "claims alongside req", + query: func() CredentialQuery { + q := nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2000-01-01"}}) + q.Claims = []ClaimQuery{{Path: StringPath("ssc")}} + return q + }(), + want: "discloses nothing", + }, + { + name: "the wrong format", + query: CredentialQuery{Format: FormatMsoMdoc, Meta: MetaQuery{DoctypeValue: "d"}, Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a"}}}, + want: "describes zkp_req requests", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateProfile(ProfileNymAuth, tc.query) + require.Error(t, err, "this violates a rulebook MUST and the profile must say so") + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +// Exactly 365 days is allowed; 364 is not. The rulebook counts the range +// inclusively, so an off-by-one here silently moves where the line is. +func TestNymAuthProfileAgeSpanBoundary(t *testing.T) { + // 2010-01-01 to 2010-12-31 is 365 days inclusive (2010 is not a leap year). + ok := nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2010-01-01", "le": "2010-12-31"}}) + assert.NoError(t, ValidateProfile(ProfileNymAuth, ok)) + + short := nymQuery(ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2010-01-01", "le": "2010-12-30"}}) + require.Error(t, ValidateProfile(ProfileNymAuth, short)) +} + +// No profile means no validation. That is the default, and it is what +// lets this verifier send a request no rulebook would accept. +func TestNoProfileValidatesNothing(t *testing.T) { + nonsense := CredentialQuery{ + Format: FormatZkpReq, + Req: []ReqItem{ + {FnID: "whatever", Return: "same"}, + {FnID: "whatever", Return: "same"}, + }, + } + assert.NoError(t, ValidateProfile("", nonsense)) + + err := ValidateProfile("no-such-profile", nonsense) + require.Error(t, err, "an unknown profile name is a configuration mistake, not a silent no-op") + assert.Contains(t, err.Error(), "unknown validation profile") +} + +// A raw query is bytes this package does not model, so the profile says +// it cannot check it rather than passing it as if it had. +func TestProfileRefusesToJudgeARawQuery(t *testing.T) { + err := ValidateProfile(ProfileNymAuth, CredentialQuery{Raw: json.RawMessage(`{"format":"zkp_req"}`)}) + require.Error(t, err) + assert.Contains(t, err.Error(), "raw query cannot be checked") +} + +// A profile named after the rulebook has to check the credential the +// rulebook is about. "Some doctype is present" would pass a request for +// an unrelated document. +func TestNymAuthProfilePinsTheDoctype(t *testing.T) { + conforming := pseudonymItem(map[string]any{"config": "known-offender-matching-ssc"}) + + for _, tc := range []struct{ name, doctype, want string }{ + {"the rulebook's own", nymAuthDoctype, ""}, + {"absent", "", "required"}, + {"an unrelated document", "org.iso.18013.5.1.mDL", "defines this credential as"}, + {"a version the rulebook has not minted", "eu.europa.ec.eudi.nym_auth.2", "defines this credential as"}, + } { + t.Run(tc.name, func(t *testing.T) { + query := nymQuery(conforming) + query.Meta.DoctypeValue = tc.doctype + + err := ValidateProfile(ProfileNymAuth, query) + if tc.want == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +// The shared matcher vectors put an explicit false among the requests +// that are well-formed and rulebook-UNSATISFIABLE +// (ok_satisfiability_not_judged.json). The planner passes it by design; +// the profile is the thing that is supposed to say so. +// +// Absent must stay fine: OpenID4VP 1.0 6.1 defaults it to true, and a nil +// pointer is how that default is preserved. +func TestNymAuthProfileRejectsExplicitlyDisabledHolderBinding(t *testing.T) { + conforming := pseudonymItem(map[string]any{"config": "known-offender-matching-ssc"}) + no, yes := false, true + + for _, tc := range []struct { + name string + set *bool + want string + }{ + {"absent, which means true", nil, ""}, + {"explicitly true", &yes, ""}, + {"explicitly false", &no, "require_cryptographic_holder_binding"}, + } { + t.Run(tc.name, func(t *testing.T) { + query := nymQuery(conforming) + query.RequireCryptographicHolderBinding = tc.set + + err := ValidateProfile(ProfileNymAuth, query) + if tc.want == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +// A -psc configuration is well-defined by the rulebook and cannot be +// requested: a psc is a commitment the holder already knows, and nothing +// specifies yet how a Verifier conveys one to a wallet. Accepting it +// would report a preset as valid that no wallet can be asked for. +// +// The error has to say which of the two it is. "Not a configuration the +// rulebook defines" would be false, and would send an operator looking +// for a typo. +func TestNymAuthProfileRefusesPSCConfigurationsAsPending(t *testing.T) { + err := ValidateProfile(ProfileNymAuth, nymQuery(pseudonymItem(map[string]any{ + "config": "known-offender-matching-psc", + }))) + require.Error(t, err) + assert.Contains(t, err.Error(), "defined by the rulebook but cannot be requested yet") + assert.Contains(t, err.Error(), "conveying one to a wallet") + assert.NotContains(t, err.Error(), "is not a configuration the rulebook defines") +} + +// Two named members the rulebook does not allow on a request. Separate +// from the unknown-member case, which stays permissive: a newer rulebook +// adding a parameter is what this whole format is built around. +func TestNymAuthProfileRefusesMembersTheRulebookDoesNotAllow(t *testing.T) { + base := map[string]any{"config": "known-offender-matching-ssc"} + with := func(name string, value any) map[string]any { + params := map[string]any{} + maps.Copy(params, base) + params[name] = value + return params + } + + for _, tc := range []struct{ name, param, want string }{ + {"a psc, which only a holder has", "psc", "every configuration this profile supports is -ssc"}, + {"a verifier_id, where the request names the kind", "verifier_id", "the wallet derives the verifier"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateProfile(ProfileNymAuth, nymQuery(pseudonymItem(with(tc.param, "x")))) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + assert.Contains(t, err.Error(), "params."+tc.param) + }) + } + + t.Run("an unknown parameter is still allowed", func(t *testing.T) { + assert.NoError(t, ValidateProfile(ProfileNymAuth, + nymQuery(pseudonymItem(with("some_future_param", "x"))))) + }) +} + +// verifier_id_type is no longer part of the request +// (nym_auth_rulebook#5): the wallet decides how to identify the verifier, +// from its own ordered list of identity kinds, so there is nothing for a +// request to say about it. +// +// This profile therefore says nothing about it either, in BOTH +// directions, and that is what this pins. A missing one is not a +// violation - every other case in this file now omits it - and a request +// still carrying one stays sendable whatever its value, because testing a +// wallet against the legacy shape is a thing this format exists to allow. +// An earlier revision of this profile REQUIRED it on some configurations +// and REFUSED it on others; neither rule survives, so both are exercised +// here against the configurations that used to carry them. +func TestNymAuthProfileIgnoresVerifierIDType(t *testing.T) { + for _, tc := range []struct { + name string + value any + }{ + {"the kind the legacy requests carried", "x509_san_dns"}, + {"another the old rulebook named", "semantic"}, + {"empty", ""}, + {"an unknown kind", "dns"}, + {"a different case, which used to be refused", "X509_SAN_DNS"}, + {"and a value that is not even a string", 7}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, config := range []string{ + "known-offender-matching-ssc", // used to REQUIRE it + "user-managed-pseudonym-xsite-ssc", // used to REFUSE it + } { + assert.NoError(t, ValidateProfile(ProfileNymAuth, nymQuery(pseudonymItem( + map[string]any{"config": config, "verifier_id_type": tc.value}))), + "config %q must neither require nor refuse verifier_id_type", config) + } + }) + } + + // And the request shape the rulebook now defines - no + // verifier_id_type at all - validates on every configuration. + for _, params := range []map[string]any{ + {"config": "known-offender-matching-ssc"}, + {"config": "user-managed-pseudonym-xsite-ssc"}, + {"config": "account-recovery-ssc", "verifier_context": "ctx"}, + } { + assert.NoError(t, ValidateProfile(ProfileNymAuth, nymQuery(pseudonymItem(params))), + "the normative request form must validate: %v", params) + } +} diff --git a/pkg/openid4vp/zkp_req_proof_time.go b/pkg/openid4vp/zkp_req_proof_time.go new file mode 100644 index 000000000..e472c8b2e --- /dev/null +++ b/pkg/openid4vp/zkp_req_proof_time.go @@ -0,0 +1,147 @@ +package openid4vp + +import ( + "errors" + "fmt" + "time" +) + +// ProofTimeLayout is the only spelling of T the circuits take: 20 +// ASCII bytes, YYYY-MM-DDThh:mm:ssZ. +// +// Second precision, no fractional seconds, upper-case T and Z, no +// offset. T is a public input, bound byte for byte into the proof, so +// "the same instant written differently" is a different input and a +// proof that cannot verify. +const ProofTimeLayout = "2006-01-02T15:04:05Z" + +// ProofTimeBytes is the length of that form. +const ProofTimeBytes = 20 + +// Recommended tolerances for a prover-supplied proof_time. +// +// RECOMMENDATIONS, not protocol constants: the tolerance is the +// verifier's to set. They are here so a deployment that does not +// choose gets the shared defaults rather than something invented. +const ( + RecommendedSkew = 60 * time.Second + RecommendedMaxAge = 600 * time.Second +) + +// ParseProofTime reads the exact 20-byte form and nothing else. +// +// time.Parse alone is not enough: it accepts spellings this does not +// (a fractional second, a numeric offset) and would turn a value the +// shared vectors refuse into one this verifier accepts. So the length +// and the literal separators are checked first, and the parse then +// settles whether the date is real - no month 13, no 30 February, no +// hour 24. +func ParseProofTime(text string) (time.Time, error) { + if len(text) != ProofTimeBytes { + return time.Time{}, fmt.Errorf("%w: proof_time is %d bytes; the form is %d, %q", ErrProofTimeForm, len(text), ProofTimeBytes, ProofTimeLayout) + } + if text[10] != 'T' || text[19] != 'Z' { + return time.Time{}, fmt.Errorf("%w: proof_time %q is not %q: the separators must be upper-case T and Z", ErrProofTimeForm, text, ProofTimeLayout) + } + for _, at := range []int{4, 7} { + if text[at] != '-' { + return time.Time{}, fmt.Errorf("%w: proof_time %q is not %q", ErrProofTimeForm, text, ProofTimeLayout) + } + } + for _, at := range []int{13, 16} { + if text[at] != ':' { + return time.Time{}, fmt.Errorf("%w: proof_time %q is not %q", ErrProofTimeForm, text, ProofTimeLayout) + } + } + for _, at := range []int{0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18} { + if text[at] < '0' || text[at] > '9' { + return time.Time{}, fmt.Errorf("%w: proof_time %q is not %q: %q is not a digit", ErrProofTimeForm, text, ProofTimeLayout, text[at]) + } + } + parsed, err := time.Parse(ProofTimeLayout, text) + if err != nil { + // Reached only for a date that is well-formed and unreal. + return time.Time{}, fmt.Errorf("%w: proof_time %q is not a real UTC instant: %v", ErrProofTimeNotAnInstant, text, err) + } + return parsed.UTC(), nil +} + +// RenderProofTime is the inverse: the 20-byte form of an epoch +// second, refusing a value the form cannot spell. +// +// Out of range rather than clamped or wrapped: a year 10000 or a +// pre-epoch instant has no 20-byte spelling, and inventing one would +// put a value on the wire that no other implementation reads back. +func RenderProofTime(secs int64) (string, error) { + if secs < ProofTimeMinSecs || secs > ProofTimeMaxSecs { + return "", fmt.Errorf("%w: %d is outside %d..%d", ErrProofTimeOutOfRange, secs, ProofTimeMinSecs, ProofTimeMaxSecs) + } + return time.Unix(secs, 0).UTC().Format(ProofTimeLayout), nil +} + +// The proof_time failures, as distinguishable sentinels. +// +// The RESPONSE path reports every spelling failure as proof_time_form, +// which is what the shared response vectors demand. These finer codes +// are the proof_time helper's own vocabulary, which the proof_time +// vectors pin: a caller that wants to tell "not the 20-byte form" from +// "a form that denotes no real instant" can, and the response +// validator deliberately does not. +var ( + // ErrProofTimeForm is a value that is not the 20-byte spelling. + ErrProofTimeForm = errors.New("proof_time_form") + // ErrProofTimeNotAnInstant is the right spelling for a date that + // does not exist - month 13, 30 February, hour 24, second 60. + ErrProofTimeNotAnInstant = errors.New("proof_time_not_an_instant") + // ErrProofTimeOutOfRange is an epoch second outside the range the + // 20-byte form can spell at all. + ErrProofTimeOutOfRange = errors.New("proof_time_out_of_range") + // ErrProofTimeInFuture and ErrProofTimeTooOld are the two sides of + // the tolerance window. + ErrProofTimeInFuture = errors.New("proof_time_in_future") + ErrProofTimeTooOld = errors.New("proof_time_too_old") +) + +// The range the 20-byte form can express: 1970-01-01T00:00:00Z to +// 9999-12-31T23:59:59Z. +const ( + ProofTimeMinSecs int64 = 0 + ProofTimeMaxSecs int64 = 253402300799 +) + +// CheckProofTimeTolerance accepts now-maxAge <= proofTime <= now+skew, +// both inclusive. +// +// The prover supplies T, so a verifier that accepted any T would +// accept a proof made at any time - including one replayed from long +// ago, against a credential since revoked, or one made with a clock +// set forward. The window is what makes a prover-supplied value safe +// to use, so it REFUSES rather than clamping: clamping would verify +// against a T the prover did not use, which cannot succeed anyway and +// would report the wrong reason when it failed. +// +// The message names the observed deviation and the limit, because the +// operator's next question is always "by how much". +func CheckProofTimeTolerance(proofTime, now time.Time, skew, maxAge time.Duration) error { + if proofTime.After(now.Add(skew)) { + return fmt.Errorf("%w: proof_time is %s in the future and the skew limit is %s", + ErrProofTimeInFuture, proofTime.Sub(now).Round(time.Second), skew) + } + if proofTime.Before(now.Add(-maxAge)) { + return fmt.Errorf("%w: proof_time is %s old and the age limit is %s", + ErrProofTimeTooOld, now.Sub(proofTime).Round(time.Second), maxAge) + } + return nil +} + +// RenderProofTimeOrPanic is RenderProofTime for a time.Time already +// known to be in range - this century, in practice. It panics rather +// than returning an error so a caller with a literal instant does not +// have to handle one it cannot hit. +func RenderProofTimeOrPanic(t time.Time) string { + text, err := RenderProofTime(t.UTC().Unix()) + if err != nil { + panic("openid4vp: proof_time out of range: " + err.Error()) + } + return text +} diff --git a/pkg/openid4vp/zkp_req_proof_time_test.go b/pkg/openid4vp/zkp_req_proof_time_test.go new file mode 100644 index 000000000..4516010dd --- /dev/null +++ b/pkg/openid4vp/zkp_req_proof_time_test.go @@ -0,0 +1,140 @@ +package openid4vp + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The shared proof_time vectors: rendering, parsing and the tolerance +// window. +// +// T is the one value the prover and this verifier must agree on byte +// for byte - it is a public input to the circuit - so its spelling is +// not a local matter. The vectors' expected texts were computed with +// an independent implementation, which is what makes them worth +// running rather than a restatement of this code. +func TestProofTimeVectors(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(vectorsDir, "proof_time", "proof_time.json")) + require.NoError(t, err) + + var vectors struct { + Render []struct { + Secs int64 `json:"secs"` + Text string `json:"text"` + } `json:"render"` + RenderErrors []struct { + Secs int64 `json:"secs"` + Error string `json:"error"` + } `json:"render_errors"` + ParseOK []struct { + Text string `json:"text"` + Secs int64 `json:"secs"` + } `json:"parse_ok"` + ParseErrors []struct { + Text string `json:"text"` + Error string `json:"error"` + } `json:"parse_errors"` + Tolerance []struct { + Name string `json:"name"` + NowSecs int64 `json:"now_secs"` + SkewSecs int64 `json:"skew_secs"` + MaxAgeSecs int64 `json:"max_age_secs"` + ProofTime string `json:"proof_time"` + Expect string `json:"expect"` + } `json:"tolerance"` + } + require.NoError(t, json.Unmarshal(raw, &vectors)) + require.NotEmpty(t, vectors.Render) + require.NotEmpty(t, vectors.Tolerance) + + t.Run("render", func(t *testing.T) { + for _, tc := range vectors.Render { + got, err := RenderProofTime(tc.Secs) + require.NoError(t, err, "%d", tc.Secs) + assert.Equal(t, tc.Text, got, "%d", tc.Secs) + assert.Len(t, got, ProofTimeBytes) + } + }) + + t.Run("render errors", func(t *testing.T) { + for _, tc := range vectors.RenderErrors { + _, err := RenderProofTime(tc.Secs) + require.Error(t, err, "%d", tc.Secs) + assert.Equal(t, tc.Error, proofTimeCode(err), "%d", tc.Secs) + } + }) + + t.Run("parse", func(t *testing.T) { + for _, tc := range vectors.ParseOK { + got, err := ParseProofTime(tc.Text) + require.NoError(t, err, tc.Text) + assert.Equal(t, tc.Secs, got.Unix(), tc.Text) + // And round-trips, so the two directions agree. + back, err := RenderProofTime(got.Unix()) + require.NoError(t, err) + assert.Equal(t, tc.Text, back) + } + }) + + t.Run("parse errors", func(t *testing.T) { + for _, tc := range vectors.ParseErrors { + _, err := ParseProofTime(tc.Text) + require.Error(t, err, tc.Text) + assert.Equal(t, tc.Error, proofTimeCode(err), "%q", tc.Text) + } + }) + + t.Run("tolerance", func(t *testing.T) { + for _, tc := range vectors.Tolerance { + t.Run(tc.Name, func(t *testing.T) { + now := time.Unix(tc.NowSecs, 0).UTC() + skew := time.Duration(tc.SkewSecs) * time.Second + maxAge := time.Duration(tc.MaxAgeSecs) * time.Second + + parsed, parseErr := ParseProofTime(tc.ProofTime) + if parseErr != nil { + // A tolerance case whose value is not even the + // right form fails on the form, which is the + // stricter statement. + assert.Equal(t, tc.Expect, proofTimeCode(parseErr)) + return + } + err := CheckProofTimeTolerance(parsed, now, skew, maxAge) + if tc.Expect == "ok" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Equal(t, tc.Expect, proofTimeCode(err)) + }) + } + }) +} + +// proofTimeCode names the sentinel an error carries, which is what the +// vectors assert. +func proofTimeCode(err error) string { + for _, sentinel := range []error{ + ErrProofTimeForm, ErrProofTimeNotAnInstant, ErrProofTimeOutOfRange, + ErrProofTimeInFuture, ErrProofTimeTooOld, + } { + if errors.Is(err, sentinel) { + return sentinel.Error() + } + } + return "unclassified: " + err.Error() +} + +// The recommended tolerances are the shared ones, so a deployment +// that does not choose gets what every other implementation assumes. +func TestRecommendedTolerancesMatchTheSpecification(t *testing.T) { + assert.Equal(t, 60*time.Second, RecommendedSkew) + assert.Equal(t, 600*time.Second, RecommendedMaxAge) +} diff --git a/pkg/openid4vp/zkp_req_response.go b/pkg/openid4vp/zkp_req_response.go new file mode 100644 index 000000000..3cbf33e0a --- /dev/null +++ b/pkg/openid4vp/zkp_req_response.go @@ -0,0 +1,412 @@ +package openid4vp + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "time" + + "github.com/fxamacker/cbor/v2" +) + +// Reason codes for a refused zkp_req presentation. +// +// Stable identifiers rather than message text, because they are the +// shared conformance vocabulary: the Rust matcher, the Kotlin and +// Swift SDKs and this verifier all name the same failure the same +// way, and a vector asserts the code and not the sentence. +const ( + ReasonResponseNotParseable = "response_not_parseable" + ReasonDuplicateMember = "duplicate_member" + ReasonQueryIDMissing = "query_id_missing" + ReasonPresentationsNotArray = "presentations_not_array" + ReasonPresentationsEmpty = "presentations_empty" + ReasonPresentationsMultipleNotAllow = "presentations_multiple_not_allowed" + ReasonPresentationNotString = "presentation_not_string" + ReasonPresentationNotBase64URL = "presentation_not_base64url" + ReasonCBORMalformed = "cbor_malformed" + ReasonCBORIndefiniteLength = "cbor_indefinite_length" + ReasonCBORTrailingBytes = "cbor_trailing_bytes" + ReasonCBORDuplicateKey = "cbor_duplicate_key" + ReasonPresentationNotMap = "presentation_not_map" + ReasonPresentationKeyNotText = "presentation_key_not_text" + ReasonPresentationUnknownMember = "presentation_unknown_member" + ReasonPresentationMemberMissing = "presentation_member_missing" + ReasonPresentationMemberWrongType = "presentation_member_wrong_type" + ReasonSystemIDNotOffered = "system_id_not_offered" + ReasonProofEmpty = "proof_empty" + ReasonIssuerKeySetEmpty = "issuer_key_set_empty" + ReasonIssuerKeySetTooMany = "issuer_key_set_too_many" + ReasonIssuerKeyNotBytes = "issuer_key_not_bytes" + ReasonIssuerKeyEmpty = "issuer_key_empty" + ReasonIssuerKeyTooLarge = "issuer_key_too_large" + ReasonProofTimeMissing = "proof_time_missing" + ReasonProofTimeNotTDate = "proof_time_not_tdate" + ReasonProofTimeForm = "proof_time_form" + ReasonResultKeyNotText = "result_key_not_text" + ReasonResultHandleUnexpected = "result_handle_unexpected" + ReasonResultHandleMissing = "result_handle_missing" + ReasonResultWrongType = "result_wrong_type" + ReasonResultNotSatisfied = "result_not_satisfied" + ReasonResultFunctionUnknown = "result_function_unknown" + ReasonPseudonymWrongLength = "pseudonym_wrong_length" + ReasonPseudonymPrfUnknown = "pseudonym_prf_unknown" +) + +// Limits on the issuer key set, normative in zkp_req_response.md rule 7. +// +// They bound attacker-controlled input on its way to an X.509 parser: +// the set arrives from the holder and is parsed before anything about +// it has been judged. +const ( + MaxIssuerKeySetSize = 8 + MaxIssuerKeyBytes = 4096 + maxCBORNestingLevels = 16 +) + +// ZkpReqResponseError is a refusal, carrying the reason code the +// conformance vectors name. +type ZkpReqResponseError struct { + Reason string + Detail string +} + +func (e *ZkpReqResponseError) Error() string { + if e.Detail == "" { + return "zkp_req response refused: " + e.Reason + } + return "zkp_req response refused: " + e.Reason + ": " + e.Detail +} + +func refuse(reason, format string, args ...any) *ZkpReqResponseError { + return &ZkpReqResponseError{Reason: reason, Detail: fmt.Sprintf(format, args...)} +} + +// ZkpReqRequest is the verifier-side view of the request a response +// answers. +type ZkpReqRequest struct { + // QueryID is the DCQL credential query id the response is keyed by. + QueryID string + // Items are the request's _req items, in request order. + Items []ReqItem + // Multiple is the credential query's DCQL `multiple`. Omitted + // means false, so more than one presentation is refused. + Multiple bool + // SourceFormat is meta.source_format: the stored attestation the + // proof is made over. "mso_mdoc" makes proof_time required. + SourceFormat string + // OfferedSystemIDs is + // client_metadata.vp_formats_supported.zkp_req.system_id_values. + // Empty means nothing was offered, and every response is refused. + OfferedSystemIDs []string +} + +// ZkpPresentation is one validated presentation. +// +// Nothing here is a verified fact: a pseudonym is the holder's claim +// until a proof has been checked against it. +type ZkpPresentation struct { + // SystemID names the approved system, and is one the request + // offered. + SystemID string + // Proof is opaque at this layer. + Proof []byte + // IssuerKeySet is the issuer's certificates, leaf first, DER. + // Bounded and nothing more: this package does not parse X.509. + IssuerKeySet [][]byte + // ProofTime is the prover-supplied T, and HasProofTime says + // whether it was carried at all. T is a public input to the + // circuit, byte-identical between prover and verifier. + ProofTime time.Time + HasProofTime bool + // ProofTimeText is T exactly as it arrived, which is what the + // circuit takes: 20 bytes, YYYY-MM-DDThh:mm:ssZ. + ProofTimeText string + // Pseudonyms are the claimed pseudonyms, keyed by handle. + Pseudonyms map[string][]byte + // Satisfied names the handles whose predicate the proof asserts. + Satisfied []string +} + +// ValidateZkpReqResponse checks a vp_token against the request that +// asked for it, and returns the presentations it accepted. +// +// SHAPE and BINDING TO THE REQUEST only. It does not verify the proof +// and it does not parse or trust a certificate. +// +// The whole response is refused on the first failure, in the order the +// shared specification fixes. There is no partial acceptance: a +// presentation that breaks a rule is one whose author disagrees with +// this verifier about the format, and accepting its siblings would be +// guessing which half to trust. +// +// vpToken is the RAW TEXT, because a repeated JSON member cannot be +// seen after decoding: Go's decoder silently keeps the last. +func ValidateZkpReqResponse(vpToken []byte, request ZkpReqRequest) ([]ZkpPresentation, error) { + if err := refuseDuplicateJSONMembers(vpToken); err != nil { + return nil, err + } + + var envelope map[string]json.RawMessage + if err := strictDecode(vpToken, &envelope); err != nil { + return nil, refuse(ReasonResponseNotParseable, "%v", err) + } + + raw, present := envelope[request.QueryID] + if !present { + // Entries for other query ids are ignored; the one this + // request asked for has to be there. + return nil, refuse(ReasonQueryIDMissing, "no entry for credential query %q", request.QueryID) + } + + var elements []json.RawMessage + if !isJSONArray(raw) { + return nil, refuse(ReasonPresentationsNotArray, "the entry for %q is not an array", request.QueryID) + } + if err := strictDecode(raw, &elements); err != nil { + return nil, refuse(ReasonPresentationsNotArray, "%v", err) + } + if len(elements) == 0 { + // A response with no presentations is a decline in disguise, + // and a decline is an OpenID4VP error response. + return nil, refuse(ReasonPresentationsEmpty, "credential query %q carries no presentations", request.QueryID) + } + if len(elements) > 1 && !request.Multiple { + return nil, refuse(ReasonPresentationsMultipleNotAllow, + "%d presentations for a query that did not set multiple", len(elements)) + } + + presentations := make([]ZkpPresentation, 0, len(elements)) + for _, element := range elements { + presentation, err := validateZkpPresentation(element, request) + if err != nil { + return nil, err + } + presentations = append(presentations, presentation) + } + return presentations, nil +} + +// presentationMembers are the only members a presentation may carry. +var presentationMembers = []string{"system_id", "proof", "issuer_key_set", "proof_time", "results"} + +// requiredPresentationMembers are the members it must carry. +// proof_time is required only for mso_mdoc, which rule 8 handles. +var requiredPresentationMembers = []string{"system_id", "proof", "issuer_key_set", "results"} + +func validateZkpPresentation(element json.RawMessage, request ZkpReqRequest) (ZkpPresentation, error) { + var encoded string + if err := strictDecode(element, &encoded); err != nil { + return ZkpPresentation{}, refuse(ReasonPresentationNotString, "a presentation is not a JSON string") + } + decoded, err := decodeCanonicalBase64URL(encoded) + if err != nil { + return ZkpPresentation{}, refuse(ReasonPresentationNotBase64URL, "%v", err) + } + + members, cborErr := decodeZkpPresentationCBOR(decoded) + if cborErr != nil { + return ZkpPresentation{}, cborErr + } + + for name := range members { + if !slices.Contains(presentationMembers, name) { + return ZkpPresentation{}, refuse(ReasonPresentationUnknownMember, "unknown member %q", name) + } + } + for _, name := range requiredPresentationMembers { + if _, found := members[name]; !found { + return ZkpPresentation{}, refuse(ReasonPresentationMemberMissing, "missing member %q", name) + } + } + + presentation := ZkpPresentation{Pseudonyms: map[string][]byte{}} + if err := cborInto(members["system_id"], &presentation.SystemID); err != nil { + return ZkpPresentation{}, refuse(ReasonPresentationMemberWrongType, "\"system_id\" is not a text string") + } + if err := cborInto(members["proof"], &presentation.Proof); err != nil { + return ZkpPresentation{}, refuse(ReasonPresentationMemberWrongType, "\"proof\" is not a byte string") + } + var rawKeySet []cbor.RawMessage + if err := cborInto(members["issuer_key_set"], &rawKeySet); err != nil { + return ZkpPresentation{}, refuse(ReasonPresentationMemberWrongType, "\"issuer_key_set\" is not an array") + } + rawResults, resultsErr := cborResultsMap(members["results"]) + if resultsErr != nil { + return ZkpPresentation{}, resultsErr + } + + // Binding to the offer. The holder STATES system_id; this verifier + // trusts it no further than membership in what it offered. A + // request that offered no list offered nothing, so there is + // nothing a response could be a member of - refused rather than + // waved through, because the alternative is the holder choosing + // what it is verified against. + if !slices.Contains(request.OfferedSystemIDs, presentation.SystemID) { + return ZkpPresentation{}, refuse(ReasonSystemIDNotOffered, + "system_id %q is not one this request offered", presentation.SystemID) + } + + if len(presentation.Proof) == 0 { + return ZkpPresentation{}, refuse(ReasonProofEmpty, "the proof is empty") + } + + keySet, keyErr := decodeIssuerKeySet(rawKeySet) + if keyErr != nil { + return ZkpPresentation{}, keyErr + } + presentation.IssuerKeySet = keySet + + rawProofTime, carriesProofTime := members["proof_time"] + if !carriesProofTime { + if request.SourceFormat == SourceFormatMsoMdoc { + // Every approved mso_mdoc circuit checks the MSO validity + // period, so T is not optional there. + return ZkpPresentation{}, refuse(ReasonProofTimeMissing, + "proof_time is required for source_format %q", SourceFormatMsoMdoc) + } + } else { + text, err := decodeProofTimeTDate(rawProofTime) + if err != nil { + return ZkpPresentation{}, err + } + parsed, formErr := ParseProofTime(text) + if formErr != nil { + return ZkpPresentation{}, refuse(ReasonProofTimeForm, "%v", formErr) + } + presentation.ProofTime = parsed + presentation.ProofTimeText = text + presentation.HasProofTime = true + } + + if err := checkZkpHandles(rawResults, request); err != nil { + return ZkpPresentation{}, err + } + // Values in REQUEST order, so the reported failure does not depend + // on how a decoder happened to order a map. + for _, item := range request.Items { + if err := readZkpResult(item, rawResults[item.Return], &presentation); err != nil { + return ZkpPresentation{}, err + } + } + return presentation, nil +} + +// checkZkpHandles refuses a results map whose keys are not exactly the +// request's return handles. +func checkZkpHandles(results map[string]cbor.RawMessage, request ZkpReqRequest) error { + expected := make(map[string]bool, len(request.Items)) + for _, item := range request.Items { + expected[item.Return] = true + } + // Unexpected before missing, which is the shared order: a + // response that is wrong in two ways is reported the same way by + // every implementation. + for handle := range results { + if !expected[handle] { + return refuse(ReasonResultHandleUnexpected, "the response carries handle %q, which this request did not ask for", handle) + } + } + for _, item := range request.Items { + if _, found := results[item.Return]; !found { + return refuse(ReasonResultHandleMissing, "no result for handle %q", item.Return) + } + } + return nil +} + +// readZkpResult checks one result against the function that asked for +// it. +func readZkpResult(item ReqItem, raw cbor.RawMessage, presentation *ZkpPresentation) error { + switch item.FnID { + case FunctionPseudonym: + want, known := pseudonymLengthFor(item) + if !known { + // No prf, no length to demand - and a pseudonym whose + // width nobody agreed is not one to accept. + return refuse(ReasonPseudonymPrfUnknown, "the %q item names no prf this build knows", item.Return) + } + var value []byte + if err := cborInto(raw, &value); err != nil { + return refuse(ReasonResultWrongType, "the pseudonym for %q is not a byte string", item.Return) + } + if len(value) != want { + return refuse(ReasonPseudonymWrongLength, "the pseudonym for %q is %d bytes, not %d", item.Return, len(value), want) + } + presentation.Pseudonyms[item.Return] = value + case FunctionAgeCheck: + var satisfied bool + if err := cborInto(raw, &satisfied); err != nil { + return refuse(ReasonResultWrongType, "the result for %q is not a boolean", item.Return) + } + if !satisfied { + // A predicate that does not hold produces NO PROOF; the + // prover refuses. So false is a wallet asserting something + // the proof system cannot say, and an unsatisfiable + // request is a decline rather than a negative result. + return refuse(ReasonResultNotSatisfied, "the result for %q says the predicate is not satisfied, which is a decline and not a result", item.Return) + } + presentation.Satisfied = append(presentation.Satisfied, item.Return) + default: + // A request may legally carry a function this build passes + // through opaquely; a RESULT for one cannot be checked at all, + // so it is refused rather than accepted unexamined. + return refuse(ReasonResultFunctionUnknown, "no result shape for function %q", item.FnID) + } + return nil +} + +// pseudonymLengthFor is the width the item's prf fixes. +func pseudonymLengthFor(item ReqItem) (int, bool) { + prf, _ := item.Params["prf"].(string) + switch prf { + case PrfSHA256: + return 32, true + case PrfHashDHBLS12381G1: + return 48, true + default: + return 0, false + } +} + +// decodeIssuerKeySet bounds and reads the issuer certificates. +func decodeIssuerKeySet(rawKeySet []cbor.RawMessage) ([][]byte, error) { + if len(rawKeySet) == 0 { + return nil, refuse(ReasonIssuerKeySetEmpty, "the issuer key set is empty") + } + if len(rawKeySet) > MaxIssuerKeySetSize { + return nil, refuse(ReasonIssuerKeySetTooMany, "the issuer key set has %d elements; at most %d", + len(rawKeySet), MaxIssuerKeySetSize) + } + keySet := make([][]byte, 0, len(rawKeySet)) + for i, raw := range rawKeySet { + var der []byte + if err := cborInto(raw, &der); err != nil { + return nil, refuse(ReasonIssuerKeyNotBytes, "issuer key %d is not a byte string", i) + } + if len(der) == 0 { + return nil, refuse(ReasonIssuerKeyEmpty, "issuer key %d is empty", i) + } + if len(der) > MaxIssuerKeyBytes { + return nil, refuse(ReasonIssuerKeyTooLarge, "issuer key %d is %d bytes; at most %d", i, len(der), MaxIssuerKeyBytes) + } + keySet = append(keySet, der) + } + return keySet, nil +} + +// cborInto decodes one raw CBOR item into a typed value, refusing +// CBOR null: null is a value of the WRONG TYPE here, never "absent". +func cborInto(raw cbor.RawMessage, into any) error { + if isCBORNull(raw) { + return errors.New("the value is null") + } + return zkpCBORMode.Unmarshal(raw, into) +} + +// isCBORNull reports whether raw is the CBOR null (0xf6) or +// undefined (0xf7). +func isCBORNull(raw cbor.RawMessage) bool { + return len(raw) == 1 && (raw[0] == 0xf6 || raw[0] == 0xf7) +} diff --git a/pkg/openid4vp/zkp_req_response_vectors_test.go b/pkg/openid4vp/zkp_req_response_vectors_test.go new file mode 100644 index 000000000..d2d5a5c21 --- /dev/null +++ b/pkg/openid4vp/zkp_req_response_vectors_test.go @@ -0,0 +1,153 @@ +package openid4vp + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// vectorsDir is the vendored conformance set; SOURCE records where +// from and at which commit. +const vectorsDir = "testdata/zkp_req" + +// vectorSetDigest pins it, computed the way the matcher's +// scripts/vectors_digest.sh computes it: the sha256 of the sha256sum +// manifest of every *.json under the four directories, LC_ALL=C +// sorted, README.md excluded. +// +// These vectors are what tells us this verifier, the Rust matcher and +// the two SDKs agree about what a zkp_req presentation is. A local +// edit - or a stale copy after the upstream file moves - turns that +// cross-implementation check into a self-consistency check that +// passes while the implementations disagree. +const vectorSetDigest = "9f482d7a3fb87cb1381d0dfb7a2fc396b6cd3c1eba07c5d76f44e47142b751ac" + +type responseVector struct { + Description string `json:"description"` + Query json.RawMessage `json:"query"` + ClientMetadata json.RawMessage `json:"client_metadata"` + VPToken json.RawMessage `json:"vp_token"` + VPTokenText *string `json:"vp_token_text"` + Expect struct { + Valid bool `json:"valid"` + Error string `json:"error"` + } `json:"expect"` +} + +// requestFromVector builds the verifier-side request a vector's +// response is checked against. +func requestFromVector(t *testing.T, v responseVector) ZkpReqRequest { + t.Helper() + + var query struct { + ID string `json:"id"` + Multiple bool `json:"multiple"` + Req []ReqItem `json:"_req"` + Meta struct { + SourceFormat string `json:"source_format"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(v.Query, &query)) + + request := ZkpReqRequest{ + QueryID: query.ID, + Items: query.Req, + Multiple: query.Multiple, + SourceFormat: query.Meta.SourceFormat, + } + if len(v.ClientMetadata) > 0 { + var metadata struct { + VPFormatsSupported struct { + ZkpReq struct { + SystemIDValues []string `json:"system_id_values"` + } `json:"zkp_req"` + } `json:"vp_formats_supported"` + } + require.NoError(t, json.Unmarshal(v.ClientMetadata, &metadata)) + request.OfferedSystemIDs = metadata.VPFormatsSupported.ZkpReq.SystemIDValues + } + return request +} + +// The whole vendored response set, one subtest per file. +func TestZkpReqResponseVectors(t *testing.T) { + dir := filepath.Join(vectorsDir, "zkp_req_response") + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + ran := 0 + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + + var vector responseVector + require.NoError(t, json.Unmarshal(raw, &vector)) + + // Exactly one of vp_token / vp_token_text: the text form + // carries inputs a parsed object cannot hold. + var vpToken []byte + switch { + case vector.VPTokenText != nil: + require.Empty(t, vector.VPToken, "%s: both vp_token and vp_token_text", name) + vpToken = []byte(*vector.VPTokenText) + default: + require.NotEmpty(t, vector.VPToken, "%s: neither vp_token nor vp_token_text", name) + vpToken = vector.VPToken + } + + got, err := ValidateZkpReqResponse(vpToken, requestFromVector(t, vector)) + if vector.Expect.Error == "" { + require.NoError(t, err, "%s: %s", name, vector.Description) + assert.NotEmpty(t, got) + return + } + var refusal *ZkpReqResponseError + require.ErrorAs(t, err, &refusal, "%s: %s", name, vector.Description) + assert.Equal(t, vector.Expect.Error, refusal.Reason, + "%s: %s (detail: %s)", name, vector.Description, refusal.Detail) + }) + ran++ + } + require.NotZero(t, ran, "no vectors were read") +} + +// The vendored copy must be the one the other implementations use. +func TestZkpReqVectorsAreTheVendoredSet(t *testing.T) { + var manifest strings.Builder + var names []string + for _, set := range []string{"zkp_req", "zkp_req_response", "zkp_req_unsat", "proof_time"} { + entries, err := os.ReadDir(filepath.Join(vectorsDir, set)) + require.NoError(t, err) + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".json") { + names = append(names, filepath.Join(set, entry.Name())) + } + } + } + sort.Strings(names) + require.NotEmpty(t, names) + + for _, name := range names { + raw, err := os.ReadFile(filepath.Join(vectorsDir, name)) + require.NoError(t, err) + fmt.Fprintf(&manifest, "%x %s\n", sha256.Sum256(raw), name) + } + sum := sha256.Sum256([]byte(manifest.String())) + assert.Equal(t, vectorSetDigest, hex.EncodeToString(sum[:]), + "the vendored vectors differ from the pinned set; re-vendor and re-pin rather than edit, "+ + "or this stops checking agreement with the other implementations") +} diff --git a/pkg/openid4vp/zkp_req_test.go b/pkg/openid4vp/zkp_req_test.go new file mode 100644 index 000000000..edaf3f07b --- /dev/null +++ b/pkg/openid4vp/zkp_req_test.go @@ -0,0 +1,1404 @@ +package openid4vp + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + yamlv2 "gopkg.in/yaml.v2" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const vectorDir = "testdata/zkp_req/zkp_req" + +// vector is one shared conformance case. Only the parts this package is +// answerable for are decoded - the planner's expected output is the +// matcher's business, not ours. +type vector struct { + Description string `json:"description"` + Query json.RawMessage `json:"query"` + QueryText *string `json:"query_text"` + ClientMetadata json.RawMessage `json:"client_metadata"` + ClientMetadataTxt *string `json:"client_metadata_text"` +} + +func loadVectors(t *testing.T) map[string]vector { + t.Helper() + entries, err := os.ReadDir(vectorDir) + require.NoError(t, err) + + out := map[string]vector{} + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + raw, err := os.ReadFile(filepath.Join(vectorDir, filepath.Clean(name))) + require.NoError(t, err) + var v vector + require.NoError(t, json.Unmarshal(raw, &v), "%s", name) + out[name] = v + } + require.NotEmpty(t, out, "no vectors found; the vendored set is the whole point of this file") + return out +} + +// Every vector's credential query must be emittable with every member +// and value intact. +// +// Not byte for byte, which this never checked and the implementation +// cannot promise: encoding/json compacts and HTML-escapes whatever a +// MarshalJSON returns, so an operator's layout and an ampersand's +// spelling do not survive. What does survive, and what the comparison +// checks, is every member, its nesting, and every value - numeric +// literals included, which is why that comparison decodes with +// UseNumber rather than into a float64. +// +// This is the conformance that matters for a REQUEST builder. The planner +// vectors describe what a wallet must accept, so a query we cannot +// reproduce is a query we cannot send - including the shapes this +// build does not model, which is most of the point: the rulebook's WIP +// claims._req form, members whose type a later generation changed, and +// unknown members at every level. +// +// Two routes, and the test records which each vector took. A shape the +// typed model understands round-trips through it; anything else goes +// through Raw. A vector that needs neither would be a shape this verifier +// could not produce at all. +func TestEveryVectorQueryCanBeEmitted(t *testing.T) { + typed, raw := 0, 0 + + for name, v := range loadVectors(t) { + if v.Query == nil { + continue // query_text cases are deliberately unparseable + } + t.Run(name, func(t *testing.T) { + var query CredentialQuery + // Raw non-nil means the decode fell back rather than + // modelling the shape - decoding is total now, so "no error" + // no longer means the model understood it. + if err := json.Unmarshal(v.Query, &query); err == nil && query.Raw == nil { + encoded, marshalErr := json.Marshal(query) + require.NoError(t, marshalErr) + if sameJSON(t, v.Query, encoded) { + typed++ + return + } + // Decoded but did not survive re-encoding. Raw is the + // escape hatch for that too, but say so rather than + // letting it pass quietly as if the model had handled it. + t.Logf("%s: the typed model decodes this but does not reproduce it; Raw carries it\n vector: %s\n typed: %s", + v.Description, v.Query, encoded) + } + + encoded, err := json.Marshal(CredentialQuery{Raw: v.Query}) + require.NoError(t, err) + assert.True(t, sameJSON(t, v.Query, encoded), + "%s: neither the typed model nor Raw can emit this\n vector: %s\n ours: %s", + v.Description, v.Query, encoded) + raw++ + }) + } + + t.Logf("emitted through the typed model: %d, through Raw: %d", typed, raw) + assert.Positive(t, typed, "no vector went through the typed model; it is not modelling anything") +} + +// Every shape a wallet must ACCEPT, the typed model must produce - with +// one named exception. +// +// Without this, the previous test would still pass if the model stopped +// understanding the normative shape entirely: everything would quietly +// fall through to Raw and the count assertion would not notice. This is +// the guard that says which shapes we model rather than merely transmit. +func TestTheModelProducesEveryAcceptedShape(t *testing.T) { + // The rulebook's WIP example puts an object at "claims", where DCQL + // has an array. The issue says this verifier emits the normative req + // form, so the shape is reachable through Raw and deliberately not + // modelled. If that changes, this list is where it changes. + rawOnly := map[string]string{ + "ok_example_envelope.json": "claims is an object in the rulebook's WIP example", + } + + for name, v := range loadVectors(t) { + if v.Query == nil || !strings.HasPrefix(name, "ok_") { + continue + } + t.Run(name, func(t *testing.T) { + var query CredentialQuery + err := json.Unmarshal(v.Query, &query) + if err == nil && query.Raw == nil { + encoded, marshalErr := json.Marshal(query) + require.NoError(t, marshalErr) + if sameJSON(t, v.Query, encoded) { + assert.NotContains(t, rawOnly, name, + "this is listed as raw-only but the model handles it; remove the entry") + return + } + } + why, expected := rawOnly[name] + require.True(t, expected, + "%s\nthe typed model cannot reproduce a shape wallets accept. Either model it, "+ + "or add it to rawOnly with the reason.\n vector: %s", v.Description, v.Query) + t.Logf("raw-only, as recorded: %s", why) + }) + } +} + +// What a Go struct cannot SAY, and what this type nevertheless carries. +// +// A string field cannot distinguish an absent member from an empty one, +// and a slice with omitempty cannot distinguish an absent array from an +// empty one. So four shapes in the shared set have no typed +// representation: an item with no "id" or no "return", a query with no +// "meta", and a query whose "req" is []. Making the fields pointers would +// move that limitation rather than remove it - an empty-string handle +// would then be the shape with nowhere to live. +// +// They survive anyway, because decoding does not ask "did this parse" but +// "does it encode back to what arrived". A shape the typed model would +// change is kept verbatim in Raw instead, so it reaches the wallet as +// configured and survives the HA cache on the way. +// +// Which matters precisely because these are not requests a verifier has a +// reason to send: they are fixtures for wallet behaviour, every planner +// rejects them, and a serialiser that quietly repaired them would make +// the negative tests untestable. +func TestShapesWithNoTypedRepresentationSurviveAnyway(t *testing.T) { + for _, tc := range []struct{ name, in string }{ + {"an item with no id", `{"id":"q","format":"zkp_req","meta":{},"_req":[{"return":"h"}]}`}, + {"an item with no return", `{"id":"q","format":"zkp_req","meta":{},"_req":[{"fn_id":"age_check"}]}`}, + {"an empty req array", `{"id":"q","format":"zkp_req","meta":{},"_req":[]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(tc.in), &query)) + require.NotNil(t, query.Raw, + "the typed form of this shape is not the shape, so it has to be kept verbatim") + + encoded, err := json.Marshal(query) + require.NoError(t, err) + assert.True(t, sameJSON(t, []byte(tc.in), encoded), + "what was configured has to be what is sent") + + // Including across the HA cache, which is the round trip that + // made this worth fixing rather than documenting. + stored, err := json.Marshal(&DCQL{Credentials: []CredentialQuery{query}}) + require.NoError(t, err) + var loaded DCQL + require.NoError(t, json.Unmarshal(stored, &loaded)) + require.Len(t, loaded.Credentials, 1) + out, err := json.Marshal(loaded.Credentials[0]) + require.NoError(t, err) + assert.True(t, sameJSON(t, []byte(tc.in), out)) + }) + } +} + +// The counterpart: a shape the typed model DOES represent faithfully must +// not be pushed down the raw path, or the distinction stops meaning +// anything and every query becomes opaque bytes. +func TestFaithfulShapesStayTyped(t *testing.T) { + for _, in := range []string{ + `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"age_check","return":"h"}]}`, + `{"id":"q","format":"mso_mdoc","meta":{"doctype_value":"d"}}`, + `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"multiple":true}`, + } { + var query CredentialQuery + require.NoError(t, json.Unmarshal([]byte(in), &query)) + assert.Nilf(t, query.Raw, "this shape is modelled exactly and must decode typed: %s", in) + } +} + +// Raw replaces the query entirely, and may not be combined with Extra. +func TestRawReplacesTheQuery(t *testing.T) { + encoded, err := json.Marshal(CredentialQuery{ + ID: "ignored", Format: "ignored", + Raw: json.RawMessage(`{"anything":[1,2,3]}`), + }) + require.NoError(t, err) + assert.JSONEq(t, `{"anything":[1,2,3]}`, string(encoded)) + + _, err = json.Marshal(CredentialQuery{ + Raw: json.RawMessage(`{"a":1}`), + Extra: map[string]any{"b": 2}, + }) + require.Error(t, err, "raw would discard extra, so setting both is a configuration mistake") + + _, err = json.Marshal(CredentialQuery{Raw: json.RawMessage(`{not json`)}) + require.Error(t, err, "invalid JSON must not reach the wire inside a request object") +} + +// And every vector's client_metadata, for the same reason: the circuit +// filter lives there, in two shapes the matcher accepts. +func TestEveryVectorClientMetadataRoundTrips(t *testing.T) { + for name, v := range loadVectors(t) { + if v.ClientMetadata == nil { + continue + } + var probe struct { + VPFormats json.RawMessage `json:"vp_formats_supported"` + } + if err := json.Unmarshal(v.ClientMetadata, &probe); err != nil || probe.VPFormats == nil { + continue // err_client_metadata_* cases with a non-object there + } + t.Run(name, func(t *testing.T) { + var formats VPFormatsSupported + if err := json.Unmarshal(probe.VPFormats, &formats); err != nil { + t.Skipf("%s: vp_formats_supported is not an object of entries", v.Description) + } + encoded, err := json.Marshal(formats) + require.NoError(t, err) + assert.True(t, sameJSON(t, probe.VPFormats, encoded), + "%s\n vector: %s\n ours: %s", v.Description, probe.VPFormats, encoded) + }) + } +} + +// The two shapes named in the issue, spelled out rather than left to the +// vector loop: the normative req form this verifier emits, and the WIP +// envelope form it must still be able to produce for a wallet that wants +// one. +func TestTheTwoShapesTheIssueNames(t *testing.T) { + t.Run("normative req", func(t *testing.T) { + query := CredentialQuery{ + ID: "nym_attestation", + Format: FormatZkpReq, + Meta: MetaQuery{ + SourceFormat: SourceFormatMsoMdoc, + DoctypeValue: "eu.europa.ec.eudi.nym_auth.1", + }, + Req: []ReqItem{ + // ver and prf are REQUIRED by v0.4: the pseudonym's + // width is the prf's, and a verifier that left it out + // would be asking for a value nobody agreed the shape + // of. + {FnID: FunctionPseudonym, Return: "nym-123", Params: map[string]any{ + "config": "known-offender-matching-ssc", + "ver": 1, + "prf": PrfSHA256, + }}, + {FnID: FunctionAgeCheck, Return: "age-range-123", Params: map[string]any{ + "ge": "2008-10-10", "le": "2013-10-09", + }}, + }, + } + encoded, err := json.Marshal(query) + require.NoError(t, err) + + raw, err := os.ReadFile(filepath.Join(vectorDir, "ok_normative_plain.json")) + require.NoError(t, err) + var v vector + require.NoError(t, json.Unmarshal(raw, &v)) + + assert.True(t, sameJSON(t, v.Query, encoded), + "the builder must produce the rulebook's normative example\n vector: %s\n ours: %s", v.Query, encoded) + }) + + t.Run("the envelope form, through Extra", func(t *testing.T) { + formats := VPFormatsSupported{ZkpReq: &ZkpReqFormat{Extra: map[string]any{ + "fn_id": "circuit_zk_longfellow", + "input": "eyJjaXJjdWl0X3Byb29mX3N5c3RlbV92YWx1ZXMiOiBbImxvbmdmZWxsb3ctbGliemstdjEiXSwgImNpcmN1aXRfaWRfdmFsdWVzIjogWyJ1cm46Y2lyY3VpdHM6bG9uZ2ZlbGxvdzpueW1fYXV0aDp2MSJdfQ==", + }}} + encoded, err := json.Marshal(formats) + require.NoError(t, err) + assert.JSONEq(t, + `{"zkp_req":{"fn_id":"circuit_zk_longfellow","input":"eyJjaXJjdWl0X3Byb29mX3N5c3RlbV92YWx1ZXMiOiBbImxvbmdmZWxsb3ctbGliemstdjEiXSwgImNpcmN1aXRfaWRfdmFsdWVzIjogWyJ1cm46Y2lyY3VpdHM6bG9uZ2ZlbGxvdzpueW1fYXV0aDp2MSJdfQ=="}}`, + string(encoded), + "an operator must be able to send a shape this build does not model") + }) +} + +// A request this build considers wrong must go out exactly as configured. +// +// The verifier is the tool wallets are tested WITH, so the builder is a +// serialiser and not a validator: no repair, no dropping, no reordering +// that changes meaning. Every case here violates something the nym_auth +// rulebook requires. +func TestBadRequestsAreEmittedUnchanged(t *testing.T) { + for _, tc := range []struct { + name string + query CredentialQuery + want string + }{ + { + name: "le before ge, and a span under 365 days", + query: CredentialQuery{ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{"ge": "2013-10-09", "le": "2008-10-10"}}}}, + want: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"age_check","return":"a","params":{"ge":"2013-10-09","le":"2008-10-10"}}]}`, + }, + { + name: "two pseudonym items, which the rulebook forbids", + query: CredentialQuery{ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{ + {FnID: FunctionPseudonym, Return: "p1"}, + {FnID: FunctionPseudonym, Return: "p2"}, + }}, + want: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"pseudonym","return":"p1"},{"fn_id":"pseudonym","return":"p2"}]}`, + }, + { + name: "a duplicate handle", + query: CredentialQuery{ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "same"}, {FnID: FunctionPseudonym, Return: "same"}}}, + want: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"age_check","return":"same"},{"fn_id":"pseudonym","return":"same"}]}`, + }, + { + name: "an unknown function", + query: CredentialQuery{ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: "no-such-function-v9", Return: "h", Params: map[string]any{"whatever": 1}}}}, + want: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"no-such-function-v9","return":"h","params":{"whatever":1}}]}`, + }, + { + name: "no verifier_id_type, and a verifier_context past 64 bytes", + query: CredentialQuery{ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionPseudonym, Return: "p", Params: map[string]any{ + "config": "known-offender-matching-ssc", "verifier_context": strings.Repeat("c", 65), + }}}}, + want: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"pseudonym","return":"p","params":{"config":"known-offender-matching-ssc","verifier_context":"` + strings.Repeat("c", 65) + `"}}]}`, + }, + { + name: "an empty req, and an empty handle", + query: CredentialQuery{ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: ""}}}, + want: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"age_check","return":""}]}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + encoded, err := json.Marshal(tc.query) + require.NoError(t, err, "a bad request must still serialise; refusing here would make negative tests impossible") + assert.JSONEq(t, tc.want, string(encoded)) + }) + } +} + +// Extra may not supply a member the struct models, whether or not that +// member's field happens to be set. +// +// Checking the ENCODED output would only catch the collision when the +// typed field survived omitempty - so an empty one would let Extra take +// over the member silently, and only sometimes. Changing a modelled +// member's type is what Raw is for. +func TestExtraCannotSupplyAModelledMember(t *testing.T) { + for _, tc := range []struct { + name string + do func() ([]byte, error) + }{ + {"req item, field set", func() ([]byte, error) { + return json.Marshal(ReqItem{FnID: "f", Return: "h", Extra: map[string]any{"fn_id": "other"}}) + }}, + {"req item, field EMPTY", func() ([]byte, error) { + return json.Marshal(ReqItem{Return: "h", Extra: map[string]any{"params": "not an object"}}) + }}, + {"credential query, field set", func() ([]byte, error) { + return json.Marshal(CredentialQuery{ID: "q", Format: FormatZkpReq, Extra: map[string]any{"format": "other"}}) + }}, + {"credential query, field EMPTY", func() ([]byte, error) { + return json.Marshal(CredentialQuery{ID: "q", Format: FormatZkpReq, Extra: map[string]any{"_req": "not an array"}}) + }}, + {"circuit metadata, field set", func() ([]byte, error) { + return json.Marshal(ZkpReqFormat{SystemIDValues: []string{"a"}, Extra: map[string]any{"system_id_values": []string{"b"}}}) + }}, + {"circuit metadata, field EMPTY", func() ([]byte, error) { + return json.Marshal(ZkpReqFormat{Extra: map[string]any{"system_id_values": "x"}}) + }}, + {"vp_formats_supported, field EMPTY", func() ([]byte, error) { + return json.Marshal(VPFormatsSupported{Extra: map[string]any{"zkp_req": "x"}}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.do() + require.Error(t, err) + assert.Contains(t, err.Error(), "a member this type models") + }) + } + + // A member the type does not model still goes through, which is the + // whole purpose of Extra. + encoded, err := json.Marshal(ReqItem{FnID: "f", Return: "h", Extra: map[string]any{"source": map[string]any{"path": []string{"ssc"}}}}) + require.NoError(t, err) + assert.JSONEq(t, `{"fn_id":"f","return":"h","source":{"path":["ssc"]}}`, string(encoded)) +} + +// A function parameter is passed through unchanged, and "unchanged" +// includes numbers a float64 cannot hold. +// +// Decoding an `any` with plain Unmarshal makes every number a float64, so +// 9007199254740993 comes back out as ...992 - a parameter the verifier +// never configured, inside a request a wallet is being tested against. +func TestParamsPreserveNumbersExactly(t *testing.T) { + const in = `{"fn_id":"f","return":"h","params":{` + + `"big":9007199254740993,` + + `"negative":-9007199254740993,` + + `"huge":123456789012345678901234567890,` + + `"precise":0.1234567890123456789,` + + `"nested":{"also_big":9007199254740993},` + + `"list":[9007199254740993]}}` + + var item ReqItem + require.NoError(t, json.Unmarshal([]byte(in), &item)) + encoded, err := json.Marshal(item) + require.NoError(t, err) + assert.JSONEq(t, in, string(encoded)) + assert.Contains(t, string(encoded), "9007199254740993", "the exact integer must survive") + + // The same for members this build does not model, which reach the + // wire through Extra. + const withExtra = `{"fn_id":"f","return":"h","future":{"n":9007199254740993}}` + var item2 ReqItem + require.NoError(t, json.Unmarshal([]byte(withExtra), &item2)) + encoded, err = json.Marshal(item2) + require.NoError(t, err) + assert.JSONEq(t, withExtra, string(encoded)) + + // And for a whole credential query, since Extra lives there too. + const query = `{"id":"q","format":"zkp_req","meta":{},"future":9007199254740993}` + var q CredentialQuery + require.NoError(t, json.Unmarshal([]byte(query), &q)) + encoded, err = json.Marshal(q) + require.NoError(t, err) + assert.JSONEq(t, query, string(encoded)) +} + +func TestIsCircuitZkFormat(t *testing.T) { + assert.True(t, IsZkpReqFormat(FormatZkpReq)) + assert.False(t, IsZkpReqFormat(FormatMsoMdocZk), "circuit_zk is not multipaz's ZK mdoc") + assert.False(t, IsZkpReqFormat(FormatMsoMdoc)) + assert.False(t, IsZkpReqFormat("")) +} + +// sameJSON compares two encodings for semantic equality. +func sameJSON(t *testing.T, a, b []byte) bool { + t.Helper() + return decodeExactly(t, a) == decodeExactly(t, b) +} + +// decodeExactly renders a JSON document in a form where two documents +// compare equal only if their VALUES are equal, numbers included. +// +// json.Unmarshal into an any decodes every number as a float64, so a +// comparison built on it reports equality after a value has changed +// precision - 9007199254740993 against 9007199254740992, say. These +// helpers carry the all-vectors conformance test's entire claim that a +// request is emitted unchanged, and a request parameter is an input to a +// proof, so that is the one thing they must not miss. +// +// UseNumber keeps each literal as text; re-encoding then gives a string +// that differs whenever any value does. Member order does not matter, +// because the decoded maps are re-encoded by encoding/json, which sorts +// keys. +func decodeExactly(t *testing.T, data []byte) string { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + require.NoError(t, decoder.Decode(&value)) + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} + +// The helper the all-vectors conformance test rests on has to see a +// changed number. +// +// json.Unmarshal into an any decodes every number as a float64, so the +// version this replaced reported equality after a value had lost +// precision - and a req param is an input to a proof, so a comparison +// that cannot see that is not checking the thing the test exists for. +func TestSameJSONSeesAChangedNumber(t *testing.T) { + for _, tc := range []struct { + name, a, b string + same bool + }{ + {"identical", `{"p":1}`, `{"p":1}`, true}, + {"member order, which is not a difference", `{"a":1,"b":2}`, `{"b":2,"a":1}`, true}, + {"whitespace, likewise", `{"p":1}`, "{ \"p\" : 1 }", true}, + + // The pair a float64 cannot tell apart. This is the case the old + // helper passed. + {"a value that lost precision", `{"p":9007199254740993}`, `{"p":9007199254740992}`, false}, + {"and a decimal that did", `{"p":1.00000000000000001}`, `{"p":1}`, false}, + + {"an added member", `{"p":1}`, `{"p":1,"q":2}`, false}, + {"a dropped array", `{"p":[]}`, `{}`, false}, + {"a number against its string", `{"p":1}`, `{"p":"1"}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.same, sameJSON(t, []byte(tc.a), []byte(tc.b))) + }) + } +} + +// A request whose REQ this build cannot model must survive the HA cache. +// +// NewRawCredentialQuery accepts the vendored negative vectors - that is +// the escape hatch working - but reading one back refused it: the outer +// req is an array, so the decode guard classified its item-level failure +// as an ordinary invalid query rather than an unmodelled shape. A request +// object is JSON-encoded into the cache and read by another node, so such +// a request could be published and then reload nowhere but the node that +// built it. +// +// Driven from the vendored vectors rather than hand-written JSON: these +// are the shapes the shared conformance set says a verifier must be able +// to send, so if they change, this changes with them. +func TestMalformedReqSurvivesACacheRoundTrip(t *testing.T) { + for _, name := range []string{ + "err_item_function_id_not_string.json", // a numeric req-item id + "err_params_not_object.json", // array-valued params + } { + t.Run(name, func(t *testing.T) { + query := vectorQuery(t, name) + + built, err := NewRawCredentialQuery(query) + require.NoError(t, err, "the constructor accepts it; that is the escape hatch") + + // Out to the cache... + encoded, err := json.Marshal(built) + require.NoError(t, err) + + // ...and back in on another node. + var reloaded CredentialQuery + require.NoError(t, json.Unmarshal(encoded, &reloaded), + "a request this verifier published must reload") + require.NotNil(t, reloaded.Raw, + "a shape the model cannot hold must reload raw, not be repaired into it") + + // And what reloaded is what was sent. A reload that silently + // normalised the request would pass the assertions above and + // still change what the wallet receives. + again, err := json.Marshal(reloaded) + require.NoError(t, err) + assert.JSONEq(t, string(encoded), string(again)) + }) + } +} + +// And the strict rule still holds everywhere else, which is the half that +// must not be lost. +// +// meta and claims are what the SD-JWT and mdoc validation reads. A decode +// error there laundered into Raw would turn an unreadable constraint into +// a query that validates, because there would be nothing left to judge - +// so these stay refused on the way back in, even though the constructor +// accepts them. The asymmetry is deliberate: sending one is a test of a +// wallet, reloading one as if it were fine is a hole in this verifier. +func TestMalformedConstraintsAreStillRefusedOnReload(t *testing.T) { + for _, name := range []string{ + "err_meta_doctype_not_string.json", + "err_meta_vct_values_not_strings.json", + } { + t.Run(name, func(t *testing.T) { + built, err := NewRawCredentialQuery(vectorQuery(t, name)) + require.NoError(t, err) + encoded, err := json.Marshal(built) + require.NoError(t, err) + + var reloaded CredentialQuery + assert.Error(t, json.Unmarshal(encoded, &reloaded), + "a constraint the validation reads must not be laundered into Raw") + }) + } +} + +// vectorQuery reads the "query" member of a vendored conformance vector. +func vectorQuery(t *testing.T, name string) json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join(vectorDir, name)) + require.NoError(t, err) + var vec struct { + Query json.RawMessage `json:"query"` + } + require.NoError(t, json.Unmarshal(raw, &vec)) + require.True(t, isJSONObject(vec.Query), "%s does not carry an object query", name) + return vec.Query +} + +// A member spelled in a different case is an UNKNOWN member, not the +// modelled one. +// +// encoding/json matches a struct field case-insensitively, so +// "CIRCUIT_ID_VALUES" bound to SystemIDValues, while the extension half +// matched exactly and kept it as unknown too. Re-encoding wrote both, and +// an extension nobody modelled rewrote a modelled member - an empty +// system_id_values, which is a filter matching NOTHING, came back +// permitting the circuit the unknown member named. +func TestCaseAliasedMemberDoesNotRewriteTheModelledOne(t *testing.T) { + t.Run("circuit_zk client metadata", func(t *testing.T) { + const in = `{"system_id_values":[],"CIRCUIT_ID_VALUES":["urn:unexpected"]}` + + var format ZkpReqFormat + require.NoError(t, json.Unmarshal([]byte(in), &format)) + + // The modelled member keeps what IT was given: present, and + // empty, which is a filter that matches nothing. + require.NotNil(t, format.SystemIDValues, "an explicit [] is a filter, not an absent member") + assert.Empty(t, format.SystemIDValues) + assert.Equal(t, map[string]any{"CIRCUIT_ID_VALUES": []any{"urn:unexpected"}}, format.Extra) + + out, err := json.Marshal(format) + require.NoError(t, err) + + // Round trips unchanged: the extension survives verbatim and the + // modelled filter still matches nothing. + var back ZkpReqFormat + require.NoError(t, json.Unmarshal(out, &back)) + require.NotNil(t, back.SystemIDValues) + assert.Empty(t, back.SystemIDValues, "re-encoding must not let the extension fill the filter: %s", out) + assert.Equal(t, format.Extra, back.Extra) + }) + + t.Run("req item", func(t *testing.T) { + var item ReqItem + require.NoError(t, json.Unmarshal([]byte(`{"fn_id":"keep","FN_ID":"shadow"}`), &item)) + assert.Equal(t, "keep", item.FnID) + assert.Equal(t, map[string]any{"FN_ID": "shadow"}, item.Extra) + }) + + t.Run("vp_formats_supported", func(t *testing.T) { + var formats VPFormatsSupported + require.NoError(t, json.Unmarshal([]byte(`{"MSO_MDOC":{"alg":["ES256"]}}`), &formats)) + assert.Nil(t, formats.MsoMdoc, "a differently cased name must not bind to the modelled entry") + assert.Contains(t, formats.Extra, "MSO_MDOC") + }) +} + +// Copying a query must not REPAIR it. +// +// A raw leaf is bytes, and copyAnyMap re-parsed them, so a payload a +// Verifier wrote to be malformed came back well-formed: {"x":1,"x":2} +// became {"x":2}. The point of such a payload is to see what a wallet does +// with it, so templating the query quietly changed the request under test. +// Member order and a number's spelling went the same way. +// +// Asserted on the EMITTED TEXT, not the parsed tree, because parsing is +// exactly the step that hides the difference. +func TestCopyDCQLPreservesRawLeavesByteForByte(t *testing.T) { + const duplicate = `{"x":1,"x":2}` + const spelled = `{"b":1,"a":1.50}` // member order and a long-form number + + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "q", + Format: FormatZkpReq, + Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{ + FnID: FunctionAgeCheck, + Return: "a", + Params: map[string]any{ + "payload": json.RawMessage(duplicate), + // A raw leaf inside a slice inside a map. + "nested": []any{map[string]any{"inner": json.RawMessage(spelled)}}, + }, + }}, + }}} + + dst := copyDCQL(src) + require.NotNil(t, dst) + + params := dst.Credentials[0].Req[0].Params + emitted, err := json.Marshal(params["payload"]) + require.NoError(t, err) + assert.Equal(t, duplicate, string(emitted), + "a raw leaf must be copied as bytes; re-parsing it drops the duplicate member") + + nested := params["nested"].([]any)[0].(map[string]any) + nestedEmitted, err := json.Marshal(nested["inner"]) + require.NoError(t, err) + assert.Equal(t, spelled, string(nestedEmitted), + "a raw leaf nested inside containers must be copied as bytes too") + + // And it is a COPY: neither direction leaks. + srcParams := src.Credentials[0].Req[0].Params + srcNested := srcParams["nested"].([]any)[0].(map[string]any) + + params["payload"] = json.RawMessage(`{"changed":true}`) + nested["inner"] = json.RawMessage(`{"changed":true}`) + assert.Equal(t, duplicate, string(srcParams["payload"].(json.RawMessage)), + "writing to the copy must not reach the original") + assert.Equal(t, spelled, string(srcNested["inner"].(json.RawMessage)), + "the nested container must not be shared with the original") + + srcNested["inner"] = json.RawMessage(`{"other":true}`) + assert.Equal(t, `{"changed":true}`, string(nested["inner"].(json.RawMessage)), + "writing to the original must not reach the copy") +} + +// A nil slice is not an empty one. +// +// nil encodes as null and an empty slice as [], so allocating during the +// copy sent [] where the query said null. +func TestCopyDCQLPreservesNilSlices(t *testing.T) { + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{ + "absent": []any(nil), + "present": []any{}, + }}}, + }}} + + before, err := json.Marshal(src.Credentials[0].Req[0].Params) + require.NoError(t, err) + after, err := json.Marshal(copyDCQL(src).Credentials[0].Req[0].Params) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "copying must not turn null into []") + assert.Contains(t, string(after), `"absent":null`) + assert.Contains(t, string(after), `"present":[]`) +} + +// A raw leaf inside a TYPED container is still a raw leaf. +// +// map[string]json.RawMessage and []json.RawMessage fell through to the +// encoding fallback, where re-parsing collapsed a duplicate member - so +// templating still changed a deliberately malformed payload even after +// direct raw leaves were preserved. +// +// Compared as text, never semantically: a semantic comparison is exactly +// what hides a dropped duplicate. +func TestCopyDCQLPreservesRawLeavesInTypedContainers(t *testing.T) { + const inMap = `{"x":1,"x":2}` + const inSlice = `{"y":1,"y":2}` + + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{ + "typed_map": map[string]json.RawMessage{"a": json.RawMessage(inMap)}, + "typed_slice": []json.RawMessage{json.RawMessage(inSlice)}, + }}}, + }}} + + params := copyDCQL(src).Credentials[0].Req[0].Params + + encoded, err := json.Marshal(params) + require.NoError(t, err) + assert.Contains(t, string(encoded), inMap, "a raw leaf in a typed map must survive as bytes") + assert.Contains(t, string(encoded), inSlice, "a raw leaf in a typed slice must survive as bytes") + + // And the containers are copies, not the originals. + srcParams := src.Credentials[0].Req[0].Params + params["typed_map"].(map[string]json.RawMessage)["a"] = json.RawMessage(`{"changed":true}`) + assert.Equal(t, inMap, string(srcParams["typed_map"].(map[string]json.RawMessage)["a"]), + "writing to the copied typed map must not reach the original") +} + +// namedObject and namedItems are the containers the reflective copy did +// not survive: a NAMED map or slice of any, holding a nil interface +// beside a raw leaf. +type ( + namedObject map[string]any + namedItems []any +) + +// A nil element must not condemn the whole container. +// +// copyAnyValue returns a nil interface unchanged, reflect.ValueOf gives +// an invalid Value for it, and treating that as "does not fit" sent the +// entire container through the encoding - re-parsing every raw leaf +// beside the nil and collapsing the duplicate members inside them. +// +// Compared as text: a semantic comparison hides a dropped duplicate. +func TestCopyDCQLPreservesRawLeavesBesideNilElements(t *testing.T) { + const inMap = `{"x":1,"x":2}` + const inSlice = `{"y":1,"y":2}` + + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{ + "named_map": namedObject{"absent": nil, "raw": json.RawMessage(inMap)}, + "named_slice": namedItems{nil, json.RawMessage(inSlice)}, + }}}, + }}} + + before, err := json.Marshal(src.Credentials[0].Req[0].Params) + require.NoError(t, err) + after, err := json.Marshal(copyDCQL(src).Credentials[0].Req[0].Params) + require.NoError(t, err) + + assert.Equal(t, string(before), string(after), "copying a named container must change nothing") + assert.Contains(t, string(after), inMap, "a raw leaf beside a nil in a named map must survive as bytes") + assert.Contains(t, string(after), inSlice, "a raw leaf beside a nil in a named slice must survive as bytes") + assert.Contains(t, string(after), `"absent":null`, "the nil element must still be null") +} + +// An absent raw leaf and an empty one are not the same leaf. +// +// Appending to a nil slice returns a nil slice, so an empty-but-present +// json.RawMessage came out nil. They do not mean the same thing: an empty +// one fails to marshal, while nil marshals as null - so flattening them +// turned a request that cannot be sent into one that quietly says null. +// +// Found while auditing this function for the absent/empty confusion that +// the nil-slice and nil-element findings were both instances of. +func TestCopyPreservesTheDifferenceBetweenAbsentAndEmptyRawLeaves(t *testing.T) { + var absent json.RawMessage + empty := json.RawMessage{} + + copiedAbsent, ok := copyAnyValue(absent).(json.RawMessage) + require.True(t, ok) + assert.Nil(t, copiedAbsent, "an absent raw leaf stays absent") + + copiedEmpty, ok := copyAnyValue(empty).(json.RawMessage) + require.True(t, ok) + assert.NotNil(t, copiedEmpty, "an empty raw leaf must not become an absent one") + assert.Empty(t, copiedEmpty) + + // The distinction is observable: one marshals, the other refuses. + _, err := json.Marshal(map[string]any{"k": copiedAbsent}) + require.NoError(t, err, "absent marshals as null") + _, err = json.Marshal(map[string]any{"k": copiedEmpty}) + require.Error(t, err, "empty must still refuse to marshal, as it did before copying") +} + +// A pointer to a raw leaf is still a raw leaf. +// +// *json.RawMessage missed the json.RawMessage case and went to the +// encoding, where re-parsing collapsed a duplicate member - so the +// pointered spelling was still repaired while the value form beside it +// was preserved. +func TestCopyDCQLPreservesPointerRawLeaves(t *testing.T) { + const duplicate = `{"x":1,"x":2}` + payload := json.RawMessage(duplicate) + + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "q", Format: FormatZkpReq, Meta: MetaQuery{DoctypeValue: "d"}, + Req: []ReqItem{{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{ + "payload": &payload, + }}}, + }}} + + params := copyDCQL(src).Credentials[0].Req[0].Params + encoded, err := json.Marshal(params) + require.NoError(t, err) + assert.Contains(t, string(encoded), duplicate, + "a pointer-valued raw leaf must be copied as bytes") + + // json.RawMessage carries its own MarshalJSON, so the pointer is + // snapshotted as the bytes it marshals to rather than walked. What + // the copy owes is that it serialises identically and does not share + // memory with the original - not that it is still a pointer. + payload = json.RawMessage(`{"other":true}`) + after, err := json.Marshal(params) + require.NoError(t, err) + assert.Contains(t, string(after), duplicate, + "writing to the original must not reach the copy") + assert.NotContains(t, string(after), "other") +} + +// rawHolder and privateHolder are the struct shapes the copy had to +// learn: one it can copy field by field, one it must refuse to. +type ( + rawHolder struct { + Payload json.RawMessage `json:"payload"` + } + privateHolder struct { + Payload json.RawMessage `json:"payload"` + hidden string + } +) + +// Arrays and structs hold raw leaves too. +// +// Only map, slice and pointer were copied structurally, so an array or a +// struct went to the encoding and had its duplicate members collapsed - +// the same defect as the slice and pointer cases, in the two shapes left +// over. Found by enumerating what can still reach the encoding fallback +// while holding a raw leaf, rather than waiting for the next review. +func TestCopyPreservesRawLeavesInArraysAndStructs(t *testing.T) { + t.Run("array", func(t *testing.T) { + const duplicate = `{"x":1,"x":2}` + src := map[string]any{"arr": [2]json.RawMessage{json.RawMessage(duplicate), json.RawMessage(`{"y":1}`)}} + out, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Contains(t, string(out), duplicate, "a raw leaf in an array must survive as bytes") + }) + + t.Run("struct", func(t *testing.T) { + const duplicate = `{"z":1,"z":2}` + src := map[string]any{"s": rawHolder{Payload: json.RawMessage(duplicate)}} + + // A struct is kept as the bytes it marshals to rather than walked + // field by field: walking it followed self-referencing fields + // that Marshal ignores, until the stack died. What the copy owes + // is that it serialises identically, not that it keeps the Go + // type. + before, err := json.Marshal(src) + require.NoError(t, err) + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) + assert.Contains(t, string(after), duplicate, "a raw leaf in a struct field must survive as bytes") + }) + + t.Run("an unexported field must not cost the raw leaf beside it", func(t *testing.T) { + const duplicate = `{"z":1,"z":2}` + src := map[string]any{"s": privateHolder{Payload: json.RawMessage(duplicate), hidden: "x"}} + + // encoding/json ignores an unexported field, so it was never in + // the output this copy exists to preserve - and refusing the + // whole struct over it cost the payload and saved nothing. + before, err := json.Marshal(src) + require.NoError(t, err) + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), + "the copy must marshal exactly as the original did") + assert.Contains(t, string(after), duplicate) + }) +} + +// A null filter is neither absent nor empty, and the model cannot hold it. +// +// {"system_id_values":null} decoded to a nil slice, which omitzero then +// omitted, so an explicitly supplied filter re-encoded as {} - no filter +// at all, and the wallet saw an unfiltered request. {"system_id_values": +// [null]} decoded the element to the zero string and re-encoded as [""], +// turning a filter that names nothing into one that names the empty +// string. Both are refused rather than repaired. +func TestNullFiltersAreRefusedNotRepaired(t *testing.T) { + for _, name := range []string{"system_id_values", "proof_system_values"} { + t.Run(name+" null array (JSON)", func(t *testing.T) { + var f ZkpReqFormat + err := json.Unmarshal([]byte(`{"`+name+`":null}`), &f) + require.Error(t, err, "a null filter must not decode to an absent one") + assert.Contains(t, err.Error(), "cannot be told from an absent member") + }) + + t.Run(name+" null element (JSON)", func(t *testing.T) { + var f ZkpReqFormat + err := json.Unmarshal([]byte(`{"`+name+`":[null]}`), &f) + require.Error(t, err, "a null element must not decode to an empty string") + assert.Contains(t, err.Error(), "read back as an empty string") + }) + } + + // The YAML path decodes the same two spellings into the same two + // wrong answers, so it carries the same refusal. + t.Run("null array (YAML)", func(t *testing.T) { + var f ZkpReqFormat + err := yamlv2.Unmarshal([]byte("system_id_values: null\n"), &f) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be told from an absent member") + }) + + t.Run("null element (YAML)", func(t *testing.T) { + var f ZkpReqFormat + err := yamlv2.Unmarshal([]byte("system_id_values:\n - null\n"), &f) + require.Error(t, err) + assert.Contains(t, err.Error(), "read back as an empty string") + }) + + // The two spellings that DO have a meaning still work, in both + // directions: absent is no filter, empty is a filter matching nothing. + t.Run("absent and empty still round-trip", func(t *testing.T) { + var absent ZkpReqFormat + require.NoError(t, json.Unmarshal([]byte(`{}`), &absent)) + encoded, err := json.Marshal(absent) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(encoded)) + + var empty ZkpReqFormat + require.NoError(t, json.Unmarshal([]byte(`{"system_id_values":[]}`), &empty)) + encoded, err = json.Marshal(empty) + require.NoError(t, err) + assert.JSONEq(t, `{"system_id_values":[]}`, string(encoded)) + }) +} + +// countingHolder is a struct mixing a scalar with a raw leaf - the shape +// that defeated the struct copy. namedCount checks a NAMED scalar too, +// which naming concrete types rather than testing the kind would miss. +type ( + namedCount int + countingHolder struct { + Count namedCount `json:"count"` + Plain int `json:"plain"` + Payload json.RawMessage `json:"payload"` + } +) + +// A scalar beside a raw leaf must not condemn the whole container. +// +// Sending a scalar through the encoding changes its TYPE - an int comes +// back a json.Number - which is not assignable to the field it came from, +// so the struct holding it failed the assignability check and went to the +// encoding whole. The raw payload was then collapsed because of the +// integer sitting next to it. +func TestCopyPreservesRawLeavesBesideScalars(t *testing.T) { + const duplicate = `{"x":1,"x":2}` + + t.Run("struct", func(t *testing.T) { + src := map[string]any{"s": countingHolder{Count: 7, Plain: 9, Payload: json.RawMessage(duplicate)}} + before, err := json.Marshal(src) + require.NoError(t, err) + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "a scalar field must not cost the raw leaf beside it") + assert.Contains(t, string(after), duplicate) + }) + + t.Run("scalars keep their Go type", func(t *testing.T) { + assert.Equal(t, 42, copyAnyValue(42), "an int must stay an int, not become a json.Number") + assert.Equal(t, namedCount(3), copyAnyValue(namedCount(3)), "a named scalar stays itself") + assert.Equal(t, uint8(5), copyAnyValue(uint8(5))) + }) + + t.Run("typed scalar container", func(t *testing.T) { + src := map[string]any{"m": map[string]int{"a": 1}} + copied, ok := copyAnyMap(src)["m"].(map[string]int) + require.True(t, ok, "a map of scalars keeps its type instead of being normalised") + assert.Equal(t, 1, copied["a"]) + }) +} + +// A repeated member cannot survive a decode, so it is refused. +// +// splitMembers decodes into a map, which keeps the LAST of a repeated +// member, and the extension values are decoded into any, so a duplicate +// nested inside one went the same way. Three of the four bags silently +// reduced the request; only the credential query survived, and only +// because its decode failure routes the bytes to Raw. +func TestDuplicateMembersAreRefusedAcrossEveryBag(t *testing.T) { + t.Run("circuit_zk client metadata", func(t *testing.T) { + for _, name := range []string{"system_id_values", "proof_system_values"} { + var f ZkpReqFormat + err := json.Unmarshal([]byte(`{"`+name+`":[],"`+name+`":["urn:x"]}`), &f) + require.Errorf(t, err, "%s: an empty filter must not be replaced by a repeat of itself", name) + assert.Contains(t, err.Error(), "more than once") + } + }) + + t.Run("req item", func(t *testing.T) { + var item ReqItem + err := json.Unmarshal([]byte(`{"fn_id":"age_check","return":"a","return":"b"}`), &item) + require.Error(t, err) + assert.Contains(t, err.Error(), "more than once") + }) + + t.Run("vp_formats_supported", func(t *testing.T) { + var formats VPFormatsSupported + err := json.Unmarshal([]byte(`{"mso_mdoc":{},"mso_mdoc":{"alg":["ES256"]}}`), &formats) + require.Error(t, err) + assert.Contains(t, err.Error(), "more than once") + }) + + // The credential query keeps its total decode: a duplicate is not a + // refusal there, it is a shape the model cannot hold, so the bytes go + // to Raw and come back out exactly as they went in. + t.Run("credential query keeps it raw instead", func(t *testing.T) { + const in = `{"id":"q","format":"dc+sd-jwt","meta":{"vct_values":["urn:x"]},"id":"other"}` + var q CredentialQuery + require.NoError(t, json.Unmarshal([]byte(in), &q)) + out, err := json.Marshal(q) + require.NoError(t, err) + assert.Equal(t, in, string(out), "a duplicate must survive verbatim through Raw") + }) + + // A duplicate nested inside an extension value is dropped by the same + // decode, so it is refused by the same guard. + t.Run("nested inside an extension", func(t *testing.T) { + var item ReqItem + err := json.Unmarshal([]byte(`{"id":"age_check","ext":{"x":1,"x":2}}`), &item) + require.Error(t, err) + assert.Contains(t, err.Error(), "more than once") + }) +} + +// derivedHolder marshals from unexported state, so its output cannot be +// rebuilt from its exported fields. +type derivedHolder struct { + Payload json.RawMessage `json:"payload"` + hidden string +} + +func (d derivedHolder) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{"payload": d.Payload, "derived": d.hidden}) +} + +// Whatever cannot be copied structurally is kept as the bytes it marshals +// to, rather than re-parsed. +// +// Re-parsing was the defect itself: a value reaching the fallback is +// exactly the one nothing else knows how to preserve, so decoding it +// there threw away the duplicate members, member order and number +// spellings that the fallback was supposed to be protecting. +func TestFallbackKeepsBytesRatherThanReparsingThem(t *testing.T) { + t.Run("a custom marshaller keeps its derived output AND its payload", func(t *testing.T) { + src := map[string]any{"d": derivedHolder{Payload: json.RawMessage(`{"y":1,"y":2}`), hidden: "derived-value"}} + before, err := json.Marshal(src) + require.NoError(t, err) + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), + "neither the derived member nor the duplicate payload may be lost") + assert.Contains(t, string(after), `{"y":1,"y":2}`) + assert.Contains(t, string(after), "derived-value") + }) +} + +// node is the shape that killed the stack: a self-reference Marshal never +// follows, because the field is tagged json:"-". +type node struct { + Value int `json:"value"` + Payload json.RawMessage `json:"payload"` + Parent *node `json:"-"` +} + +// Copying must terminate on a value that serialises perfectly well. +// +// A struct walked field by field follows EVERY exported field, including +// ones Marshal is told to skip, so a self-referencing Parent recursed +// until a fatal stack overflow - not a panic a test can recover, the +// process dies. The copy no longer walks structs at all, and the walk +// that remains is depth-bounded, so a cycle reached any other way ends in +// encodeCopy rather than in the runtime. +func TestCopyTerminatesOnCyclicValues(t *testing.T) { + t.Run("self-referencing struct behind a json:\"-\" tag", func(t *testing.T) { + const duplicate = `{"x":1,"x":2}` + n := &node{Value: 1, Payload: json.RawMessage(duplicate)} + n.Parent = n // the cycle Marshal never sees + + src := map[string]any{"n": n} + before, err := json.Marshal(src) + require.NoError(t, err, "the value serialises fine; only copying it used to crash") + + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "the copy must serialise identically") + assert.Contains(t, string(after), duplicate, "and still keep the raw leaf intact") + }) + + t.Run("self-referencing map", func(t *testing.T) { + m := map[string]any{"k": "v"} + m["self"] = m // Marshal reports this one as a cycle + + // What matters is that copying RETURNS rather than exhausting the + // stack; a value Marshal itself refuses cannot be copied + // faithfully, so the original is left in place. + require.NotPanics(t, func() { _ = copyAnyMap(map[string]any{"m": m}) }) + }) + + t.Run("a legitimately deep tree is still copied exactly", func(t *testing.T) { + const duplicate = `{"d":1,"d":2}` + leaf := map[string]any{"payload": json.RawMessage(duplicate)} + tree := any(leaf) + for range 40 { + tree = map[string]any{"next": tree} + } + src := map[string]any{"root": tree} + + before, err := json.Marshal(src) + require.NoError(t, err) + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) + assert.Contains(t, string(after), duplicate, "depth must not cost the raw leaf") + }) +} + +// stamped is a NAMED container with its own marshaller, which expects a +// concrete element type. A structural copy keeps the named type - and so +// the method - while retyping what is inside it. +type stamped map[string]any + +func (s stamped) MarshalJSON() ([]byte, error) { + when, ok := s["at"].(time.Time) + if !ok { + return nil, fmt.Errorf("stamped: at is %T, not a time.Time", s["at"]) + } + return json.Marshal(map[string]any{"at": when.UTC().Format(time.RFC3339), "payload": s["payload"]}) +} + +// A branching cycle exhausts work, not just stack. +// +// The depth bound stopped the stack growing but not the walk: a map +// holding ITSELF TWICE expands into a binary tree, so it burned 2^65 +// steps before depth 65 could stop it. Identity on the active path stops +// it at the first repeat. +func TestCopyTerminatesOnBranchingCycles(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + m := map[string]any{"payload": json.RawMessage(`{"x":1,"x":2}`)} + m["a"] = m + m["b"] = m // two routes back to m, not one + _ = copyAnyMap(map[string]any{"m": m}) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("copying a branching cycle did not terminate") + } +} + +// A container that marshals itself is snapshotted, not walked. +// +// Copying its elements kept the named type - and therefore its +// MarshalJSON - while turning a time.Time element into a json.RawMessage, +// so marshalling the copied request failed where the original succeeded. +func TestCopyKeepsCustomMarshalledContainersIntact(t *testing.T) { + src := map[string]any{"s": stamped{ + "at": time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), + "payload": json.RawMessage(`{"x":1,"x":2}`), + }} + + before, err := json.Marshal(src) + require.NoError(t, err, "the original marshals") + + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err, "so must the copy: its marshaller must still find a time.Time") + assert.Equal(t, string(before), string(after)) + assert.Contains(t, string(after), `{"x":1,"x":2}`, "and the raw leaf must survive") +} + +// textStamped marshals through encoding.TextMarshaler rather than +// json.Marshaler. encoding/json honours both. +type textStamped map[string]any + +func (t textStamped) MarshalText() ([]byte, error) { + when, ok := t["at"].(time.Time) + if !ok { + return nil, fmt.Errorf("textStamped: at is %T, not a time.Time", t["at"]) + } + return []byte(when.UTC().Format(time.RFC3339)), nil +} + +// The []any fast path must enter the walk's path like everything else. +// +// It went around the reflective walk and took that walk's cycle guard +// with it, so a self-referencing slice recursed until the stack died +// while the guards on maps and on typed slices sat unused. +func TestCopyTerminatesOnSelfReferencingSlices(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + s := make([]any, 1) + s[0] = s + _ = copyAnyMap(map[string]any{"s": s}) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("copying a self-referencing slice did not terminate") + } +} + +// A text-marshalled container is snapshotted too. +// +// encoding/json serialises through encoding.TextMarshaler as readily as +// through json.Marshaler, so recognising only the latter left a named map +// with a MarshalText on the structural path: its time.Time element became +// a json.RawMessage under a method that still expected a time.Time, and +// serialising the copy failed where the original succeeded. +func TestCopyKeepsTextMarshalledContainersIntact(t *testing.T) { + src := map[string]any{"t": textStamped{"at": time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}} + + before, err := json.Marshal(src) + require.NoError(t, err, "the original marshals") + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err, "so must the copy: its MarshalText must still find a time.Time") + assert.Equal(t, string(before), string(after)) + assert.Contains(t, string(after), "2026-10-15T12:00:00Z") +} + +// appendStamped serialises through encoding.TextAppender only - no +// MarshalJSON, no MarshalText. encoding/json dispatches to it all the +// same. +type appendStamped map[string]any + +func (a appendStamped) AppendText(b []byte) ([]byte, error) { + when, ok := a["at"].(time.Time) + if !ok { + return nil, fmt.Errorf("appendStamped: at is %T, not a time.Time", a["at"]) + } + return append(b, when.UTC().Format(time.RFC3339)...), nil +} + +// Every interface encoding/json serialises through has to be recognised. +// +// Recognising only some of them is what let a container through twice +// already: first json.Marshaler alone, then json.Marshaler and +// encoding.TextMarshaler. encoding.TextAppender reaches the same +// serialiser and broke the same way - found by finishing the list rather +// than waiting for the next report. +func TestCopyKeepsAppendTextContainersIntact(t *testing.T) { + src := map[string]any{"a": appendStamped{"at": time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}} + + before, err := json.Marshal(src) + require.NoError(t, err, "the original marshals through AppendText") + after, err := json.Marshal(copyAnyMap(src)) + require.NoError(t, err, "so must the copy") + assert.Equal(t, string(before), string(after)) + assert.Contains(t, string(after), "2026-10-15T12:00:00Z") +} + +// A key is as much of the request as a value is. +// +// The reflective map copy walked the values carefully and handed the KEYS +// over untouched. A key that is a pointer - or holds one - is shared, and +// keys are part of the wire representation: a map[*big.Int]string key +// marshals through TextMarshaler, so mutating the original integer after +// the copy renamed the copy's member. +func TestCopyIsolatesNonStringMapKeys(t *testing.T) { + key := big.NewInt(1) + src := map[string]any{"m": map[*big.Int]string{key: "v"}} + + copied := copyAnyMap(src) + before, err := json.Marshal(copied) + require.NoError(t, err) + assert.Contains(t, string(before), `"1":"v"`) + + // Mutate the ORIGINAL key after copying. + key.SetInt64(999) + + after, err := json.Marshal(copied) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), + "the copied request's member name must not follow the original key") + assert.NotContains(t, string(after), "999") +} + +// copyDCQL's own fields need the care Params got. +// +// The walk inside Params and Extra was taken apart repeatedly while the +// hand-written copy AROUND them kept two of the same defects: a claim +// value is `any`, and appending copied the slice while sharing what its +// elements point at; and Raw was appended to a nil slice, so an +// empty-but-present one came out absent. +func TestCopyDCQLIsolatesItsOwnFields(t *testing.T) { + t.Run("claim values are not shared with the original", func(t *testing.T) { + payload := json.RawMessage(`{"x":1,"x":2}`) + src := &DCQL{Credentials: []CredentialQuery{{ + ID: "q", Format: "dc+sd-jwt", Meta: MetaQuery{VCTValues: []string{"urn:x"}}, + Claims: []ClaimQuery{{Path: StringPath("a"), Values: []any{payload}}}, + }}} + + dst := copyDCQL(src) + before, err := json.Marshal(dst.Credentials[0].Claims[0].Values) + require.NoError(t, err) + assert.Contains(t, string(before), `{"x":1,"x":2}`, "the duplicate must survive the copy") + + // Mutate the ORIGINAL value's bytes. + payload[1] = 'Z' + + after, err := json.Marshal(dst.Credentials[0].Claims[0].Values) + require.NoError(t, err, "the copy must still be valid JSON") + assert.Equal(t, string(before), string(after), + "writing to the original claim value must not reach the copy") + }) + + t.Run("an empty Raw stays empty, not absent", func(t *testing.T) { + src := &DCQL{Credentials: []CredentialQuery{{Raw: json.RawMessage{}}}} + copied := copyDCQL(src) + require.NotNil(t, copied.Credentials[0].Raw, + "an empty-but-present Raw must not come out absent") + assert.Empty(t, copied.Credentials[0].Raw) + }) +} diff --git a/pkg/openid4vp/zkp_req_unsat_vectors_test.go b/pkg/openid4vp/zkp_req_unsat_vectors_test.go new file mode 100644 index 000000000..3ed3c3543 --- /dev/null +++ b/pkg/openid4vp/zkp_req_unsat_vectors_test.go @@ -0,0 +1,88 @@ +package openid4vp + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// unsatVector is one shared conformance vector for the rulebook's +// "unsatisfiable request" rules: a well-formed query the evaluator must +// never offer. +type unsatVector struct { + Description string `json:"description"` + Query json.RawMessage `json:"query"` + Expect struct { + Satisfiable bool `json:"satisfiable"` + Error string `json:"error"` + } `json:"expect"` +} + +// The vendored zkp_req_unsat set was being hashed for the pinned digest +// and never executed. That is the whole of what the nym_auth profile is +// for - the rules a preset is judged against at startup - stated by the +// implementation every other party checks against, and it was sitting +// unused while the profile silently stopped short of three of them +// (source_format, ver, prf). +// +// Only the verdict is compared, not the reason: the vectors carry the +// matcher's stable reason codes and this profile reports prose aimed at +// whoever is editing the configuration. The verdict is the part that has +// to agree. +// deliberatelyStricter names the vectors where this profile refuses a +// request the matcher calls satisfiable, with the reason. A divergence +// has to be stated somewhere a reader will find it; a vector quietly +// skipped is indistinguishable from one that was never noticed. +var deliberatelyStricter = map[string]string{ + // The matcher is right about the rulebook: known-offender-matching-psc + // is a defined configuration, and whether a system can serve it is + // its registry entry's say. This profile judges PRESETS, for a + // deployment whose circuits prove -ssc only, so it refuses the + // configuration with an explanation while it cannot be served - + // which is more use to an operator at startup than a preset that + // loads and is declined by every wallet. + "ok_psc_and_xsite_configs_are_rulebook_defined": "psc cannot be requested until a circuit proves it", +} + +func TestNymAuthProfileAgreesWithTheUnsatisfiableVectors(t *testing.T) { + dir := filepath.Join(vectorsDir, "zkp_req_unsat") + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + ran := 0 + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + + var vector unsatVector + require.NoError(t, json.Unmarshal(raw, &vector), name) + + var query CredentialQuery + require.NoError(t, json.Unmarshal(vector.Query, &query), name) + + t.Run(strings.TrimSuffix(name, ".json"), func(t *testing.T) { + err := ValidateProfile(ProfileNymAuth, query) + if why, stricter := deliberatelyStricter[strings.TrimSuffix(name, ".json")]; stricter { + assert.Errorf(t, err, "%s is listed as a deliberate divergence (%s), but the profile now accepts it; remove the entry", name, why) + return + } + if vector.Expect.Satisfiable { + assert.NoError(t, err, "%s: %s", name, vector.Description) + return + } + assert.Errorf(t, err, "%s: %s (the matcher refuses this as %s)", + name, vector.Description, vector.Expect.Error) + }) + ran++ + } + require.NotZero(t, ran, "no vectors were read") +} diff --git a/pkg/openid4vp/zkp_req_yaml_test.go b/pkg/openid4vp/zkp_req_yaml_test.go new file mode 100644 index 000000000..e56ebdd34 --- /dev/null +++ b/pkg/openid4vp/zkp_req_yaml_test.go @@ -0,0 +1,1110 @@ +package openid4vp + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yamlv2 "gopkg.in/yaml.v2" +) + +// The extension bags have to work from YAML, because that is where an +// operator actually configures them. +// +// Extra is tagged yaml:"-", so without MarshalYAML/UnmarshalYAML its +// members are read and silently dropped - an escape hatch that exists in +// the documentation and not in the product. Both +// .verifier.preferred_vp_formats and the presentation request templates +// (pkg/configuration, openid4vp.DCQL) are loaded from YAML. +func TestExtraSurvivesYAML(t *testing.T) { + t.Run("the envelope form an operator is most likely to need", func(t *testing.T) { + const in = ` +fn_id: circuit_zk_longfellow +input: eyJhIjoxfQ== +` + var format ZkpReqFormat + require.NoError(t, yamlv2.Unmarshal([]byte(in), &format)) + require.NotNil(t, format.Extra, "the envelope members must not be dropped") + assert.Equal(t, "circuit_zk_longfellow", format.Extra["fn_id"]) + assert.Equal(t, "eyJhIjoxfQ==", format.Extra["input"]) + + // And out again, so a config read in is a request sent out. + encoded, err := json.Marshal(format) + require.NoError(t, err) + assert.JSONEq(t, `{"fn_id":"circuit_zk_longfellow","input":"eyJhIjoxfQ=="}`, string(encoded)) + }) + + t.Run("the plain arrays, alongside an unknown member", func(t *testing.T) { + const in = ` +proof_system_values: ["longfellow-libzk-v1"] +system_id_values: ["urn:circuits:longfellow:nym_auth:v1"] +future_member: kept +` + var format ZkpReqFormat + require.NoError(t, yamlv2.Unmarshal([]byte(in), &format)) + assert.Equal(t, []string{"longfellow-libzk-v1"}, format.ProofSystemValues) + assert.Equal(t, []string{"urn:circuits:longfellow:nym_auth:v1"}, format.SystemIDValues) + assert.Equal(t, "kept", format.Extra["future_member"]) + }) + + t.Run("a req item", func(t *testing.T) { + const in = ` +fn_id: pseudonym +return: nym-123 +params: + config: known-offender-matching-ssc + verifier_id_type: x509_san_dns +source: + path: [ssc] +` + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(in), &item)) + assert.Equal(t, FunctionPseudonym, item.FnID) + assert.Equal(t, "nym-123", item.Return) + assert.Equal(t, "known-offender-matching-ssc", item.Params["config"]) + require.NotNil(t, item.Extra, "a member outside the model must survive") + assert.Contains(t, item.Extra, "source") + }) + + t.Run("a whole credential query, as a presentation request template carries it", func(t *testing.T) { + const in = ` +id: nym_attestation +format: zkp_req +meta: + doctype_value: eu.europa.ec.eudi.nym_auth.1 +_req: + - fn_id: pseudonym + return: nym-123 + params: + config: known-offender-matching-ssc + - fn_id: age_check + return: age-123 + params: + ge: "2008-10-10" + le: "2013-10-09" +future_member: kept +` + var query CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte(in), &query)) + + assert.Equal(t, FormatZkpReq, query.Format) + assert.Equal(t, "eu.europa.ec.eudi.nym_auth.1", query.Meta.DoctypeValue) + require.Len(t, query.Req, 2, "the functions must survive the YAML decode") + assert.Equal(t, FunctionPseudonym, query.Req[0].FnID) + assert.Equal(t, "age_check", query.Req[1].FnID) + assert.Equal(t, "2008-10-10", query.Req[1].Params["ge"]) + assert.Equal(t, "kept", query.Extra["future_member"]) + + // The request that leaves is the request that was configured. + encoded, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, `{ + "id":"nym_attestation","format":"zkp_req", + "meta":{"doctype_value":"eu.europa.ec.eudi.nym_auth.1"}, + "_req":[ + {"fn_id":"pseudonym","return":"nym-123","params":{"config":"known-offender-matching-ssc"}}, + {"fn_id":"age_check","return":"age-123","params":{"ge":"2008-10-10","le":"2013-10-09"}} + ], + "future_member":"kept"}`, string(encoded)) + }) +} + +// YAML and JSON must agree about what is modelled and what is an +// extension, or a request behaves differently depending on which side of +// the configuration it was written on. +func TestYAMLAndJSONAgreeOnWhatIsModelled(t *testing.T) { + const yamlIn = ` +id: nym_attestation +format: zkp_req +meta: + doctype_value: d +_req: + - fn_id: age_check + return: h + params: {ge: "2008-10-10"} +unknown_member: x +` + const jsonIn = `{"id":"nym_attestation","format":"zkp_req","meta":{"doctype_value":"d"}, + "_req":[{"fn_id":"age_check","return":"h","params":{"ge":"2008-10-10"}}],"unknown_member":"x"}` + + var fromYAML, fromJSON CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte(yamlIn), &fromYAML)) + require.NoError(t, json.Unmarshal([]byte(jsonIn), &fromJSON)) + + yamlEncoded, err := json.Marshal(fromYAML) + require.NoError(t, err) + jsonEncoded, err := json.Marshal(fromJSON) + require.NoError(t, err) + assert.JSONEq(t, string(jsonEncoded), string(yamlEncoded), + "the same request written in YAML and in JSON must go out the same") +} + +// YAML applies the same rule as JSON: an extension may not supply a +// member the type models. +func TestYAMLExtraCannotSupplyAModelledMember(t *testing.T) { + for _, tc := range []struct { + name string + do func() ([]byte, error) + }{ + // The empty cases are the ones that matter: a check against the + // populated map would pass them, which is how the JSON side was + // wrong before review and how this side was wrong an hour later. + {"circuit metadata, field EMPTY", func() ([]byte, error) { + return yamlv2.Marshal(ZkpReqFormat{Extra: map[string]any{"system_id_values": []string{"x"}}}) + }}, + {"circuit metadata, field set", func() ([]byte, error) { + return yamlv2.Marshal(ZkpReqFormat{SystemIDValues: []string{"a"}, Extra: map[string]any{"system_id_values": []string{"x"}}}) + }}, + {"req item, params EMPTY", func() ([]byte, error) { + return yamlv2.Marshal(ReqItem{FnID: "f", Return: "h", Extra: map[string]any{"params": "x"}}) + }}, + {"credential query, req EMPTY", func() ([]byte, error) { + return yamlv2.Marshal(CredentialQuery{ID: "q", Format: FormatZkpReq, Extra: map[string]any{"_req": "x"}}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.do() + require.Error(t, err) + assert.Contains(t, err.Error(), "a member this type models") + }) + } +} + +// A wrong type in YAML must not be silently replaced by a zero value. +// +// The first implementation here assigned through a comma-ok assertion, so +// `fn_id: 7` became "" - the serialiser repairing a request it promised to +// pass through, and losing the operator's value in the process. Known +// members go through a methodless alias now, so yaml.v2 handles the type +// exactly as it does for every other configuration struct in this +// repository: a scalar is coerced to the field's type, and a structural +// mismatch is an error. +// +// Coercion rather than refusal is deliberate. Diverging from yaml.v2 here +// would make this one struct behave unlike the rest of the configuration +// surface, which is a worse surprise than "7" where 7 was written. +func TestYAMLWrongTypesAreNotSilentlyEmptied(t *testing.T) { + t.Run("a scalar is coerced, not emptied", func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte("fn_id: 7\nreturn: h\n"), &item)) + assert.Equal(t, "7", item.FnID, "the configured value must survive in some form, not become empty") + assert.NotEmpty(t, item.FnID) + }) + + for _, tc := range []struct{ name, in string }{ + {"params that are a list", "fn_id: age_check\nreturn: h\nparams: [1, 2]\n"}, + {"params that are a scalar", "fn_id: age_check\nreturn: h\nparams: 7\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + require.Error(t, yamlv2.Unmarshal([]byte(tc.in), &item), + "a structural mismatch is an error, as it is everywhere else in the configuration") + }) + } + + t.Run("a circuit list that is not a list", func(t *testing.T) { + var format ZkpReqFormat + require.Error(t, yamlv2.Unmarshal([]byte("system_id_values: {a: b}\n"), &format)) + }) +} + +// yaml.v2 materialises every nested mapping as map[any]any. Carrying that +// into Extra and out through JSON depends on what encoding/json does with +// a non-string key, which has changed between releases: it currently +// coerces an integer key and drops a sequence key SILENTLY. Neither is an +// acceptable way to read a verifier's own configuration. +func TestNestedYAMLExtraIsNormalisedAndNonStringKeysRefused(t *testing.T) { + t.Run("nested mappings survive", func(t *testing.T) { + const in = ` +fn_id: age_check +return: h +source: + path: [birth_date] + nested: + deep: 1 +` + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(in), &item)) + encoded, err := json.Marshal(item) + require.NoError(t, err) + assert.JSONEq(t, `{"fn_id":"age_check","return":"h","source":{"path":["birth_date"],"nested":{"deep":1}}}`, string(encoded)) + }) + + t.Run("a non-string key is refused rather than coerced or dropped", func(t *testing.T) { + var item ReqItem + err := yamlv2.Unmarshal([]byte("fn_id: age_check\nreturn: h\nsource:\n 2: two\n"), &item) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a string key") + }) +} + +// The whole point of the alias decode: an ordinary, pre-existing +// presentation template with nested meta still loads. +func TestOrdinaryTemplatesStillLoad(t *testing.T) { + const in = ` +id: pid +format: dc+sd-jwt +meta: + vct_values: ["urn:eudi:pid:1"] +claims: + - path: [family_name] +` + var query CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte(in), &query)) + assert.Equal(t, FormatSDJWTVC, query.Format) + assert.Equal(t, []string{"urn:eudi:pid:1"}, query.Meta.VCTValues) + require.Len(t, query.Claims, 1) + assert.Nil(t, query.Extra, "nothing here is an extension") +} + +// A valid nested params tree must come through the normalisation walk +// unchanged. +// +// This direction is the one the walk could break: it rebuilds every +// mapping it touches, and params is an INPUT TO A PROOF - the pseudonym a +// wallet computes depends on it byte for byte. The behaviour the walk +// CHANGES is in TestReqItemParamsRefusesNonStringKeys; on this toolchain +// encoding/json already handles a nested map[any]any whose keys are all +// strings, so this case would pass without the walk too. +func TestReqItemParamsSurviveNormalisationUnchanged(t *testing.T) { + const in = ` +fn_id: pseudonym +return: nym-123 +params: + config: known-offender-matching-ssc + nested: + inner: value + deeper: + - key: in-a-sequence +` + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(in), &item)) + + // The point is that this encodes at all, and keeps every member. + encoded, err := json.Marshal(item) + require.NoError(t, err) + assert.JSONEq(t, `{ + "fn_id": "pseudonym", + "return": "nym-123", + "params": { + "config": "known-offender-matching-ssc", + "nested": {"inner": "value", "deeper": [{"key": "in-a-sequence"}]} + } + }`, string(encoded)) +} + +// A key with no JSON spelling is refused rather than coerced to its +// rendering or dropped, for the same reason: silently changing a proof +// input is worse than refusing to load the configuration. +func TestReqItemParamsRefusesNonStringKeys(t *testing.T) { + for _, tc := range []struct{ name, in, wantErr string }{ + // An integer key reaches the walk: yaml.v2 is happy to put it in a + // map[any]any, and JSON would then quietly render it as "7". + {"an integer key", "fn_id: f\nreturn: h\nparams:\n nested:\n 7: seven\n", "not a string key"}, + // A sequence key never gets that far - yaml.v2 refuses it on its + // own. Asserted anyway, and on the OUTCOME rather than the + // wording: this is the case JSON drops in silence, and which of + // the two layers catches it is not the point. + {"a sequence key", "fn_id: f\nreturn: h\nparams:\n nested:\n [a, b]: pair\n", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + err := yamlv2.Unmarshal([]byte(tc.in), &item) + require.Error(t, err) + if tc.wantErr != "" { + assert.Contains(t, err.Error(), tc.wantErr) + } + }) + } +} + +// verifier.preferred_vp_formats loads into VPFormatsSupported from YAML, +// so that is the path a format identifier postdating this build actually +// arrives by. With JSON methods alone the extension bag was dropped +// exactly where an operator would use it. +func TestVPFormatsSupportedCarriesUnknownFormatsThroughYAML(t *testing.T) { + const in = ` +mso_mdoc: {} +zkp_req: + proof_system_values: [longfellow-libzk-v1] +some_future_format: + alg_values: [ES256] +` + var formats VPFormatsSupported + require.NoError(t, yamlv2.Unmarshal([]byte(in), &formats)) + + require.NotNil(t, formats.MsoMdoc) + require.NotNil(t, formats.ZkpReq) + assert.Equal(t, []string{"longfellow-libzk-v1"}, formats.ZkpReq.ProofSystemValues) + require.Contains(t, formats.Extra, "some_future_format") + + // And out to the wallet unchanged - client_metadata is JSON. + encoded, err := json.Marshal(formats) + require.NoError(t, err) + assert.JSONEq(t, `{ + "mso_mdoc": {}, + "zkp_req": {"proof_system_values": ["longfellow-libzk-v1"]}, + "some_future_format": {"alg_values": ["ES256"]} + }`, string(encoded)) + + // And back to YAML, so a config that round-trips keeps it. + reencoded, err := yamlv2.Marshal(formats) + require.NoError(t, err) + var again VPFormatsSupported + require.NoError(t, yamlv2.Unmarshal(reencoded, &again)) + assert.Equal(t, formats.Extra, again.Extra) +} + +// A req param is an input to a proof, and yaml.v2 resolves a non-integer +// scalar to a float64 before anything here can look at it - so the +// configured precision is gone upstream of every check, and the check +// downstream sees a value that is perfectly stable because the damage +// already happened. +// +// Integers survive yaml.v2 intact. It is the decimals that do not, which +// is why this pins both. +func TestReqItemParamsKeepTheConfiguredDigits(t *testing.T) { + for _, tc := range []struct{ name, in, want, wantLiteral string }{ + { + name: "a decimal yaml.v2 would round", + in: "fn_id: f\nreturn: h\nparams:\n p: 1.00000000000000001\n", + want: `{"fn_id":"f","return":"h","params":{"p":1.00000000000000001}}`, + wantLiteral: "1.00000000000000001", + }, + { + name: "an ordinary decimal", + in: "fn_id: f\nreturn: h\nparams:\n p: 0.1\n", + want: `{"fn_id":"f","return":"h","params":{"p":0.1}}`, + wantLiteral: "0.1", + }, + { + name: "a large integer, which survived already", + in: "fn_id: f\nreturn: h\nparams:\n p: 9007199254740993\n", + want: `{"fn_id":"f","return":"h","params":{"p":9007199254740993}}`, + wantLiteral: "9007199254740993", + }, + { + name: "nested inside a mapping", + in: "fn_id: f\nreturn: h\nparams:\n outer:\n p: 1.00000000000000001\n", + want: `{"fn_id":"f","return":"h","params":{"outer":{"p":1.00000000000000001}}}`, + wantLiteral: "1.00000000000000001", + }, + { + name: "and inside a sequence", + in: "fn_id: f\nreturn: h\nparams:\n list:\n - 1.00000000000000001\n", + want: `{"fn_id":"f","return":"h","params":{"list":[1.00000000000000001]}}`, + wantLiteral: "[1.00000000000000001]", + }, + { + name: "a string stays a string", + in: "fn_id: f\nreturn: h\nparams:\n p: \"1.00000000000000001\"\n", + want: `{"fn_id":"f","return":"h","params":{"p":"1.00000000000000001"}}`, + wantLiteral: `"1.00000000000000001"`, + }, + { + name: "and a bool is untouched", + in: "fn_id: f\nreturn: h\nparams:\n p: true\n", + want: `{"fn_id":"f","return":"h","params":{"p":true}}`, + wantLiteral: "true", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(tc.in), &item)) + encoded, err := json.Marshal(item) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(encoded)) + + // JSONEq parses both sides as float64, so it cannot see the + // digits it was given - it passes on a rounded value. The + // literal has to be checked as text. + assert.Contains(t, string(encoded), tc.wantLiteral, + "the configured digits have to reach the wire, not a float64's rendering of them") + }) + } +} + +// A spelling YAML allows and JSON does not keeps the resolved value: +// inventing a JSON spelling for it would be a worse answer than the +// rounding. +func TestReqItemParamsFallBackForNonJSONSpellings(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + {"hexadecimal", "fn_id: f\nreturn: h\nparams:\n p: 0x10\n", `{"fn_id":"f","return":"h","params":{"p":16}}`}, + {"octal", "fn_id: f\nreturn: h\nparams:\n p: 0o17\n", `{"fn_id":"f","return":"h","params":{"p":15}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(tc.in), &item)) + encoded, err := json.Marshal(item) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(encoded)) + }) + } +} + +// Writing the configuration back out must not change a proof input +// either. The YAML marshal path bridges through JSON, so an +// exactly-preserved param arrives as a json.Number - and yaml.v2 encodes +// one through Int64 and then Float64, which rounds an integer beyond +// int64 and a decimal with more digits than a float64 holds. +// +// Every other path keeps these exact, so the honest answer here is to +// stop rather than to round quietly or to repair. +func TestYAMLRefusesToWriteANumberItWouldChange(t *testing.T) { + for _, tc := range []struct { + name, literal string + ok bool + }{ + {"a small integer", "7", true}, + {"the largest int64", "9223372036854775807", true}, + {"an integer beyond int64", "123456789012345678901234567890", false}, + {"an ordinary decimal", "0.1", true}, + {"a decimal beyond float64 precision", "1.00000000000000001", false}, + {"a large integer inside float64's exact range", "9007199254740993", true}, + } { + t.Run(tc.name, func(t *testing.T) { + query := CredentialQuery{ + ID: "q", Format: FormatZkpReq, + Req: []ReqItem{{FnID: "f", Return: "h", Params: map[string]any{"p": json.Number(tc.literal)}}}, + } + encoded, err := yamlv2.Marshal(query) + if !tc.ok { + require.Error(t, err) + assert.Contains(t, err.Error(), "without changing it") + assert.Contains(t, err.Error(), tc.literal, "the error has to name the value an operator must fix") + return + } + require.NoError(t, err) + assert.Contains(t, string(encoded), tc.literal, + "an accepted number has to come back out as the digits that went in") + }) + } +} + +// Nested, and named by path so an operator can find it. +func TestYAMLNumberRefusalNamesThePath(t *testing.T) { + query := CredentialQuery{ + ID: "q", Format: FormatZkpReq, + Req: []ReqItem{{FnID: "f", Return: "h", Params: map[string]any{ + "outer": map[string]any{"inner": json.Number("1.00000000000000001")}, + }}}, + } + _, err := yamlv2.Marshal(query) + require.Error(t, err) + assert.Contains(t, err.Error(), "outer.inner") +} + +// Two YAML scalars that have no JSON number to become, each failing in a +// different and quiet way before this refused them. +func TestReqItemParamsRefuseScalarsWithNoJSONNumber(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + { + // yaml.v2 resolves an overflowing literal to a STRING, and + // nothing in what it hands back says whether the operator + // wrote 1e400 or "1e400". A proof input quietly changing type + // is invisible downstream: a string always looks stable. + name: "a literal too large for yaml.v2 to hold", + in: "fn_id: f\nreturn: h\nparams:\n p: 1e400\n", + want: "cannot tell", + }, + { + // These reach json.Marshal, which refuses them - so the whole + // request object becomes unencodable at SEND time, far from + // the configuration that caused it. + name: "infinity", + in: "fn_id: f\nreturn: h\nparams:\n p: .inf\n", + want: "no JSON spelling", + }, + { + name: "negative infinity", + in: "fn_id: f\nreturn: h\nparams:\n p: -.inf\n", + want: "no JSON spelling", + }, + { + name: "not a number", + in: "fn_id: f\nreturn: h\nparams:\n p: .nan\n", + want: "no JSON spelling", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + err := yamlv2.Unmarshal([]byte(tc.in), &item) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +// And the neighbours that must keep working, so the refusal above is not +// quietly a ban on exponents or on strings. +func TestReqItemParamsKeepWhatIsRepresentable(t *testing.T) { + for _, tc := range []struct{ name, in, wantJSON string }{ + {"an underflowing literal, which yaml.v2 does resolve", "fn_id: f\nreturn: h\nparams:\n p: 1e-400\n", `{"p":1e-400}`}, + {"an ordinary exponent", "fn_id: f\nreturn: h\nparams:\n p: 1e10\n", `{"p":1e10}`}, + {"a string that is not a number spelling", "fn_id: f\nreturn: h\nparams:\n p: \"inf\"\n", `{"p":"inf"}`}, + {"an ordinary string", "fn_id: f\nreturn: h\nparams:\n p: x509_san_dns\n", `{"p":"x509_san_dns"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(tc.in), &item)) + encoded, err := json.Marshal(item.Params) + require.NoError(t, err) + assert.JSONEq(t, tc.wantJSON, string(encoded)) + }) + } +} + +// Raw is tagged yaml:"-", so the YAML bridge emitted only the modelled +// shadow - a DIFFERENT query, missing whatever made the bytes raw, and +// missing it silently. A presentation template written back out lost the +// one member it existed to carry. +// +// Both directions, because emitting a shape this type then refuses to +// read back would be the same defect wearing the other face. +func TestRawSurvivesTheYAMLRoundTrip(t *testing.T) { + const configured = `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"claims":{"_req":[{"fn_id":"age_check","id":"h"}]}}` + + query, err := NewRawCredentialQuery(json.RawMessage(configured)) + require.NoError(t, err) + + out, err := yamlv2.Marshal(query) + require.NoError(t, err) + assert.Contains(t, string(out), "_req", "the member that made this raw has to be in the output") + + var reloaded CredentialQuery + require.NoError(t, yamlv2.Unmarshal(out, &reloaded), + "a query this type just wrote must load; refusing to would be a repair by other means") + require.NotNil(t, reloaded.Raw, "and it is still the shape the typed model cannot hold") + + // And what reaches the wallet is what was configured. + encoded, err := json.Marshal(reloaded) + require.NoError(t, err) + assert.JSONEq(t, configured, string(encoded)) + + // The shadow comes back too, so the response path can still match it. + assert.Equal(t, "q", reloaded.ID) + assert.Equal(t, FormatZkpReq, reloaded.Format) + assert.Equal(t, "d", reloaded.Meta.DoctypeValue) +} + +// A number the YAML writer cannot reproduce is refused rather than +// rounded, on the raw path as on the typed one. +func TestRawYAMLRefusesANumberItWouldChange(t *testing.T) { + query, err := NewRawCredentialQuery(json.RawMessage( + `{"id":"q","format":"zkp_req","claims":{"_req":[]},"_req":[{"params":{"p":123456789012345678901234567890}}]}`)) + require.NoError(t, err) + + _, err = yamlv2.Marshal(query) + require.Error(t, err) + assert.Contains(t, err.Error(), "without changing it") +} + +// The laundering guard applies on this path too: a member of the right +// kind that the model rejects is an invalid query, not an unmodelled one, +// and must not become raw just because it arrived as YAML. +func TestYAMLDoesNotLaunderInvalidModelledMembers(t *testing.T) { + const in = "id: q\nformat: dc+sd-jwt\nmeta:\n vct_values: [urn:x]\nclaims:\n - path: []\n" + var query CredentialQuery + require.Error(t, yamlv2.Unmarshal([]byte(in), &query), + "an empty claim path is refused by the model, and YAML is not a way around that") +} + +// The extension bag needs the lexeme-preserving decode as much as params +// does - more so, since the shapes it exists for are the ones a newer +// rulebook defines and this build does not understand. Preserving params +// and rounding these would be a distinction with no basis. +func TestExtensionMembersKeepTheConfiguredDigits(t *testing.T) { + for _, tc := range []struct{ name, in, wantLiteral string }{ + { + name: "an unknown member of a req item", + in: "fn_id: f\nreturn: h\nfuture: 1.00000000000000001\n", + wantLiteral: "1.00000000000000001", + }, + { + name: "nested inside an unknown member", + in: "fn_id: f\nreturn: h\nfuture:\n inner: 1.00000000000000001\n", + wantLiteral: "1.00000000000000001", + }, + { + name: "and in a sequence under one", + in: "fn_id: f\nreturn: h\nfuture:\n - 1.00000000000000001\n", + wantLiteral: "[1.00000000000000001]", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(tc.in), &item)) + encoded, err := json.Marshal(item) + require.NoError(t, err) + assert.Contains(t, string(encoded), tc.wantLiteral, + "an extension member is a proof input too; a float64's rendering is not what was configured") + }) + } +} + +// And the refusals reach it, rather than being swallowed as "not a +// mapping" the way a structural mismatch rightly is. +func TestExtensionMembersRefuseUnrepresentableScalars(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + {"an overflowing literal", "fn_id: f\nreturn: h\nfuture: 1e400\n", "cannot tell"}, + {"infinity", "fn_id: f\nreturn: h\nfuture: .inf\n", "no JSON spelling"}, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + err := yamlv2.Unmarshal([]byte(tc.in), &item) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +// An operator configures the negative requests this verifier exists to +// send, so a TEMPLATE has to be able to express them. The typed decode +// turns them into zero values and re-emits something else: _req: [] is +// dropped by omitempty, an item with no id comes back carrying one, a +// null param disappears. +// +// Checked on shape rather than value - see jsonShapeEquivalent - so +// yaml.v2's scalar coercion, which is this repository's settled policy, +// is not mistaken for loss. +func TestTemplatesCanExpressTheNegativeShapes(t *testing.T) { + for _, tc := range []struct { + name, in, wantJSON string + }{ + { + name: "an empty req array", + in: "id: q\nformat: zkp_req\nmeta:\n doctype_value: d\n_req: []\n", + wantJSON: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[]}`, + }, + { + name: "an item with no id", + in: "id: q\nformat: zkp_req\nmeta:\n doctype_value: d\n_req:\n - return: h\n", + wantJSON: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"return":"h"}]}`, + }, + { + name: "an item with no return", + in: "id: q\nformat: zkp_req\nmeta:\n doctype_value: d\n_req:\n - fn_id: age_check\n", + wantJSON: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"age_check"}]}`, + }, + { + name: "a null params", + in: "id: q\nformat: zkp_req\nmeta:\n doctype_value: d\n_req:\n - fn_id: f\n return: h\n params: null\n", + wantJSON: `{"id":"q","format":"zkp_req","meta":{"doctype_value":"d"},"_req":[{"fn_id":"f","return":"h","params":null}]}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var query CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte(tc.in), &query)) + + encoded, err := json.Marshal(query) + require.NoError(t, err) + assert.JSONEq(t, tc.wantJSON, string(encoded), + "a template has to send what it says, including the shapes a typed model cannot hold") + }) + } +} + +// And the coercion policy survives: a scalar of the wrong type is still +// coerced by yaml.v2 exactly as it is everywhere else in this +// repository's configuration, rather than being treated as loss and +// pushed down the raw path. +func TestScalarCoercionIsNotMistakenForLoss(t *testing.T) { + const in = "id: q\nformat: zkp_req\nmeta:\n doctype_value: d\n_req:\n - fn_id: 7\n return: h\n" + var query CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte(in), &query)) + + assert.Nil(t, query.Raw, "coercion is this repository's policy, not a shape the model cannot hold") + require.Len(t, query.Req, 1) + assert.Equal(t, "7", query.Req[0].FnID, "coerced, as every other configuration struct here coerces") +} + +// An ordinary template is unaffected: it must still decode typed, or the +// distinction stops meaning anything. +func TestOrdinaryTemplatesStayTyped(t *testing.T) { + const in = "id: q\nformat: zkp_req\nmeta:\n doctype_value: d\n_req:\n - fn_id: pseudonym\n return: nym-1\n params:\n config: known-offender-matching-ssc\n" + var query CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte(in), &query)) + assert.Nil(t, query.Raw) + require.Len(t, query.Req, 1) + assert.Equal(t, FunctionPseudonym, query.Req[0].FnID) +} + +// age_check takes full-dates, and a full-date is exactly what YAML has a +// native type for - so an unquoted `ge: 2008-10-10` is both the most +// natural way to write the rulebook's central input and the one most at +// risk of arriving as something else. +// +// It reaches the wire as the string it was written as. On yaml.v2 that is +// the library's own doing: it sets a timestamp-resolved scalar as a +// string when the destination is an interface, deliberately and for +// backward compatibility. That is also why this test is here rather than +// taken on trust - the same source marks the behaviour "TODO(v3) Drop +// this", so it is someone else's compatibility promise, not ours. +func TestUnquotedFullDatesReachTheWireUnchanged(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + { + name: "the rulebook's own age_check range, unquoted", + in: "fn_id: age_check\nreturn: age-1\nparams:\n ge: 2008-10-10\n le: 2013-10-09\n", + want: `{"fn_id":"age_check","return":"age-1","params":{"ge":"2008-10-10","le":"2013-10-09"}}`, + }, + { + name: "and quoted, which must come out the same", + in: "fn_id: age_check\nreturn: age-1\nparams:\n ge: \"2008-10-10\"\n le: \"2013-10-09\"\n", + want: `{"fn_id":"age_check","return":"age-1","params":{"ge":"2008-10-10","le":"2013-10-09"}}`, + }, + { + name: "a full timestamp, likewise untouched", + in: "fn_id: age_check\nreturn: age-1\nparams:\n ge: 2008-10-10T00:00:00Z\n", + want: `{"fn_id":"age_check","return":"age-1","params":{"ge":"2008-10-10T00:00:00Z"}}`, + }, + { + name: "a date nested under an unknown member", + in: "fn_id: age_check\nreturn: age-1\nfuture:\n ge: 2008-10-10\n", + want: `{"fn_id":"age_check","return":"age-1","future":{"ge":"2008-10-10"}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte(tc.in), &item)) + encoded, err := json.Marshal(item) + require.NoError(t, err) + // want pins it exactly, which is the point: a bare date that + // had become an RFC 3339 timestamp would fail here, and the + // matcher would reject such a value as not a full-date. + assert.JSONEq(t, tc.want, string(encoded)) + }) + } +} + +// The time.Time arm of yamlValue cannot be reached through yaml.v2: that +// library sets a timestamp-resolved scalar as a string when the +// destination is an interface, deliberately, with "TODO(v3) Drop this" +// beside it. So the arm is exercised directly rather than left as code no +// test can reach - it exists for the day that TODO is acted on, and the +// cost of being wrong then is the rulebook's central input silently +// becoming a value the matcher rejects. +func TestYAMLValueRecoversALexemeFromATimestamp(t *testing.T) { + when, err := time.Parse(time.DateOnly, "2008-10-10") + require.NoError(t, err) + + // Stands in for a library that resolves a full-date to a time.Time: + // an interface target gets the time, a string target gets the lexeme, + // which is what yaml.v2 itself does for every other scalar. + unmarshal := func(into any) error { + switch target := into.(type) { + case *any: + *target = when + return nil + case *string: + *target = "2008-10-10" + return nil + } + return fmt.Errorf("unexpected target %T", into) + } + + var value yamlValue + require.NoError(t, value.UnmarshalYAML(unmarshal)) + assert.Equal(t, "2008-10-10", value.value, + "a full-date must survive as the full-date it was written as, not as an RFC 3339 timestamp") + + // And the shape that would have gone out without it. + encoded, err := json.Marshal(when) + require.NoError(t, err) + assert.Equal(t, `"2008-10-10T00:00:00Z"`, string(encoded), + "which is what a time.Time encodes as, and what the matcher rejects as not a full-date") +} + +// The number check walked only any-valued containers, so a TYPED one - +// map[string]json.Number, []json.Number - was skipped entirely and +// yaml.v2 rounded what was inside it with nothing said. +// +// CredentialQuery.MarshalYAML happened to be safe, because it bridges +// through JSON before the check runs. ReqItem.MarshalYAML builds its map +// directly and was not - so this goes through ReqItem, which is the +// caller that was actually broken, and through CredentialQuery, which +// must stay safe for a better reason than luck. +func TestTypedContainersDoNotHideANumberYAMLWouldRound(t *testing.T) { + const unrepresentable = "1.00000000000000001" + + for _, tc := range []struct { + name string + params map[string]any + refused bool + }{ + {"a typed map", map[string]any{"nested": map[string]json.Number{"p": unrepresentable}}, true}, + {"a typed slice", map[string]any{"list": []json.Number{unrepresentable}}, true}, + {"a typed map nested in a typed slice", map[string]any{ + "list": []map[string]json.Number{{"p": unrepresentable}}, + }, true}, + // Representable, so it passes - the walk descends typed + // containers rather than refusing them on sight. + {"a typed map of representable numbers", map[string]any{"nested": map[string]float64{"p": 1.5}}, false}, + {"and a typed map of strings", map[string]any{"nested": map[string]string{"p": unrepresentable}}, false}, + {"the any-valued form, which was already caught", map[string]any{ + "nested": map[string]any{"p": json.Number(unrepresentable)}, + }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + item := ReqItem{FnID: "f", Return: "h", Params: tc.params} + _, err := yamlv2.Marshal(item) + if !tc.refused { + require.NoError(t, err) + return + } + require.Error(t, err, "a proof input YAML would round must be refused wherever it is nested") + assert.Contains(t, err.Error(), "without changing it") + }) + } + + // And through the whole query, which normalises on its own account. + query := CredentialQuery{ID: "q", Format: FormatZkpReq, Req: []ReqItem{{ + FnID: "f", Return: "h", + Params: map[string]any{"nested": map[string]json.Number{"p": unrepresentable}}, + }}} + _, err := yamlv2.Marshal(query) + require.Error(t, err) +} + +// A duplicate member has no YAML representation that keeps both. +// +// The raw YAML path decodes the bytes to a tree, which collapses them to +// the last value - so writing the configuration back out would emit a +// different request, and the whole reason such a query is carried raw is +// that it must not change. Refused rather than quietly reduced. +func TestYAMLRefusesARawQueryWithDuplicateMembers(t *testing.T) { + // Not a constraint member - those are refused outright now - but one + // the model still carries raw. + const in = `{"id":"first","id":"last","format":"zkp_req","meta":{"doctype_value":"d"}}` + + query, err := NewRawCredentialQuery(json.RawMessage(in)) + require.NoError(t, err, "this is a request a verifier may legitimately send") + + _, err = yamlv2.Marshal(query) + require.Error(t, err) + assert.Contains(t, err.Error(), "more than once") + + // And the JSON path still carries it, which is the point of the + // refusal being narrow: only YAML cannot say this. + encoded, err := json.Marshal(query) + require.NoError(t, err) + assert.Equal(t, in, string(encoded)) +} + +// An EMPTY constraint list is a filter that nothing matches; an ABSENT +// one is no filter at all. The matcher's contract distinguishes them, so +// this serialisation has to. +// +// omitempty could not: an explicit {"system_id_values": []} became {}, +// and the wallet saw an unfiltered request where the operator had asked +// for one that matches nothing. omitzero omits only nil. +// +// Both encodings, because they reach the distinction by different means - +// JSON through the struct tag, YAML through MarshalYAML's own != nil +// test - and a fix to one would not have fixed the other. +func TestZkpReqFormatKeepsEmptyApartFromAbsent(t *testing.T) { + for _, tc := range []struct { + name string + format ZkpReqFormat + wantJSON string + wantYAML string + }{ + { + name: "absent", + format: ZkpReqFormat{}, + wantJSON: `{}`, + wantYAML: "{}\n", + }, + { + name: "empty, which matches nothing", + format: ZkpReqFormat{SystemIDValues: []string{}, ProofSystemValues: []string{}}, + wantJSON: `{"proof_system_values":[],"system_id_values":[]}`, + wantYAML: "proof_system_values: []\nsystem_id_values: []\n", + }, + { + name: "one empty and one absent", + format: ZkpReqFormat{SystemIDValues: []string{}}, + wantJSON: `{"system_id_values":[]}`, + wantYAML: "system_id_values: []\n", + }, + { + name: "populated", + format: ZkpReqFormat{SystemIDValues: []string{"urn:x"}}, + wantJSON: `{"system_id_values":["urn:x"]}`, + wantYAML: "system_id_values:\n- urn:x\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + encoded, err := json.Marshal(tc.format) + require.NoError(t, err) + assert.Equal(t, tc.wantJSON, string(encoded)) + + asYAML, err := yamlv2.Marshal(tc.format) + require.NoError(t, err) + assert.Equal(t, tc.wantYAML, string(asYAML)) + + // And back: the distinction has to survive a round trip, not + // just the encode. A decode that turned [] into nil would + // lose the constraint one step later. + var backJSON ZkpReqFormat + require.NoError(t, json.Unmarshal(encoded, &backJSON)) + assert.Equal(t, tc.format.SystemIDValues == nil, backJSON.SystemIDValues == nil, + "nil-ness of system_id_values must survive JSON") + assert.Equal(t, len(tc.format.SystemIDValues), len(backJSON.SystemIDValues)) + + var backYAML ZkpReqFormat + require.NoError(t, yamlv2.Unmarshal(asYAML, &backYAML)) + assert.Equal(t, tc.format.SystemIDValues == nil, backYAML.SystemIDValues == nil, + "nil-ness of system_id_values must survive YAML") + assert.Equal(t, len(tc.format.SystemIDValues), len(backYAML.SystemIDValues)) + }) + } +} + +// The YAML routes to Raw need the same guard as the JSON ones. +// +// A template is as capable of naming a constraint twice, or in the wrong +// case, as a JSON request is - and a configuration is the MORE likely +// place for a typo, because nobody runs a JSON schema over it first. +// Checking only the JSON paths left the whole of this open: a template +// with a case-aliased constraint reaches Raw through the +// shape-equivalence fallback, not the decode-failure one. +// +// Checked on the NORMALISED bytes, which is what gets emitted and what a +// wallet reads. +func TestYAMLRawPathIsNotAWayPastTheConstraintChecks(t *testing.T) { + for _, tc := range []struct{ name, template, want string }{ + { + name: "a case-aliased constraint inside meta", + template: "id: q\nformat: dc+sd-jwt\nmeta:\n VCT_VALUES: [urn:x]\n", + want: "differs only in case", + }, + { + name: "a case-aliased meta itself", + template: "id: q\nformat: dc+sd-jwt\nMETA:\n vct_values: [urn:x]\n", + want: "differs only in case", + }, + { + name: "a case-aliased doctype_value", + template: "id: q\nformat: mso_mdoc\nmeta:\n DocType_Value: org.iso.18013.5.1.mDL\n", + want: "differs only in case", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var q CredentialQuery + err := yamlv2.Unmarshal([]byte(tc.template), &q) + require.Error(t, err, "this template must not be readable; it decoded to %#v", q) + assert.Contains(t, err.Error(), tc.want) + }) + } + + // And an ordinary template still reads, with its constraint intact - + // the guard refuses aliases, not YAML. + var ok CredentialQuery + require.NoError(t, yamlv2.Unmarshal([]byte("id: q\nformat: dc+sd-jwt\nmeta:\n vct_values: [urn:x]\n"), &ok)) + assert.Equal(t, []string{"urn:x"}, ok.Meta.VCTValues) +} + +// An extension bag that cannot be read is an error, not an empty bag. +// +// yamlExtra decodes the node a second time to collect members the typed +// model does not know. A structural fault the typed decode IGNORES - a +// sequence used as a map key under an unknown member - makes that second +// decode fail, and the failure used to be swallowed: the whole bag was +// discarded, valid siblings and all, with no error. A metadata block +// carrying fn_id, input and one malformed sibling loaded with NO +// extensions, silently, and the verifier sent different metadata than +// the operator had configured. +func TestYAMLExtensionsAreNotDiscardedOnADecodeError(t *testing.T) { + // The precondition: these siblings really are collected when nothing + // else is wrong, so the assertion below is about the error and not + // about a bag that was always empty. + var ok ZkpReqFormat + require.NoError(t, yamlv2.Unmarshal([]byte("fn_id: circuit_zk_longfellow\ninput: abc\n"), &ok)) + require.Len(t, ok.Extra, 2) + + var broken ZkpReqFormat + err := yamlv2.Unmarshal([]byte( + "fn_id: circuit_zk_longfellow\ninput: abc\nfuture:\n ? [a, b]\n : pair\n"), &broken) + require.Error(t, err, "the extensions could not be read, so this must not look like success") + assert.Contains(t, err.Error(), "zkp_req client metadata") + assert.Empty(t, broken.Extra, + "and nothing half-read is kept; the caller gets the error, not a partial bag") +} + +// The YAML path must emit the tree it CHECKED. +// +// checkYAMLRendersNumbersExactly normalised a temporary copy to walk its +// numbers and then handed yaml.v2 the caller's own containers, where a +// json.RawMessage is a []byte - written out as a sequence of byte +// integers rather than the object it encodes. +func TestYAMLEmitsRawLeavesAsTheObjectsTheyEncode(t *testing.T) { + t.Run("a raw leaf is an object, not bytes", func(t *testing.T) { + item := ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{ + "payload": json.RawMessage(`{"x":1}`), + }} + + out, err := yamlv2.Marshal(item) + require.NoError(t, err) + assert.Contains(t, string(out), "x: 1", "a raw leaf must render as the object it encodes: %s", out) + assert.NotContains(t, string(out), "- 123", "rendered as a byte sequence: %s", out) + }) + + t.Run("a duplicate member is refused, not dropped", func(t *testing.T) { + item := ReqItem{FnID: FunctionAgeCheck, Return: "a", Params: map[string]any{ + "payload": json.RawMessage(`{"x":1,"x":2}`), + }} + + _, err := yamlv2.Marshal(item) + require.Error(t, err, "YAML cannot write a duplicate member, so it must refuse rather than drop one") + assert.Contains(t, err.Error(), "more than once") + }) +} + +// A repeated YAML key keeps only the last, so it is refused. +// +// The configuration loaders use yaml.v2's NON-strict Unmarshal, which +// takes the last of a repeated key without complaint. An operator writing +// system_id_values twice got the second silently - an empty filter, +// which matches nothing, replaced by one that permits a circuit. The same +// applied to a repeated nested parameter, which is a proof input. +func TestDuplicateYAMLKeysAreRefused(t *testing.T) { + t.Run("metadata filter", func(t *testing.T) { + var f ZkpReqFormat + err := yamlv2.Unmarshal([]byte("system_id_values: []\nsystem_id_values: [urn:unexpected]\n"), &f) + require.Error(t, err, "an empty filter must not be replaced by a repeat of itself") + assert.Contains(t, err.Error(), "named more than once") + assert.Contains(t, err.Error(), "system_id_values") + }) + + t.Run("the other filter", func(t *testing.T) { + var f ZkpReqFormat + err := yamlv2.Unmarshal([]byte("proof_system_values: []\nproof_system_values: [x]\n"), &f) + require.Error(t, err) + assert.Contains(t, err.Error(), "named more than once") + }) + + t.Run("nested parameter", func(t *testing.T) { + var item ReqItem + err := yamlv2.Unmarshal([]byte("fn_id: age_check\nreturn: a\nparams:\n ge: '2013-10-09'\n ge: '2020-01-01'\n"), &item) + require.Error(t, err, "a repeated proof input must not take its last spelling silently") + assert.Contains(t, err.Error(), "named more than once") + assert.Contains(t, err.Error(), "params.ge", "the path should say where it is") + }) + + t.Run("inside a sequence", func(t *testing.T) { + var f ZkpReqFormat + err := yamlv2.Unmarshal([]byte("future:\n - k: 1\n k: 2\n"), &f) + require.Error(t, err, "depth and sequences must not hide a repeat") + assert.Contains(t, err.Error(), "named more than once") + }) + + t.Run("a document with no repeats still loads", func(t *testing.T) { + var item ReqItem + require.NoError(t, yamlv2.Unmarshal([]byte("fn_id: age_check\nreturn: a\nparams:\n ge: '2013-10-09'\n le: '2020-01-01'\n"), &item)) + assert.Equal(t, "age_check", item.FnID) + assert.Len(t, item.Params, 2) + }) +} diff --git a/pkg/pubsub/memory_test.go b/pkg/pubsub/memory_test.go index 2617b2375..b841f4bb1 100644 --- a/pkg/pubsub/memory_test.go +++ b/pkg/pubsub/memory_test.go @@ -199,10 +199,10 @@ func TestService_HAWithoutRedisFallsBackToMemory(t *testing.T) { func TestParseBackend(t *testing.T) { for in, want := range map[string]Backend{ - "": BackendMemory, - "memory": BackendMemory, - "redis": BackendRedis, - "valkey": BackendValkey, + "": BackendMemory, + "memory": BackendMemory, + "redis": BackendRedis, + "valkey": BackendValkey, "nonsense": BackendMemory, } { assert.Equal(t, want, ParseBackend(in), "ParseBackend(%q)", in)