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
13 changes: 12 additions & 1 deletion src/wingfoot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__":
Expand Down
32 changes: 28 additions & 4 deletions src/wingfoot/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions tests/test_keys.py
Original file line number Diff line number Diff line change
@@ -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
Loading