diff --git a/docs/site/installation.mdx b/docs/site/installation.mdx index 1316e77..e8f370a 100644 --- a/docs/site/installation.mdx +++ b/docs/site/installation.mdx @@ -113,6 +113,15 @@ export AUTHSOME_HOME=/var/lib/authsome authsome init ``` +For ephemeral agents or fresh CI runners, you can also supply the acting identity from the environment instead of relying on local identity files: + +```bash +export AUTHSOME_IDENTITY=steady-wisely-boldly-0042 +export AUTHSOME_IDENTITY_PRIVATE_KEY=<32-byte-ed25519-private-key-as-hex> +``` + +When `AUTHSOME_IDENTITY_PRIVATE_KEY` is set, authsome derives the `did:key` from that private key and uses the environment-backed identity before falling back to `~/.authsome/client/identities/`. + See [Filesystem layout](/reference/file-layout) for the full directory model. ## Choose the encryption backend diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index 7e0488c..8612432 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -94,6 +94,7 @@ class AuthsomeApiClient: def __init__(self, base_url: str | None = None) -> None: self._base_url = (base_url or resolve_daemon_url()).rstrip("/") self._home = Path(os.environ.get("AUTHSOME_HOME", str(Path.home() / ".authsome"))) + self._identity_cache: IdentityMetadata | None = None @property def base_url(self) -> str: @@ -141,12 +142,21 @@ async def _proof_headers(self, method: str, path: str, body: bytes) -> dict[str, async def ensure_identity_ready(self) -> IdentityMetadata: """Ensure the acting identity is registered and, in hosted mode, claimed.""" - identity = ensure_local_identity(self._home, active_handle=_selected_identity_handle(self._home)) + selected_handle = _selected_identity_handle(self._home) + if self._identity_cache is not None and ( + selected_handle is None or self._identity_cache.handle == selected_handle + ): + if self._identity_cache.claimed: + return self._identity_cache + + identity = ensure_local_identity(self._home, active_handle=selected_handle) + self._identity_cache = identity status: dict[str, Any] | None = None if not identity.registered: status = await self.register_identity(identity.handle, identity.did) identity = mark_registered(self._home, identity.handle) + self._identity_cache = identity elif identity.claimed: return identity else: @@ -167,11 +177,16 @@ async def ensure_identity_ready(self) -> IdentityMetadata: except Exception: pass await self._poll_claim_completion(identity.handle) - return mark_claimed(self._home, identity.handle) + identity = mark_claimed(self._home, identity.handle) + self._identity_cache = identity + return identity if registration_status in {"claimed", "registered"}: - return mark_claimed(self._home, identity.handle) + identity = mark_claimed(self._home, identity.handle) + self._identity_cache = identity + return identity + self._identity_cache = identity return identity async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> dict[str, Any]: diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index df9d80e..3ff4a9c 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -6,6 +6,7 @@ import os import random import re +from collections.abc import Mapping from datetime import UTC, datetime from enum import StrEnum from pathlib import Path @@ -20,6 +21,8 @@ _ED25519_MULTICODEC_PREFIX = b"\xed\x01" _DID_KEY_PREFIX = "did:key:z" _HANDLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$") +_IDENTITY_HANDLE_ENV_VAR = "AUTHSOME_IDENTITY" +_IDENTITY_PRIVATE_KEY_ENV_VAR = "AUTHSOME_IDENTITY_PRIVATE_KEY" _ADJECTIVES = ( "brisk", @@ -151,15 +154,99 @@ def private_key_from_hex(value: str) -> Ed25519PrivateKey: return Ed25519PrivateKey.from_private_bytes(raw) +def _env_values(env: Mapping[str, str] | None = None) -> Mapping[str, str]: + return env if env is not None else os.environ + + +def _env_identity_handle(env: Mapping[str, str] | None = None) -> str | None: + raw_handle = _env_values(env).get(_IDENTITY_HANDLE_ENV_VAR) + if raw_handle is None: + return None + handle = raw_handle.strip() + if not handle: + raise ValueError(f"{_IDENTITY_HANDLE_ENV_VAR} is set but empty.") + return validate_handle(handle) + + +def _env_identity_private_key(env: Mapping[str, str] | None = None) -> Ed25519PrivateKey | None: + raw_key = _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) + if raw_key is None: + return None + if not raw_key.strip(): + raise ValueError(f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} is set but empty.") + return private_key_from_hex(raw_key) + + +def _validate_env_identity_configuration(env: Mapping[str, str] | None = None) -> None: + values = _env_values(env) + has_handle = _IDENTITY_HANDLE_ENV_VAR in values + has_private_key = _IDENTITY_PRIVATE_KEY_ENV_VAR in values + if has_handle and not has_private_key: + handle = _env_identity_handle(env) + raise ValueError( + f"{_IDENTITY_HANDLE_ENV_VAR} is set to '{handle}' but {_IDENTITY_PRIVATE_KEY_ENV_VAR} is not set. " + f"Unset {_IDENTITY_HANDLE_ENV_VAR} to use local identity files, or set both environment variables " + "to use an environment-backed identity." + ) + if has_private_key and not has_handle: + raise ValueError( + f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} is set but {_IDENTITY_HANDLE_ENV_VAR} is not set. " + f"Unset {_IDENTITY_PRIVATE_KEY_ENV_VAR} to use local identity files, or set both environment variables " + "to use an environment-backed identity." + ) + + +def _load_env_identity(env: Mapping[str, str] | None = None) -> IdentityMetadata | None: + _validate_env_identity_configuration(env) + handle = _env_identity_handle(env) + private_key = _env_identity_private_key(env) + if private_key is None or handle is None: + return None + + now = datetime.now(UTC) + return IdentityMetadata( + handle=handle, + did=public_key_to_did_key(private_key.public_key()), + created_at=now, + updated_at=now, + ) + + +def _load_matching_env_identity(handle: str, env: Mapping[str, str] | None = None) -> IdentityMetadata | None: + env_identity = _load_env_identity(env) + if env_identity is None or env_identity.handle != handle: + return None + return env_identity + + def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: + env_identity = _load_matching_env_identity(handle) + if env_identity is not None: + env_key = _env_identity_private_key() + if env_key is not None: + return env_key return private_key_from_hex(identity_key_path(home, handle).read_text(encoding="utf-8")) def load_identity(home: Path, handle: str) -> IdentityMetadata: - return IdentityMetadata.model_validate_json(identity_metadata_path(home, handle).read_text(encoding="utf-8")) + path = identity_metadata_path(home, handle) + if path.exists(): + metadata = IdentityMetadata.model_validate_json(path.read_text(encoding="utf-8")) + env_identity = _load_matching_env_identity(handle) + if env_identity is None: + return metadata + return metadata.model_copy(update={"did": env_identity.did}) + + env_identity = _load_matching_env_identity(handle) + if env_identity is not None: + return env_identity + + return IdentityMetadata.model_validate_json(path.read_text(encoding="utf-8")) def identity_exists(home: Path, handle: str) -> bool: + if _load_matching_env_identity(handle) is not None: + return True return identity_metadata_path(home, handle).exists() and identity_key_path(home, handle).exists() @@ -236,7 +323,9 @@ def mark_registered(home: Path, handle: str) -> IdentityMetadata: updated = metadata.model_copy( update={"identity_status": IdentityStatus.REGISTERED, "updated_at": datetime.now(UTC)} ) - identity_metadata_path(home, handle).write_text(updated.model_dump_json(indent=2), encoding="utf-8") + metadata_path = identity_metadata_path(home, handle) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(updated.model_dump_json(indent=2), encoding="utf-8") return updated @@ -244,13 +333,23 @@ def mark_claimed(home: Path, handle: str) -> IdentityMetadata: """Persist a claimed state for a local identity after ownership resolution.""" metadata = load_identity(home, handle) updated = metadata.model_copy(update={"identity_status": IdentityStatus.CLAIMED, "updated_at": datetime.now(UTC)}) - identity_metadata_path(home, handle).write_text(updated.model_dump_json(indent=2), encoding="utf-8") + metadata_path = identity_metadata_path(home, handle) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(updated.model_dump_json(indent=2), encoding="utf-8") return updated def ensure_local_identity(home: Path, active_handle: str | None = None) -> IdentityMetadata: """Return the active local identity, creating one if none exists.""" remove_legacy_default_identity(home) + env_identity = _load_env_identity() + if env_identity is not None: + if active_handle is not None and active_handle != env_identity.handle: + raise FileNotFoundError( + f"Configured identity '{active_handle}' does not match environment identity " + f"'{env_identity.handle}' from {_IDENTITY_HANDLE_ENV_VAR}." + ) + return load_identity(home, env_identity.handle) if active_handle is None: active_handle = _read_active_identity_handle(home) if active_handle: diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index 3ea8e44..013f9db 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -6,7 +6,8 @@ from authsome.cli.client import AuthsomeApiClient from authsome.cli.client_config import ClientConfig, load_client_config, save_client_config -from authsome.identity import create_identity, mark_claimed, mark_registered +from authsome.identity import create_identity, load_private_key, mark_claimed, mark_registered +from authsome.identity.local import private_key_to_hex @pytest.mark.asyncio @@ -160,11 +161,13 @@ def fake_request(method, url, data=None, headers=None, timeout=None): @pytest.mark.asyncio async def test_identity_env_override_wins_over_active_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") create_identity(tmp_path, "steady-wisely-boldly-0042") override_identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") mark_registered(tmp_path, override_identity.handle) mark_claimed(tmp_path, override_identity.handle) + private_key_hex = private_key_to_hex(load_private_key(tmp_path, override_identity.handle)) + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) save_client_config(tmp_path, ClientConfig(active_identity="steady-wisely-boldly-0042")) client = AuthsomeApiClient("http://127.0.0.1:7998") @@ -173,6 +176,39 @@ async def test_identity_env_override_wins_over_active_identity(monkeypatch, tmp_ assert identity.handle == "rapid-brightly-firmly-0007" +@pytest.mark.asyncio +async def test_env_backed_identity_is_cached_after_claim(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "05" * 32) + calls: list[tuple[str, str]] = [] + + def fake_request(method, url, data=None, headers=None, timeout=None): + calls.append((method, url)) + response = Mock() + response.raise_for_status.return_value = None + if url.endswith("/identities/register"): + response.json.return_value = { + "identity": "rapid-brightly-firmly-0007", + "registration_status": "claimed", + } + else: + response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}} + return response + + monkeypatch.setattr("authsome.cli.client.requests.request", fake_request) + + client = AuthsomeApiClient("http://127.0.0.1:7998") + await client.list_connections() + await client.list_connections() + + assert calls == [ + ("POST", "http://127.0.0.1:7998/identities/register"), + ("GET", "http://127.0.0.1:7998/connections"), + ("GET", "http://127.0.0.1:7998/connections"), + ] + + @pytest.mark.asyncio async def test_start_login_bootstraps_identity_readiness(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index b8b600b..aff051c 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -9,8 +9,10 @@ create_identity, ensure_local_identity, identity_key_path, + load_private_key, mark_claimed, mark_registered, + private_key_to_hex, public_key_from_did_key, public_key_to_did_key, ) @@ -83,3 +85,59 @@ async def test_current_from_home_uses_client_side_active_identity(tmp_path: Path identity = await current_from_home(tmp_path) assert identity.handle == first.handle + + +def test_ensure_local_identity_prefers_environment_identity(monkeypatch, tmp_path: Path) -> None: + create_identity(tmp_path, "steady-wisely-boldly-0042") + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv( + "AUTHSOME_IDENTITY_PRIVATE_KEY", + "02" * 32, + ) + + identity = ensure_local_identity(tmp_path) + + assert identity.handle == "rapid-brightly-firmly-0007" + assert identity.did.startswith("did:key:z6Mk") + assert identity.identity_status == IdentityStatus.UNREGISTERED + + +def test_ensure_local_identity_preserves_local_status_for_matching_env_identity(monkeypatch, tmp_path: Path) -> None: + identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") + mark_registered(tmp_path, identity.handle) + mark_claimed(tmp_path, identity.handle) + private_key_hex = private_key_to_hex(load_private_key(tmp_path, identity.handle)) + monkeypatch.setenv("AUTHSOME_IDENTITY", identity.handle) + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) + + resolved = ensure_local_identity(tmp_path) + + assert resolved.handle == identity.handle + assert resolved.claimed is True + assert resolved.registered is True + + +def test_load_private_key_prefers_environment_identity_key(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv( + "AUTHSOME_IDENTITY_PRIVATE_KEY", + "03" * 32, + ) + + private_key = load_private_key(tmp_path, "rapid-brightly-firmly-0007") + + assert public_key_to_did_key(private_key.public_key()).startswith("did:key:z6Mk") + + +def test_environment_identity_key_requires_handle(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "04" * 32) + + with pytest.raises(ValueError, match="AUTHSOME_IDENTITY is not set"): + ensure_local_identity(tmp_path) + + +def test_environment_identity_handle_without_private_key_has_clear_error(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_IDENTITY", "calm-clearly-quickly-1216") + + with pytest.raises(ValueError, match="AUTHSOME_IDENTITY_PRIVATE_KEY is not set"): + ensure_local_identity(tmp_path)