Skip to content
9 changes: 9 additions & 0 deletions docs/site/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions src/authsome/cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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]:
Expand Down
105 changes: 102 additions & 3 deletions src/authsome/identity/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -236,21 +323,33 @@ 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


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:
Expand Down
40 changes: 38 additions & 2 deletions tests/cli/test_client_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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))
Expand Down
58 changes: 58 additions & 0 deletions tests/identity/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Loading