From 4a6c4db286875a224215b0837e1cc73d6759f499 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 20 Oct 2026 10:25:34 +0200 Subject: [PATCH 1/2] create-pki: SIGNING_CERT_VERIFIER_HOST adds the verifier's dNSName by re-issuing the signing cert from the same key and root The vc-verifier's client_id is x509_san_dns: and it signs request objects with the shared signing certificate, whose SAN lacked that host. Deploy passes the verifier's public host; signing_cert_current() checks it exactly (no substring or wildcard match). Co-Authored-By: Claude Sonnet 5.5 --- fixtures/create-pki.sh | 28 +++++- sirosid_core/deploy.py | 2 + tests/test_create_pki_verifier_san.py | 128 ++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/test_create_pki_verifier_san.py diff --git a/fixtures/create-pki.sh b/fixtures/create-pki.sh index 0dc38358..1803c713 100755 --- a/fixtures/create-pki.sh +++ b/fixtures/create-pki.sh @@ -11,6 +11,7 @@ # ./create-pki.sh # PKI_DIR_OVERRIDE=/path/to/env-pki ./create-pki.sh # e.g. per Fly environment # SIGNING_CERT_ISSUER_URL=https://issuer.example ./create-pki.sh +# SIGNING_CERT_VERIFIER_HOST=verifier.example ./create-pki.sh # # SIGNING_CERT_ISSUER_URL adds a URI SAN naming the credential issuer to the # signing certificate. mdoc verifiers derive an mDL's issuer identity from its @@ -22,6 +23,13 @@ # vc-verifier rejects the presentation with "issuer not trusted". Unset for # the docker-compose stack, where localhost IS the identity. # +# SIGNING_CERT_VERIFIER_HOST adds a DNS SAN naming the vc-verifier's public host. The verifier's OpenID4VP +# client_id is `x509_san_dns:` and it signs its request objects with this same certificate (the key +# is shared with the issuer's DS certificate), so a strict wallet or relying party checking the client_id against +# the request's x5c leaf rejects it when the SAN is missing. Re-issued from the existing key and CA exactly like +# the issuer URI: neither the key nor the root changes, so already-issued credentials keep verifying. A test or +# demo shortcut: a deployment that signs credentials for real should keep separate issuer and verifier keys. +# # Each artifact group is independently guarded by its own file-existence # check, so re-running after adding a new artifact group only generates what's # missing - existing keys already relied upon elsewhere (hardcoded thumbprints, @@ -120,17 +128,28 @@ SIGNING_CERT_URI_SAN_LINE="" if [ -n "${SIGNING_CERT_ISSUER_URL:-}" ]; then SIGNING_CERT_URI_SAN_LINE="URI.1 = ${SIGNING_CERT_ISSUER_URL}" fi +SIGNING_CERT_VERIFIER_SAN_LINE="" +if [ -n "${SIGNING_CERT_VERIFIER_HOST:-}" ]; then + SIGNING_CERT_VERIFIER_SAN_LINE="DNS.4 = ${SIGNING_CERT_VERIFIER_HOST}" +fi signing_cert_current() { [ -f "${PKI_DIR}/signing_ec.crt" ] || return 1 - [ -z "${SIGNING_CERT_ISSUER_URL:-}" ] && return 0 - openssl x509 -in "${PKI_DIR}/signing_ec.crt" -noout -ext subjectAltName 2>/dev/null \ - | grep -qF "URI:${SIGNING_CERT_ISSUER_URL}" + local sans + sans=$(openssl x509 -in "${PKI_DIR}/signing_ec.crt" -noout -ext subjectAltName 2>/dev/null) + if [ -n "${SIGNING_CERT_ISSUER_URL:-}" ]; then + grep -qF "URI:${SIGNING_CERT_ISSUER_URL}" <<<"$sans" || return 1 + fi + if [ -n "${SIGNING_CERT_VERIFIER_HOST:-}" ]; then + # ", " or line start before and ", " or line end after, so `verifier.example` does not match `x.verifier.example`. + grep -qE "(^|[ ,])DNS:${SIGNING_CERT_VERIFIER_HOST//./\\.}(,|$)" <<<"$sans" || return 1 + fi + return 0 } if signing_cert_current; then echo "EC signing certificate already current, skipping." else if [ -f "${PKI_DIR}/signing_ec.crt" ]; then - echo "Re-issuing EC signing certificate (issuer URI SAN ${SIGNING_CERT_ISSUER_URL}) from the existing key..." + echo "Re-issuing EC signing certificate (issuer URI SAN '${SIGNING_CERT_ISSUER_URL:-}', verifier DNS SAN '${SIGNING_CERT_VERIFIER_HOST:-}') from the existing key..." else echo "Issuing EC signing certificate..." fi @@ -163,6 +182,7 @@ subjectAltName = @alt_names DNS.1 = localhost DNS.2 = vc-issuer DNS.3 = vc-verifier +${SIGNING_CERT_VERIFIER_SAN_LINE} ${SIGNING_CERT_URI_SAN_LINE} EOF diff --git a/sirosid_core/deploy.py b/sirosid_core/deploy.py index c222573f..12e95766 100644 --- a/sirosid_core/deploy.py +++ b/sirosid_core/deploy.py @@ -131,6 +131,8 @@ def generate_pki(ctx: DeployContext) -> Path: **os.environ, "PKI_DIR_OVERRIDE": str(pki_dir), "SIGNING_CERT_ISSUER_URL": naming.url("vc-apigw"), + # The verifier's client_id is x509_san_dns: and it signs with this same certificate. + "SIGNING_CERT_VERIFIER_HOST": naming.host("vc-verifier"), } fly.exec(["bash", "./create-pki.sh"], cwd=resources.fixtures, env=env_vars) return pki_dir diff --git a/tests/test_create_pki_verifier_san.py b/tests/test_create_pki_verifier_san.py new file mode 100644 index 00000000..a4f0a883 --- /dev/null +++ b/tests/test_create_pki_verifier_san.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""fixtures/create-pki.sh: SIGNING_CERT_VERIFIER_HOST re-issues the signing certificate with the verifier's dNSName +from the EXISTING key and root. + +The verifier signs its OpenID4VP request objects with the same certificate the issuer uses as its DS certificate, and +its client_id is `x509_san_dns:`. The re-issue must therefore never touch the key or the root: the PDP's trust +and every credential already issued depend on them.""" +import hashlib +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "fixtures" / "create-pki.sh" +ISSUER = "https://issuer.example" +HOST = "verifier.example" + +NEEDS = unittest.skipUnless(shutil.which("openssl") and shutil.which("bash"), "needs openssl and bash") + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class Pki: + def __init__(self): + self.dir = Path(tempfile.mkdtemp()) + + def run(self, **env): + proc = subprocess.run(["bash", str(SCRIPT)], cwd=SCRIPT.parent, capture_output=True, text=True, + env={**os.environ, "PKI_DIR_OVERRIDE": str(self.dir), **env}) + assert proc.returncode == 0, proc.stdout + proc.stderr + return proc.stdout + + def sans(self) -> str: + out = subprocess.run(["openssl", "x509", "-in", str(self.dir / "signing_ec.crt"), "-noout", "-ext", "subjectAltName"], + capture_output=True, text=True, check=True).stdout + return out.splitlines()[-1].strip() + + def verifies(self) -> bool: + return subprocess.run(["openssl", "verify", "-CAfile", str(self.dir / "rootCA.crt"), str(self.dir / "signing_ec.crt")], + capture_output=True).returncode == 0 + + +@NEEDS +class VerifierSan(unittest.TestCase): + def setUp(self): + self.pki = Pki() + self.addCleanup(shutil.rmtree, self.pki.dir, ignore_errors=True) + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER) # an environment deployed before the verifier SAN existed + self.key, self.root = digest(self.pki.dir / "signing_ec_private.pem"), digest(self.pki.dir / "rootCA.crt") + self.old_cert = digest(self.pki.dir / "signing_ec.crt") + + def test_the_existing_certificate_has_no_verifier_dns_name(self): + self.assertNotIn(f"DNS:{HOST}", self.pki.sans()) + + def test_reissue_adds_the_dns_name_and_keeps_the_key_and_the_root(self): + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + self.assertIn("Re-issuing", out) + sans = self.pki.sans() + self.assertIn(f"DNS:{HOST}", sans) + self.assertIn(f"URI:{ISSUER}", sans, "the issuer URI the PDP's mdociaca allowlist matches on must survive") + self.assertIn("DNS:vc-issuer", sans) + self.assertNotEqual(digest(self.pki.dir / "signing_ec.crt"), self.old_cert, "a new leaf") + self.assertEqual(digest(self.pki.dir / "signing_ec_private.pem"), self.key, "the key must not rotate") + self.assertEqual(digest(self.pki.dir / "rootCA.crt"), self.root, "the root must not change") + self.assertTrue(self.pki.verifies(), "the new leaf chains to the unchanged root") + chain = (self.pki.dir / "signing_ec_chain.pem").read_text() + self.assertEqual(chain, (self.pki.dir / "signing_ec.crt").read_text() + (self.pki.dir / "rootCA.crt").read_text()) + + def test_the_public_key_is_unchanged_so_old_signatures_still_verify(self): + before = (self.pki.dir / "signing_ec_public.pem").read_text() + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + leaf_pub = subprocess.run(["openssl", "x509", "-in", str(self.pki.dir / "signing_ec.crt"), "-noout", "-pubkey"], + capture_output=True, text=True, check=True).stdout + self.assertEqual(leaf_pub, before) + + def test_a_certificate_that_already_has_it_is_left_alone(self): + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + issued = digest(self.pki.dir / "signing_ec.crt") + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + self.assertIn("already current", out) + self.assertEqual(digest(self.pki.dir / "signing_ec.crt"), issued) + + def test_unset_leaves_an_existing_certificate_alone(self): + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER) + self.assertIn("already current", out) + self.assertEqual(digest(self.pki.dir / "signing_ec.crt"), self.old_cert) + + def test_a_longer_host_ending_in_the_same_name_is_not_a_match(self): + """`x.verifier.example` must not satisfy `verifier.example`, and `.` must not act as a wildcard.""" + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST="x.verifier.example") + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + self.assertIn("Re-issuing", out) + self.assertIn(f"DNS:{HOST}", self.pki.sans()) + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST="verifierXexample") + self.assertIn("Re-issuing", out, "an unescaped '.' would have matched verifier.example") + + +class DeployPassThrough(unittest.TestCase): + def test_generate_pki_hands_the_script_the_verifiers_public_host(self): + from sirosid_core import deploy + from sirosid_core.naming import Naming + + seen = {} + + class Fly: + def exec(self, cmd, cwd=None, env=None): + seen["env"] = env + + def app_exists(self, app): + return False + + naming = Naming(env="gdc") + ctx = deploy.DeployContext(fly=Fly(), naming=naming, resources=type("R", (), {"fixtures": Path("/x")})(), + spec=None, out_dir=Path(tempfile.mkdtemp())) + self.addCleanup(shutil.rmtree, ctx.out_dir, ignore_errors=True) + deploy.generate_pki(ctx) + self.assertEqual(seen["env"]["SIGNING_CERT_VERIFIER_HOST"], "sirosid-gdc-vc-verifier.fly.dev", + "the host is the client_id's x509_san_dns value") + self.assertEqual(seen["env"]["SIGNING_CERT_ISSUER_URL"], "https://sirosid-gdc-vc-apigw.fly.dev") + + +if __name__ == "__main__": + unittest.main() From 9be835f0711cab4ab6dc45f007aabea9b3d69d71 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 20 Oct 2026 10:37:17 +0200 Subject: [PATCH 2/2] create-pki: verifier SAN re-issue is opt-in per environment (values.verifier.signingCertDnsSan), set only for gdc Co-Authored-By: Claude Sonnet 5.5 --- environments/gdc.yaml | 3 +++ sirosid_core/deploy.py | 7 +++++-- tests/test_create_pki_verifier_san.py | 27 +++++++++++++++++++++------ values-base.yaml | 5 +++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/environments/gdc.yaml b/environments/gdc.yaml index 7299aba3..6058f580 100644 --- a/environments/gdc.yaml +++ b/environments/gdc.yaml @@ -283,6 +283,9 @@ values: description: "did:web DID document resolution" timeout: 10s verifier: + # The verifier's client_id is x509_san_dns:sirosid-gdc-vc-verifier.fly.dev; put that host in the SAN of the + # certificate it signs request objects with (re-issued once from the same key and root; see create-pki.sh). + signingCertDnsSan: true # Selectable options in the verifier's own presentation-definition UI. # NOT the same thing as presentation-request templates, which are only # reachable through a real OIDC /authorize?scope=... request. diff --git a/sirosid_core/deploy.py b/sirosid_core/deploy.py index 12e95766..6a184621 100644 --- a/sirosid_core/deploy.py +++ b/sirosid_core/deploy.py @@ -131,9 +131,12 @@ def generate_pki(ctx: DeployContext) -> Path: **os.environ, "PKI_DIR_OVERRIDE": str(pki_dir), "SIGNING_CERT_ISSUER_URL": naming.url("vc-apigw"), - # The verifier's client_id is x509_san_dns: and it signs with this same certificate. - "SIGNING_CERT_VERIFIER_HOST": naming.host("vc-verifier"), } + # Opt-in per environment (values.verifier.signingCertDnsSan): re-issues the signing leaf (same key and root) + # once so the verifier's client_id, x509_san_dns:, appears in the SAN of the certificate it signs + # request objects with. Unset, an environment's certificate is left exactly as it is. + if ((ctx.spec.values or {}).get("verifier") or {}).get("signingCertDnsSan"): + env_vars["SIGNING_CERT_VERIFIER_HOST"] = naming.host("vc-verifier") fly.exec(["bash", "./create-pki.sh"], cwd=resources.fixtures, env=env_vars) return pki_dir diff --git a/tests/test_create_pki_verifier_san.py b/tests/test_create_pki_verifier_san.py index a4f0a883..abc12271 100644 --- a/tests/test_create_pki_verifier_san.py +++ b/tests/test_create_pki_verifier_san.py @@ -101,7 +101,7 @@ def test_a_longer_host_ending_in_the_same_name_is_not_a_match(self): class DeployPassThrough(unittest.TestCase): - def test_generate_pki_hands_the_script_the_verifiers_public_host(self): + def generate(self, values): from sirosid_core import deploy from sirosid_core.naming import Naming @@ -114,14 +114,29 @@ def exec(self, cmd, cwd=None, env=None): def app_exists(self, app): return False - naming = Naming(env="gdc") - ctx = deploy.DeployContext(fly=Fly(), naming=naming, resources=type("R", (), {"fixtures": Path("/x")})(), - spec=None, out_dir=Path(tempfile.mkdtemp())) + spec = type("S", (), {"values": values})() + ctx = deploy.DeployContext(fly=Fly(), naming=Naming(env="gdc"), resources=type("R", (), {"fixtures": Path("/x")})(), + spec=spec, out_dir=Path(tempfile.mkdtemp())) self.addCleanup(shutil.rmtree, ctx.out_dir, ignore_errors=True) deploy.generate_pki(ctx) - self.assertEqual(seen["env"]["SIGNING_CERT_VERIFIER_HOST"], "sirosid-gdc-vc-verifier.fly.dev", + return seen["env"] + + def test_opted_in_environment_hands_the_script_the_verifiers_public_host(self): + env = self.generate({"verifier": {"signingCertDnsSan": True}}) + self.assertEqual(env["SIGNING_CERT_VERIFIER_HOST"], "sirosid-gdc-vc-verifier.fly.dev", "the host is the client_id's x509_san_dns value") - self.assertEqual(seen["env"]["SIGNING_CERT_ISSUER_URL"], "https://sirosid-gdc-vc-apigw.fly.dev") + self.assertEqual(env["SIGNING_CERT_ISSUER_URL"], "https://sirosid-gdc-vc-apigw.fly.dev") + + def test_every_other_environment_is_unchanged(self): + for values in ({}, {"verifier": {}}, {"verifier": {"signingCertDnsSan": False}}, None): + self.assertNotIn("SIGNING_CERT_VERIFIER_HOST", self.generate(values), values) + + def test_gdc_opts_in_and_nothing_else_does(self): + import yaml + for env_file in sorted((ROOT / "environments").glob("*.yaml")): + cfg = yaml.safe_load(env_file.read_text()) or {} + on = (((cfg.get("values") or {}).get("verifier") or {}).get("signingCertDnsSan")) + self.assertEqual(bool(on), env_file.stem == "gdc", env_file.name) if __name__ == "__main__": diff --git a/values-base.yaml b/values-base.yaml index d23fd178..5e74d133 100644 --- a/values-base.yaml +++ b/values-base.yaml @@ -653,6 +653,11 @@ issuer: secret_file_path: "/secrets/secrets.yaml" verifier: + # Fly deploys only (read by sirosid_core.deploy.generate_pki, not rendered into the chart): re-issue the signing + # certificate once, from the same key and root, with the verifier's public host as a DNS SAN (the verifier's + # client_id is x509_san_dns: and it signs request objects with this certificate). Off = the + # certificate is left as it is. A test-environment shortcut: the verifier and the issuer share one key. + signingCertDnsSan: false oidc_provider: enabled: true subjectType: pairwise