diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index 0f1019f..e5abbd7 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -1,3 +1,4 @@ +pub mod biometric; pub mod session_store; use std::sync::Arc; @@ -201,6 +202,7 @@ pub async fn cmd_run(ctx: &CommandContext, agent: &str, cmd: &[String]) -> Resul } pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result { + biometric::require_biometric(&format!("Revoke session(s) for {}", agent))?; let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; let target_session = Session { @@ -229,6 +231,7 @@ pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result { } pub async fn cmd_teardown(ctx: &CommandContext, agent: &str) -> Result { + biometric::require_biometric(&format!("Tear down agent {} (deletes all credentials)", agent))?; let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; let agent_id = WalletAddress(agent.to_string()); @@ -401,6 +404,7 @@ pub async fn cmd_recover(ctx: &CommandContext, identity: &str, method: &str) -> } pub async fn cmd_approve(ctx: &CommandContext, pair_code: &str, auto_yes: bool) -> Result { + biometric::require_biometric("Approve pair request (creates a new child session)")?; let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; if ctx.verbose { diff --git a/crates/agentkeys-cli/tests/cli_tests.rs b/crates/agentkeys-cli/tests/cli_tests.rs index 99e73e3..6cddd89 100644 --- a/crates/agentkeys-cli/tests/cli_tests.rs +++ b/crates/agentkeys-cli/tests/cli_tests.rs @@ -1,10 +1,14 @@ use std::sync::Arc; -use agentkeys_cli::{cmd_init, cmd_link, cmd_read, cmd_revoke, cmd_store, cmd_teardown, cmd_usage, CommandContext}; +use agentkeys_cli::{cmd_approve, cmd_init, cmd_link, cmd_read, cmd_revoke, cmd_store, cmd_teardown, cmd_usage, CommandContext}; use agentkeys_core::backend::CredentialBackend; use agentkeys_mock_server::test_client::InProcessBackend; use agentkeys_types::Session; +fn disable_biometric() { + unsafe { std::env::set_var("AGENTKEYS_BIOMETRIC", "off") }; +} + fn create_test_backend() -> Arc { Arc::new(InProcessBackend::new()) } @@ -94,6 +98,7 @@ async fn cli_run_injects_env() { // Test 5: revoke then read — exercises the revoke path without blocking on keychain #[tokio::test(flavor = "multi_thread")] async fn cli_revoke_then_read() { + disable_biometric(); let backend = create_test_backend(); let (wallet, session) = init_session_direct(&backend).await; let context = ctx_with_session(backend, session); @@ -112,6 +117,7 @@ async fn cli_revoke_then_read() { // Test 6: teardown then read returns error #[tokio::test(flavor = "multi_thread")] async fn cli_teardown_deletes_all() { + disable_biometric(); let backend = create_test_backend(); let (wallet, session) = init_session_direct(&backend).await; let context = ctx_with_session(backend, session); @@ -267,3 +273,53 @@ async fn cli_error_format_unreachable() { "unexpected error: {err}" ); } + +// Test 15: biometric gate is skipped when AGENTKEYS_BIOMETRIC=off +#[tokio::test(flavor = "multi_thread")] +async fn cmd_approve_skips_biometric_in_test_mode() { + disable_biometric(); + let backend = create_test_backend(); + let (_wallet, session) = init_session_direct(&backend).await; + let context = ctx_with_session(backend, session); + + // approve with a non-existent pair code — biometric gate must not block it; + // expect an error from the backend (not a biometric error). + let result = cmd_approve(&context, "NONEXISTENT-PAIR-CODE", true).await; + assert!(result.is_err(), "expected backend error for unknown pair code"); + let err = result.unwrap_err().to_string(); + assert!( + !err.contains("biometric") && !err.contains("Biometric") && !err.contains("cancelled"), + "unexpected biometric error leaked: {err}" + ); +} + +// Test 16: cmd_revoke skips biometric when AGENTKEYS_BIOMETRIC=off +#[tokio::test(flavor = "multi_thread")] +async fn cmd_revoke_skips_biometric_in_test_mode() { + disable_biometric(); + let backend = create_test_backend(); + let (wallet, session) = init_session_direct(&backend).await; + let context = ctx_with_session(backend, session); + + // revoke proceeds past the biometric gate regardless of result + let result = cmd_revoke(&context, &wallet).await; + // accept ok or backend error — just not a biometric gate error + if let Err(ref err) = result { + assert!( + !err.to_string().contains("biometric") && !err.to_string().contains("cancelled"), + "unexpected biometric error: {err}" + ); + } +} + +// Test 17: cmd_teardown skips biometric when AGENTKEYS_BIOMETRIC=off +#[tokio::test(flavor = "multi_thread")] +async fn cmd_teardown_skips_biometric_in_test_mode() { + disable_biometric(); + let backend = create_test_backend(); + let (wallet, session) = init_session_direct(&backend).await; + let context = ctx_with_session(backend, session); + + let result = cmd_teardown(&context, &wallet).await; + assert!(result.is_ok(), "teardown should succeed: {:?}", result.err()); +} diff --git a/docs/spec/1-step-analysis.md b/docs/spec/1-step-analysis.md index 2779416..34c38a1 100644 --- a/docs/spec/1-step-analysis.md +++ b/docs/spec/1-step-analysis.md @@ -119,10 +119,10 @@ AgentKeys' answer is structurally different from 1Password: **we don't hand user > **Correction (2026-04-12, verified against Heima source `tee-worker/omni-executor/core/src/auth/auth_token.rs`):** The original table below described session "keys" (keypairs) stored client-side. Heima's actual implementation uses **JWT-based stateless auth tokens**: the TEE signs a JWT with its RSA private key, and the client holds the JWT string as a bearer token — NOT a private key. The security properties and storage requirements are different from what the original table assumed. OS keychain is no longer required (a JWT can go in a plain file); `keyring-rs` complexity drops significantly. The table is preserved below as the original design-time thinking, with the correction noted. -| Tier | Lifetime | Storage (original spec) | Storage (corrected, JWT model) | Usage | +| Tier | Lifetime | Storage (original spec) | Storage (corrected, session-token model) | Usage | | --------------------- | ---------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| **Master auth token** | Short (15 min – 24 h, configurable via `AuthOptions.expires_at`) | OS keychain | Plain file or env var (JWT string, not a private key) | Management commands: `agentkeys init`, `store`, `usage`, `teardown`, `approve`. Never used by running agents. | -| **Agent auth token** | Long (hours to days) | Sandbox filesystem (`~/.agentkeys/session`, 0600) | Same (JWT string in file, 0600) | MCP Credential Server authentication. Scoped to specific credentials for a specific agent. | +| **Master auth token** | Short (15 min – 24 h, configurable via `AuthOptions.expires_at`) | OS keychain | Plain file or env var (session-token string, not a private key) | Management commands: `agentkeys init`, `store`, `usage`, `teardown`, `approve`. Never used by running agents. | +| **Agent auth token** | Long (hours to days) | Sandbox filesystem (`~/.agentkeys/session`, 0600) | Same (session-token string in file, 0600) | MCP Credential Server authentication. Scoped to specific credentials for a specific agent. | ### 3.3 Storage choices (Rounds 5–6) diff --git a/docs/spec/tech-brief.md b/docs/spec/tech-brief.md index 95557e2..c686a1d 100644 --- a/docs/spec/tech-brief.md +++ b/docs/spec/tech-brief.md @@ -172,11 +172,12 @@ User (Mac) Mock Backend / Heima Agent Sandbox │ │ generate master keypair │ │ │ generate mock wallet addr │ │◄─────────────────────────│ │ - │ JWT auth token → file │ (NOTE: original spec said│ + │ session token → file │ (NOTE: original spec said│ │ (was: session key → │ "session key → OS │ │ OS keychain; corrected │ keychain"; corrected │ │ 2026-04-12 per Heima │ after verifying Heima │ - │ source verification) │ uses JWT auth tokens) │ + │ source verification) │ uses JWT-format session │ + │ │ tokens) │ ``` ### Flow B — Pair a daemon (universal, works for Docker / cloud VM / cloud LLM)