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 environments/gdc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 24 additions & 4 deletions fixtures/create-pki.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:<that host>` 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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions sirosid_core/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ def generate_pki(ctx: DeployContext) -> Path:
"PKI_DIR_OVERRIDE": str(pki_dir),
"SIGNING_CERT_ISSUER_URL": naming.url("vc-apigw"),
}
# Opt-in per environment (values.verifier.signingCertDnsSan): re-issues the signing leaf (same key and root)
# once so the verifier's client_id, x509_san_dns:<its host>, 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")
Comment on lines +138 to +139
fly.exec(["bash", "./create-pki.sh"], cwd=resources.fixtures, env=env_vars)
return pki_dir

Expand Down
143 changes: 143 additions & 0 deletions tests/test_create_pki_verifier_san.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""fixtures/create-pki.sh: SIGNING_CERT_VERIFIER_HOST re-issues the signing certificate with the verifier's dNSName
from the EXISTING key and root.

The verifier signs its OpenID4VP request objects with the same certificate the issuer uses as its DS certificate, and
its client_id is `x509_san_dns:<host>`. The re-issue must therefore never touch the key or the root: the PDP's trust
and every credential already issued depend on them."""
import hashlib
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "fixtures" / "create-pki.sh"
ISSUER = "https://issuer.example"
HOST = "verifier.example"

NEEDS = unittest.skipUnless(shutil.which("openssl") and shutil.which("bash"), "needs openssl and bash")


def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()


class Pki:
def __init__(self):
self.dir = Path(tempfile.mkdtemp())

def run(self, **env):
proc = subprocess.run(["bash", str(SCRIPT)], cwd=SCRIPT.parent, capture_output=True, text=True,
env={**os.environ, "PKI_DIR_OVERRIDE": str(self.dir), **env})
assert proc.returncode == 0, proc.stdout + proc.stderr
return proc.stdout

def sans(self) -> str:
out = subprocess.run(["openssl", "x509", "-in", str(self.dir / "signing_ec.crt"), "-noout", "-ext", "subjectAltName"],
capture_output=True, text=True, check=True).stdout
return out.splitlines()[-1].strip()

def verifies(self) -> bool:
return subprocess.run(["openssl", "verify", "-CAfile", str(self.dir / "rootCA.crt"), str(self.dir / "signing_ec.crt")],
capture_output=True).returncode == 0


@NEEDS
class VerifierSan(unittest.TestCase):
def setUp(self):
self.pki = Pki()
self.addCleanup(shutil.rmtree, self.pki.dir, ignore_errors=True)
self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER) # an environment deployed before the verifier SAN existed
self.key, self.root = digest(self.pki.dir / "signing_ec_private.pem"), digest(self.pki.dir / "rootCA.crt")
self.old_cert = digest(self.pki.dir / "signing_ec.crt")

def test_the_existing_certificate_has_no_verifier_dns_name(self):
self.assertNotIn(f"DNS:{HOST}", self.pki.sans())

def test_reissue_adds_the_dns_name_and_keeps_the_key_and_the_root(self):
out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST)
self.assertIn("Re-issuing", out)
sans = self.pki.sans()
self.assertIn(f"DNS:{HOST}", sans)
self.assertIn(f"URI:{ISSUER}", sans, "the issuer URI the PDP's mdociaca allowlist matches on must survive")
self.assertIn("DNS:vc-issuer", sans)
self.assertNotEqual(digest(self.pki.dir / "signing_ec.crt"), self.old_cert, "a new leaf")
self.assertEqual(digest(self.pki.dir / "signing_ec_private.pem"), self.key, "the key must not rotate")
self.assertEqual(digest(self.pki.dir / "rootCA.crt"), self.root, "the root must not change")
self.assertTrue(self.pki.verifies(), "the new leaf chains to the unchanged root")
chain = (self.pki.dir / "signing_ec_chain.pem").read_text()
self.assertEqual(chain, (self.pki.dir / "signing_ec.crt").read_text() + (self.pki.dir / "rootCA.crt").read_text())

def test_the_public_key_is_unchanged_so_old_signatures_still_verify(self):
before = (self.pki.dir / "signing_ec_public.pem").read_text()
self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST)
leaf_pub = subprocess.run(["openssl", "x509", "-in", str(self.pki.dir / "signing_ec.crt"), "-noout", "-pubkey"],
capture_output=True, text=True, check=True).stdout
self.assertEqual(leaf_pub, before)

def test_a_certificate_that_already_has_it_is_left_alone(self):
self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST)
issued = digest(self.pki.dir / "signing_ec.crt")
out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST)
self.assertIn("already current", out)
self.assertEqual(digest(self.pki.dir / "signing_ec.crt"), issued)

def test_unset_leaves_an_existing_certificate_alone(self):
out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER)
self.assertIn("already current", out)
self.assertEqual(digest(self.pki.dir / "signing_ec.crt"), self.old_cert)

def test_a_longer_host_ending_in_the_same_name_is_not_a_match(self):
"""`x.verifier.example` must not satisfy `verifier.example`, and `.` must not act as a wildcard."""
self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST="x.verifier.example")
out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST)
self.assertIn("Re-issuing", out)
self.assertIn(f"DNS:{HOST}", self.pki.sans())
out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST="verifierXexample")
self.assertIn("Re-issuing", out, "an unescaped '.' would have matched verifier.example")


class DeployPassThrough(unittest.TestCase):
def generate(self, values):
from sirosid_core import deploy
from sirosid_core.naming import Naming

seen = {}

class Fly:
def exec(self, cmd, cwd=None, env=None):
seen["env"] = env

def app_exists(self, app):
return False

spec = type("S", (), {"values": values})()
ctx = deploy.DeployContext(fly=Fly(), naming=Naming(env="gdc"), resources=type("R", (), {"fixtures": Path("/x")})(),
spec=spec, out_dir=Path(tempfile.mkdtemp()))
self.addCleanup(shutil.rmtree, ctx.out_dir, ignore_errors=True)
deploy.generate_pki(ctx)
return seen["env"]

def test_opted_in_environment_hands_the_script_the_verifiers_public_host(self):
env = self.generate({"verifier": {"signingCertDnsSan": True}})
self.assertEqual(env["SIGNING_CERT_VERIFIER_HOST"], "sirosid-gdc-vc-verifier.fly.dev",
"the host is the client_id's x509_san_dns value")
self.assertEqual(env["SIGNING_CERT_ISSUER_URL"], "https://sirosid-gdc-vc-apigw.fly.dev")

def test_every_other_environment_is_unchanged(self):
for values in ({}, {"verifier": {}}, {"verifier": {"signingCertDnsSan": False}}, None):
self.assertNotIn("SIGNING_CERT_VERIFIER_HOST", self.generate(values), values)

def test_gdc_opts_in_and_nothing_else_does(self):
import yaml
for env_file in sorted((ROOT / "environments").glob("*.yaml")):
cfg = yaml.safe_load(env_file.read_text()) or {}
on = (((cfg.get("values") or {}).get("verifier") or {}).get("signingCertDnsSan"))
self.assertEqual(bool(on), env_file.stem == "gdc", env_file.name)


if __name__ == "__main__":
unittest.main()
5 changes: 5 additions & 0 deletions values-base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:<that host> 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
Expand Down
Loading