Skip to content

Commit 94dbbb4

Browse files
authored
feat: make PoP (IAL-1) the default badge mode with CA fallback (#69)
* feat: make PoP (IAL-1) the default badge mode with CA fallback CapiscIO.connect() now tries Proof-of-Possession badge issuance first: 1. Reads the agent's private key from keys_dir 2. Requests a PoP challenge from the registry 3. Signs the challenge to prove key ownership 4. Receives an IAL-1 badge (higher assurance than CA-issued) Falls back to CA-issued badge (IAL-0) via the gRPC keeper if PoP fails (e.g. server doesn't support PoP yet, network issues). Also passes private_key_path through to BadgeKeeper so the Go core can find the key in custom keys_dir locations (not just ~/.capiscio/keys/). * fix: prevent keeper from overwriting PoP badge + handle nil jti - Only start BadgeKeeper when PoP fails (CA fallback path) - Prevent IAL-1 PoP badge from being overwritten by IAL-0 CA badge - Only pass private_key_path when file exists (ephemeral env safety) - Use null-coalescing for jti to prevent TypeError on None values Addresses review feedback from PR #69. * fix: rename CAPISCIO_KEYS_DIR → CAPISCIO_KEY_DIR per documented convention
1 parent 69f75a9 commit 94dbbb4

2 files changed

Lines changed: 108 additions & 22 deletions

File tree

capiscio_sdk/badge_keeper.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class BadgeKeeperConfig:
5656
check_interval: Check interval in seconds (default: 5)
5757
trust_level: Trust level for CA mode (1-4, default: 1)
5858
rpc_address: Optional custom RPC address for capiscio-core
59+
private_key_path: Optional path to private key JWK file
5960
on_renew: Optional callback(token: str) called when badge renews
6061
max_retries: Max retry attempts on renewal failure (default: 3)
6162
retry_backoff: Base backoff seconds for exponential retry (default: 2)
@@ -70,6 +71,7 @@ class BadgeKeeperConfig:
7071
check_interval: int = 5
7172
trust_level: int = 1
7273
rpc_address: Optional[str] = None
74+
private_key_path: Optional[str] = None
7375
on_renew: Optional[Callable[[str], None]] = None
7476
max_retries: int = 3
7577
retry_backoff: int = 2
@@ -98,6 +100,7 @@ def __init__(
98100
check_interval: int = 5,
99101
trust_level: int = 1,
100102
rpc_address: Optional[str] = None,
103+
private_key_path: Optional[str] = None,
101104
on_renew: Optional[Callable[[str], None]] = None,
102105
max_retries: int = 3,
103106
retry_backoff: int = 2,
@@ -115,6 +118,7 @@ def __init__(
115118
check_interval: Check interval in seconds (default: 5)
116119
trust_level: Trust level for CA mode (1-4, default: 1)
117120
rpc_address: Optional custom RPC address for capiscio-core
121+
private_key_path: Optional path to private key JWK file
118122
on_renew: Optional callback(token: str) called when badge renews
119123
max_retries: Max retry attempts on renewal failure (default: 3)
120124
retry_backoff: Base backoff seconds for exponential retry (default: 2)
@@ -130,6 +134,7 @@ def __init__(
130134
check_interval=check_interval,
131135
trust_level=trust_level,
132136
rpc_address=rpc_address,
137+
private_key_path=private_key_path,
133138
on_renew=on_renew,
134139
max_retries=max_retries,
135140
retry_backoff=retry_backoff,
@@ -222,6 +227,7 @@ def _run_keeper(self) -> None:
222227
renew_before_seconds=self.config.renewal_threshold,
223228
check_interval_seconds=self.config.check_interval,
224229
trust_level=self.config.trust_level,
230+
private_key_path=self.config.private_key_path or "",
225231
):
226232
# Check stop signal
227233
if self._stop_event.is_set():

capiscio_sdk/connect.py

Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
# Env var for injecting the private key in ephemeral environments
4343
ENV_AGENT_PRIVATE_KEY = "CAPISCIO_AGENT_PRIVATE_KEY_JWK"
4444

45+
# Env var for overriding the keys directory
46+
ENV_KEYS_DIR = "CAPISCIO_KEY_DIR"
47+
4548

4649
# =============================================================================
4750
# Key injection helpers
@@ -223,7 +226,9 @@ def emit(self, event_type: str, data: Dict[str, Any]) -> bool:
223226
def get_badge(self) -> Optional[str]:
224227
"""Get current badge (auto-renewed if needed)."""
225228
if self._keeper:
226-
return self._keeper.get_current_badge()
229+
badge = self._keeper.get_current_badge()
230+
if badge:
231+
return badge
227232
return self.badge
228233

229234
def status(self) -> Dict[str, Any]:
@@ -335,6 +340,10 @@ def connect(
335340
agent_id = os.environ.get("CAPISCIO_AGENT_ID")
336341
if server_url is None:
337342
server_url = os.environ.get("CAPISCIO_SERVER_URL") or PROD_REGISTRY
343+
if keys_dir is None:
344+
env_keys = os.environ.get(ENV_KEYS_DIR)
345+
if env_keys:
346+
keys_dir = Path(env_keys)
338347

339348
connector = _Connector(
340349
api_key=api_key,
@@ -870,7 +879,15 @@ def _activate_agent(self):
870879
logger.debug(f"Agent activation failed: {e} - non-critical")
871880

872881
def _setup_badge(self):
873-
"""Set up BadgeKeeper for automatic badge management."""
882+
"""Set up badge management with PoP (IAL-1) preferred, CA (IAL-0) fallback.
883+
884+
Per RFC-003, agents SHOULD prove key possession to obtain IAL-1
885+
badges. This method tries PoP first, falling back to CA if the
886+
server or agent keys are not PoP-ready.
887+
888+
The keeper runs in CA mode for continuous renewal because the
889+
keeper streaming RPC does not yet support PoP mode.
890+
"""
874891
try:
875892
from .badge_keeper import BadgeKeeper
876893
from .simple_guard import SimpleGuard
@@ -884,34 +901,97 @@ def _setup_badge(self):
884901
keys_preloaded=True,
885902
)
886903

887-
# Set up BadgeKeeper with correct parameters
888-
# Wire on_renew so the guard's badge token stays in sync
889-
# when the keeper renews it in the background.
890-
keeper = BadgeKeeper(
891-
api_url=self.server_url,
892-
api_key=self.api_key,
893-
agent_id=self.agent_id,
894-
mode="dev" if self.dev_mode else "ca",
895-
output_file=str(self.keys_dir / "badge.jwt"),
896-
on_renew=lambda token: guard.set_badge_token(token),
897-
)
898-
899-
# Start the keeper and get initial badge
900-
keeper.start()
901-
badge = keeper.get_current_badge()
902-
# Get expiration from keeper if available, otherwise None
904+
badge = None
903905
expires_at = None
904-
if hasattr(keeper, 'badge_expires_at'):
905-
expires_at = keeper.badge_expires_at
906-
elif hasattr(keeper, 'get_badge_expiration'):
907-
expires_at = keeper.get_badge_expiration()
906+
907+
# Try PoP (IAL-1) for the initial badge — secure by default (RFC-003)
908+
if not self.dev_mode:
909+
badge, expires_at = self._request_pop_badge(guard)
910+
911+
# Start keeper for continuous renewal (CA mode).
912+
# Only start if PoP didn't succeed — otherwise the keeper would
913+
# immediately overwrite the IAL-1 PoP badge with an IAL-0 CA badge.
914+
# When PoP-based renewal is supported, keeper can be started always.
915+
keeper = None
916+
if badge is None:
917+
private_key_file = self.keys_dir / "private.jwk"
918+
keeper = BadgeKeeper(
919+
api_url=self.server_url,
920+
api_key=self.api_key,
921+
agent_id=self.agent_id,
922+
mode="dev" if self.dev_mode else "ca",
923+
output_file=str(self.keys_dir / "badge.jwt"),
924+
private_key_path=str(private_key_file) if private_key_file.exists() else None,
925+
on_renew=lambda token: guard.set_badge_token(token),
926+
)
927+
keeper.start()
928+
badge = keeper.get_current_badge()
929+
if hasattr(keeper, 'badge_expires_at'):
930+
expires_at = keeper.badge_expires_at
931+
elif hasattr(keeper, 'get_badge_expiration'):
932+
expires_at = keeper.get_badge_expiration()
908933

909934
return badge, expires_at, keeper, guard
910935

911936
except Exception as e:
912937
logger.warning(f"Badge setup failed (continuing without badge): {e}")
913938
return None, None, None, None
914939

940+
def _request_pop_badge(self, guard):
941+
"""Request initial badge via PoP challenge-response (RFC-003).
942+
943+
Returns an IAL-1 badge with cryptographic key binding (cnf claim).
944+
Falls back gracefully if PoP is unavailable.
945+
"""
946+
try:
947+
private_key_path = self.keys_dir / "private.jwk"
948+
if not private_key_path.exists():
949+
logger.warning(
950+
"No private key at %s — skipping PoP, will use CA (IAL-0)",
951+
private_key_path,
952+
)
953+
return None, None
954+
955+
private_key_jwk = private_key_path.read_text(encoding="utf-8").strip()
956+
957+
# Ensure RPC client is available (identity recovery path
958+
# may not have created one)
959+
if self._rpc_client is None:
960+
self._rpc_client = CapiscioRPCClient()
961+
self._rpc_client.connect()
962+
963+
success, result, error = self._rpc_client.badge.request_pop_badge(
964+
agent_did=self.did,
965+
private_key_jwk=private_key_jwk,
966+
api_key=self.api_key,
967+
ca_url=self.server_url,
968+
)
969+
970+
if success and result:
971+
token = result["token"]
972+
guard.set_badge_token(token)
973+
974+
# Persist badge to disk
975+
badge_path = self.keys_dir / "badge.jwt"
976+
badge_path.write_text(token, encoding="utf-8")
977+
978+
logger.info(
979+
"PoP badge acquired (IAL-1, jti=%s...)",
980+
(result.get("jti") or "unknown")[:8],
981+
)
982+
return token, result.get("expires_at")
983+
else:
984+
logger.warning(
985+
"PoP badge request failed: %s — falling back to CA (IAL-0)",
986+
error or "unknown error",
987+
)
988+
return None, None
989+
except Exception as e:
990+
logger.warning(
991+
"PoP badge request error: %s — falling back to CA (IAL-0)", e
992+
)
993+
return None, None
994+
915995

916996
# Convenience alias
917997
connect = CapiscIO.connect

0 commit comments

Comments
 (0)