From a40d4073e3871aee1954c6c5248348cd957900e5 Mon Sep 17 00:00:00 2001 From: Ehsan Date: Sat, 18 Jul 2026 15:43:33 +0300 Subject: [PATCH] fix(keys): fail with a clear error on a corrupt stored identity load_identity read config.json with json.loads(...)["agent_url"] and no guard, so a truncated/edited config or a missing agent_url surfaced as a raw JSONDecodeError/KeyError traceback on every command that loads the identity (sign, directory, doctor, serve, register). Wrap the key and config parsing and raise IdentityError (a ValueError subclass, so existing handlers keep working) with a 're-run wingfoot init' hint. main() catches it and prints a clean message with exit code 2. Add tests for the corrupt-config, missing-field, and CLI paths. --- src/wingfoot/cli.py | 13 ++++++++++- src/wingfoot/keys.py | 32 ++++++++++++++++++++++---- tests/test_keys.py | 55 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/test_keys.py diff --git a/src/wingfoot/cli.py b/src/wingfoot/cli.py index cd82178..174a2e9 100644 --- a/src/wingfoot/cli.py +++ b/src/wingfoot/cli.py @@ -9,7 +9,14 @@ from . import http as _http from .directory import directory_json from .doctor import doctor -from .keys import Identity, ephemeral_identity, generate_private_key, load_identity, save_identity +from .keys import ( + Identity, + IdentityError, + ephemeral_identity, + generate_private_key, + load_identity, + save_identity, +) from .register import PROVIDERS, register from .rfc9421 import sign_directory, sign_request from .verifier import _Colors, demo, start_verifier @@ -198,6 +205,10 @@ def main(argv=None) -> int: return args.func(args) except KeyboardInterrupt: return 130 + except IdentityError as exc: + C = _Colors() + print(f"{C.red}wingfoot:{C.reset} {exc}", file=sys.stderr) + return 2 if __name__ == "__main__": diff --git a/src/wingfoot/keys.py b/src/wingfoot/keys.py index 80298ac..c9e3f6c 100644 --- a/src/wingfoot/keys.py +++ b/src/wingfoot/keys.py @@ -72,6 +72,14 @@ def keyid_for(public_key: Ed25519PublicKey) -> str: DEFAULT_HOME = Path.home() / ".wingfoot" +class IdentityError(ValueError): + """A stored identity exists but can't be loaded (corrupt key or config). + + Subclasses ``ValueError`` so existing ``except ValueError`` handlers keep + working; the CLI catches it to print a clean message instead of a traceback. + """ + + @dataclass class Identity: private_key: Ed25519PrivateKey @@ -107,15 +115,31 @@ def save_identity(identity: Identity, home: Path = DEFAULT_HOME) -> Path: def load_identity(home: Path = DEFAULT_HOME) -> Identity | None: + """Load the persisted identity, or ``None`` if none has been created. + + Raises :class:`IdentityError` (with a re-run hint) when an identity exists but + is unreadable — a corrupt key file, a truncated/edited ``config.json``, or a + missing ``agent_url`` — so callers get an actionable message, not a raw + ``JSONDecodeError``/``KeyError``. + """ key_path = home / "private_key.pem" config_path = home / "config.json" if not key_path.exists() or not config_path.exists(): return None - private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + try: + private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + except ValueError as exc: + raise IdentityError(f"private key in {home} is unreadable ({exc}); re-run `wingfoot init`") from exc if not isinstance(private_key, Ed25519PrivateKey): - raise ValueError("stored key is not an Ed25519 private key") - config = json.loads(config_path.read_text()) - return Identity(private_key=private_key, agent_url=config["agent_url"]) + raise IdentityError(f"stored key in {home} is not an Ed25519 private key; re-run `wingfoot init`") + try: + config = json.loads(config_path.read_text()) + agent_url = config["agent_url"] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + raise IdentityError( + f"identity config {config_path} is corrupt or incomplete ({exc}); re-run `wingfoot init`" + ) from exc + return Identity(private_key=private_key, agent_url=agent_url) def ephemeral_identity(agent_url: str) -> Identity: diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..660c8a5 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,55 @@ +"""Loading and persisting the on-disk identity.""" +import pytest + +from wingfoot.cli import main +from wingfoot.keys import ( + Identity, + IdentityError, + generate_private_key, + load_identity, + save_identity, +) + + +def _make_identity(home): + ident = Identity(private_key=generate_private_key(), agent_url="https://bot.example") + save_identity(ident, home) + return ident + + +def test_load_identity_none_when_absent(tmp_path): + assert load_identity(tmp_path) is None + + +def test_save_then_load_roundtrip(tmp_path): + saved = _make_identity(tmp_path) + loaded = load_identity(tmp_path) + assert loaded is not None + assert loaded.agent_url == "https://bot.example" + assert loaded.keyid == saved.keyid + + +def test_corrupt_config_raises_identity_error(tmp_path): + _make_identity(tmp_path) + (tmp_path / "config.json").write_text('{ "keyid": "abc"') # truncated JSON + with pytest.raises(IdentityError) as exc: + load_identity(tmp_path) + assert "wingfoot init" in str(exc.value) + + +def test_config_missing_agent_url_raises_identity_error(tmp_path): + _make_identity(tmp_path) + (tmp_path / "config.json").write_text('{"keyid": "abc"}') # valid JSON, no agent_url + with pytest.raises(IdentityError): + load_identity(tmp_path) + + +def test_cli_reports_corrupt_identity_cleanly(tmp_path, monkeypatch, capsys): + """A corrupt identity should exit 2 with a clean message, not a traceback.""" + _make_identity(tmp_path) + (tmp_path / "config.json").write_text("not json") + # `directory` loads the default-home identity; point loading at our tmp home. + monkeypatch.setattr("wingfoot.cli.load_identity", lambda *a, **k: load_identity(tmp_path)) + rc = main(["directory"]) + assert rc == 2 + assert "wingfoot:" in capsys.readouterr().err