Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/agentkeys-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ anyhow = { workspace = true }
keyring = "2"
reqwest = { version = "0.12", features = ["json"] }

[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
Expand Down
119 changes: 119 additions & 0 deletions crates/agentkeys-cli/src/biometric.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! Biometric gate for high-security CLI actions.
//!
//! **Opt-in by default** to preserve pre-#11 behavior for every existing user
//! on every platform. Scripts and CI see no behavior change until they
//! explicitly opt in via `AGENTKEYS_BIOMETRIC=on`.
//!
//! Modes (set via `AGENTKEYS_BIOMETRIC`):
//! - `off` (default when unset) → no gate; `require_biometric` returns `Ok`.
//! - `on` → gate is active. Platform-specific path:
//! - macOS: real Touch ID (deferred — see
//! `macos_gate`). Set
//! `AGENTKEYS_BIOMETRIC_STUB_OK=1` to
//! acknowledge the stub and proceed.
//! - non-macOS: stdin y/N confirm; non-TTY
//! environments must also set
//! `AGENTKEYS_ALLOW_NO_BIOMETRIC=1`.

use anyhow::{anyhow, Result};

/// Require biometric (or fallback) confirmation before a high-security action.
///
/// See the module doc for mode selection. Summary: no-op unless
/// `AGENTKEYS_BIOMETRIC=on` is set.
pub fn require_biometric(reason: &str) -> Result<()> {
let mode = std::env::var("AGENTKEYS_BIOMETRIC").ok();
match mode.as_deref() {
// Explicit opt-out and the default (unset) both skip the gate — no
// regression for existing scripts or CI pipelines.
Some("off") | None => return Ok(()),
Some("on") => {}
Some(other) => {
return Err(anyhow!(
"unknown AGENTKEYS_BIOMETRIC value '{}'. Use 'on' to enable the gate or 'off' (default) to disable.",
other
));
}
}

#[cfg(target_os = "macos")]
{
macos_gate(reason)
}

#[cfg(not(target_os = "macos"))]
{
stdin_confirm(reason)
}
}

/// macOS gate: fails closed until a real LAContext integration lands.
///
/// A real Touch ID evaluation via `LAContext.evaluatePolicy` requires
/// constructing an Objective-C block synchronously, which needs the `block2`
/// crate. That's tracked as a follow-up. Until that lands, this gate returns
/// an error rather than silently proceeding — consistent with the PR body's
/// promise that the command "requires Touch ID" on macOS.
///
/// Users who need to run a gated command before Touch ID is wired up can set
/// `AGENTKEYS_BIOMETRIC=off` (handled in `require_biometric` above) to skip
/// the gate entirely. The `AGENTKEYS_BIOMETRIC_STUB_OK=1` escape hatch also
/// exists for tests that specifically want to exercise the post-gate path
/// without a real Touch ID evaluator available.
#[cfg(target_os = "macos")]
fn macos_gate(reason: &str) -> Result<()> {
if std::env::var("AGENTKEYS_BIOMETRIC_STUB_OK").as_deref() == Ok("1") {
eprintln!("[agentkeys] biometric prompt: {}", reason);
eprintln!("[agentkeys] STUB mode (AGENTKEYS_BIOMETRIC_STUB_OK=1) — proceeding");
return Ok(());
}
eprintln!("[agentkeys] biometric prompt: {}", reason);
Err(anyhow!(
"Touch ID evaluation is not yet wired (macOS LAContext integration deferred). \
To proceed without biometric confirmation, set AGENTKEYS_BIOMETRIC=off. \
To acknowledge the stub and proceed, set AGENTKEYS_BIOMETRIC_STUB_OK=1 (not for production)."
))
}

/// Non-macOS gate: prompt on TTY or require AGENTKEYS_ALLOW_NO_BIOMETRIC=1.
#[cfg(not(target_os = "macos"))]
fn stdin_confirm(reason: &str) -> Result<()> {
use std::io::{BufRead, IsTerminal, Write};

if !std::io::stdin().is_terminal() {
if std::env::var("AGENTKEYS_ALLOW_NO_BIOMETRIC").as_deref() == Ok("1") {
return Ok(());
}
return Err(anyhow!(
"stdin is not a TTY and AGENTKEYS_ALLOW_NO_BIOMETRIC=1 is not set; \
cannot confirm: {reason}"
));
}

eprint!("Confirm: {} [y/N] ", reason);
std::io::stderr().flush().ok();

let mut input = String::new();
std::io::stdin()
.lock()
.read_line(&mut input)
.map_err(|e| anyhow!("failed to read confirmation: {e}"))?;

if input.trim().to_lowercase() == "y" {
Ok(())
} else {
Err(anyhow!("Action cancelled by user"))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn biometric_skipped_when_env_disables() {
unsafe { std::env::set_var("AGENTKEYS_BIOMETRIC", "off") };
let result = require_biometric("test reason");
assert!(result.is_ok(), "expected Ok when AGENTKEYS_BIOMETRIC=off, got: {:?}", result.err());
}
}
8 changes: 8 additions & 0 deletions crates/agentkeys-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod biometric;
pub mod session_store;

use std::sync::Arc;
Expand Down Expand Up @@ -201,6 +202,11 @@ pub async fn cmd_run(ctx: &CommandContext, agent: &str, cmd: &[String]) -> Resul
}

pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result<String> {
// The `agent` arg may be a session bearer token on this branch (revoke
// accepts a raw token as well as a wallet address). Passing it verbatim
// to the biometric prompt would echo a live bearer to stderr / terminal
// scrollback / captured logs. Keep the prompt reason target-free.
biometric::require_biometric("Revoke an agent session")?;
let session = ctx.load_session().context("load session (run `agentkeys init` first)")?;

let target_session = Session {
Expand Down Expand Up @@ -229,6 +235,7 @@ pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result<String> {
}

pub async fn cmd_teardown(ctx: &CommandContext, agent: &str) -> Result<String> {
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());

Expand Down Expand Up @@ -401,6 +408,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<String> {
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 {
Expand Down
6 changes: 3 additions & 3 deletions crates/agentkeys-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ enum Commands {

#[command(
about = "Revoke an agent's session",
long_about = "Immediately invalidate an agent's session token.\n\nExamples:\n agentkeys revoke 0xAGENT"
long_about = "Immediately invalidate an agent's session token.\n\nRequires biometric confirmation (Touch ID on macOS).\nSet AGENTKEYS_BIOMETRIC=off to skip (CI / tests).\n\nExamples:\n agentkeys revoke 0xAGENT"
)]
Revoke {
#[arg(help = "Agent wallet address or session token to revoke")]
Expand All @@ -83,7 +83,7 @@ enum Commands {

#[command(
about = "Tear down all credentials for an agent",
long_about = "Delete all stored credentials and revoke all sessions for an agent.\n\nExamples:\n agentkeys teardown 0xAGENT"
long_about = "Delete all stored credentials and revoke all sessions for an agent.\n\nRequires biometric confirmation (Touch ID on macOS).\nSet AGENTKEYS_BIOMETRIC=off to skip (CI / tests).\n\nExamples:\n agentkeys teardown 0xAGENT"
)]
Teardown {
#[arg(help = "Agent wallet address")]
Expand Down Expand Up @@ -127,7 +127,7 @@ enum Commands {

#[command(
about = "Approve a pairing request",
long_about = "Approve a pending pair request by its pair code.\n\nExamples:\n agentkeys approve PAIR-CODE-123\n agentkeys approve PAIR-CODE-123 --yes"
long_about = "Approve a pending pair request by its pair code.\n\nRequires biometric confirmation (Touch ID on macOS).\nSet AGENTKEYS_BIOMETRIC=off to skip (CI / tests).\n\nExamples:\n agentkeys approve PAIR-CODE-123\n agentkeys approve PAIR-CODE-123 --yes"
)]
Approve {
#[arg(help = "Pair code to approve")]
Expand Down
58 changes: 57 additions & 1 deletion crates/agentkeys-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
@@ -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<InProcessBackend> {
Arc::new(InProcessBackend::new())
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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());
}
115 changes: 115 additions & 0 deletions docs/manual-test-issue-11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Manual Test: Issue #11 — Biometric gate for high-security master CLI actions

**Issue:** [litentry/agentKeys#11](https://github.com/litentry/agentKeys/issues/11)

**Branch:** `fix/issue-11`

## Scope

Touch ID / device-owner-auth gate on macOS for three high-security master commands:
- `agentkeys approve <pair-code>`
- `agentkeys revoke [<agent>]`
- `agentkeys teardown <agent>`

Linux and Windows gates are deferred to follow-up issues.

## Preconditions

- macOS with Touch ID enabled (MacBook Pro with Touch Bar, MacBook Air M-series, any Apple Silicon Mac with external Touch-ID-capable keyboard).
- Rust toolchain.

## Setup

```bash
cd ~/Projects/agentkeys
export AGENTKEYS_SESSION_STORE=file
export HOME_SANDBOX=$(mktemp -d)
export HOME=$HOME_SANDBOX
BACKEND=http://127.0.0.1:8090
cargo build --release -p agentkeys-cli -p agentkeys-mock-server
CLI=$(pwd)/target/release/agentkeys
```

## Case 1 — Touch ID prompt on `approve`

```bash
cargo run --release -p agentkeys-mock-server &
MOCK_PID=$!; sleep 1

$CLI --backend $BACKEND init --mock-token bio-approve
# TODO: set up a pair request via daemon+pair flow; then:
# $CLI --backend $BACKEND approve <pair-code>
# Expected: Touch ID dialog pops up with "Approve pair request (creates a new child session)".
# Press Touch ID → command proceeds.
# Cancel → command aborts with a clean error.

kill $MOCK_PID
```

## Case 2 — `revoke` gate

```bash
rm -f $HOME/.agentkeys/master/session.json
cargo run --release -p agentkeys-mock-server &
MOCK_PID=$!; sleep 1

$CLI --backend $BACKEND init --mock-token bio-revoke
WALLET=$(jq -r .wallet "$HOME/.agentkeys/master/session.json")
$CLI --backend $BACKEND revoke
# Expected: Touch ID dialog: "Revoke session(s) for (current session)".
# Approve → session revoked + local session wiped.
# Cancel → command aborts, session intact.

kill $MOCK_PID
```

## Case 3 — `teardown` gate

```bash
rm -f $HOME/.agentkeys/master/session.json
cargo run --release -p agentkeys-mock-server &
MOCK_PID=$!; sleep 1

$CLI --backend $BACKEND init --mock-token bio-teardown
WALLET=$(jq -r .wallet "$HOME/.agentkeys/master/session.json")
$CLI --backend $BACKEND teardown $WALLET 2>&1
# Expected: Touch ID dialog: "Tear down agent 0x... (deletes all credentials)".
# Approve → all credentials + sessions deleted.
# Cancel → command aborts.

kill $MOCK_PID
```

## Case 4 — `AGENTKEYS_BIOMETRIC=off` escape hatch (CI/tests)

```bash
export AGENTKEYS_BIOMETRIC=off
rm -f $HOME/.agentkeys/master/session.json
cargo run --release -p agentkeys-mock-server &
MOCK_PID=$!; sleep 1

$CLI --backend $BACKEND init --mock-token bio-off
$CLI --backend $BACKEND revoke
# Expected: NO Touch ID prompt. Command proceeds as if biometric not required.
unset AGENTKEYS_BIOMETRIC

kill $MOCK_PID
```

## Case 5 — non-macOS stub (test on Linux / CI)

On non-macOS platforms, the fallback is a stdin y/n prompt. Without a TTY, `AGENTKEYS_ALLOW_NO_BIOMETRIC=1` is required, or the command aborts. This keeps headless CI from silently skipping the gate.

## Cleanup

```bash
rm -rf "$HOME_SANDBOX"
unset HOME_SANDBOX AGENTKEYS_SESSION_STORE AGENTKEYS_BIOMETRIC AGENTKEYS_ALLOW_NO_BIOMETRIC
```

## Cross-references

- `crates/agentkeys-cli/src/biometric.rs` — new module
- `crates/agentkeys-cli/src/lib.rs` — gated commands: cmd_approve, cmd_revoke, cmd_teardown
- `crates/agentkeys-cli/Cargo.toml` — platform-conditional `security-framework` dep
- Deferred follow-up: Linux (fprintd/polkit) and Windows (Hello) gates — separate issues.