Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,6 @@ fixtures/wrpac-clients/*.issued/

# bootmgr editable install metadata (make install)
*.egg-info/

# nym_auth circuit artifact (decompressed, ~1 MB): a build input for dockerfiles/verifier-nymauth.Dockerfile
fixtures/nym-auth-circuits/*.bin
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ The verifier has matching presentation-request templates in
`eu.europa.ec.eudi.nym_auth.1`, carrying `birth_date` (full-date, tag 1004),
`ssc` - SHA-256 of a 16-byte `secret_seed` the issuer and the prover share -
and optionally `psc` (SHA-256 of a prover-only seed). It exists for
`circuit_zk` presentations: pseudonyms and age predicates proven in zero
`zkp_req` presentations (called `circuit_zk` before rulebook v0.4): pseudonyms and age predicates proven in zero
knowledge, never disclosed. `fixtures/vc-metadata/nym_auth.mdoc.json` is a
byte-for-byte copy of the rulebook's schema until registry.siros.org
publishes it (mdoc types stay vendored even with `REGISTRY=external`).
Expand Down Expand Up @@ -517,6 +517,20 @@ in:** holder-supplied commitments (the `known-offender-matching-psc` configurati
lands, REMOVE the fixed `psc` from `nym_auth*.json`: the issuer refuses any document that already carries a value
for a configured commitment claim.

**Verifying a pseudonym (gdc config; image comes with vc's verifier release).** `environments/gdc.yaml`
`values.verifier.extraConfig` carries `verifier.nym_auth` (SUNET/vc#832): the allowlist entry
`known-offender-matching-ssc` (circuit ids, Ligero block parameters, artifact size and SHA-256 from
zk-cred-longfellow `src/nymauth/profile.rs`; do not copy the digest from vc's `docs/CONFIGURATION.md`, whose
example has 52 zeros in the middle) and `preferred_vp_formats.zkp_req` (`system_id_values`, `proof_system_values`; rulebook v0.4 names). The ~1 MB decompressed artifact cannot
travel as a Fly secret and is not in the vc repo, so `dockerfiles/verifier-nymauth.Dockerfile` bakes it into the
verifier image at `/etc/vc/circuits/nym_auth-ssc.bin`, checking size and digest at build time (build input:
`fixtures/nym-auth-circuits/nym_auth-ssc.bin`, gitignored; the recipe is in the file). The verifier needs the
`zknative` build and refuses to start without `trust.pdp_url`: it takes the issuer key from the HOLDER's own
chain, so the PDP is the only check that key belongs to an issuer (here: action `mdoc-issuer`, the mDOC IACA
registry; a self-signed chain with the same SAN gets decision false). **`verifier_id` is the verifier's client_id exactly as published,
`x509_san_dns:<host of public_url>` with the prefix (rulebook v0.4; the bare host was the rule before), and scopes every
pseudonym: changing it changes every pseudonym this verifier reports.**

### Service Architecture

```
Expand Down
34 changes: 34 additions & 0 deletions dockerfiles/verifier-nymauth.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# The vc verifier image plus the nym_auth circuit artifact, for environments that verify nym_auth pseudonym proofs
# (environments/gdc.yaml, verifier.nym_auth).
#
# The artifact is the DECOMPRESSED Longfellow circuit (1,062,842 bytes). It cannot travel as a Fly file secret
# (base64 of it exceeds the ~1 MiB machine config body) and the vc repo does not carry it, so it is baked in here.
# The size and digest are checked at build time against the values the allowlist entry in environments/gdc.yaml
# states, which are in turn the ones zk-cred-longfellow's src/nymauth/profile.rs ships; vc re-checks both at startup
# and refuses anything else, so a wrong file fails the build rather than the first presentation.
#
# zstd -dc <archive>/longfellow/mdoc/circuits.zst > fixtures/nym-auth-circuits/nym_auth-ssc.bin # gitignored
# docker build -f dockerfiles/verifier-nymauth.Dockerfile \
# --build-arg BASE=registry.fly.io/sirosid-gdc-vc-verifier:<the vc verifier build, zknative> \
# -t registry.fly.io/sirosid-gdc-vc-verifier:local-<epoch> .
#
# The source archive is ours (no repository yet): ~/.cache/nymzip/nym-auth-circuits/longfellow/mdoc/circuits.zst
# (395,608 bytes, sha256 6c4b96ed431ee547a3a2e5b62621943a9a0a44646267653bf2db0fcc269b5c3b). Fetch it from its
# repository once there is one.
ARG BASE
FROM alpine:3.20 AS check
ARG ARTIFACT_BYTES=1062842
ARG ARTIFACT_SHA256=92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f
COPY fixtures/nym-auth-circuits/nym_auth-ssc.bin /nym_auth-ssc.bin
RUN set -e; \
[ "$(wc -c < /nym_auth-ssc.bin)" = "${ARTIFACT_BYTES}" ] || { echo "artifact is not ${ARTIFACT_BYTES} bytes (decompressed?)"; exit 1; }; \
echo "${ARTIFACT_SHA256} /nym_auth-ssc.bin" | sha256sum -c -

# Staged with its directories here: a distroless runtime has no shell to chmod with, and `COPY --chmod` gives the
# directories it creates the FILE's mode (0444: no x bit), which makes the artifact unreadable to the non-root user
# ("open /etc/vc/circuits/nym_auth-ssc.bin: permission denied" at startup).
RUN mkdir -p /out/etc/vc/circuits && cp /nym_auth-ssc.bin /out/etc/vc/circuits/nym_auth-ssc.bin \
&& chmod 0755 /out/etc /out/etc/vc /out/etc/vc/circuits && chmod 0444 /out/etc/vc/circuits/nym_auth-ssc.bin

FROM ${BASE}
COPY --from=check /out/ /
76 changes: 76 additions & 0 deletions environments/gdc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,42 @@ values:
description: "did:web DID document resolution"
timeout: 10s
verifier:
# nym_auth pseudonym verification (SUNET/vc#827/#829/#830/#832). Needs a vc verifier BUILT WITH the zknative tag
# and the circuit artifact baked in (dockerfiles/verifier-nymauth.Dockerfile); an older verifier ignores these
# keys (non-strict unmarshal) and simply cannot answer a zkp_req request.
#
# verifier_id scopes EVERY pseudonym: SHA-256(verifier_id) is the pseudonym's verifier input. Since rulebook v0.4
# (vc 2026-10-20, integration/gdc-nymauth-v5 @ fd562f10) it is the verifier's client_id EXACTLY AS PUBLISHED,
# prefix and all: x509_san_dns:<public_url's host> (it was the bare host before v0.4, and vc refused the prefix).
# CHANGING IT CHANGES EVERY PSEUDONYM this verifier ever reports. The allowlist values are zk-cred-longfellow's
# src/nymauth/profile.rs (authoritative); do NOT take the digest from docs/CONFIGURATION.md, whose example is a
# placeholder with 52 zeros in the middle.
#
# trust.pdp_url is already set by the chart and MUST stay: nym_auth takes the issuer key from the HOLDER's own
# certificate chain, so the PDP is the only check that key belongs to a real issuer (the verifier refuses to
# start without it). Checked against this environment's PDP 2026-10-20: the gdc issuer's chain is trusted for
# eu.europa.ec.eudi.nym_auth.1 (action mdoc-issuer, the mDOC IACA registry) and a self-signed chain with the same
# SAN is not.
extraConfig:
verifier:
nym_auth:
enable: true
verifier_id: x509_san_dns:sirosid-gdc-vc-verifier.fly.dev
circuits:
allow:
- config: known-offender-matching-ssc
hash_circuit_id: 5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729
sig_circuit_id: 00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb
block_enc_hash: 6983
block_enc_sig: 4679
artifact_path: /etc/vc/circuits/nym_auth-ssc.bin
artifact_sha256: 92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f
artifact_bytes: 1062842
preferred_vp_formats:
# v0.4 names (circuit_zk / circuit_id_values / circuit_proof_system_values fail startup, naming these)
zkp_req:
system_id_values: ["urn:circuits:longfellow:nym_auth:v1"]
proof_system_values: ["longfellow-libzk-v1"]
# Selectable options in the verifier's own presentation-definition UI.
# NOT the same thing as presentation-request templates, which are only
# reachable through a real OIDC /authorize?scope=... request.
Expand All @@ -299,6 +335,46 @@ values:
# (category/credentials/zk_system_type) is only understood by the vc build
# pinned in environments/gdc.yaml, not by vc main.
presets:
# nym_auth pseudonym request (rulebook v0.4 zkp_req; needs preferred_vp_formats.zkp_req and verifier.nym_auth
# above, else the preset is not published). `req` items are {fn_id, return, params}; ver and prf are required.
# The scope key is the verifier's own credential_metadata scope for nym_auth, which supplies meta.source_format
# (mso_mdoc). `validate: nym_auth` makes vc refuse this preset AT STARTUP if it breaks the rulebook. The
# pseudonym is scoped by verifier.nym_auth.verifier_id: changing that changes every pseudonym.
# nym_auth_pseudonym_age adds an age_check; its bounds are vc's example (see there).
"nym_auth_pseudonym":
category: "ZK Proofs"
credentials:
nym_auth:
format: "zkp_req"
validate: "nym_auth"
req:
- fn_id: pseudonym
return: nym
params:
config: known-offender-matching-ssc
ver: 1
prf: sha-256
"nym_auth_pseudonym_age":
category: "ZK Proofs"
credentials:
nym_auth:
format: "zkp_req"
validate: "nym_auth"
req:
- fn_id: pseudonym
return: nym
params:
config: known-offender-matching-ssc
ver: 1
prf: sha-256
- fn_id: age_check
return: age
# The range must span at least 365 days (a narrower one is an exact birth date) and must suit the
# holder: this window (born 1980-01-01..2000-12-31) is satisfied by alice-001 (born 1990-01-15), so
# it is a positive test; the 2008..2013 example in vc's brief is not.
params:
ge: "1980-01-01"
le: "2000-12-31"
"PID (mdoc) - ZK Proof (Vega)":
category: "ZK Proofs"
credentials:
Expand Down
3 changes: 3 additions & 0 deletions fixtures/vc-config-golden/accepted-diffs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,6 @@ fly-gdc:
- path: verifier.zk_circuits
reason: "now driven by environments/<name>.yaml's zk_circuits_sources rather than a CLI-only flag"

- path: verifier.nym_auth
kind: added
reason: "gdc verifies nym_auth pseudonym proofs (SUNET/vc#832; environments/gdc.yaml verifier.extraConfig). Needs a zknative verifier image with the circuit artifact baked in (dockerfiles/verifier-nymauth.Dockerfile)."
14 changes: 13 additions & 1 deletion sirosid_core/vc_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,18 @@ def write_as_rules(docs: list, out_dir: Path) -> Path:
return _write_documents(docs, out_dir, "wallet-backend-as-rules", "as-rules")


def integral_nym_auth_numbers(config: dict) -> dict:
"""Helm renders an integer of a million or more as a float (artifact_bytes 1062842 becomes `1.062842e+06`,
which PyYAML then dumps as `1062842.0`), and vc's allowlist wants an integer there. Only the allowlist's
numeric fields are touched, and only when the float is integral."""
allow = (((config.get("verifier") or {}).get("nym_auth") or {}).get("circuits") or {}).get("allow") or []
for entry in allow:
for key in ("artifact_bytes", "block_enc_hash", "block_enc_sig"):
if isinstance(entry.get(key), float) and entry[key].is_integer():
entry[key] = int(entry[key])
return config


def render_vc(docs: list, out_dir: Path, target: str, secrets_dir: Path, gen_secret,
plain_http_hosts: set = None, secret_overrides: dict = None,
env: str = None, mongo_password: str = None,
Expand All @@ -539,7 +551,7 @@ def render_vc(docs: list, out_dir: Path, target: str, secrets_dir: Path, gen_sec
seeds_enabled = bool((config.get("apigw") or {}).get("seeds"))
if target == "fly":
drop_twin_imports(config)
config = strip_unrenderable(config)
config = integral_nym_auth_numbers(strip_unrenderable(config))
if credential_registries:
config = use_credential_registry(config, credential_types or {}, credential_registries)
(out_dir / filename).write_text(yaml.dump(config, sort_keys=False))
Expand Down
140 changes: 140 additions & 0 deletions tests/test_nymauth_verifier_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""The gdc verifier's nym_auth block (SUNET/vc#832): what the rendered verifier config must say, and the one value
that is easy to get wrong silently.

`verifier_id` scopes every pseudonym, and a wrong one is not an error anyone sees: it yields a pseudonym no other
implementation reports. The artifact digest has a placeholder lookalike (docs/CONFIGURATION.md's example keeps the
real digest's first 8 and last 4 hex characters and zeroes the middle), so the check below is on the whole value."""
import re
import shutil
import sys
import tempfile
import unittest
from pathlib import Path

import yaml

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))

import env_config # noqa: E402
from sirosid_core.naming import Naming # noqa: E402
from sirosid_core.render import render # noqa: E402
from sirosid_core.resources import Resources # noqa: E402

NEEDS = unittest.skipUnless(shutil.which("helm"), "needs helm")

# zk-cred-longfellow src/nymauth/profile.rs - the authoritative source (the native loader hashes the decompressed
# artifact and compares against exactly these).
PROFILE = {
"hash_circuit_id": "5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729",
"sig_circuit_id": "00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb",
"block_enc_hash": 6983,
"block_enc_sig": 4679,
"artifact_bytes": 1062842,
"artifact_sha256": "92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f",
}


def render_gdc_verifier():
tmp = Path(tempfile.mkdtemp())
try:
values = env_config.load_environment_config("gdc").get("values") or {}
naming = Naming(env="gdc")
render("fly", ROOT / "chart", env="gdc", resources=Resources(ROOT), out_dir=tmp / "out", secrets_dir=tmp / "secrets",
env_values=values, naming=naming, say=lambda *a, **k: None, mongo_password="x")
text = (tmp / "out" / "fly-gdc" / "vc-verifier.yaml").read_text()
return text, yaml.safe_load(text)
finally:
shutil.rmtree(tmp, ignore_errors=True)


@NEEDS
class GdcNymAuthVerifier(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.text, cfg = render_gdc_verifier()
cls.full = cfg
cls.cfg = cfg["verifier"]

def test_enabled_with_exactly_the_profile_the_circuit_ships(self):
entry = self.cfg["nym_auth"]["circuits"]["allow"]
self.assertEqual(len(entry), 1)
self.assertEqual({k: entry[0][k] for k in PROFILE}, PROFILE)
self.assertEqual(entry[0]["config"], "known-offender-matching-ssc")
self.assertTrue(self.cfg["nym_auth"]["enable"])

def test_numbers_are_integers_in_the_file_vc_reads(self):
"""Helm renders 1062842 as 1.062842e+06 and PyYAML dumps that back as 1062842.0; a Go int field refuses it."""
for k in ("artifact_bytes", "block_enc_hash", "block_enc_sig"):
self.assertIs(type(self.cfg["nym_auth"]["circuits"]["allow"][0][k]), int, k)
self.assertRegex(self.text, r"artifact_bytes: 1062842\n")

def test_the_digest_is_not_the_documentation_placeholder(self):
digest = self.cfg["nym_auth"]["circuits"]["allow"][0]["artifact_sha256"]
self.assertRegex(digest, r"^[0-9a-f]{64}$")
self.assertNotRegex(digest, r"0{8}", "a run of zeros is the doc_example placeholder, not a SHA-256")

def test_ids_are_strings_not_numbers_the_yaml_could_reinterpret(self):
entry = self.cfg["nym_auth"]["circuits"]["allow"][0]
for k in ("hash_circuit_id", "sig_circuit_id", "artifact_sha256"):
self.assertIsInstance(entry[k], str, k)

def test_verifier_id_is_the_published_client_id(self):
"""Rulebook v0.4: verifier_id is the client_id exactly as published, x509_san_dns:<host of public_url>, prefix
included (the bare host was the rule before v0.4 and is the NOT-a-verifier_id value now: it yields a
pseudonym no other implementation reports). Changing it changes every pseudonym."""
vid = self.cfg["nym_auth"]["verifier_id"]
self.assertEqual(vid, "x509_san_dns:" + re.sub(r"^https://", "", self.cfg["public_url"]))

def test_the_artifact_path_is_where_the_overlay_image_puts_it(self):
self.assertEqual(self.cfg["nym_auth"]["circuits"]["allow"][0]["artifact_path"], "/etc/vc/circuits/nym_auth-ssc.bin")
dockerfile = (ROOT / "dockerfiles" / "verifier-nymauth.Dockerfile").read_text()
self.assertIn("/etc/vc/circuits/nym_auth-ssc.bin", dockerfile)
self.assertIn(PROFILE["artifact_sha256"], dockerfile)
self.assertIn(str(PROFILE["artifact_bytes"]), dockerfile)

def test_a_pdp_is_configured_because_the_verifier_refuses_to_start_without_one(self):
self.assertTrue(self.cfg["trust"]["pdp_url"], "nym_auth takes the issuer key from the holder's chain; no PDP = refuse to start")

def test_the_request_may_name_only_what_this_verifier_offers(self):
zk = self.cfg["preferred_vp_formats"]["zkp_req"]
self.assertEqual(zk["system_id_values"], ["urn:circuits:longfellow:nym_auth:v1"])
self.assertEqual(zk["proof_system_values"], ["longfellow-libzk-v1"])
self.assertIn("dc+sd-jwt", self.cfg["preferred_vp_formats"], "adding zkp_req must not drop the existing formats")

def test_the_nym_auth_presets_are_shaped_as_vc_reads_them(self):
"""vc's brief (SUNET/vc#832): preset key `req` (the WIRE member `_req` was silently accepted and left empty),
item key fn_id, ver and prf required, no source_format / doctype_value (both derive from the scope's
credential_metadata, so a scope key that is not that entry reproduces invalid_meta in every wallet)."""
presets = self.cfg["presets"]
scopes = self.full["common"]["credential_metadata"]
for label in ("nym_auth_pseudonym", "nym_auth_pseudonym_age"):
self.assertEqual(list(presets[label]["credentials"]), ["nym_auth"], label)
self.assertIn("nym_auth", scopes, "the preset's scope key must be a common.credential_metadata scope")
self.assertEqual(scopes["nym_auth"]["format"], "mso_mdoc")
cred = presets[label]["credentials"]["nym_auth"]
self.assertEqual((cred["format"], cred["validate"]), ("zkp_req", "nym_auth"))
self.assertNotIn("_req", cred)
for banned in ("source_format", "doctype_value"):
self.assertNotIn(banned, cred, banned)
first = cred["req"][0]
self.assertEqual((first["fn_id"], first["params"]["ver"], first["params"]["prf"]), ("pseudonym", 1, "sha-256"))
self.assertEqual(first["params"]["config"], "known-offender-matching-ssc")
for item in cred["req"]:
self.assertIn("fn_id", item)
self.assertNotIn("id", item, "`id` was the pre-v0.4 item key")
ages = [i for i in presets["nym_auth_pseudonym_age"]["credentials"]["nym_auth"]["req"] if i["fn_id"] == "age_check"]
self.assertEqual(len(ages), 1)
self.assertEqual(len(presets["nym_auth_pseudonym"]["credentials"]["nym_auth"]["req"]), 1)

def test_no_pre_v0_4_names_remain(self):
"""vc fails startup on circuit_zk / circuit_id_values / circuit_proof_system_values, naming the replacement."""
text = self.text
for old in ("circuit_zk", "circuit_id_values", "circuit_proof_system_values"):
self.assertNotIn(old, text, old)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion values-base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ features:
claims: [given_name, family_name]
# Nym-authentication attestation (sirosfoundation/nym_auth_rulebook v0.3):
# birth_date + ssc (SHA-256 of a 16-byte secret seed shared by issuer and
# prover) + optional psc, for circuit_zk pseudonym and age-predicate
# prover) + optional psc, for zkp_req (formerly circuit_zk) pseudonym and age-predicate
# presentations. doctype and namespace are both eu.europa.ec.eudi.nym_auth.1.
#
# fixtures/vc-metadata/nym_auth.mdoc.json is a byte-for-byte COPY of the
Expand Down