diff --git a/README.md b/README.md index 0ea701d..be2d57b 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ from capiscio_sdk import CapiscIO agent = CapiscIO.connect(api_key="sk_live_...") # Agent is now ready -print(agent.did) # did:key:z6Mk... +print(agent.did) # did:web:registry.capisc.io:agents:... print(agent.badge) # Current badge (auto-renewed) print(agent.name) # Agent name ``` @@ -171,11 +171,46 @@ agent = CapiscIO.from_env() ``` **Environment Variables:** -- `CAPISCIO_API_KEY` (required) - Your API key -- `CAPISCIO_AGENT_NAME` - Agent name for lookup/creation -- `CAPISCIO_AGENT_ID` - Specific agent UUID -- `CAPISCIO_SERVER_URL` - Registry URL -- `CAPISCIO_DEV_MODE` - Enable dev mode (`true`/`false`) + +| Variable | Required | Description | +|----------|----------|-------------| +| `CAPISCIO_API_KEY` | Yes | Your API key | +| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation | +| `CAPISCIO_AGENT_ID` | No | Specific agent UUID | +| `CAPISCIO_SERVER_URL` | No | Registry URL (default: production) | +| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) | +| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments | + +### Deploying to Containers / Serverless + +In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory +doesn't survive restarts. On first run the SDK generates a keypair and logs a capture hint: + +``` +╔══════════════════════════════════════════════════════════════════╗ +║ New agent identity generated — save key for persistence ║ +╚══════════════════════════════════════════════════════════════════╝ + + Add to your secrets manager / .env: + + CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}' +``` + +Copy that value into your secrets manager and set it as an environment variable. +On subsequent starts the SDK recovers the same DID without generating a new identity. + +**Key resolution priority:** env var → local file → generate new. + +```yaml +# docker-compose.yml +services: + my-agent: + environment: + CAPISCIO_API_KEY: "sk_live_..." + CAPISCIO_AGENT_PRIVATE_KEY_JWK: "${AGENT_KEY_JWK}" # from secrets +``` + +See the [Configuration Guide](https://docs.capisc.io/reference/sdk-python/config/) for full deployment examples. ## 🎯 Agent Card Validation with CoreValidator diff --git a/capiscio_sdk/connect.py b/capiscio_sdk/connect.py index f98e73f..bfae092 100644 --- a/capiscio_sdk/connect.py +++ b/capiscio_sdk/connect.py @@ -38,6 +38,41 @@ PROD_REGISTRY = "https://registry.capisc.io" PROD_DASHBOARD = "https://app.capisc.io" +# Env var for injecting the private key in ephemeral environments +ENV_AGENT_PRIVATE_KEY = "CAPISCIO_AGENT_PRIVATE_KEY_JWK" + + +# ============================================================================= +# Key injection helpers +# ============================================================================= + + +def _public_jwk_from_private(private_jwk: dict) -> dict: + """Derive the public JWK from a private JWK (remove 'd' parameter).""" + public = {k: v for k, v in private_jwk.items() if k != "d"} + return public + + +def _log_agent_key_capture_hint(agent_id: str, private_jwk: dict) -> None: + """Log a one-time hint telling the user how to persist key material.""" + compact_json = json.dumps(private_jwk, separators=(",", ":")) + logger.warning( + "\n" + " \u2554" + "\u2550" * 62 + "\u2557\n" + " \u2551 New agent identity generated \u2014 save key for persistence \u2551\n" + " \u255a" + "\u2550" * 62 + "\u255d\n" + "\n" + " If this agent runs in an ephemeral environment (containers,\n" + " serverless, CI) the identity will be lost on restart unless\n" + " you persist the private key.\n" + "\n" + " Add to your secrets manager / .env:\n" + "\n" + " CAPISCIO_AGENT_PRIVATE_KEY_JWK='" + compact_json + "'\n" + "\n" + " The DID will be recovered automatically from the JWK on startup.\n" + ) + # ============================================================================= # Standalone Helper Functions (for testing and direct use) @@ -278,6 +313,8 @@ def from_env(cls, **kwargs) -> AgentIdentity: - CAPISCIO_AGENT_NAME (optional) - CAPISCIO_SERVER_URL (optional, default: production) - CAPISCIO_DEV_MODE (optional, default: false) + - CAPISCIO_AGENT_PRIVATE_KEY_JWK (optional — JSON-encoded Ed25519 + private JWK for ephemeral environments; printed on first generation) """ api_key = os.environ.get("CAPISCIO_API_KEY") if not api_key: @@ -568,16 +605,44 @@ def _init_identity(self) -> str: All cryptographic operations are performed by capiscio-core Go library. - Identity Recovery: - - If keys exist locally (private.jwk + public.jwk), we derive the DID - from public.jwk's `kid` field (per RFC-002 §6.1: did:key is self-describing) - - No did.txt file is required - it's redundant - - If server has a did:web assigned, we use that instead + Identity Recovery (priority order): + - CAPISCIO_AGENT_PRIVATE_KEY_JWK env var (ephemeral / containerised) + - Local keys on disk (private.jwk + public.jwk) + - Generate new identity via Init RPC (first run) """ private_key_path = self.keys_dir / "private.jwk" public_key_path = self.keys_dir / "public.jwk" - - # Check if we already have keys (for idempotency) + + # ------------------------------------------------------------------ + # Source 1: Environment variable (highest priority) + # ------------------------------------------------------------------ + env_jwk_raw = os.environ.get(ENV_AGENT_PRIVATE_KEY) + if env_jwk_raw: + try: + private_jwk = json.loads(env_jwk_raw) + did = private_jwk.get("kid") + if not did or not did.startswith("did:"): + raise ValueError("JWK is missing a valid 'kid' field with a DID") + + logger.info(f"Loaded agent identity from {ENV_AGENT_PRIVATE_KEY}: {did}") + + # Derive public JWK and persist to disk for subsequent restarts + public_jwk = _public_jwk_from_private(private_jwk) + self.keys_dir.mkdir(parents=True, exist_ok=True) + private_key_path.write_text(json.dumps(private_jwk, indent=2)) + os.chmod(private_key_path, 0o600) + public_key_path.write_text(json.dumps(public_jwk, indent=2)) + + # Register with server (idempotent) + server_did = self._ensure_did_registered(did, public_jwk) + return server_did if server_did else did + + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e} — falling through to local keys") + + # ------------------------------------------------------------------ + # Source 2: Local keys on disk + # ------------------------------------------------------------------ if private_key_path.exists() and public_key_path.exists(): logger.debug("Found existing keys - recovering identity") @@ -598,8 +663,9 @@ def _init_identity(self) -> str: except (json.JSONDecodeError, IOError) as e: logger.warning(f"Failed to read public.jwk: {e} - regenerating") - # No valid keys exist - generate new identity via Init RPC - # Connect to capiscio-core gRPC + # ------------------------------------------------------------------ + # Source 3: Generate new identity via Init RPC (first run) + # ------------------------------------------------------------------ if not self._rpc_client: self._rpc_client = CapiscioRPCClient() self._rpc_client.connect() @@ -625,7 +691,15 @@ def _init_identity(self) -> str: logger.info(f"Identity initialized: {did}") if result.get("registered"): logger.info("DID registered with server") - + + # Log capture hint for ephemeral environments + if private_key_path.exists(): + try: + private_jwk = json.loads(private_key_path.read_text()) + _log_agent_key_capture_hint(self.agent_id, private_jwk) + except Exception: + pass # Best-effort hint + return did def _ensure_did_registered(self, did: str, public_jwk: dict) -> Optional[str]: diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 079c9fc..02f7ff4 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -631,6 +631,8 @@ os.environ['CAPISCIO_BINARY'] = '/opt/capiscio/v2.4.0/capiscio-core' services: agent: environment: + - CAPISCIO_API_KEY=sk_live_... + - CAPISCIO_AGENT_PRIVATE_KEY_JWK=${AGENT_KEY_JWK} - CAPISCIO_FAIL_MODE=block - CAPISCIO_RATE_LIMITING=true - CAPISCIO_RATE_LIMIT_RPM=120 @@ -650,8 +652,61 @@ data: CAPISCIO_RATE_LIMITING: "true" CAPISCIO_RATE_LIMIT_RPM: "200" CAPISCIO_TIMEOUT_MS: "5000" +--- +apiVersion: v1 +kind: Secret +metadata: + name: capiscio-identity +type: Opaque +stringData: + api-key: "sk_live_..." + agent-private-key-jwk: '{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}' +``` + +### Agent Identity Variables (CapiscIO.connect) + +These variables are used by `CapiscIO.connect()` and `CapiscIO.from_env()` for agent identity management: + +| Variable | Required | Description | +|----------|----------|-------------| +| `CAPISCIO_API_KEY` | Yes | Registry API key | +| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation | +| `CAPISCIO_AGENT_ID` | No | Specific agent UUID | +| `CAPISCIO_SERVER_URL` | No | Registry URL (default: `https://registry.capisc.io`) | +| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) | +| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments | + +#### Ephemeral Environment Key Injection + +In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory doesn't survive restarts. Set `CAPISCIO_AGENT_PRIVATE_KEY_JWK` to inject the agent's Ed25519 private key from your secrets manager. + +**Key resolution priority:** + +| Priority | Source | When Used | +|----------|--------|-----------| +| 1 | `CAPISCIO_AGENT_PRIVATE_KEY_JWK` env var | Containers, serverless, CI | +| 2 | Local key file (`~/.capiscio/keys/{agent_id}/private.jwk`) | Persistent environments | +| 3 | Generate new via capiscio-core Init RPC | First run only | + +**First-run capture:** On the very first run, the SDK logs a capture hint to stderr with the full JWK. Copy it into your secrets manager: + +``` +CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}' ``` +!!! warning "DID Changes on New Key Generation" + If neither the env var nor local files are available, the SDK generates a **new** keypair with a **different** DID. Any badges issued to the old DID will no longer be valid. Always persist the key in ephemeral environments. + +#### Key Rotation + +To rotate the agent identity: + +1. Unset `CAPISCIO_AGENT_PRIVATE_KEY_JWK` +2. Remove local key files (`~/.capiscio/keys/{agent_id}/`) +3. Restart the agent — a new keypair and DID will be generated +4. Capture the new JWK from the log hint +5. Store the new key in your secrets manager + --- ## Middleware Observability (Auto-Events) diff --git a/tests/unit/test_connect.py b/tests/unit/test_connect.py index 89c87f2..deb34b3 100644 --- a/tests/unit/test_connect.py +++ b/tests/unit/test_connect.py @@ -1,12 +1,14 @@ """Unit tests for capiscio_sdk.connect module.""" +import importlib +import json import os import pytest import httpx from pathlib import Path from unittest.mock import MagicMock, patch -import capiscio_sdk.connect as connect_module +connect_module = importlib.import_module("capiscio_sdk.connect") from capiscio_sdk.connect import ( AgentIdentity, CapiscIO, @@ -14,7 +16,10 @@ ConfigurationError, DEFAULT_CONFIG_DIR, DEFAULT_KEYS_DIR, + ENV_AGENT_PRIVATE_KEY, PROD_REGISTRY, + _log_agent_key_capture_hint, + _public_jwk_from_private, ) @@ -834,6 +839,134 @@ def test_init_identity_rpc_error(self, tmp_path): with pytest.raises(ConfigurationError, match="Failed to initialize identity"): connector._init_identity() + def test_init_identity_uses_env_var_jwk(self, tmp_path): + """_init_identity should load identity from CAPISCIO_AGENT_PRIVATE_KEY_JWK.""" + private_jwk = { + "kty": "OKP", + "crv": "Ed25519", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "kid": "did:key:z6MkEnvVar", + } + + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + connector._ensure_did_registered = MagicMock(return_value=None) + + with patch.dict(os.environ, {ENV_AGENT_PRIVATE_KEY: json.dumps(private_jwk)}): + result = connector._init_identity() + + assert result == "did:key:z6MkEnvVar" + # Should have persisted keys to disk + assert (tmp_path / "private.jwk").exists() + priv_on_disk = json.loads((tmp_path / "private.jwk").read_text()) + assert priv_on_disk["kid"] == "did:key:z6MkEnvVar" + assert (tmp_path / "public.jwk").exists() + pub_on_disk = json.loads((tmp_path / "public.jwk").read_text()) + assert "d" not in pub_on_disk # public JWK must not contain private key + + def test_init_identity_env_var_precedence_over_local(self, tmp_path): + """Env var key should override a different key on disk.""" + # Write local keys with a different DID + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "private.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkLocal", + "d": "old", "x": "old", + })) + (tmp_path / "public.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkLocal", + "x": "old", + })) + + env_jwk = { + "kty": "OKP", "crv": "Ed25519", + "d": "new_private", "x": "new_public", + "kid": "did:key:z6MkFromEnv", + } + + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + connector._ensure_did_registered = MagicMock(return_value=None) + + with patch.dict(os.environ, {ENV_AGENT_PRIVATE_KEY: json.dumps(env_jwk)}): + result = connector._init_identity() + + assert result == "did:key:z6MkFromEnv" # env var wins, not local + + def test_init_identity_logs_capture_hint_on_new_gen(self, tmp_path): + """_init_identity should log a capture hint when generating a new identity.""" + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + + mock_rpc = MagicMock() + mock_rpc.simpleguard.init.return_value = ( + {"did": "did:key:z6MkNew", "registered": True}, + None, + ) + connector._rpc_client = mock_rpc + + # Write the private.jwk that the RPC would create so capture-hint reads it + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "private.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkNew", + "d": "gen", "x": "gen", + })) + + with patch.object(connect_module, "_log_agent_key_capture_hint") as mock_hint: + connector._init_identity() + + mock_hint.assert_called_once() + assert mock_hint.call_args[0][0] == "agent-123" + + def test_init_identity_no_capture_hint_on_recovery(self, tmp_path): + """_init_identity should NOT log a capture hint when recovering from local keys.""" + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "private.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkExisting", + "d": "priv", "x": "pub", + })) + (tmp_path / "public.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkExisting", + "x": "pub", + })) + + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + connector._ensure_did_registered = MagicMock(return_value=None) + + with patch.object(connect_module, "_log_agent_key_capture_hint") as mock_hint: + connector._init_identity() + + mock_hint.assert_not_called() + def test_setup_badge_success(self, tmp_path): """Test _setup_badge sets up keeper and guard.""" connector = _Connector(