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