-
Notifications
You must be signed in to change notification settings - Fork 0
feat: env var key injection for ephemeral environments #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1974d42
d0b0f02
ef32833
54805bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+57
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+74
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ============================================================================= | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+629
to
+636
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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) | |
| # Derive public JWK and (best-effort) persist to disk for subsequent restarts | |
| public_jwk = _public_jwk_from_private(private_jwk) | |
| try: | |
| self.keys_dir.mkdir(parents=True, exist_ok=True) | |
| private_key_path.write_text(json.dumps(private_jwk, indent=2)) | |
| try: | |
| os.chmod(private_key_path, 0o600) | |
| except (OSError, NotImplementedError) as chmod_err: | |
| # On some platforms or filesystems chmod may not be supported | |
| logger.debug( | |
| f"Unable to set permissions on {private_key_path}: {chmod_err!r}" | |
| ) | |
| public_key_path.write_text(json.dumps(public_jwk, indent=2)) | |
| except (OSError, PermissionError) as fs_err: | |
| # In read-only/container/serverless environments, persisting keys may fail. | |
| # Continue using the in-memory key from the environment variable. | |
| logger.warning( | |
| "Unable to persist agent keys to %s: %s; " | |
| "continuing with in-memory key from %s", | |
| self.keys_dir, | |
| fs_err, | |
| ENV_AGENT_PRIVATE_KEY, | |
| ) | |
| # Register with server (idempotent) even if persistence failed |
Copilot
AI
Mar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When CAPISCIO_AGENT_PRIVATE_KEY_JWK is present but invalid JSON / missing fields, the code logs an error and silently falls through to local keys / generating a new identity. In ephemeral deployments this can cause an unexpected DID/key rotation (and invalidate existing badges) due to a simple misconfiguration. Safer behavior would be to fail fast with a ConfigurationError when the env var is set but invalid, so operators notice immediately rather than getting a new identity.
| logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e} — falling through to local keys") | |
| logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e}") | |
| raise ConfigurationError(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e}") from e |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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..."}' | ||
| ``` | ||
|
Comment on lines
+691
to
695
|
||
|
|
||
| !!! 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These deployment instructions show a "capture hint" where the SDK logs the full
CAPISCIO_AGENT_PRIVATE_KEY_JWKvalue, including the private Ed25519 key, and recommend copying it from logs. Emitting long‑lived private keys into application logs exposes them to log collectors and anyone with observability access, allowing undetected agent/DID impersonation. The bootstrap flow should be redesigned so private keys are never written to logs, and the docs should direct users to obtain the key from a secure export path (e.g., a one‑time CLI or local file), not from log output.