diff --git a/infra/govern/govd.py b/infra/govern/govd.py index 21be259..7918899 100644 --- a/infra/govern/govd.py +++ b/infra/govern/govd.py @@ -119,6 +119,28 @@ def load_config(path=None): return cfg +def install_builtin_verifier(cfg): + """Register the built-in identity verifier NAMED by `auth_verifier`, if any. + + The seam accepts any registered `bearer -> subject` resolver, but a resolver has to be REGISTERED to + exist — and a config key on its own registers nothing. Without this, setting `auth_verifier: "ed25519"` + would fail closed on every claim: correct, but silently unusable. + + Only a name we ship is installed. An unknown name installs nothing and therefore resolves nobody, which + is the intended fail-closed behaviour for a typo — it must never fall back to the bearer-secret path. + Verifier CODE deliberately lives in the image, never on the mounted config: a verifier loaded from a + writable path would be ungoverned code executing inside the syscall boundary, which is the one thing + this system exists to prevent. The mount configures WHICH verifier and its parameters, never its body. + """ + name = str(cfg.get("auth_verifier") or "") + if name in ("", "token_sha"): + return None # the built-in bearer-secret path; nothing to install + if name == "ed25519": + from infra.govern import ed25519_auth + return ed25519_auth.install("ed25519") + return None # unknown -> nothing registered -> resolves nobody + + def ensure_monitor_token(cfg): """Fill the monitor-token default by the final mode: a friendly 'admin' for LOCAL use, a strong random token for REMOTE (network-exposed) so it is never guessable. Override anytime with GOVD_MONITOR_TOKEN.""" @@ -1571,6 +1593,7 @@ def _load_exec_mode(cfg, httpd): def serve(cfg): Handler.timeout = cfg.get("socket_timeout", SOCKET_TIMEOUT) ensure_monitor_token(cfg) # final mode is known here (after --mode) + install_builtin_verifier(cfg) # wire the identity scheme BEFORE the first claim require_closed_auth(cfg) # refuse a network-exposed plane with auth off store = Store(cfg["record_root"], cfg=cfg) if cfg["mode"] == "remote": diff --git a/tests/test_ed25519_auth.py b/tests/test_ed25519_auth.py index ddeebfb..2d182e0 100644 --- a/tests/test_ed25519_auth.py +++ b/tests/test_ed25519_auth.py @@ -155,3 +155,84 @@ def test_verifier_never_raises(verifier): def test_bearer_secret_does_not_authenticate_under_this_scheme(key): EA.install("t_ed4") assert P.resolve_principal("s3cret", _reg(key), "t_ed4") is None + + +# ───────────────────────── govd wiring ───────────────────────── +# A config key registers nothing on its own. Found by standing a real govd up with +# auth_verifier="ed25519" and watching a valid assertion resolve to nobody: correct +# fail-closed behaviour, and a silently unusable feature. + +def test_govd_installs_the_named_builtin_verifier(): + from infra.govern import govd + from infra.govern import principals as PP + PP._VERIFIERS.pop("ed25519", None) + assert govd.install_builtin_verifier({"auth_verifier": "ed25519"}) is not None + assert "ed25519" in PP._VERIFIERS + + +def test_govd_installs_nothing_for_the_default_or_an_unknown_name(): + from infra.govern import govd + from infra.govern import principals as PP + for name in ("", "token_sha", "oidc-typo", None): + PP._VERIFIERS.pop("oidc-typo", None) + assert govd.install_builtin_verifier({"auth_verifier": name}) is None + # an unknown name must leave NOTHING registered — it must never fall back to bearer secrets + assert "oidc-typo" not in PP._VERIFIERS + + +def test_serve_actually_wires_the_verifier_END_TO_END(tmp_path): + """Covers the CALL SITE, not just the function. + + Mutation found that deleting `install_builtin_verifier(cfg)` from serve() left every unit test green — + the helper was tested, its invocation was not. This starts a real govd with auth_verifier=ed25519 and + authenticates a real claim over HTTP, so the wiring cannot silently disappear again. + """ + import json as _json, os as _os, socket, subprocess, sys, time, urllib.error, urllib.request + from cryptography.hazmat.primitives import serialization + + repo = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))) + k = Ed25519PrivateKey.generate() + (tmp_path / "principals.json").write_text(_json.dumps({"principals": { + "alice": {"subject": EA.subject_for(EA._sign.public_raw(k)), "rate": 100, "burst": 100}}})) + (tmp_path / "govd.json").write_text(_json.dumps({ + "mode": "local", "auth_verifier": "ed25519", "record_root": str(tmp_path / "rec")})) + with socket.socket() as s: + s.bind(("127.0.0.1", 0)); port = s.getsockname()[1] + + p = subprocess.Popen([sys.executable, "-m", "infra.govern.govd", + "--config", str(tmp_path / "govd.json"), "--port", str(port)], + cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env={**_os.environ, "PYTHONPATH": repo, + "GOVD_PRINCIPALS": str(tmp_path / "principals.json")}) + try: + base = f"http://127.0.0.1:{port}" + for _ in range(80): + try: + cat = _json.loads(urllib.request.urlopen(base + "/catalog", timeout=1).read()); break + except Exception: + time.sleep(0.25) + else: + p.kill(); pytest.fail("govd did not start") + + sk = next(s for s in cat["skills"] if s.get("verified") + and any(not q.get("destructive") for q in s["perks"])) + perk = next(q for q in sk["perks"] if not q.get("destructive")) + claim = _json.dumps({"skill": sk["skill"], "perk": perk["id"], + "var_keys": (perk.get("vars") or {}).get("required") or []}).encode() + + def _post(bearer): + r = urllib.request.Request(base + "/govern", data=claim, method="POST") + r.add_header("Content-Type", "application/json") + if bearer: + r.add_header("Authorization", "Bearer " + bearer) + try: + return urllib.request.urlopen(r, timeout=10).status + except urllib.error.HTTPError as e: + return e.code + + assert _post(EA.mint_assertion(k)) == 200, "a declared key must authenticate — is serve() wiring it?" + assert _post(EA.mint_assertion(Ed25519PrivateKey.generate())) == 401 # undeclared + assert _post("a-static-bearer-secret") == 401 # wrong scheme + assert _post(None) == 401 + finally: + p.kill() diff --git a/tests/test_govd.py b/tests/test_govd.py index 2a435a9..5fc8473 100644 --- a/tests/test_govd.py +++ b/tests/test_govd.py @@ -920,3 +920,27 @@ def test_govern_propagates_cargo_only_on_allow_not_on_reject(): v = govd.govern(ledger, {}, scope={"skills": ["*"]}) assert v["decision"] == "reject" and v.get("cargo") is None assert any(p["id"] == "acl_cargo_denied" for p in v.get("problems", [])) + + +# ───────────────────────── identity scheme wiring ───────────────────────── +# install_builtin_verifier lives in govd.py, so its proof belongs in govd.py's designated ratchet slice +# (infra/govern/selfmonitor_policy.json). Covered here rather than only in test_ed25519_auth.py, where the +# mutation ratchet would never see it — the `==` branch below survived precisely because of that. + +def test_install_builtin_verifier_registers_only_a_name_we_ship(): + from infra.govern import govd as G + from infra.govern import principals as PP + + PP._VERIFIERS.pop("ed25519", None) + assert G.install_builtin_verifier({"auth_verifier": "ed25519"}) is not None + assert "ed25519" in PP._VERIFIERS + + # the default bearer-secret path installs nothing + assert G.install_builtin_verifier({"auth_verifier": ""}) is None + assert G.install_builtin_verifier({"auth_verifier": "token_sha"}) is None + assert G.install_builtin_verifier({}) is None + + # an UNKNOWN name must register nothing — a typo must never quietly install a scheme, nor fall back + PP._VERIFIERS.pop("not-a-scheme", None) + assert G.install_builtin_verifier({"auth_verifier": "not-a-scheme"}) is None + assert "not-a-scheme" not in PP._VERIFIERS