4242# Env var for injecting the private key in ephemeral environments
4343ENV_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
917997connect = CapiscIO .connect
0 commit comments