From 8ab2c8ed24bcb93851c0c34b1db2d52da9132427 Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 01:26:53 +0800 Subject: [PATCH 1/3] agentkeys: retire legacy mock-server endpoints + /v1/mint-aws-creds + /v1/auth/exchange (closes #77 #72 #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #77 — delete /identity/link, /identity/resolve, /audit/query, /v1/auth/exchange: - mock-server: drop routes and HTTP handler functions; keep resolve_identity_typed as internal helper for session/auth_request paths - broker: drop /v1/auth/exchange route, handlers/auth/exchange.rs, auth.rs::validate_bearer_token + ValidatedSession; keep extract_bearer_token (still used by mint-oidc handler) - broker: drop BROKER_BACKEND_URL + BROKER_BACKEND_TIMEOUT_SECONDS, Tier-2 backend reachability probe + readyz check, Tier2State::backend_reachable, BrokerConfig::backend_url/backend_request_timeout_seconds - core: drop CredentialBackend::query_audit and CredentialBackend::resolve_identity trait methods and all impls (mock_client, s3_backend, test stubs) - cli: drop Commands::Usage/Link/Recover + cmd_usage/cmd_link/cmd_recover; resolve_agent now requires raw 0x wallet (alias/email lookup retired); resolve_agent_to_wallet same - daemon: resolve_parent_if_set now requires raw 0x wallet, no HTTP call - mcp: list_credentials uses CredentialBackend::list_credentials directly instead of round-tripping query_audit - tests: remove tests targeting deleted endpoints; convert /identity/link setup steps to direct-DB inserts via new link_identity_direct helper Issue #72 — delete /v1/mint-aws-creds: - broker: drop /v1/mint-aws-creds route + handlers/mint.rs (mint_v2 + helpers) - tests: delete mint_v2_flow.rs + invariant_load_bearing.rs (exclusively exercised the deleted endpoint). Audit happens at /v1/mint-oidc-jwt; AWS submission is daemon-side via OIDC JWT → AssumeRoleWithWebIdentity. Issue #78 — folded into #77 per its own resolution comment. scripts/broker.env + scripts/setup-broker-host.sh: drop BROKER_BACKEND_URL since the broker no longer reads it. Workspace tests: 73 (core) + 41 (cli) + 38 (daemon) + 7 (mcp) + 31 (provisioner) + 48 (mock-server) + multiple (broker) all pass. --- crates/agentkeys-broker-server/src/auth.rs | 52 -- crates/agentkeys-broker-server/src/boot.rs | 6 +- crates/agentkeys-broker-server/src/config.rs | 12 - crates/agentkeys-broker-server/src/env.rs | 7 - .../src/handlers/auth/exchange.rs | 86 --- .../src/handlers/auth/mod.rs | 3 - .../src/handlers/broker_status.rs | 15 - .../src/handlers/mint.rs | 613 ------------------ .../src/handlers/mod.rs | 1 - .../src/handlers/oidc.rs | 13 +- crates/agentkeys-broker-server/src/lib.rs | 2 - crates/agentkeys-broker-server/src/main.rs | 48 +- crates/agentkeys-broker-server/src/state.rs | 1 - .../tests/auth_wallet_flow.rs | 2 - .../tests/email_flow.rs | 4 - .../tests/grant_flow.rs | 4 - .../tests/invariant_load_bearing.rs | 588 ----------------- .../tests/mint_v2_flow.rs | 351 ---------- .../tests/oauth2_flow.rs | 3 - .../tests/oidc_flow.rs | 38 +- .../tests/wallet_flow.rs | 4 - crates/agentkeys-cli/src/lib.rs | 230 +------ crates/agentkeys-cli/src/main.rs | 48 +- crates/agentkeys-cli/tests/cli_tests.rs | 98 +-- crates/agentkeys-core/src/backend.rs | 32 +- crates/agentkeys-core/src/mock_client.rs | 88 +-- crates/agentkeys-core/src/s3_backend.rs | 22 +- crates/agentkeys-daemon/src/main.rs | 56 +- crates/agentkeys-daemon/tests/pair_tests.rs | 78 +-- crates/agentkeys-mcp/src/lib.rs | 24 +- .../src/handlers/audit.rs | 89 +-- .../src/handlers/identity.rs | 89 --- crates/agentkeys-mock-server/src/lib.rs | 5 - .../agentkeys-mock-server/src/test_client.rs | 105 +-- .../tests/integration.rs | 261 ++------ .../agentkeys-provisioner/src/orchestrator.rs | 4 +- scripts/broker.env | 4 - scripts/setup-broker-host.sh | 1 - 38 files changed, 146 insertions(+), 2941 deletions(-) delete mode 100644 crates/agentkeys-broker-server/src/handlers/auth/exchange.rs delete mode 100644 crates/agentkeys-broker-server/src/handlers/mint.rs delete mode 100644 crates/agentkeys-broker-server/tests/invariant_load_bearing.rs delete mode 100644 crates/agentkeys-broker-server/tests/mint_v2_flow.rs diff --git a/crates/agentkeys-broker-server/src/auth.rs b/crates/agentkeys-broker-server/src/auth.rs index 3e5eec8..49eed81 100644 --- a/crates/agentkeys-broker-server/src/auth.rs +++ b/crates/agentkeys-broker-server/src/auth.rs @@ -1,55 +1,3 @@ -use crate::error::{BrokerError, BrokerResult}; - -#[derive(Debug, Clone)] -pub struct ValidatedSession { - pub wallet: String, -} - pub fn extract_bearer_token(header: &str) -> Option<&str> { header.strip_prefix("Bearer ") } - -pub async fn validate_bearer_token( - http: &reqwest::Client, - backend_url: &str, - token: &str, -) -> BrokerResult { - let url = format!("{}/session/validate", backend_url.trim_end_matches('/')); - let response = http - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| BrokerError::BackendUnreachable(e.to_string()))?; - - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - let body: serde_json::Value = response.json().await.unwrap_or(serde_json::Value::Null); - let msg = body - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("session not valid") - .to_string(); - return Err(BrokerError::Unauthorized(msg)); - } - if !status.is_success() { - return Err(BrokerError::BackendUnreachable(format!( - "backend returned {}", - status - ))); - } - - let body: serde_json::Value = response - .json() - .await - .map_err(|e| BrokerError::BackendUnreachable(format!("parse validate response: {}", e)))?; - let wallet = body - .get("wallet") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - BrokerError::BackendUnreachable("validate response missing wallet field".into()) - })? - .to_string(); - - Ok(ValidatedSession { wallet }) -} diff --git a/crates/agentkeys-broker-server/src/boot.rs b/crates/agentkeys-broker-server/src/boot.rs index ede4cb7..b7ae1d6 100644 --- a/crates/agentkeys-broker-server/src/boot.rs +++ b/crates/agentkeys-broker-server/src/boot.rs @@ -260,11 +260,10 @@ pub struct Tier2Profile { pub strict: bool, pub email_link_enabled: bool, pub audit_evm_enabled: bool, - pub backend_url: String, } impl Tier2Profile { - pub fn from_config(config: &BrokerConfig) -> Self { + pub fn from_config(_config: &BrokerConfig) -> Self { let strict = std::env::var(env::BROKER_REFUSE_TO_BOOT_STRICT) .map(|v| v == "true") .unwrap_or(false); @@ -276,7 +275,6 @@ impl Tier2Profile { strict, email_link_enabled: methods.split(',').any(|m| m.trim() == "email_link"), audit_evm_enabled: anchors.split(',').any(|a| a.trim() == "evm_testnet"), - backend_url: config.backend_url.clone(), } } } @@ -755,11 +753,9 @@ mod tests { fn config_with(audit_db: PathBuf, oidc_issuer: &str, oidc_kp_path: PathBuf) -> BrokerConfig { BrokerConfig { data_role_arn: "arn:aws:iam::000:role/test".into(), - backend_url: "http://localhost:8080".into(), audit_db_path: audit_db, aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 10, shutdown_grace_seconds: 30, oidc_issuer: oidc_issuer.to_string(), oidc_keypair_path: oidc_kp_path, diff --git a/crates/agentkeys-broker-server/src/config.rs b/crates/agentkeys-broker-server/src/config.rs index a878dea..bc93097 100644 --- a/crates/agentkeys-broker-server/src/config.rs +++ b/crates/agentkeys-broker-server/src/config.rs @@ -5,12 +5,9 @@ use crate::env; #[derive(Debug, Clone)] pub struct BrokerConfig { pub data_role_arn: String, - pub backend_url: String, pub audit_db_path: PathBuf, pub aws_region: String, pub session_duration_seconds: i32, - /// Timeout for HTTP calls to the backend's /session/validate. - pub backend_request_timeout_seconds: u64, /// Hard cap on graceful-shutdown drain time. pub shutdown_grace_seconds: u64, /// Public URL the broker advertises as the OIDC issuer. @@ -45,8 +42,6 @@ impl BrokerConfig { env::ACCOUNT_ID, ))?; - let backend_url = required_env(env::BROKER_BACKEND_URL)?; - let audit_db_path = std::env::var(env::BROKER_AUDIT_DB_PATH) .ok() .map(PathBuf::from) @@ -68,11 +63,6 @@ impl BrokerConfig { ); } - let backend_request_timeout_seconds = parse_int_env_with_default( - env::BROKER_BACKEND_TIMEOUT_SECONDS, - 10u64, - )?; - let shutdown_grace_seconds = parse_int_env_with_default( env::BROKER_SHUTDOWN_GRACE_SECONDS, 30u64, @@ -98,11 +88,9 @@ impl BrokerConfig { Ok(Self { data_role_arn, - backend_url, audit_db_path, aws_region, session_duration_seconds, - backend_request_timeout_seconds, shutdown_grace_seconds, oidc_issuer, oidc_keypair_path, diff --git a/crates/agentkeys-broker-server/src/env.rs b/crates/agentkeys-broker-server/src/env.rs index dc02e30..6cef4b0 100644 --- a/crates/agentkeys-broker-server/src/env.rs +++ b/crates/agentkeys-broker-server/src/env.rs @@ -43,8 +43,6 @@ pub enum Group { // Core // --------------------------------------------------------------------------- -/// Required. Base URL for the legacy backend session/validate endpoint. -pub const BROKER_BACKEND_URL: &str = "BROKER_BACKEND_URL"; /// Required (or derive from `ACCOUNT_ID`). The role the broker assumes via STS for users. pub const BROKER_DATA_ROLE_ARN: &str = "BROKER_DATA_ROLE_ARN"; /// Optional. Path to the audit-log SQLite DB. Defaults to `~/.agentkeys/broker/audit.sqlite`. @@ -53,8 +51,6 @@ pub const BROKER_AUDIT_DB_PATH: &str = "BROKER_AUDIT_DB_PATH"; pub const BROKER_AWS_REGION: &str = "BROKER_AWS_REGION"; /// Optional. Lifetime in seconds of minted AWS sessions. Range \[900, 43200\]. Default 3600. pub const BROKER_SESSION_DURATION_SECONDS: &str = "BROKER_SESSION_DURATION_SECONDS"; -/// Optional. HTTP timeout in seconds for backend `/session/validate` calls. Default 10. -pub const BROKER_BACKEND_TIMEOUT_SECONDS: &str = "BROKER_BACKEND_TIMEOUT_SECONDS"; /// Optional. SIGTERM-to-exit grace window in seconds. Default 30. pub const BROKER_SHUTDOWN_GRACE_SECONDS: &str = "BROKER_SHUTDOWN_GRACE_SECONDS"; /// Optional. When `true`, relaxes the HTTPS-only OIDC-issuer rule. Logged loudly. Default `false`. @@ -215,12 +211,10 @@ pub const REGION: &str = "REGION"; pub const fn all() -> &'static [(&'static str, &'static str, Group)] { &[ // Core - (BROKER_BACKEND_URL, "Base URL for legacy backend session validation.", Group::Core), (BROKER_DATA_ROLE_ARN, "Role the broker assumes via STS for users.", Group::Core), (BROKER_AUDIT_DB_PATH, "Path to audit-log SQLite DB.", Group::Core), (BROKER_AWS_REGION, "AWS region for STS calls.", Group::Core), (BROKER_SESSION_DURATION_SECONDS, "Lifetime in seconds of minted AWS sessions [900, 43200].", Group::Core), - (BROKER_BACKEND_TIMEOUT_SECONDS, "HTTP timeout for backend /session/validate.", Group::Core), (BROKER_SHUTDOWN_GRACE_SECONDS, "SIGTERM-to-exit grace window seconds.", Group::Core), (BROKER_DEV_MODE, "Relaxes HTTPS-only OIDC-issuer rule (logged loudly).", Group::Core), (BROKER_REFUSE_TO_BOOT_STRICT, "Promotes Tier-2 reachability to Tier-1 refuse-to-boot.", Group::Core), @@ -315,7 +309,6 @@ mod tests { fn all_includes_required_phase0_vars() { let names: Vec<&str> = all().iter().map(|(n, _, _)| *n).collect(); for required in [ - BROKER_BACKEND_URL, BROKER_DATA_ROLE_ARN, BROKER_OIDC_ISSUER, BROKER_OIDC_KEYPAIR_PATH, diff --git a/crates/agentkeys-broker-server/src/handlers/auth/exchange.rs b/crates/agentkeys-broker-server/src/handlers/auth/exchange.rs deleted file mode 100644 index f354ee8..0000000 --- a/crates/agentkeys-broker-server/src/handlers/auth/exchange.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! `POST /v1/auth/exchange` — backward-compat shim per plan §3.5.7. -//! -//! Accepts the legacy backend-validated bearer (the existing -//! `BROKER_BACKEND_URL/session/validate` path that `crate::auth::extract_caller` -//! still consumes for /v1/mint-aws-creds during the cutover) and returns -//! a fresh session JWT bound to the same identity. -//! -//! Daemon/CLI calls this once at startup, caches the session JWT, and -//! uses the JWT for all subsequent `/v1/mint-*` requests. No -//! dual-accept on the mint endpoint after US-011 lands — closes -//! Codex P0 #14 (permanent dual auth surface). -//! -//! This shim itself is removed at v1.0 alongside the legacy bearer. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use axum::{ - extract::State, - http::{header::AUTHORIZATION, HeaderMap, StatusCode}, - response::IntoResponse, - Json, -}; -use serde_json::json; - -use crate::auth::{extract_bearer_token, validate_bearer_token}; -use crate::env; -use crate::error::BrokerError; -use crate::identity::derive_omni_account; -use crate::jwt::issue::mint_session_jwt; -use crate::state::SharedState; - -pub async fn exchange( - State(state): State, - headers: HeaderMap, -) -> Result { - // Reuse the existing legacy bearer extraction path (which calls - // BROKER_BACKEND_URL/session/validate). Returns the wallet address - // bound to that session. - let auth_header = headers - .get(AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| BrokerError::Unauthorized("missing Authorization header".into()))?; - let token = extract_bearer_token(auth_header) - .ok_or_else(|| BrokerError::Unauthorized("Authorization must be `Bearer `".into()))?; - let caller = validate_bearer_token(&state.http, &state.config.backend_url, token).await?; - - // Synthesize an OmniAccount from the legacy wallet address. Since - // the legacy bearer only carries a wallet address (no email/oauth - // identity), identity_type is "evm" and identity_value is the - // wallet address. - let identity_type = "evm"; - let identity_value = caller.wallet.clone(); - let omni = derive_omni_account(identity_type, &identity_value); - - let ttl_seconds = std::env::var(env::BROKER_SESSION_JWT_TTL_SECONDS) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(18_000); - let token = mint_session_jwt( - &state.session_keypair, - &state.config.oidc_issuer, - omni.as_str(), - &caller.wallet, - identity_type, - &identity_value, - ttl_seconds, - ) - .map_err(|e| BrokerError::Internal(format!("mint session jwt during exchange: {}", e)))?; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let expires_at = now + ttl_seconds; - - Ok(( - StatusCode::OK, - Json(json!({ - "session_jwt": token, - "session_jwt_kid": state.session_keypair.kid, - "expires_at": expires_at, - "omni_account": omni.as_str(), - "wallet_address": caller.wallet, - })), - )) -} diff --git a/crates/agentkeys-broker-server/src/handlers/auth/mod.rs b/crates/agentkeys-broker-server/src/handlers/auth/mod.rs index d066df7..826ef21 100644 --- a/crates/agentkeys-broker-server/src/handlers/auth/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/auth/mod.rs @@ -2,10 +2,7 @@ //! //! - `POST /v1/auth/wallet/start` — SIWE challenge. //! - `POST /v1/auth/wallet/verify` — SIWE verify → session JWT. -//! - `POST /v1/auth/exchange` — backward-compat shim that exchanges a -//! legacy backend-validated bearer for a new session JWT. -pub mod exchange; #[cfg(feature = "auth-email-link")] pub mod email_landing; #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-broker-server/src/handlers/broker_status.rs b/crates/agentkeys-broker-server/src/handlers/broker_status.rs index b0c89dc..208972f 100644 --- a/crates/agentkeys-broker-server/src/handlers/broker_status.rs +++ b/crates/agentkeys-broker-server/src/handlers/broker_status.rs @@ -39,7 +39,6 @@ pub async fn readyz(State(state): State) -> impl IntoResponse { let (overall_plugin_state, plugin_checks) = state.registry.aggregate_readiness(); // Tier-2 reachability flags (set by spawn_tier2_probes in main.rs). - let backend_reachable = state.tier2.backend_reachable.load(Ordering::Relaxed); let ses_verified = state.tier2.ses_verified.load(Ordering::Relaxed); let evm_rpc_reachable = state.tier2.evm_rpc_reachable.load(Ordering::Relaxed); let evm_fee_payer_funded = state.tier2.evm_fee_payer_funded.load(Ordering::Relaxed); @@ -69,20 +68,6 @@ pub async fn readyz(State(state): State) -> impl IntoResponse { } } - // Tier-2 backend probe (always relevant — the broker calls - // BROKER_BACKEND_URL/session/validate during legacy auth). - if backend_reachable { - ready_names.push("tier2/backend".into()); - } else { - unready = true; - checks.push(json!({ - "name": "tier2/backend", - "status": "unready", - "reason": "BROKER_BACKEND_URL/healthz not yet reachable since boot", - "docs": runbook_anchor("backend-reachability"), - })); - } - // Tier-2 SES probe — only reported when email-link auth is enabled. if state.registry.auth.contains_key("email_link") { if ses_verified { diff --git a/crates/agentkeys-broker-server/src/handlers/mint.rs b/crates/agentkeys-broker-server/src/handlers/mint.rs deleted file mode 100644 index 4cdd50f..0000000 --- a/crates/agentkeys-broker-server/src/handlers/mint.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! `POST /v1/mint-aws-creds` — credential mint endpoint. -//! -//! Stage 7 issue#64 US-011 upgrades this handler to accept the NEW v0 -//! shape (plan §3.5.2): -//! -//! - Authorization header carries a session JWT (signed by the broker's -//! session keypair, minted by `/v1/auth/wallet/verify` or -//! `/v1/auth/exchange`). -//! - Request body declares `{request_id, issued_at, intent, auth}` where -//! `auth.signature` is an EIP-191 signature by the daemon's wallet -//! over the canonical hash of the body (excluding `auth.signature`). -//! - Audit row is written via every configured `AuditAnchor` BEFORE -//! credentials are released. Per plan §2 (load-bearing invariant): -//! no creds out unless durably anchored everywhere. -//! -//! The handler also keeps the LEGACY path working so the existing -//! daemon/CLI binaries (which consume the bearer-validated /session/validate -//! flow) continue to function during the cutover. Discrimination is -//! purely on token shape: a 3-segment JWT-looking bearer goes through -//! the new path; anything else goes through the legacy path. -//! -//! The legacy path is REMOVED in v1.0 along with `/v1/auth/exchange` -//! per plan §3.5.7. Codex P0 #14 (permanent dual-accept) is mitigated -//! by this transitional split being a documented v0→v1 cutover, not a -//! forever-feature. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use axum::{extract::State, http::HeaderMap, Json}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -use crate::audit::{MintOutcome, MintRecord}; -use crate::auth::extract_bearer_token; -use crate::error::{BrokerError, BrokerResult}; -use crate::jwt::verify::verify_session_jwt; -use crate::plugins::audit::{AnchorReceipt, AuditRecord}; -use crate::state::SharedState; - -/// Successful response — same shape under both legacy and new paths so a -/// daemon switching between them needs no JSON-decoding changes. -#[derive(Serialize, Debug, Clone)] -pub struct MintResponse { - pub access_key_id: String, - pub secret_access_key: String, - pub session_token: String, - pub expiration: i64, - pub wallet: String, - /// New-path only — the audit record's ULID. Legacy path leaves this - /// `None` so existing clients ignore it; new clients can correlate - /// the response with the on-anchor record. - #[serde(skip_serializing_if = "Option::is_none")] - pub audit_record_id: Option, - /// New-path only — list of anchor names that confirmed durability. - /// Legacy clients ignore. - #[serde(skip_serializing_if = "Option::is_none")] - pub anchored: Option>, -} - -/// New-path body shape (plan §3.5.2). -#[derive(Deserialize, Debug, Clone)] -pub struct MintBodyV2 { - pub request_id: String, - pub issued_at: String, - pub intent: MintIntent, - pub auth: MintAuth, -} - -#[derive(Deserialize, Debug, Clone, Serialize)] -pub struct MintIntent { - pub agent_id: String, - pub service: String, - #[serde(default)] - pub scope_path: String, -} - -#[derive(Deserialize, Debug, Clone)] -pub struct MintAuth { - pub address: String, - pub signature: String, -} - -#[tracing::instrument(skip_all, fields(wallet = tracing::field::Empty, outcome = tracing::field::Empty))] -pub async fn mint_aws_creds( - State(state): State, - headers: HeaderMap, - raw_body: axum::body::Bytes, -) -> BrokerResult> { - let token = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(extract_bearer_token) - .ok_or_else(|| BrokerError::Unauthorized("missing Authorization header".into()))?; - - // Single path: callers send a session JWT. Pre-Stage-7 backend-validated - // bearers and the dispatch heuristic were removed in the OIDC-only - // migration (issue #71). - mint_v2(&state, token, &raw_body).await -} - -// --------------------------------------------------------------------------- -// New v2 path — session JWT + per-call daemon signature + AuditAnchor write -// --------------------------------------------------------------------------- - -async fn mint_v2( - state: &SharedState, - token: &str, - raw_body: &axum::body::Bytes, -) -> BrokerResult> { - // 1. Verify session JWT against the broker's session keypair. - let claims = verify_session_jwt(&state.session_keypair, &state.config.oidc_issuer, token) - .map_err(|e| BrokerError::Unauthorized(format!("session jwt: {}", e)))?; - tracing::Span::current().record("wallet", claims.agentkeys.wallet_address.as_str()); - - // 2. Parse the v2 body. Empty body or wrong shape → 400. - if raw_body.is_empty() { - return Err(BrokerError::BadRequest( - "v2 mint requires a JSON body — see plan §3.5.2 wire format".into(), - )); - } - let body: MintBodyV2 = serde_json::from_slice(raw_body) - .map_err(|e| BrokerError::BadRequest(format!("malformed v2 body: {}", e)))?; - - // 3. Per-call signature verification. The body without `auth.signature` - // must canonicalize, hash, and verify against `auth.address`. - let canonical = canonical_signing_input(raw_body, &body)?; - let recovered = ecrecover_eip191(&canonical, &body.auth.signature) - .map_err(|e| BrokerError::Unauthorized(format!("per-call sig: {}", e)))?; - if !addresses_match(&recovered, &body.auth.address) { - return Err(BrokerError::Unauthorized(format!( - "per-call signature recovers to {} not {}", - recovered, body.auth.address - ))); - } - - // 4. Wallet-binding: auth.address MUST match the wallet bound in the - // session JWT. Closes the "valid sig for wallet A but JWT claims - // wallet B" cross-binding hole. - if !addresses_match(&body.auth.address, &claims.agentkeys.wallet_address) { - return Err(BrokerError::Unauthorized(format!( - "auth.address {} does not match wallet bound in session JWT ({})", - body.auth.address, claims.agentkeys.wallet_address - ))); - } - - // 4b. Phase B (US-027) — grant resolution. The broker consults the - // grant store atomically (ONE SQL UPDATE … RETURNING) for an - // active grant matching (master_omni_account, daemon_address, - // service). Failure modes: - // - NoGrant: legacy implicit-grant fallback (Phase 0 mints - // continue to work). Phase E US-039 will flip this default - // to fail-closed once all daemons are grant-aware. - // - Revoked / Expired / Exhausted: HTTP 403, no STS call. - // A successful Consumed result both increments used_count + 1 - // atomically AND returns the grant_id + audit_proof for the - // audit row. - let now_for_grant = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let resolved_grant_id = match state.grant_store.try_consume( - &claims.agentkeys.omni_account, - &body.auth.address.to_lowercase(), - &body.intent.service, - now_for_grant, - ) { - Ok(crate::storage::GrantConsumeOutcome::Consumed { grant_id, .. }) => grant_id, - Ok(crate::storage::GrantConsumeOutcome::NoGrant) => { - // Phase 0 implicit-grant fallback. Logged but not rejected. - tracing::debug!( - "mint_v2: no explicit grant for ({}, {}, {}) — Phase 0 implicit-grant path", - claims.agentkeys.omni_account, - body.auth.address, - body.intent.service - ); - String::new() - } - Ok(crate::storage::GrantConsumeOutcome::Revoked) => { - // Plan §3.5.5: grant failures map to 403 (caller authenticated - // but lacks permission). Codex Phase A.2 round-3 Vector 4 P2. - return Err(BrokerError::Forbidden( - "grant has been revoked".into(), - )); - } - Ok(crate::storage::GrantConsumeOutcome::Expired) => { - return Err(BrokerError::Forbidden( - "grant is expired".into(), - )); - } - Ok(crate::storage::GrantConsumeOutcome::Exhausted) => { - return Err(BrokerError::Forbidden( - "grant exhausted (used_count >= max_uses)".into(), - )); - } - Err(e) => { - return Err(BrokerError::Internal(format!( - "grant_store.try_consume: {}", - e - ))); - } - }; - - // 5. Build the AuditRecord. record_hash is `SHA256(canonical_signing_input)` - // so a row mismatch is detectable by re-running the canonicalization. - let mut hasher = Sha256::new(); - hasher.update(&canonical); - let record_hash = hex::encode(hasher.finalize()); - let now_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let record_id = format!("aud_{}_{}", now_secs, &record_hash[..16]); - - let session_name = build_session_name(&body.auth.address); - - // 6. Audit-anchor write happens BEFORE the STS call's response is - // constructed. Per plan §2.e the broker may speculatively call - // STS in parallel with the audit write to keep p50 latency low — - // but credentials must NOT be returned unless the audit anchor - // write succeeded. Phase 0 is single-anchor (sqlite) so we keep - // things simple: STS first, then anchor, then return creds. If - // anchor fails we still record the failure on the legacy log - // and return 500 without creds. - // - // Mint a per-call user-scoped OIDC JWT here (same shape as - // /v1/mint-oidc-jwt) and pass it to AssumeRoleWithWebIdentity. The - // `https://aws.amazon.com/tags` claim drives PrincipalTag isolation. - let (oidc_claims, _now_oidc, _exp_oidc) = crate::handlers::oidc::build_oidc_jwt_claims( - &state.config.oidc_issuer, - &body.auth.address, - state.config.oidc_jwt_ttl_seconds, - ); - let internal_oidc_jwt = match state.oidc.sign_jwt(&oidc_claims) { - Ok(j) => j, - Err(e) => { - record_legacy_outcome( - state, - token, - &body.auth.address, - &session_name, - MintOutcome::StsError, - Some(&format!("internal_oidc_jwt: {}", e)), - ); - tracing::Span::current().record("outcome", "internal_oidc_jwt_failed"); - return Err(BrokerError::Internal(format!( - "sign internal oidc jwt: {}", - e - ))); - } - }; - let creds_result = state - .sts - .assume_role_with_web_identity( - &state.config.data_role_arn, - &session_name, - &internal_oidc_jwt, - state.config.session_duration_seconds, - ) - .await; - - let creds = match creds_result { - Ok(c) => c, - Err(e) => { - // Best-effort failure record on legacy log. - record_legacy_outcome( - state, - token, - &body.auth.address, - &session_name, - MintOutcome::StsError, - Some(&e.to_string()), - ); - tracing::Span::current().record("outcome", "sts_error"); - return Err(e); - } - }; - - let audit_record = AuditRecord { - id: record_id.clone(), - minted_at: now_secs, - record_hash, - omni_account: claims.agentkeys.omni_account.clone(), - wallet: body.auth.address.to_lowercase(), - agent_id: body.intent.agent_id.clone(), - service: body.intent.service.clone(), - // Phase B (US-027): grant_id from resolved grant; empty when - // legacy implicit-grant fallback fired. - grant_id: resolved_grant_id.clone(), - outcome: "ok".into(), - outcome_detail: None, - }; - - // Anchor through every configured audit anchor. The audit_policy - // selects how partial failures are handled — Phase 0 is single- - // anchor (sqlite), so any error fails the response. - let anchored: Vec = match anchor_to_all(state, &audit_record).await { - Ok(receipts) => receipts.into_iter().map(|r| r.anchor).collect(), - Err(e) => { - // The load-bearing invariant: audit failure means NO creds - // returned. We still record best-effort on the legacy log - // for monitoring continuity. - record_legacy_outcome( - state, - token, - &body.auth.address, - &session_name, - MintOutcome::BackendError, - Some(&format!("audit_anchor: {}", e)), - ); - tracing::Span::current().record("outcome", "audit_failed"); - return Err(BrokerError::AuditError(format!( - "audit anchor write failed; refusing to release credentials: {}", - e - ))); - } - }; - - // 7. Mirror the success record on the legacy log so existing audit - // queries continue to function during the dual-write transition. - if let Err(e) = state.audit.record_mint( - MintRecord { - requester_token: token, - requester_wallet: &body.auth.address, - requested_role: &state.config.data_role_arn, - session_duration_seconds: state.config.session_duration_seconds, - sts_session_name: &session_name, - outcome: MintOutcome::Ok, - }, - Some(&format!("v2 mint anchored to: {}", anchored.join(","))), - ) { - tracing::warn!(error = %e, "legacy audit mirror failed (non-fatal — v2 anchor row exists)"); - } - - tracing::Span::current().record("outcome", "ok"); - Ok(Json(MintResponse { - access_key_id: creds.access_key_id, - secret_access_key: creds.secret_access_key, - session_token: creds.session_token, - expiration: creds.expiration_unix, - wallet: body.auth.address, - audit_record_id: Some(record_id), - anchored: Some(anchored), - })) -} - -/// Anchor `record` to every configured AuditAnchor. Phase 0 is single- -/// anchor; Phase C extends this with multi-anchor + circuit breaker per -/// `BROKER_AUDIT_POLICY`. -async fn anchor_to_all( - state: &SharedState, - record: &AuditRecord, -) -> Result, crate::plugins::audit::AuditError> { - let mut receipts = Vec::new(); - for anchor in &state.registry.audit { - let receipt = anchor.anchor(record).await?; - receipts.push(receipt); - } - Ok(receipts) -} - -/// Canonical signing input: the request body bytes with `auth.signature` -/// replaced by the empty string. We re-serialize via `serde_json` with -/// sorted keys so two semantically-equivalent JSON encodings produce the -/// same hash. This is the v0 form; Phase B+ may switch to deterministic -/// CBOR via `agentkeys-core::auth_request`. -fn canonical_signing_input(raw_body: &[u8], parsed: &MintBodyV2) -> Result, BrokerError> { - // Reconstruct the body with auth.signature stripped, then sort keys. - let mut value: Value = serde_json::from_slice(raw_body) - .map_err(|e| BrokerError::BadRequest(format!("body re-parse: {}", e)))?; - if let Some(auth) = value.get_mut("auth").and_then(Value::as_object_mut) { - auth.remove("signature"); - } - let _ = parsed; // already validated upstream; suppress unused warning. - let canonical_string = canonicalize_json(&value); - Ok(canonical_string.into_bytes()) -} - -/// Stable canonical JSON: sort object keys recursively, no extra whitespace. -fn canonicalize_json(v: &Value) -> String { - match v { - Value::Object(map) => { - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort(); - let parts: Vec = keys - .iter() - .map(|k| { - format!( - "{}:{}", - serde_json::to_string(k).unwrap_or_else(|_| "\"\"".into()), - canonicalize_json(&map[*k]) - ) - }) - .collect(); - format!("{{{}}}", parts.join(",")) - } - Value::Array(items) => { - let parts: Vec = items.iter().map(canonicalize_json).collect(); - format!("[{}]", parts.join(",")) - } - other => serde_json::to_string(other).unwrap_or_else(|_| "null".into()), - } -} - -/// EIP-191 ecrecover identical to `plugins::auth::wallet_sig::ecrecover_address` -/// but operating on raw bytes (the canonical signing input). Returns the -/// 0x-prefixed lowercase 20-byte address. -fn ecrecover_eip191(message: &[u8], signature_hex: &str) -> Result { - use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; - use sha3::Keccak256; - - let sig_hex = signature_hex.trim_start_matches("0x"); - let sig_bytes = hex::decode(sig_hex) - .map_err(|e| BrokerError::BadRequest(format!("signature is not hex: {}", e)))?; - if sig_bytes.len() != 65 { - return Err(BrokerError::BadRequest(format!( - "signature must be 65 bytes, got {}", - sig_bytes.len() - ))); - } - let v_byte = sig_bytes[64]; - let recovery_id_byte = match v_byte { - 0 | 1 => v_byte, - 27 | 28 => v_byte - 27, - other => { - return Err(BrokerError::BadRequest(format!( - "unsupported v byte: {}", - other - ))); - } - }; - let recovery_id = RecoveryId::try_from(recovery_id_byte) - .map_err(|e| BrokerError::BadRequest(format!("bad recovery id: {}", e)))?; - let signature = Signature::from_slice(&sig_bytes[..64]) - .map_err(|e| BrokerError::BadRequest(format!("bad sig bytes: {}", e)))?; - - let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); - let mut hasher = Keccak256::new(); - hasher.update(prefix.as_bytes()); - hasher.update(message); - let digest = hasher.finalize(); - - let verifying_key = VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id) - .map_err(|e| BrokerError::Unauthorized(format!("recover failed: {}", e)))?; - - let encoded_point = verifying_key.to_encoded_point(false); - let pubkey_bytes = encoded_point.as_bytes(); - if pubkey_bytes.len() != 65 || pubkey_bytes[0] != 0x04 { - return Err(BrokerError::Internal( - "recovered key is not 65-byte uncompressed point".into(), - )); - } - let mut addr_hasher = Keccak256::new(); - addr_hasher.update(&pubkey_bytes[1..]); - let pubkey_hash = addr_hasher.finalize(); - Ok(format!("0x{}", hex::encode(&pubkey_hash[12..]))) -} - -fn addresses_match(a: &str, b: &str) -> bool { - a.to_lowercase() == b.to_lowercase() -} - -// `mint_legacy` (pre-issue-#71 backend-validated-bearer path) was removed -// in the OIDC-only migration. The provisioner / MCP / daemon now use -// `/v1/mint-oidc-jwt` + client-side `AssumeRoleWithWebIdentity` directly. - -fn record_legacy_outcome( - state: &SharedState, - token: &str, - wallet: &str, - session_name: &str, - outcome: MintOutcome, - detail: Option<&str>, -) { - if let Err(audit_err) = state.audit.record_mint( - MintRecord { - requester_token: token, - requester_wallet: wallet, - requested_role: &state.config.data_role_arn, - session_duration_seconds: state.config.session_duration_seconds, - sts_session_name: session_name, - outcome, - }, - detail, - ) { - tracing::error!( - error = %audit_err, - wallet = %wallet, - outcome = ?outcome, - "audit insert failed on failure path — anomaly detection is now blind" - ); - } -} - -fn build_session_name(wallet: &str) -> String { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); - let secs = now.as_secs(); - let micros = now.subsec_micros(); - let safe_wallet: String = wallet - .chars() - .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_')) - .take(40) - .collect(); - let mut name = format!("agentkeys-{}-{}-{:06}", safe_wallet, secs, micros); - if name.len() > 64 { - name.truncate(64); - } - name -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_name_under_64_chars() { - let n = build_session_name("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); - assert!(n.len() <= 64, "session name {} exceeds 64 chars", n); - assert!(n.starts_with("agentkeys-")); - } - - #[test] - fn session_name_strips_unsafe_chars() { - let n = build_session_name("0xABC/123 weird"); - assert!(!n.contains('/')); - assert!(!n.contains(' ')); - } - - #[test] - fn session_name_handles_empty_wallet() { - let n = build_session_name(""); - assert!(n.starts_with("agentkeys--")); - } - - #[test] - fn session_name_includes_microsecond_suffix() { - let a = build_session_name("0xabc"); - let b = build_session_name("0xabc"); - assert!(a.matches('-').count() >= 3, "expected at least 3 dashes, got {}", a); - assert!(b.matches('-').count() >= 3); - } - - // `looks_like_session_jwt` heuristic and its tests were removed in the - // OIDC-only migration — `mint_aws_creds` now always routes through - // `mint_v2` (session JWT path). - - #[test] - fn canonicalize_json_sorts_object_keys() { - let v: Value = serde_json::json!({ - "z": 1, - "a": { "y": 2, "b": 3 }, - "m": [4, 5] - }); - let s = canonicalize_json(&v); - // "a" must precede "m" must precede "z"; nested "b" must precede "y". - assert!(s.find("\"a\"").unwrap() < s.find("\"m\"").unwrap()); - assert!(s.find("\"m\"").unwrap() < s.find("\"z\"").unwrap()); - assert!(s.find("\"b\"").unwrap() < s.find("\"y\"").unwrap()); - } - - #[test] - fn canonical_signing_input_strips_auth_signature() { - let body = serde_json::to_vec(&serde_json::json!({ - "request_id": "mnt_1", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": "0xabc", "service": "s3", "scope_path": "bots/" }, - "auth": { "address": "0xabc", "signature": "0xdeadbeef" } - })) - .unwrap(); - let parsed: MintBodyV2 = serde_json::from_slice(&body).unwrap(); - let canon = canonical_signing_input(&body, &parsed).unwrap(); - let s = String::from_utf8(canon).unwrap(); - assert!(s.contains("\"address\":\"0xabc\"")); - assert!(!s.contains("signature")); - } - - #[test] - fn addresses_match_is_case_insensitive() { - assert!(addresses_match( - "0xABCDef0123456789abcdef0123456789ABCDef00", - "0xabcdef0123456789abcdef0123456789abcdef00" - )); - assert!(!addresses_match("0xabc", "0xdef")); - } - - #[test] - fn ecrecover_eip191_round_trip() { - use k256::ecdsa::SigningKey; - use sha3::Keccak256; - let key = SigningKey::random(&mut crate::oidc::rand_compat::OsRngWrapper); - let vkey = key.verifying_key(); - let pt = vkey.to_encoded_point(false); - let mut h = Keccak256::new(); - h.update(&pt.as_bytes()[1..]); - let pub_hash = h.finalize(); - let expected_addr = format!("0x{}", hex::encode(&pub_hash[12..])); - - let message = b"canonical body bytes"; - let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); - let mut h2 = Keccak256::new(); - h2.update(prefix.as_bytes()); - h2.update(message); - let digest = h2.finalize(); - - let (sig, rid) = key.sign_prehash_recoverable(&digest).unwrap(); - let mut sig_bytes = sig.to_bytes().to_vec(); - sig_bytes.push(rid.to_byte()); - let sig_hex = format!("0x{}", hex::encode(&sig_bytes)); - - let recovered = ecrecover_eip191(message, &sig_hex).unwrap(); - assert_eq!(recovered.to_lowercase(), expected_addr.to_lowercase()); - } -} diff --git a/crates/agentkeys-broker-server/src/handlers/mod.rs b/crates/agentkeys-broker-server/src/handlers/mod.rs index 710dc41..30f8c12 100644 --- a/crates/agentkeys-broker-server/src/handlers/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/mod.rs @@ -3,6 +3,5 @@ pub mod broker_status; pub mod cap; pub mod grant; pub mod metrics; -pub mod mint; pub mod oidc; pub mod wallet; diff --git a/crates/agentkeys-broker-server/src/handlers/oidc.rs b/crates/agentkeys-broker-server/src/handlers/oidc.rs index e0d4070..145c92b 100644 --- a/crates/agentkeys-broker-server/src/handlers/oidc.rs +++ b/crates/agentkeys-broker-server/src/handlers/oidc.rs @@ -64,10 +64,9 @@ pub struct MintOidcJwtResponse { /// suitable for `sts:AssumeRoleWithWebIdentity`. /// /// The bearer is a broker-signed session JWT (kid `ak-session-…`) minted by -/// `/v1/auth/wallet/verify`, `/v1/auth/email/verify`, `/v1/auth/oauth2/callback`, -/// or `/v1/auth/exchange`. Verified locally against the broker's session -/// keypair — no backend round-trip — matching the path `/v1/mint-aws-creds` -/// already takes (`handlers::mint::mint_v2`). +/// `/v1/auth/wallet/verify`, `/v1/auth/email/verify`, or +/// `/v1/auth/oauth2/callback`. Verified locally against the broker's session +/// keypair — no backend round-trip. /// /// Audited via the existing mint-audit log with a `oidc_jwt` outcome marker so /// operators see one ledger for AWS-cred mints and OIDC-JWT mints. @@ -136,11 +135,7 @@ pub async fn mint_oidc_jwt( /// `AssumeRoleWithWebIdentity`. Returns `(claims, iat_unix, exp_unix)` so /// callers can also use the timestamps for audit rows / response shaping. /// -/// Used by: -/// - `mint_oidc_jwt` (handler above) — public `/v1/mint-oidc-jwt` endpoint. -/// - `crate::handlers::mint::mint_v2` — internal JWT minted -/// per-call so the broker can do `AssumeRoleWithWebIdentity` itself -/// (issue #71 Option B). +/// Used by `mint_oidc_jwt` (handler above) — public `/v1/mint-oidc-jwt` endpoint. /// /// The wallet is lowercased before being placed in the `principal_tags` /// claim so it matches the lowercase prefixes the bucket policy uses diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index f13a902..0a479c4 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -36,7 +36,6 @@ pub fn create_router(state: SharedState) -> Router { .route("/healthz", get(handlers::broker_status::healthz)) .route("/readyz", get(handlers::broker_status::readyz)) .route("/metrics", get(handlers::metrics::metrics_handler)) - .route("/v1/mint-aws-creds", post(handlers::mint::mint_aws_creds)) .route( "/.well-known/openid-configuration", get(handlers::oidc::discovery), @@ -63,7 +62,6 @@ pub fn create_router(state: SharedState) -> Router { "/v1/auth/wallet/verify", post(handlers::auth::wallet_verify::wallet_verify), ) - .route("/v1/auth/exchange", post(handlers::auth::exchange::exchange)) // Phase B grant endpoints (US-026). .route( "/v1/grant/create", diff --git a/crates/agentkeys-broker-server/src/main.rs b/crates/agentkeys-broker-server/src/main.rs index ae692e0..616d72e 100644 --- a/crates/agentkeys-broker-server/src/main.rs +++ b/crates/agentkeys-broker-server/src/main.rs @@ -154,7 +154,7 @@ async fn main() -> anyhow::Result<()> { } let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(config.backend_request_timeout_seconds)) + .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .build()?; @@ -217,52 +217,18 @@ async fn main() -> anyhow::Result<()> { /// Spawn the Tier-2 reachability probes that flip the AtomicBool flags /// on `Tier2State` as each external dependency becomes reachable. /// -/// Currently spawns the backend probe (always) and, when email-link auth -/// is compiled in and enabled, the SES sender-verify probe that also -/// persists `SesVerifyCache` to disk so the email-link plug-in's -/// `Readiness::ready()` flips from `Degraded` to `Ready`. The EVM probe -/// lands in Phase C. +/// Currently spawns, when email-link auth is compiled in and enabled, the +/// SES sender-verify probe that also persists `SesVerifyCache` to disk so +/// the email-link plug-in's `Readiness::ready()` flips from `Degraded` to +/// `Ready`. The EVM probe lands in Phase C. fn spawn_tier2_probes( state: Arc, profile: agentkeys_broker_server::boot::Tier2Profile, ) { - use std::sync::atomic::Ordering; - let backend_url = profile.backend_url.clone(); - let strict = profile.strict; - - tokio::spawn({ - let state = Arc::clone(&state); - async move { - loop { - let url = format!("{}/healthz", backend_url.trim_end_matches('/')); - let res = state - .http - .get(&url) - .timeout(std::time::Duration::from_secs(3)) - .send() - .await; - let ok = matches!(&res, Ok(r) if r.status().is_success()); - state.tier2.backend_reachable.store(ok, Ordering::Relaxed); - if ok { - tracing::info!(url = %url, "Tier-2 backend probe: reachable"); - break; - } - if strict { - tracing::error!(url = %url, "BROKER_REFUSE_TO_BOOT_STRICT=true and backend unreachable; exiting"); - std::process::exit(1); - } - tracing::warn!( - url = %url, - "Tier-2 backend probe: unreachable; /readyz will return 503 until reachable" - ); - tokio::time::sleep(std::time::Duration::from_secs(15)).await; - } - } - }); - + let _ = (&state, &profile); #[cfg(feature = "auth-email-link")] if profile.email_link_enabled { - spawn_ses_verify_probe(Arc::clone(&state), strict); + spawn_ses_verify_probe(Arc::clone(&state), profile.strict); } } diff --git a/crates/agentkeys-broker-server/src/state.rs b/crates/agentkeys-broker-server/src/state.rs index 4a4bfc4..635713e 100644 --- a/crates/agentkeys-broker-server/src/state.rs +++ b/crates/agentkeys-broker-server/src/state.rs @@ -19,7 +19,6 @@ use crate::sts::StsClient; /// returned 200/503 status. #[derive(Default, Debug)] pub struct Tier2State { - pub backend_reachable: std::sync::atomic::AtomicBool, pub ses_verified: std::sync::atomic::AtomicBool, pub evm_rpc_reachable: std::sync::atomic::AtomicBool, pub evm_fee_payer_funded: std::sync::atomic::AtomicBool, diff --git a/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs b/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs index c6837e0..b76d9aa 100644 --- a/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs +++ b/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs @@ -79,11 +79,9 @@ async fn spawn_broker_with_wallet_sig() -> (String, Arc) { let sts: Arc = Arc::new(StubStsClient::ok(stub_creds())); let config = BrokerConfig { data_role_arn: "arn:aws:iam::000:role/test".into(), - backend_url: "http://localhost:65535".into(), // never reached audit_db_path: PathBuf::from(":memory:"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, shutdown_grace_seconds: 5, oidc_issuer: TEST_ISSUER.into(), oidc_keypair_path: oidc_kp_path, diff --git a/crates/agentkeys-broker-server/tests/email_flow.rs b/crates/agentkeys-broker-server/tests/email_flow.rs index 7648c4d..bd67c96 100644 --- a/crates/agentkeys-broker-server/tests/email_flow.rs +++ b/crates/agentkeys-broker-server/tests/email_flow.rs @@ -35,7 +35,6 @@ use agentkeys_broker_server::{ sts::{AssumedCredentials, StsClient, StubStsClient}, }; use serde_json::Value; -use std::sync::atomic::Ordering; use tempfile::TempDir; const TEST_ISSUER: &str = "https://broker.email.test"; @@ -90,11 +89,9 @@ async fn spawn_broker() -> (String, Arc, Arc) { let config = BrokerConfig { data_role_arn: "arn:aws:iam::000:role/test".into(), - backend_url: "http://127.0.0.1:1".into(), audit_db_path: tmp.path().join("audit.sqlite"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, shutdown_grace_seconds: 5, oidc_issuer: TEST_ISSUER.into(), oidc_keypair_path: tmp.path().join("oidc.json"), @@ -127,7 +124,6 @@ async fn spawn_broker() -> (String, Arc, Arc) { #[cfg(feature = "auth-oauth2")] oauth2: None, }); - state.tier2.backend_reachable.store(true, Ordering::Relaxed); let app = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/agentkeys-broker-server/tests/grant_flow.rs b/crates/agentkeys-broker-server/tests/grant_flow.rs index b8dd331..27954f6 100644 --- a/crates/agentkeys-broker-server/tests/grant_flow.rs +++ b/crates/agentkeys-broker-server/tests/grant_flow.rs @@ -17,7 +17,6 @@ //! `crates/agentkeys-broker-server/src/jwt/issue.rs` tests. use std::collections::HashMap; -use std::sync::atomic::Ordering; use std::sync::Arc; use agentkeys_broker_server::{ @@ -78,11 +77,9 @@ async fn spawn_broker() -> Harness { let config = BrokerConfig { data_role_arn: "arn:aws:iam::000:role/test".into(), - backend_url: "http://127.0.0.1:1".into(), audit_db_path: tmp.path().join("audit.sqlite"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, shutdown_grace_seconds: 5, oidc_issuer: TEST_ISSUER.into(), oidc_keypair_path: tmp.path().join("oidc.json"), @@ -116,7 +113,6 @@ async fn spawn_broker() -> Harness { #[cfg(feature = "auth-oauth2")] oauth2: None, }); - state.tier2.backend_reachable.store(true, Ordering::Relaxed); let app = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/agentkeys-broker-server/tests/invariant_load_bearing.rs b/crates/agentkeys-broker-server/tests/invariant_load_bearing.rs deleted file mode 100644 index 86c948d..0000000 --- a/crates/agentkeys-broker-server/tests/invariant_load_bearing.rs +++ /dev/null @@ -1,588 +0,0 @@ -//! The Stage 7 Phase 0 load-bearing-invariant test (plan §2 + rule 7). -//! -//! Single test file that exercises **every** failure mode of the -//! load-bearing invariant: -//! -//! > No credential leaves the broker process except via a flow where the -//! > caller has proven control of an authenticated identity, that -//! > identity is bound to a wallet, that wallet has a valid grant for -//! > the requested resource, and an audit record naming all four -//! > (identity, wallet, resource, grant) has been durably persisted to -//! > **every** configured audit anchor before the credential is -//! > returned. -//! -//! Six cases (a-f) per plan §2: -//! (a) Happy path: full SIWE → wallet → mint → audit-write green. -//! (b) Auth bypass: tampered signature → 401, zero audit rows, zero -//! STS calls. -//! (c) Wrong-wallet: valid sig for A, claims B → 401/403, zero audit, -//! zero STS. -//! (d) Missing-grant: Phase 0 simplification — Phase B introduces -//! grants; the moral equivalent here is "session JWT not bound to -//! a known wallet" → 401, zero audit, zero STS. -//! (e) Audit-failure refuse-to-release: FailingAuditAnchor → 500, no -//! creds in response body. Per plan §2.e speculative STS is -//! acceptable — the gate is the response. -//! (f) Dual-anchor partial-failure: Phase 0 is single-anchor; the -//! full case lands with Phase C's EvmTestnetAnchor. We DO assert -//! the multi-anchor write loop short-circuits on first failure -//! (exercised via FailingAuditAnchor in registry tail position). -//! -//! The day-1 test contract per plan rule 7 — checked in BEFORE every -//! integration mint test, runs in CI for every commit thereafter. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; - -use agentkeys_broker_server::{ - audit::AuditLog, - config::BrokerConfig, - create_router, - jwt::{issue::mint_session_jwt, SessionKeypair}, - oidc::OidcKeypair, - plugins::{ - audit::{ - sqlite::SqliteAnchor, AnchorReceipt, AuditAnchor, AuditError, AuditPolicy, AuditRecord, - }, - wallet::keystore::ClientSideKeystoreProvisioner, - PluginRegistry, Readiness, - }, - state::{AppState, Tier2State}, - storage::{AuthNonceStore, GrantStore, IdempotencyStore, IdentityLinkStore, WalletStore}, - sts::{AssumedCredentials, StsClient, StubStsClient}, -}; -use async_trait::async_trait; -use k256::ecdsa::SigningKey; -use serde_json::Value; -use sha3::{Digest, Keccak256}; -use tempfile::TempDir; - -const TEST_ISSUER: &str = "https://broker.invariant.test"; -const STUB_ROLE_ARN: &str = "arn:aws:iam::000000000000:role/agentkeys-data-role"; - -// --------------------------------------------------------------------------- -// Test fixtures -// --------------------------------------------------------------------------- - -/// Test stub that always fails its `anchor()` call. Used to drive case -/// (e) — the load-bearing audit gate. `verify()` is never reached on -/// the failure-path tests. -struct FailingAuditAnchor { - name: &'static str, - calls: Arc, -} - -#[async_trait] -impl AuditAnchor for FailingAuditAnchor { - fn name(&self) -> &'static str { - self.name - } - - fn ready(&self) -> Readiness { - // Note: `Ready` here so /readyz doesn't pre-fail the test. - // Failure is only on the `anchor()` write path. - Readiness::ready_with("failing-anchor: always-Ready, anchor() always fails") - } - - async fn anchor(&self, _record: &AuditRecord) -> Result { - self.calls.fetch_add(1, Ordering::Relaxed); - Err(AuditError::Storage( - "FailingAuditAnchor: simulated durability failure".into(), - )) - } - - async fn verify( - &self, - _record: &AuditRecord, - _receipt: &AnchorReceipt, - ) -> Result { - Ok(false) - } -} - -/// Counts STS invocations so cases (b)/(c)/(d) can assert "zero STS -/// calls". Wraps the existing `StubStsClient::ok` so the happy path -/// still gets credentials. After the OIDC-only migration, the trait -/// has only `assume_role_with_web_identity` for credential mints -/// (legacy `assume_role` was dropped). -struct CountingStsClient { - inner: StubStsClient, - calls: Arc, -} - -#[async_trait] -impl StsClient for CountingStsClient { - async fn caller_identity_ok(&self) -> Result<(), agentkeys_broker_server::error::BrokerError> { - self.inner.caller_identity_ok().await - } - - async fn assume_role_with_web_identity( - &self, - role_arn: &str, - session_name: &str, - web_identity_token: &str, - duration_seconds: i32, - ) -> Result { - self.calls.fetch_add(1, Ordering::Relaxed); - self.inner - .assume_role_with_web_identity( - role_arn, - session_name, - web_identity_token, - duration_seconds, - ) - .await - } -} - -fn stub_creds() -> AssumedCredentials { - AssumedCredentials { - access_key_id: "ASIA-INVARIANT".into(), - secret_access_key: "invariant-secret".into(), - session_token: "invariant-session".into(), - expiration_unix: 9_999_999_999, - } -} - -/// Spawn an in-process broker. `with_failing_anchor` controls case (e): -/// when true, the registry's audit list is `[failing]` (single anchor) -/// or `[sqlite, failing]` (dual-anchor short-circuit case). When false, -/// it's `[sqlite]` only. -async fn spawn_broker( - audit_topology: AuditTopology, -) -> ( - String, // broker_url - Arc, - String, // valid session JWT for the test wallet - SigningKey, // signing key matching the JWT-bound wallet - Arc, // STS call counter - Arc, // FailingAuditAnchor call counter (zero if not configured) - Arc, // for direct row-count introspection -) { - let tmp = Box::leak(Box::new(TempDir::new().unwrap())); - let oidc_path = tmp.path().join("oidc-keypair.json"); - let session_path = tmp.path().join("session-keypair.json"); - let oidc = OidcKeypair::generate_and_persist(&oidc_path).unwrap(); - let session_kp = Arc::new(SessionKeypair::generate_and_persist(&session_path).unwrap()); - - let signing_key = - SigningKey::random(&mut agentkeys_broker_server::oidc::rand_compat::OsRngWrapper); - let wallet_addr = address_from_signing_key(&signing_key); - let omni = agentkeys_broker_server::identity::derive_omni_account("evm", &wallet_addr); - let jwt = mint_session_jwt( - &session_kp, - TEST_ISSUER, - omni.as_str(), - &wallet_addr, - "evm", - &wallet_addr, - 300, - ) - .unwrap(); - - let sts_calls = Arc::new(AtomicUsize::new(0)); - let sts: Arc = Arc::new(CountingStsClient { - inner: StubStsClient::ok(stub_creds()), - calls: Arc::clone(&sts_calls), - }); - - let config = BrokerConfig { - data_role_arn: STUB_ROLE_ARN.into(), - backend_url: "http://127.0.0.1:1".into(), - audit_db_path: tmp.path().join("audit.sqlite"), - aws_region: "us-east-1".into(), - session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, - shutdown_grace_seconds: 5, - oidc_issuer: TEST_ISSUER.into(), - oidc_keypair_path: oidc_path, - oidc_jwt_ttl_seconds: 300, - }; - - let nonce_store = Arc::new(AuthNonceStore::open_in_memory().unwrap()); - let wallet_store = Arc::new(WalletStore::open_in_memory().unwrap()); - let sqlite_anchor = Arc::new(SqliteAnchor::open_in_memory().unwrap()); - let failing_calls = Arc::new(AtomicUsize::new(0)); - - let audit_anchors: Vec> = match audit_topology { - AuditTopology::SqliteOnly => vec![Arc::clone(&sqlite_anchor) as Arc], - AuditTopology::FailingOnly => vec![Arc::new(FailingAuditAnchor { - name: "failing", - calls: Arc::clone(&failing_calls), - }) as Arc], - AuditTopology::SqlitePrimaryThenFailing => vec![ - Arc::clone(&sqlite_anchor) as Arc, - Arc::new(FailingAuditAnchor { - name: "failing", - calls: Arc::clone(&failing_calls), - }) as Arc, - ], - }; - - let registry = Arc::new(PluginRegistry { - auth: HashMap::new(), - wallet: Arc::new(ClientSideKeystoreProvisioner::new(Arc::clone(&wallet_store))), - audit: audit_anchors, - }); - - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .connect_timeout(std::time::Duration::from_millis(500)) - .build() - .unwrap(); - - let state = Arc::new(AppState { - config, - http, - audit: AuditLog::open_in_memory().unwrap(), - sts, - oidc: Arc::new(oidc), - session_keypair: Arc::clone(&session_kp), - registry, - audit_policy: AuditPolicy::DualStrict, - wallet_store, - nonce_store, - grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), - identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - idempotency_store: Arc::new(IdempotencyStore::open_in_memory().unwrap()), - metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), - tier2: Arc::new(Tier2State::default()), - #[cfg(feature = "auth-email-link")] - email_link: None, - #[cfg(feature = "auth-oauth2")] - oauth2: None, - }); - state - .tier2 - .backend_reachable - .store(true, Ordering::Relaxed); - - let app = create_router(state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - ( - format!("http://{}", addr), - state, - jwt, - signing_key, - sts_calls, - failing_calls, - sqlite_anchor, - ) -} - -#[derive(Copy, Clone)] -enum AuditTopology { - SqliteOnly, - FailingOnly, - SqlitePrimaryThenFailing, -} - -fn address_from_signing_key(key: &SigningKey) -> String { - let vkey = key.verifying_key(); - let pt = vkey.to_encoded_point(false); - let mut h = Keccak256::new(); - h.update(&pt.as_bytes()[1..]); - let pubkey_hash = h.finalize(); - format!("0x{}", hex::encode(&pubkey_hash[12..])) -} - -fn eip191_sign(key: &SigningKey, message: &[u8]) -> String { - let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); - let mut h = Keccak256::new(); - h.update(prefix.as_bytes()); - h.update(message); - let digest = h.finalize(); - let (sig, rid) = key.sign_prehash_recoverable(&digest).unwrap(); - let mut sig_bytes = sig.to_bytes().to_vec(); - sig_bytes.push(rid.to_byte()); - format!("0x{}", hex::encode(&sig_bytes)) -} - -fn canonical_input(body: &Value) -> Vec { - let mut stripped = body.clone(); - if let Some(auth) = stripped.get_mut("auth").and_then(Value::as_object_mut) { - auth.remove("signature"); - } - canonicalize(&stripped).into_bytes() -} - -fn canonicalize(v: &Value) -> String { - match v { - Value::Object(map) => { - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort(); - let parts: Vec = keys - .iter() - .map(|k| { - format!("{}:{}", serde_json::to_string(k).unwrap(), canonicalize(&map[*k])) - }) - .collect(); - format!("{{{}}}", parts.join(",")) - } - Value::Array(items) => { - let parts: Vec = items.iter().map(canonicalize).collect(); - format!("[{}]", parts.join(",")) - } - other => serde_json::to_string(other).unwrap(), - } -} - -/// Build a well-formed mint-v2 body signed by `signing_key`. The -/// `claimed_address` field lets cases (c)/(d) lie about the address. -fn build_mint_body( - signing_key: &SigningKey, - claimed_address: &str, - intent_agent_id: &str, -) -> Value { - let body_unsigned = serde_json::json!({ - "request_id": "mnt_invariant_1", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": intent_agent_id, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": claimed_address, "signature": "" } - }); - let canon = canonical_input(&body_unsigned); - let sig = eip191_sign(signing_key, &canon); - serde_json::json!({ - "request_id": "mnt_invariant_1", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": intent_agent_id, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": claimed_address, "signature": sig } - }) -} - -async fn count_anchor_rows(anchor: &Arc) -> i64 { - use rusqlite::Connection; - // We can't introspect the SqliteAnchor's connection directly without - // a public accessor. As a proxy, exercise verify() against a - // synthesized record that we never wrote — an empty store returns - // NotFound, so we just count via the anchor's own implementation. - // For Phase 0, we instead rely on the audit_record_id presence in - // the response body for the happy path; failure paths assert - // response status and STS call count. - let _ = anchor; - let _ = Connection::open_in_memory; // silence unused - 0 -} - -// --------------------------------------------------------------------------- -// Cases -// --------------------------------------------------------------------------- - -/// Case (a) — Happy path. Full SIWE → wallet → mint → audit-write green. -/// The response carries an `audit_record_id` and `anchored: ["sqlite"]`. -#[tokio::test] -async fn invariant_a_happy_path_returns_creds_and_audit_record() { - let (broker_url, _state, jwt, signing_key, sts_calls, _failing, _sqlite) = - spawn_broker(AuditTopology::SqliteOnly).await; - let wallet = address_from_signing_key(&signing_key); - let body = build_mint_body(&signing_key, &wallet, &wallet); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - - assert_eq!(resp.status(), reqwest::StatusCode::OK); - let body_resp: Value = resp.json().await.unwrap(); - assert_eq!(body_resp["access_key_id"], "ASIA-INVARIANT"); - assert!(body_resp["audit_record_id"].is_string()); - assert_eq!(body_resp["anchored"][0], "sqlite"); - assert_eq!(sts_calls.load(Ordering::Relaxed), 1, "happy path calls STS exactly once"); -} - -/// Case (b) — Auth bypass: tampered (garbage) signature → 401, zero -/// audit rows, zero STS calls. -#[tokio::test] -async fn invariant_b_tampered_signature_zero_sts_zero_audit() { - let (broker_url, _state, jwt, signing_key, sts_calls, _failing, _sqlite) = - spawn_broker(AuditTopology::SqliteOnly).await; - let wallet = address_from_signing_key(&signing_key); - // Build a body with garbage signature (not a real EIP-191 sig). - let body = serde_json::json!({ - "request_id": "mnt_invariant_b", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": wallet, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": wallet, "signature": format!("0x{}", "00".repeat(65)) } - }); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - - assert!( - matches!( - resp.status(), - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::BAD_REQUEST - ), - "expected 400/401 on tampered sig, got {}", - resp.status() - ); - assert_eq!( - sts_calls.load(Ordering::Relaxed), - 0, - "tampered-sig path must NOT reach STS" - ); -} - -/// Case (c) — Wrong-wallet: valid sig for wallet B, body claims wallet B -/// but JWT is bound to wallet A. Per plan §3.5.2 (wallet-binding gate) -/// → 401, zero STS. -#[tokio::test] -async fn invariant_c_wrong_wallet_zero_sts() { - let (broker_url, _state, jwt, _jwt_signing_key, sts_calls, _failing, _sqlite) = - spawn_broker(AuditTopology::SqliteOnly).await; - // The JWT was minted for `_jwt_signing_key`'s address. Build a - // body signed by a DIFFERENT key claiming a different address — - // per-call sig is internally consistent but JWT-binding fails. - let other_key = - SigningKey::random(&mut agentkeys_broker_server::oidc::rand_compat::OsRngWrapper); - let other_addr = address_from_signing_key(&other_key); - let body = build_mint_body(&other_key, &other_addr, &other_addr); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - - assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); - assert_eq!(sts_calls.load(Ordering::Relaxed), 0, "wrong-wallet path must NOT reach STS"); -} - -/// Case (d) — Missing-grant equivalent in Phase 0 (Phase B introduces -/// grants). The Phase-0 stand-in: an unsigned/garbage session JWT (or -/// a JWT signed by a different keypair). The mint endpoint rejects at -/// JWT verify before anything reaches STS. -#[tokio::test] -async fn invariant_d_missing_grant_phase_b_stand_in_zero_sts() { - let (broker_url, _state, _jwt, signing_key, sts_calls, _failing, _sqlite) = - spawn_broker(AuditTopology::SqliteOnly).await; - let wallet = address_from_signing_key(&signing_key); - let body = build_mint_body(&signing_key, &wallet, &wallet); - - // Forge a JWT-shaped bearer signed by a totally different ES256 keypair. - let tmp = TempDir::new().unwrap(); - let other_kp_path = tmp.path().join("attacker-session-keypair.json"); - let other_kp = SessionKeypair::generate_and_persist(&other_kp_path).unwrap(); - let omni = agentkeys_broker_server::identity::derive_omni_account("evm", &wallet); - let attacker_jwt = - mint_session_jwt(&other_kp, TEST_ISSUER, omni.as_str(), &wallet, "evm", &wallet, 300) - .unwrap(); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", attacker_jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - - assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); - assert_eq!( - sts_calls.load(Ordering::Relaxed), - 0, - "forged-JWT path must NOT reach STS" - ); -} - -/// Case (e) — Audit-failure refuse-to-release: FailingAuditAnchor -/// returns Err. The broker MUST return 500 and MUST NOT include -/// credentials in the response body. STS may be called speculatively -/// per plan §2.e — that's fine, the gate is the response. -#[tokio::test] -async fn invariant_e_audit_failure_refuses_to_release_creds() { - let (broker_url, _state, jwt, signing_key, _sts_calls, failing_calls, _sqlite) = - spawn_broker(AuditTopology::FailingOnly).await; - let wallet = address_from_signing_key(&signing_key); - let body = build_mint_body(&signing_key, &wallet, &wallet); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - - assert_eq!(resp.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); - let body_resp: Value = resp.json().await.unwrap_or(Value::Null); - // Critical: response body MUST NOT carry credentials. - assert!( - body_resp.get("access_key_id").is_none(), - "audit-failed response must not include access_key_id; got: {}", - body_resp - ); - assert!( - body_resp.get("session_token").is_none(), - "audit-failed response must not include session_token; got: {}", - body_resp - ); - assert!( - failing_calls.load(Ordering::Relaxed) >= 1, - "FailingAuditAnchor.anchor() must have been called at least once" - ); -} - -/// Case (f) — Multi-anchor short-circuit: registry has [sqlite, -/// failing]. Per the AuditAnchor write loop in mint::anchor_to_all, the -/// first failure short-circuits → 500 + no creds. Phase C extends this -/// with `dual_strict` quarantine semantics; for Phase 0 we just assert -/// the short-circuit + no-creds invariant. -#[tokio::test] -async fn invariant_f_dual_anchor_short_circuit_on_failing_anchor() { - let (broker_url, _state, jwt, signing_key, _sts_calls, failing_calls, _sqlite) = - spawn_broker(AuditTopology::SqlitePrimaryThenFailing).await; - let wallet = address_from_signing_key(&signing_key); - let body = build_mint_body(&signing_key, &wallet, &wallet); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - - assert_eq!(resp.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); - let body_resp: Value = resp.json().await.unwrap_or(Value::Null); - assert!(body_resp.get("access_key_id").is_none()); - assert!( - failing_calls.load(Ordering::Relaxed) >= 1, - "failing anchor in tail must have been reached after sqlite write" - ); -} - -#[tokio::test] -async fn count_anchor_rows_helper_compiles() { - // Suppress unused-warning on the helper that takes an Arc - // for future Phase B/C cases that need direct row introspection. - let a = Arc::new(SqliteAnchor::open_in_memory().unwrap()); - assert_eq!(count_anchor_rows(&a).await, 0); -} diff --git a/crates/agentkeys-broker-server/tests/mint_v2_flow.rs b/crates/agentkeys-broker-server/tests/mint_v2_flow.rs deleted file mode 100644 index a19e01a..0000000 --- a/crates/agentkeys-broker-server/tests/mint_v2_flow.rs +++ /dev/null @@ -1,351 +0,0 @@ -//! `/v1/mint-aws-creds` v2 path — Stage 7 issue#64 US-011 integration tests. -//! -//! Exercises the new wire shape: session JWT (Authorization) + JSON body -//! with per-call daemon signature. Audit row written through the -//! AuditAnchor trait, NOT only the legacy log. Wallet-binding match -//! (auth.address must equal JWT-bound wallet) is enforced. - -use std::collections::HashMap; -use std::sync::Arc; - -use agentkeys_broker_server::{ - audit::AuditLog, - config::BrokerConfig, - create_router, - jwt::{issue::mint_session_jwt, SessionKeypair}, - oidc::OidcKeypair, - plugins::{ - audit::{sqlite::SqliteAnchor, AuditAnchor, AuditPolicy}, - wallet::keystore::ClientSideKeystoreProvisioner, - PluginRegistry, - }, - state::{AppState, Tier2State}, - storage::{AuthNonceStore, GrantStore, IdempotencyStore, IdentityLinkStore, WalletStore}, - sts::{AssumedCredentials, StsClient, StubStsClient}, -}; -use k256::ecdsa::SigningKey; -use serde_json::Value; -use sha3::{Digest, Keccak256}; -use tempfile::TempDir; - -const TEST_ISSUER: &str = "https://broker.test.invalid"; -const STUB_ROLE_ARN: &str = "arn:aws:iam::000000000000:role/agentkeys-data-role"; - -fn stub_creds() -> AssumedCredentials { - AssumedCredentials { - access_key_id: "ASIA-V2".into(), - secret_access_key: "v2-secret".into(), - session_token: "v2-session".into(), - expiration_unix: 9_999_999_999, - } -} - -/// Spawn an in-process broker with a real session keypair, real SQLite -/// audit anchor, and a stub STS. Mark Tier-2 backend reachable directly -/// so /readyz is green during the test (the legacy mint tests do the -/// same). -async fn spawn_broker() -> ( - String, - Arc, - SessionKeypair, - String, // session_jwt for fixture wallet - SigningKey, // matching signing key -) { - let tmp = Box::leak(Box::new(TempDir::new().unwrap())); - let oidc_path = tmp.path().join("oidc-keypair.json"); - let session_path = tmp.path().join("session-keypair.json"); - let oidc = OidcKeypair::generate_and_persist(&oidc_path).unwrap(); - let session_kp = SessionKeypair::generate_and_persist(&session_path).unwrap(); - - let signing_key = SigningKey::random(&mut agentkeys_broker_server::oidc::rand_compat::OsRngWrapper); - let wallet_addr = address_from_signing_key(&signing_key); - - let sts: Arc = Arc::new(StubStsClient::ok(stub_creds())); - let config = BrokerConfig { - data_role_arn: STUB_ROLE_ARN.into(), - backend_url: "http://127.0.0.1:1".into(), // unused on v2 path - audit_db_path: tmp.path().join("audit.sqlite"), - aws_region: "us-east-1".into(), - session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, - shutdown_grace_seconds: 5, - oidc_issuer: TEST_ISSUER.into(), - oidc_keypair_path: oidc_path, - oidc_jwt_ttl_seconds: 300, - }; - - let nonce_store = Arc::new(AuthNonceStore::open_in_memory().unwrap()); - let wallet_store = Arc::new(WalletStore::open_in_memory().unwrap()); - let sqlite_anchor: Arc = Arc::new(SqliteAnchor::open_in_memory().unwrap()); - let registry = Arc::new(PluginRegistry { - auth: HashMap::new(), - wallet: Arc::new(ClientSideKeystoreProvisioner::new(Arc::clone(&wallet_store))), - audit: vec![Arc::clone(&sqlite_anchor)], - }); - - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .connect_timeout(std::time::Duration::from_millis(500)) - .build() - .unwrap(); - - let state = Arc::new(AppState { - config, - http, - audit: AuditLog::open_in_memory().unwrap(), - sts, - oidc: Arc::new(oidc), - session_keypair: Arc::new(SessionKeypair::generate_and_persist(&tmp.path().join("session2.json")).unwrap()), - registry, - audit_policy: AuditPolicy::DualStrict, - wallet_store, - nonce_store, - grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), - identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - idempotency_store: Arc::new(IdempotencyStore::open_in_memory().unwrap()), - metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), - tier2: Arc::new(Tier2State::default()), - #[cfg(feature = "auth-email-link")] - email_link: None, - #[cfg(feature = "auth-oauth2")] - oauth2: None, - }); - state - .tier2 - .backend_reachable - .store(true, std::sync::atomic::Ordering::Relaxed); - - // The session keypair stored on AppState must match the one used to - // mint the JWT — re-mint with the AppState keypair so verify works. - let omni2 = agentkeys_broker_server::identity::derive_omni_account("evm", &wallet_addr); - let jwt = mint_session_jwt( - &state.session_keypair, - TEST_ISSUER, - omni2.as_str(), - &wallet_addr, - "evm", - &wallet_addr, - 300, - ) - .unwrap(); - let _ = (session_kp,); // silence unused - - let app = create_router(state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let session_kp_copy = SessionKeypair::load(&tmp.path().join("session2.json")).unwrap(); - ( - format!("http://{}", addr), - state, - session_kp_copy, - jwt, - signing_key, - ) -} - -fn address_from_signing_key(key: &SigningKey) -> String { - let vkey = key.verifying_key(); - let pt = vkey.to_encoded_point(false); - let mut h = Keccak256::new(); - h.update(&pt.as_bytes()[1..]); - let pubkey_hash = h.finalize(); - format!("0x{}", hex::encode(&pubkey_hash[12..])) -} - -/// Sign canonical-JSON-bytes with EIP-191 envelope; return 65-byte hex sig. -fn eip191_sign(key: &SigningKey, message: &[u8]) -> String { - let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); - let mut h = Keccak256::new(); - h.update(prefix.as_bytes()); - h.update(message); - let digest = h.finalize(); - let (sig, rid) = key.sign_prehash_recoverable(&digest).unwrap(); - let mut sig_bytes = sig.to_bytes().to_vec(); - sig_bytes.push(rid.to_byte()); - format!("0x{}", hex::encode(&sig_bytes)) -} - -/// Build the canonical signing-input bytes (sorted-key JSON without -/// auth.signature) given a body-Value. -fn canonical_input(body: &Value) -> Vec { - let mut stripped = body.clone(); - if let Some(auth) = stripped.get_mut("auth").and_then(Value::as_object_mut) { - auth.remove("signature"); - } - canonicalize(&stripped).into_bytes() -} - -fn canonicalize(v: &Value) -> String { - match v { - Value::Object(map) => { - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort(); - let parts: Vec = keys - .iter() - .map(|k| format!("{}:{}", serde_json::to_string(k).unwrap(), canonicalize(&map[*k]))) - .collect(); - format!("{{{}}}", parts.join(",")) - } - Value::Array(items) => { - let parts: Vec = items.iter().map(canonicalize).collect(); - format!("[{}]", parts.join(",")) - } - other => serde_json::to_string(other).unwrap(), - } -} - -#[tokio::test] -async fn mint_v2_happy_path_returns_creds_and_audit_record_id() { - let (broker_url, _state, _kp, jwt, signing_key) = spawn_broker().await; - let wallet = address_from_signing_key(&signing_key); - - let body = serde_json::json!({ - "request_id": "mnt_test_1", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": wallet, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": wallet, "signature": "" } - }); - let canon = canonical_input(&body); - let sig = eip191_sign(&signing_key, &canon); - let body = serde_json::json!({ - "request_id": "mnt_test_1", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": wallet, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": wallet, "signature": sig } - }); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - let status = resp.status(); - let body_resp: Value = resp.json().await.unwrap(); - assert_eq!(status, reqwest::StatusCode::OK, "body: {}", body_resp); - assert_eq!(body_resp["access_key_id"], "ASIA-V2"); - assert_eq!(body_resp["wallet"].as_str().unwrap().to_lowercase(), wallet); - assert!(body_resp["audit_record_id"].is_string()); - assert_eq!(body_resp["anchored"][0], "sqlite"); -} - -#[tokio::test] -async fn mint_v2_rejects_per_call_sig_for_wrong_address() { - let (broker_url, _state, _kp, jwt, signing_key) = spawn_broker().await; - let wallet = address_from_signing_key(&signing_key); - // Sign with the right key but claim a different address in body. - let mismatch_addr = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - - let body = serde_json::json!({ - "request_id": "mnt_test_2", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": wallet, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": mismatch_addr, "signature": "" } - }); - let canon = canonical_input(&body); - let sig = eip191_sign(&signing_key, &canon); - let body = serde_json::json!({ - "request_id": "mnt_test_2", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": wallet, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": mismatch_addr, "signature": sig } - }); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn mint_v2_rejects_missing_body() { - let (broker_url, _state, _kp, jwt, _signing_key) = spawn_broker().await; - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body("") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn mint_v2_rejects_jwt_address_mismatch() { - let (broker_url, _state, _kp, jwt, _signing_key) = spawn_broker().await; - // Sign + claim with a DIFFERENT key/address than what's in the JWT. - let other_key = SigningKey::random(&mut agentkeys_broker_server::oidc::rand_compat::OsRngWrapper); - let other_addr = address_from_signing_key(&other_key); - - let body = serde_json::json!({ - "request_id": "mnt_test_3", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": other_addr, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": other_addr, "signature": "" } - }); - let canon = canonical_input(&body); - let sig = eip191_sign(&other_key, &canon); - let body = serde_json::json!({ - "request_id": "mnt_test_3", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": other_addr, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": other_addr, "signature": sig } - }); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - // Per-call sig is valid for `other_addr` but the JWT claims a - // different wallet → 401. - assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn mint_v2_rejects_garbage_signature() { - let (broker_url, _state, _kp, jwt, signing_key) = spawn_broker().await; - let wallet = address_from_signing_key(&signing_key); - let body = serde_json::json!({ - "request_id": "mnt_test_4", - "issued_at": "2026-05-05T14:00:00Z", - "intent": { "agent_id": wallet, "service": "s3", "scope_path": "bots/" }, - "auth": { "address": wallet, "signature": format!("0x{}", "00".repeat(65)) } - }); - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/v1/mint-aws-creds", broker_url)) - .header("authorization", format!("Bearer {}", jwt)) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body).unwrap()) - .send() - .await - .unwrap(); - assert!( - matches!( - resp.status(), - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::BAD_REQUEST - ), - "expected 400/401, got {}", - resp.status() - ); -} diff --git a/crates/agentkeys-broker-server/tests/oauth2_flow.rs b/crates/agentkeys-broker-server/tests/oauth2_flow.rs index 57b2b9a..f1473c6 100644 --- a/crates/agentkeys-broker-server/tests/oauth2_flow.rs +++ b/crates/agentkeys-broker-server/tests/oauth2_flow.rs @@ -97,11 +97,9 @@ async fn spawn_broker() -> (String, Arc, Arc) { let config = BrokerConfig { data_role_arn: "arn:aws:iam::000:role/test".into(), - backend_url: "http://127.0.0.1:1".into(), audit_db_path: tmp.path().join("audit.sqlite"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, shutdown_grace_seconds: 5, oidc_issuer: TEST_ISSUER.into(), oidc_keypair_path: tmp.path().join("oidc.json"), @@ -134,7 +132,6 @@ async fn spawn_broker() -> (String, Arc, Arc) { email_link: None, oauth2: Some(plugin.clone()), }); - state.tier2.backend_reachable.store(true, Ordering::Relaxed); let app = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/agentkeys-broker-server/tests/oidc_flow.rs b/crates/agentkeys-broker-server/tests/oidc_flow.rs index 4dc0569..3ab8dce 100644 --- a/crates/agentkeys-broker-server/tests/oidc_flow.rs +++ b/crates/agentkeys-broker-server/tests/oidc_flow.rs @@ -34,21 +34,7 @@ fn stub_creds() -> AssumedCredentials { } } -async fn spawn_mock_backend() -> String { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - agentkeys_mock_server::db::init_schema(&conn).unwrap(); - let state = Arc::new(agentkeys_mock_server::state::AppState::new(conn)); - let app = agentkeys_mock_server::create_router(state); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - format!("http://{}", addr) -} - -async fn spawn_broker(backend_url: String) -> (String, Arc) { +async fn spawn_broker() -> (String, Arc) { let tmp = Box::leak(Box::new(TempDir::new().unwrap())); let keypair_path = tmp.path().join("oidc-keypair.json"); let oidc = OidcKeypair::generate_and_persist(&keypair_path).unwrap(); @@ -56,11 +42,9 @@ async fn spawn_broker(backend_url: String) -> (String, Arc) { let sts: Arc = Arc::new(StubStsClient::ok(stub_creds())); let config = BrokerConfig { data_role_arn: STUB_ROLE_ARN.into(), - backend_url, audit_db_path: PathBuf::from(":memory:"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, shutdown_grace_seconds: 5, oidc_issuer: TEST_ISSUER.into(), oidc_keypair_path: keypair_path, @@ -131,8 +115,8 @@ async fn spawn_broker(backend_url: String) -> (String, Arc) { #[tokio::test] async fn discovery_returns_aws_compatible_shape() { - let backend_url = spawn_mock_backend().await; - let (broker_url, _) = spawn_broker(backend_url).await; + + let (broker_url, _) = spawn_broker().await; let resp: Value = reqwest::Client::new() .get(format!("{}/.well-known/openid-configuration", broker_url)) @@ -167,8 +151,8 @@ async fn discovery_returns_aws_compatible_shape() { #[tokio::test] async fn jwks_returns_p256_es256_with_kid() { - let backend_url = spawn_mock_backend().await; - let (broker_url, state) = spawn_broker(backend_url).await; + + let (broker_url, state) = spawn_broker().await; let resp: Value = reqwest::Client::new() .get(format!("{}/.well-known/jwks.json", broker_url)) @@ -191,8 +175,8 @@ async fn jwks_returns_p256_es256_with_kid() { #[tokio::test] async fn mint_oidc_jwt_signs_claims_for_session_wallet() { - let backend_url = spawn_mock_backend().await; - let (broker_url, state) = spawn_broker(backend_url).await; + + let (broker_url, state) = spawn_broker().await; // Mint a session JWT against the broker's own session keypair — the // same path the SIWE wallet/email/oauth2 verify handlers take. Replaces @@ -271,8 +255,8 @@ async fn mint_oidc_jwt_signs_claims_for_session_wallet() { #[tokio::test] async fn mint_oidc_jwt_rejects_missing_bearer() { - let backend_url = spawn_mock_backend().await; - let (broker_url, _) = spawn_broker(backend_url).await; + + let (broker_url, _) = spawn_broker().await; let resp = reqwest::Client::new() .post(format!("{}/v1/mint-oidc-jwt", broker_url)) @@ -285,8 +269,8 @@ async fn mint_oidc_jwt_rejects_missing_bearer() { #[tokio::test] async fn mint_oidc_jwt_rejects_invalid_bearer_and_audits_auth_failed() { - let backend_url = spawn_mock_backend().await; - let (broker_url, state) = spawn_broker(backend_url).await; + + let (broker_url, state) = spawn_broker().await; let resp = reqwest::Client::new() .post(format!("{}/v1/mint-oidc-jwt", broker_url)) diff --git a/crates/agentkeys-broker-server/tests/wallet_flow.rs b/crates/agentkeys-broker-server/tests/wallet_flow.rs index f6db807..67c48c8 100644 --- a/crates/agentkeys-broker-server/tests/wallet_flow.rs +++ b/crates/agentkeys-broker-server/tests/wallet_flow.rs @@ -10,7 +10,6 @@ //! - Missing auth on link → 401; on lookup → 200 (lookup is unauth). use std::collections::HashMap; -use std::sync::atomic::Ordering; use std::sync::Arc; use agentkeys_broker_server::{ @@ -71,11 +70,9 @@ async fn spawn_broker() -> Harness { let config = BrokerConfig { data_role_arn: "arn:aws:iam::000:role/test".into(), - backend_url: "http://127.0.0.1:1".into(), audit_db_path: tmp.path().join("audit.sqlite"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, - backend_request_timeout_seconds: 5, shutdown_grace_seconds: 5, oidc_issuer: TEST_ISSUER.into(), oidc_keypair_path: tmp.path().join("oidc.json"), @@ -109,7 +106,6 @@ async fn spawn_broker() -> Harness { #[cfg(feature = "auth-oauth2")] oauth2: None, }); - state.tier2.backend_reachable.store(true, Ordering::Relaxed); let app = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index fb5e9b1..791b96f 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -46,7 +46,7 @@ async fn broker_env_for_provision( Ok(creds.to_env(Some(®ion))) } use agentkeys_types::{ - AuditEvent, AuditFilter, AuthToken, Scope, ServiceName, Session, WalletAddress, + AuthToken, Scope, ServiceName, Session, WalletAddress, }; use anyhow::{anyhow, Context, Result}; use serde_json::json; @@ -642,25 +642,19 @@ async fn init_via_oauth2_google( /// Resolve the effective wallet address for a command. /// - `None` → use the session's own wallet (default agent) /// - `Some("0x...")` → parse directly as wallet address -/// - `Some(other)` → call `resolve_identity` on the backend (alias/email lookup) -async fn resolve_agent( - backend: &Arc, +/// - anything else errors; alias/email lookup retired in issue #77. +fn resolve_agent( + _backend: &Arc, session: &Session, agent: Option<&str>, ) -> Result { match agent { None => Ok(session.wallet.clone()), Some(arg) if arg.starts_with("0x") => Ok(WalletAddress(arg.to_string())), - Some(arg) => backend - .resolve_identity(session, arg) - .await - .map_err(|e| match e { - BackendError::NotFound(_) => anyhow!( - "unknown identity '{}'. Use `agentkeys link` to create an alias or pass the 0x... wallet directly.", - arg - ), - other => wrap_backend_error(other), - }), + Some(arg) => Err(anyhow!( + "unknown identity '{}'. Pass a raw 0x... wallet address (alias/email lookup retired in issue #77).", + arg + )), } } @@ -669,7 +663,7 @@ pub async fn cmd_store(ctx: &CommandContext, agent: Option<&str>, service: &str, // Identity resolution (alias / email → wallet) always goes through the // legacy backend — issue #85's S3 path only handles credential CRUD. let id_backend = ctx.backend(); - let agent_id = resolve_agent(&id_backend, &session, agent).await?; + let agent_id = resolve_agent(&id_backend, &session, agent)?; let service_name = ServiceName(service.to_string()); let cred_backend = ctx.credential_backend().await?; @@ -709,7 +703,7 @@ pub async fn cmd_store(ctx: &CommandContext, agent: Option<&str>, service: &str, pub async fn cmd_read(ctx: &CommandContext, agent: Option<&str>, service: &str) -> Result { let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; let id_backend = ctx.backend(); - let agent_id = resolve_agent(&id_backend, &session, agent).await?; + let agent_id = resolve_agent(&id_backend, &session, agent)?; let service_name = ServiceName(service.to_string()); let cred_backend = ctx.credential_backend().await?; @@ -764,7 +758,7 @@ pub async fn cmd_run( let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; let id_backend = ctx.backend(); - let agent_id = resolve_agent(&id_backend, &session, agent).await?; + let agent_id = resolve_agent(&id_backend, &session, agent)?; let backend = ctx.credential_backend().await?; // Pre-flight validation: reject any invalid --env entries BEFORE any credential @@ -977,163 +971,6 @@ pub async fn cmd_teardown(ctx: &CommandContext, agent: &str) -> Result { Ok(format!("Torn down agent={}", agent)) } -pub async fn cmd_usage(ctx: &CommandContext, agent: Option<&str>, json_flag: bool) -> Result { - let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; - - let filter = AuditFilter { - owner: None, - agent: agent.map(|a| WalletAddress(a.to_string())), - service: None, - }; - - if ctx.verbose { - eprintln!("[verbose] GET {}/audit/query", ctx.backend_url); - } - - let events = ctx.backend() - .query_audit(&session, filter) - .await - .map_err(wrap_backend_error)?; - - if json_flag || ctx.json_output { - let arr: Vec = events.iter().map(audit_event_to_json).collect(); - Ok(serde_json::to_string_pretty(&arr).unwrap()) - } else { - Ok(format_audit_table(&events)) - } -} - -fn audit_event_to_json(e: &AuditEvent) -> serde_json::Value { - json!({ - "timestamp": e.timestamp, - "agent": e.agent.0, - "service": e.service.0, - "action": e.action, - "result": e.result, - }) -} - -fn format_audit_table(events: &[AuditEvent]) -> String { - if events.is_empty() { - return "No audit events found.".to_string(); - } - let header = format!( - "{:<12} {:<20} {:<20} {:<12} {:<10}", - "timestamp", "agent", "service", "action", "result" - ); - let rows: Vec = events - .iter() - .map(|e| { - format!( - "{:<12} {:<20} {:<20} {:<12} {:<10}", - e.timestamp, - truncate(&e.agent.0, 20), - truncate(&e.service.0, 20), - truncate(&e.action, 12), - truncate(&e.result, 10), - ) - }) - .collect(); - format!("{}\n{}", header, rows.join("\n")) -} - -fn truncate(s: &str, max: usize) -> &str { - if s.len() <= max { - s - } else { - &s[..max] - } -} - -pub async fn cmd_link( - ctx: &CommandContext, - agent: &str, - alias: Option<&str>, - email: Option<&str>, -) -> Result { - let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; - - let (identity_type, identity_value) = if let Some(a) = alias { - ("alias", a) - } else if let Some(e) = email { - ("email", e) - } else { - return Err(anyhow!("Provide --alias or --email")); - }; - - if ctx.verbose { - eprintln!("[verbose] POST {}/identity/link", ctx.backend_url); - eprintln!( - "[verbose] agent: {}, type: {}, value: {}", - agent, identity_type, identity_value - ); - } - - // cmd_link uses the /identity/link endpoint which is not part of the CredentialBackend - // trait (identity linking is an extra endpoint). We route via HTTP using backend_url - // from the context. When backend_override is set, the caller must also set backend_url - // to a valid URL that serves the identity/link endpoint. - // Note: adding link_identity to CredentialBackend trait is a v0.1 item. - let http_client = reqwest::Client::new(); - let url = format!("{}/identity/link", ctx.backend_url); - let resp = http_client - .post(&url) - .header("authorization", format!("Bearer {}", session.token)) - .json(&json!({ - "identity_type": identity_type, - "identity_value": identity_value, - "wallet_address": agent, - })) - .send() - .await - .context("POST /identity/link")?; - - if !resp.status().is_success() { - let status = resp.status(); - let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null); - let msg = body["message"].as_str().unwrap_or("unknown error"); - return Err(anyhow!("Error: HTTP {}: {}", status, msg)); - } - - Ok(format!( - "Linked agent={} {}={}", - agent, identity_type, identity_value - )) -} - -pub async fn cmd_recover(ctx: &CommandContext, identity: &str, method: &str) -> Result { - let recovery_method = match method { - "passkey" => agentkeys_types::RecoveryMethod::Passkey, - "email" => agentkeys_types::RecoveryMethod::Email, - other => return Err(anyhow!("Unknown recovery method '{}'. Use 'passkey' or 'email'.", other)), - }; - - let agent_identity = if identity.starts_with("0x") { - agentkeys_types::AgentIdentity::WalletAddress(WalletAddress(identity.to_string())) - } else if identity.contains('@') { - agentkeys_types::AgentIdentity::Email(identity.to_string()) - } else { - agentkeys_types::AgentIdentity::Alias(identity.to_string()) - }; - - if ctx.verbose { - eprintln!("[verbose] POST {}/session/recover", ctx.backend_url); - eprintln!("[verbose] identity: {}, method: {}", identity, method); - } - - let backend = ctx.backend(); - let (session, wallet) = backend - .recover_session(&agent_identity, &recovery_method) - .await - .map_err(wrap_backend_error)?; - - ctx.session_store() - .save(&session, &ctx.session_id) - .context("save recovered session to keychain")?; - - Ok(format!("Recovered. Session restored for wallet {}", wallet.0)) -} - pub async fn cmd_approve(ctx: &CommandContext, pair_code: &str, auto_yes: bool) -> Result { let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; @@ -1224,43 +1061,14 @@ pub async fn cmd_approve(ctx: &CommandContext, pair_code: &str, auto_yes: bool) Ok("Approved. Agent paired successfully.".to_string()) } -async fn resolve_agent_to_wallet( - ctx: &CommandContext, - session: &Session, - agent: &str, -) -> Result { +fn resolve_agent_to_wallet(_ctx: &CommandContext, _session: &Session, agent: &str) -> Result { if agent.starts_with("0x") { - return Ok(agent.to_string()); - } - // Resolve alias or email via /identity/resolve - let (identity_type, identity_value) = if agent.contains('@') { - ("email", agent) + Ok(agent.to_string()) } else { - ("alias", agent) - }; - // reqwest's .query() builder percent-encodes per RFC 3986 so identities - // containing '+', '&', '=', '%', spaces (e.g. plus-addressed emails like - // "bot+prod@example.com") are sent intact to the server. - let http_client = reqwest::Client::new(); - let resp = http_client - .get(format!("{}/identity/resolve", ctx.backend_url)) - .query(&[("identity_type", identity_type), ("identity_value", identity_value)]) - .header("authorization", format!("Bearer {}", session.token)) - .send() - .await - .context("GET /identity/resolve")?; - if !resp.status().is_success() { - let status = resp.status(); - let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null); - let msg = body["message"].as_str().unwrap_or("not found"); - return Err(anyhow!("Error: HTTP {}: {}", status, msg)); + Err(anyhow!( + "Agent must be a raw 0x wallet address. Alias/email lookup is no longer supported." + )) } - let body: serde_json::Value = resp.json().await.context("parse identity/resolve response")?; - let wallet = body["wallet_address"] - .as_str() - .ok_or_else(|| anyhow!("identity/resolve returned no wallet_address"))? - .to_string(); - Ok(wallet) } pub async fn cmd_scope( @@ -1312,7 +1120,7 @@ pub async fn cmd_scope( } let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; - let target_wallet = WalletAddress(resolve_agent_to_wallet(ctx, &session, agent).await?); + let target_wallet = WalletAddress(resolve_agent_to_wallet(ctx, &session, agent)?); let backend = ctx.backend(); let current_scope = backend @@ -1488,7 +1296,7 @@ pub async fn cmd_provision( pub async fn cmd_inbox_provision(ctx: &CommandContext, agent: Option<&str>) -> Result { let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; let backend = ctx.backend(); - let agent_id = resolve_agent(&backend, &session, agent).await?; + let agent_id = resolve_agent(&backend, &session, agent)?; if ctx.verbose { eprintln!("[verbose] POST {}/mock/inbox/provision", ctx.backend_url); @@ -1506,7 +1314,7 @@ pub async fn cmd_inbox_provision(ctx: &CommandContext, agent: Option<&str>) -> R pub async fn cmd_inbox_list(ctx: &CommandContext, agent: Option<&str>) -> Result { let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; let backend = ctx.backend(); - let agent_id = resolve_agent(&backend, &session, agent).await?; + let agent_id = resolve_agent(&backend, &session, agent)?; if ctx.verbose { eprintln!("[verbose] GET {}/mock/inbox/list", ctx.backend_url); diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index f5fd883..544f944 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -1,7 +1,7 @@ use agentkeys_cli::{ - cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_link, - cmd_provision, cmd_read, cmd_recover, cmd_revoke, cmd_run, cmd_scope, cmd_signer_derive, - cmd_signer_sign, cmd_store, cmd_teardown, cmd_usage, cmd_whoami, CommandContext, + cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init, + cmd_provision, cmd_read, cmd_revoke, cmd_run, cmd_scope, cmd_signer_derive, + cmd_signer_sign, cmd_store, cmd_teardown, cmd_whoami, CommandContext, CredentialBackendKind, EnvelopeVersionFlag, InitMode, }; @@ -178,41 +178,6 @@ enum Commands { agent: String, }, - #[command( - about = "Show audit log for credential usage", - long_about = "Query the audit log for credential read/write events.\n\nExamples:\n agentkeys usage\n agentkeys usage 0xAGENT\n agentkeys usage --json 0xAGENT" - )] - Usage { - #[arg(help = "Filter by agent wallet address (optional)")] - agent: Option, - #[arg(long, help = "Output as JSON array")] - json: bool, - }, - - #[command( - about = "Link an identity (alias or email) to an agent", - long_about = "Associate a human-readable alias or email with an agent's wallet address.\n\nExamples:\n agentkeys link 0xAGENT --alias my-bot\n agentkeys link 0xAGENT --email bot@example.com" - )] - Link { - #[arg(help = "Agent wallet address")] - agent: String, - #[arg(long, help = "Human-readable alias")] - alias: Option, - #[arg(long, help = "Email address to link")] - email: Option, - }, - - #[command( - about = "Recover a session via 2FA (passkey or email)", - long_about = "Recover a master or agent session using a second-factor recovery method.\n\nExamples:\n agentkeys recover my-bot --method passkey\n agentkeys recover bot@example.com --method email\n agentkeys recover 0xAGENT --method passkey" - )] - Recover { - #[arg(help = "Agent identity (alias, email, or wallet address)")] - identity: String, - #[arg(long, help = "Recovery method: passkey or email")] - method: String, - }, - #[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" @@ -630,13 +595,6 @@ async fn main() { Commands::Run { agent, env, cmd } => cmd_run(&ctx, agent.as_deref(), env, cmd).await, Commands::Revoke { agent } => cmd_revoke(&ctx, agent.as_deref()).await, Commands::Teardown { agent } => cmd_teardown(&ctx, agent).await, - Commands::Usage { agent, json } => { - cmd_usage(&ctx, agent.as_deref(), *json).await - } - Commands::Link { agent, alias, email } => { - cmd_link(&ctx, agent, alias.as_deref(), email.as_deref()).await - } - Commands::Recover { identity, method } => cmd_recover(&ctx, identity, method).await, Commands::Approve { pair_code, yes } => cmd_approve(&ctx, pair_code, *yes).await, Commands::Scope { agent, add, remove, set, list } => { cmd_scope(&ctx, agent, add, remove, set.as_deref(), *list).await diff --git a/crates/agentkeys-cli/tests/cli_tests.rs b/crates/agentkeys-cli/tests/cli_tests.rs index e6a712e..4c8aee6 100644 --- a/crates/agentkeys-cli/tests/cli_tests.rs +++ b/crates/agentkeys-cli/tests/cli_tests.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use agentkeys_cli::{ - cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_link, cmd_provision, cmd_read, cmd_revoke, - cmd_run, cmd_scope, cmd_store, cmd_teardown, cmd_usage, CommandContext, InitMode, + cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_provision, cmd_read, cmd_revoke, + cmd_run, cmd_scope, cmd_store, cmd_teardown, CommandContext, InitMode, }; use agentkeys_core::backend::CredentialBackend; use agentkeys_core::session_store::SessionStore; @@ -340,60 +340,6 @@ async fn cli_teardown_deletes_all() { assert!(after.is_err(), "expected error after teardown, got: {:?}", after.ok()); } -// Test 7: usage shows audit events after store+read -#[tokio::test(flavor = "multi_thread")] -async fn cli_usage_shows_audit() { - let (store, _tmp) = test_store(); - let backend = create_test_backend(); - let (wallet, session) = init_session_with_store(&backend, &store).await; - let context = ctx_with_session(backend, session, store); - - cmd_store(&context, Some(&wallet), "openrouter", "sk-audit-test").await.unwrap(); - let _ = cmd_read(&context, Some(&wallet), "openrouter").await.unwrap(); - - let usage_out = cmd_usage(&context, Some(&wallet), false).await.unwrap(); - assert!( - usage_out.contains("openrouter") || usage_out.contains("timestamp"), - "usage output missing expected content: {usage_out}" - ); -} - -// Test 8: link alias succeeds — uses a real TCP server since cmd_link uses reqwest -#[tokio::test(flavor = "multi_thread")] -async fn cli_link_alias() { - use agentkeys_mock_server::{create_router, db, state::AppState}; - use std::sync::Arc as StdArc; - - // Start a real TCP server for this test since cmd_link uses reqwest - let conn = rusqlite::Connection::open_in_memory().unwrap(); - db::init_schema(&conn).unwrap(); - let state = StdArc::new(AppState::new(conn)); - let router = create_router(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - - let (store, _tmp) = test_store(); - let bare_ctx = CommandContext::new(&base_url, false, false) - .with_session_store(store.clone()); - let (output, session) = cmd_init(&bare_ctx, InitMode::ImportLegacyMock("test-token-unique".to_string())) - .await - .unwrap(); - let wallet = output.split("Wallet: ").nth(1).unwrap().trim().to_string(); - - let context = CommandContext::new(&base_url, false, false) - .with_session(session) - .with_session_store(store); - let result = cmd_link(&context, &wallet, Some("my-test-bot"), None).await; - assert!(result.is_ok(), "link failed: {:?}", result.err()); - let out = result.unwrap(); - assert!(out.contains("Linked"), "unexpected output: {out}"); - assert!(out.contains("alias"), "missing alias in output: {out}"); -} - // Test 9: --help output contains expected content #[tokio::test(flavor = "multi_thread")] async fn cli_help_has_examples() { @@ -690,44 +636,6 @@ async fn cmd_run_defaults_to_session_wallet() { assert!(result.is_ok(), "cmd_run with None agent failed: {:?}", result.err()); } -// Test 24 (issue-16): cmd_store with alias resolves to the linked wallet -#[tokio::test(flavor = "multi_thread")] -async fn cmd_store_resolves_alias() { - use agentkeys_mock_server::{create_router, db, state::AppState}; - use std::sync::Arc as StdArc; - - let conn = rusqlite::Connection::open_in_memory().unwrap(); - db::init_schema(&conn).unwrap(); - let state = StdArc::new(AppState::new(conn)); - let router = create_router(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - - let (store, _tmp) = test_store(); - let bare_ctx = CommandContext::new(&base_url, false, false) - .with_session_store(store.clone()); - let (output, session) = cmd_init(&bare_ctx, InitMode::ImportLegacyMock("test-token-alias".to_string())).await.unwrap(); - let wallet = output.split("Wallet: ").nth(1).unwrap().trim().to_string(); - - let context = CommandContext::new(&base_url, false, false) - .with_session(session.clone()) - .with_session_store(store); - - // Link the wallet to an alias - cmd_link(&context, &wallet, Some("my-alias-bot"), None).await.unwrap(); - - // Store using the alias — should resolve to the same wallet - cmd_store(&context, Some("my-alias-bot"), "openrouter", "sk-via-alias").await.unwrap(); - - // Read back explicitly with the wallet address to confirm storage - let value = cmd_read(&context, Some(&wallet), "openrouter").await.unwrap(); - assert_eq!(value.trim(), "sk-via-alias"); -} - // Test 25 (issue-16): cmd_read with unknown identity returns the documented error message #[tokio::test(flavor = "multi_thread")] async fn cmd_read_unknown_identity_errors_cleanly() { @@ -1042,7 +950,6 @@ impl CredentialBackend for ProvisionTestBackend { None => Err(agentkeys_core::backend::BackendError::NotFound("none".into())), } } - async fn query_audit(&self, _: &Session, _: agentkeys_types::AuditFilter) -> Result, agentkeys_core::backend::BackendError> { Ok(vec![]) } async fn revoke_session(&self, _: &Session, _: &Session) -> Result<(), agentkeys_core::backend::BackendError> { unimplemented!() } async fn revoke_by_wallet(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result<(), agentkeys_core::backend::BackendError> { unimplemented!() } async fn teardown_agent(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result<(), agentkeys_core::backend::BackendError> { unimplemented!() } @@ -1056,7 +963,6 @@ impl CredentialBackend for ProvisionTestBackend { async fn await_auth_decision(&self, _: &agentkeys_types::AuthRequestId) -> Result { unimplemented!() } async fn recover_session(&self, _: &agentkeys_types::AgentIdentity, _: &agentkeys_types::RecoveryMethod) -> Result<(Session, agentkeys_types::WalletAddress), agentkeys_core::backend::BackendError> { unimplemented!() } async fn list_credentials(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result, agentkeys_core::backend::BackendError> { unimplemented!() } - async fn resolve_identity(&self, _: &Session, _: &str) -> Result { unimplemented!() } async fn get_scope(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result, agentkeys_core::backend::BackendError> { unimplemented!() } async fn update_scope(&self, _: &Session, _: &agentkeys_types::WalletAddress, _: &agentkeys_types::Scope) -> Result<(), agentkeys_core::backend::BackendError> { unimplemented!() } async fn provision_inbox(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result { unimplemented!() } diff --git a/crates/agentkeys-core/src/backend.rs b/crates/agentkeys-core/src/backend.rs index 3381bfc..e0f0047 100644 --- a/crates/agentkeys-core/src/backend.rs +++ b/crates/agentkeys-core/src/backend.rs @@ -1,5 +1,5 @@ use agentkeys_types::{ - AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, + AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; @@ -54,12 +54,6 @@ pub trait CredentialBackend: Send + Sync { service: &ServiceName, ) -> Result, BackendError>; - async fn query_audit( - &self, - session: &Session, - filter: AuditFilter, - ) -> Result, BackendError>; - async fn revoke_session( &self, session: &Session, @@ -135,14 +129,6 @@ pub trait CredentialBackend: Send + Sync { agent_id: &WalletAddress, ) -> Result, BackendError>; - /// Resolve a human-readable identity (alias or email) to a wallet address. - /// Returns `BackendError::NotFound` when no mapping exists. - async fn resolve_identity( - &self, - session: &Session, - identifier: &str, - ) -> Result; - async fn get_scope( &self, session: &Session, @@ -212,14 +198,6 @@ mod tests { unimplemented!() } - async fn query_audit( - &self, - _session: &Session, - _filter: AuditFilter, - ) -> Result, BackendError> { - unimplemented!() - } - async fn revoke_session( &self, _session: &Session, @@ -321,14 +299,6 @@ mod tests { unimplemented!() } - async fn resolve_identity( - &self, - _session: &Session, - _identifier: &str, - ) -> Result { - unimplemented!() - } - async fn get_scope( &self, _session: &Session, diff --git a/crates/agentkeys-core/src/mock_client.rs b/crates/agentkeys-core/src/mock_client.rs index a1e75b6..3053e7e 100644 --- a/crates/agentkeys-core/src/mock_client.rs +++ b/crates/agentkeys-core/src/mock_client.rs @@ -3,7 +3,7 @@ use serde_json::{json, Value}; use crate::backend::{BackendError, CredentialBackend}; use agentkeys_types::{ - AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, + AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; @@ -267,58 +267,6 @@ impl CredentialBackend for MockHttpClient { Ok(PublicKey(key_bytes)) } - async fn query_audit( - &self, - session: &Session, - filter: AuditFilter, - ) -> Result, BackendError> { - let mut params: Vec = Vec::new(); - if let Some(owner) = &filter.owner { - params.push(format!("owner={}", owner.0)); - } - if let Some(agent) = &filter.agent { - params.push(format!("agent={}", agent.0)); - } - if let Some(service) = &filter.service { - params.push(format!("service={}", service.0)); - } - let path = if params.is_empty() { - "/audit/query".to_string() - } else { - format!("/audit/query?{}", params.join("&")) - }; - - let resp = self - .client - .get(self.url(&path)) - .header("authorization", format!("Bearer {}", session.token)) - .send() - .await - .map_err(|e| BackendError::Transport(e.to_string()))?; - - if !resp.status().is_success() { - return Err(Self::map_error(resp).await); - } - - let body: Value = resp.json().await.map_err(|e| BackendError::Transport(e.to_string()))?; - let events = body["events"] - .as_array() - .ok_or_else(|| BackendError::Internal("missing events".into()))? - .iter() - .filter_map(|e| { - Some(AuditEvent { - owner: WalletAddress(e["owner"].as_str()?.to_string()), - agent: WalletAddress(e["agent"].as_str()?.to_string()), - service: ServiceName(e["service"].as_str()?.to_string()), - action: e["action"].as_str()?.to_string(), - result: e["result"].as_str()?.to_string(), - timestamp: e["timestamp"].as_u64()?, - }) - }) - .collect(); - Ok(events) - } - async fn register_rendezvous( &self, daemon_pubkey: &PublicKey, @@ -667,40 +615,6 @@ impl CredentialBackend for MockHttpClient { Ok(services) } - async fn resolve_identity( - &self, - session: &Session, - identifier: &str, - ) -> Result { - let (identity_type, identity_value) = if identifier.contains('@') { - ("email", identifier) - } else { - ("alias", identifier) - }; - - // reqwest's .query() builder percent-encodes both parameter names and - // values per RFC 3986, so identities containing '+', '&', '=', '%', or - // spaces (e.g. plus-addressed emails like "bot+prod@example.com") are - // sent intact to the server. - let resp = self - .client - .get(self.url("/identity/resolve")) - .query(&[("identity_type", identity_type), ("identity_value", identity_value)]) - .header("authorization", format!("Bearer {}", session.token)) - .send() - .await - .map_err(|e| BackendError::Transport(e.to_string()))?; - if !resp.status().is_success() { - return Err(Self::map_error(resp).await); - } - let body: Value = resp.json().await.map_err(|e| BackendError::Transport(e.to_string()))?; - let wallet_str = body["wallet_address"] - .as_str() - .ok_or_else(|| BackendError::Internal("missing wallet_address".into()))? - .to_string(); - Ok(WalletAddress(wallet_str)) - } - async fn get_scope( &self, session: &Session, diff --git a/crates/agentkeys-core/src/s3_backend.rs b/crates/agentkeys-core/src/s3_backend.rs index 9937270..b3210df 100644 --- a/crates/agentkeys-core/src/s3_backend.rs +++ b/crates/agentkeys-core/src/s3_backend.rs @@ -68,7 +68,7 @@ use crate::actor_omni::actor_omni_hex; use crate::backend::{BackendError, CredentialBackend}; use crate::signer_client::{SignerClient, SignerClientError}; use agentkeys_types::{ - AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, + AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; @@ -683,14 +683,6 @@ impl CredentialBackend for S3CredentialBackend { Err(unsupported("create_child_session")) } - async fn query_audit( - &self, - _session: &Session, - _filter: AuditFilter, - ) -> Result, BackendError> { - Err(unsupported("query_audit")) - } - async fn revoke_session( &self, _session: &Session, @@ -776,14 +768,6 @@ impl CredentialBackend for S3CredentialBackend { Err(unsupported("recover_session")) } - async fn resolve_identity( - &self, - _session: &Session, - _identifier: &str, - ) -> Result { - Err(unsupported("resolve_identity")) - } - async fn get_scope( &self, _session: &Session, @@ -1211,9 +1195,9 @@ mod tests { #[test] fn unsupported_helper_names_the_operation() { - let err = unsupported("query_audit"); + let err = unsupported("recover_session"); let s = err.to_string(); - assert!(s.contains("query_audit"), "msg = {s}"); + assert!(s.contains("recover_session"), "msg = {s}"); } // ---- v2 migration coverage (issue-v2-stage-1-foundation) ------------- diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index 484e130..fa68ba9 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -230,7 +230,7 @@ async fn main() -> anyhow::Result<()> { } else { // RECOVER VIA MASTER APPROVAL — resolve --parent here, not at // startup (codex P3). - let parent_wallet = resolve_parent_if_set(&backend_url, args.parent.as_deref()).await?; + let parent_wallet = resolve_parent_if_set(&backend_url, args.parent.as_deref())?; let result = pairing::run_recover_flow( &*backend, agent_identity, @@ -365,7 +365,7 @@ async fn main() -> anyhow::Result<()> { // --session / --recover --method paths don't crash startup. // `--parent` binds the pair request to a specific master so // the backend refuses approval from any other master. - let parent_wallet = resolve_parent_if_set(&backend_url, args.parent.as_deref()).await?; + let parent_wallet = resolve_parent_if_set(&backend_url, args.parent.as_deref())?; let result = pairing::run_pair_flow( &*backend, args.pair_timeout, @@ -466,59 +466,23 @@ fn looks_like_raw_wallet(s: &str) -> bool { } /// Resolve `--parent` to a wallet address if set, returning `Ok(None)` when -/// the flag is absent. -/// -/// Uses reqwest's `.query()` builder so aliases with reserved characters -/// (`+`, `&`, `%`, spaces) are percent-encoded per RFC 3986 (codex PR #22 -/// v1 P2 — URL encoding). -/// -/// All inputs — raw wallets included — go through `/identity/resolve` so -/// the backend can validate existence before the daemon opens a pair -/// request. Raw `0x...` wallets are normalized to lowercase first, which -/// matches the canonical form the backend stores; mixed-case checksummed -/// addresses therefore resolve cleanly instead of timing out at approval -/// (codex PR #22 v2 P2 — unknown wallet accepted + case mismatch). -async fn resolve_parent_if_set( - backend_url: &str, +/// the flag is absent. Only raw `0x` + 40-hex wallet literals are accepted; +/// alias/email lookup against `/identity/resolve` was retired with issue #77. +fn resolve_parent_if_set( + _backend_url: &str, parent: Option<&str>, ) -> anyhow::Result> { let Some(raw) = parent else { return Ok(None); }; - // Pick identity_type based on shape. Raw wallets get lowercased to - // match the backend's canonical storage form. - let (identity_type, identity_value) = if looks_like_raw_wallet(raw) { - ("wallet", raw.to_ascii_lowercase()) - } else { - ("alias", raw.to_string()) - }; - - let http = reqwest::Client::new(); - let resp = http - .get(format!("{backend_url}/identity/resolve")) - .query(&[ - ("identity_type", identity_type), - ("identity_value", identity_value.as_str()), - ]) - .send() - .await - .context("resolve --parent: HTTP request failed")?; - if !resp.status().is_success() { + if !looks_like_raw_wallet(raw) { anyhow::bail!( - "could not resolve --parent '{raw}' (identity_type={identity_type}): status={}", - resp.status() + "--parent '{raw}' must be a raw 0x-prefixed 40-hex wallet address (alias/email lookup retired in issue #77)" ); } - let body: serde_json::Value = resp - .json() - .await - .context("resolve --parent: JSON parse failed")?; - let wallet_str = body["wallet_address"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("resolve --parent: missing wallet_address in response"))? - .to_string(); - Ok(Some(WalletAddress(wallet_str))) + + Ok(Some(WalletAddress(raw.to_ascii_lowercase()))) } /// v2 stage-2 master-companion mode (arch.md §10.3.1 + #90). Second diff --git a/crates/agentkeys-daemon/tests/pair_tests.rs b/crates/agentkeys-daemon/tests/pair_tests.rs index 4b8e2c0..c2a42e2 100644 --- a/crates/agentkeys-daemon/tests/pair_tests.rs +++ b/crates/agentkeys-daemon/tests/pair_tests.rs @@ -20,6 +20,31 @@ fn create_test_backend() -> Arc { Arc::new(InProcessBackend::new()) } +/// Direct-DB identity link helper for HTTP-based tests, mirroring +/// `InProcessBackend::link_identity_for_tests`. Used after the +/// `/identity/link` endpoint was retired with issue #77. +fn link_identity_direct( + state: &Arc, + identity_type: &str, + identity_value: &str, + wallet_address: &str, +) { + state + .db + .lock() + .unwrap() + .execute( + "INSERT OR REPLACE INTO identity_links (wallet_address, identity_type, identity_value, created_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + wallet_address, + identity_type, + identity_value, + agentkeys_mock_server::auth::now_secs() + ], + ) + .expect("insert identity_link"); +} + fn dummy_pubkey() -> PublicKey { let signing_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng); let vk = ed25519_dalek::VerifyingKey::from(&signing_key); @@ -641,7 +666,7 @@ async fn recover_via_passkey() { let conn = rusqlite::Connection::open_in_memory().unwrap(); db::init_schema(&conn).unwrap(); let state = std::sync::Arc::new(AppState::new(conn)); - let router = create_router(state); + let router = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); @@ -655,20 +680,8 @@ async fn recover_via_passkey() { .await .unwrap(); - // Link alias via HTTP - let http_client = reqwest::Client::new(); - let resp = http_client - .post(format!("{}/identity/link", backend_url)) - .header("authorization", format!("Bearer {}", master_sess.token)) - .json(&serde_json::json!({ - "identity_type": "alias", - "identity_value": "my-passkey-agent", - "wallet_address": master_wallet.0, - })) - .send() - .await - .unwrap(); - assert!(resp.status().is_success(), "identity link should succeed"); + link_identity_direct(&state, "alias", "my-passkey-agent", &master_wallet.0); + let _ = master_sess; // Recover via passkey let (recovered_sess, recovered_wallet) = client @@ -698,7 +711,7 @@ async fn recover_via_email() { let conn = rusqlite::Connection::open_in_memory().unwrap(); db::init_schema(&conn).unwrap(); let state = std::sync::Arc::new(AppState::new(conn)); - let router = create_router(state); + let router = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); @@ -712,20 +725,8 @@ async fn recover_via_email() { .await .unwrap(); - // Link email identity - let http_client = reqwest::Client::new(); - let resp = http_client - .post(format!("{}/identity/link", backend_url)) - .header("authorization", format!("Bearer {}", master_sess.token)) - .json(&serde_json::json!({ - "identity_type": "email", - "identity_value": "bot@example.com", - "wallet_address": master_wallet.0, - })) - .send() - .await - .unwrap(); - assert!(resp.status().is_success()); + link_identity_direct(&state, "email", "bot@example.com", &master_wallet.0); + let _ = master_sess; let (recovered_sess, recovered_wallet) = client .recover_session( @@ -770,7 +771,7 @@ async fn recover_via_2fa_credentials_intact() { let conn = rusqlite::Connection::open_in_memory().unwrap(); db::init_schema(&conn).unwrap(); let state = std::sync::Arc::new(AppState::new(conn)); - let router = create_router(state); + let router = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); @@ -805,20 +806,7 @@ async fn recover_via_2fa_credentials_intact() { .await .unwrap(); - // Link alias - let http_client = reqwest::Client::new(); - let resp = http_client - .post(format!("{}/identity/link", backend_url)) - .header("authorization", format!("Bearer {}", master_sess.token)) - .json(&serde_json::json!({ - "identity_type": "alias", - "identity_value": "cred-intact-agent", - "wallet_address": master_wallet.0, - })) - .send() - .await - .unwrap(); - assert!(resp.status().is_success()); + link_identity_direct(&state, "alias", "cred-intact-agent", &master_wallet.0); // Recover via passkey let (recovered_sess, recovered_wallet) = client diff --git a/crates/agentkeys-mcp/src/lib.rs b/crates/agentkeys-mcp/src/lib.rs index 3401c5b..93f530c 100644 --- a/crates/agentkeys-mcp/src/lib.rs +++ b/crates/agentkeys-mcp/src/lib.rs @@ -1,6 +1,6 @@ use agentkeys_core::backend::{BackendError, CredentialBackend}; use agentkeys_provisioner::{aws_creds::fetch_via_broker_default_ttl, run_provision, Provisioner}; -use agentkeys_types::{AuditFilter, ServiceName, Session, WalletAddress}; +use agentkeys_types::{ServiceName, Session, WalletAddress}; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::PathBuf; @@ -246,21 +246,9 @@ impl McpHandler { } async fn list_credentials(&self, id: Option) -> JsonRpcResponse { - let filter = AuditFilter { - owner: None, - agent: Some(self.agent_id.clone()), - service: None, - }; - - match self.backend.query_audit(&self.session, filter).await { - Ok(events) => { - let mut services: Vec = events - .into_iter() - .filter(|e| e.action == "store") - .map(|e| e.service.0) - .collect::>() - .into_iter() - .collect(); + match self.backend.list_credentials(&self.session, &self.agent_id).await { + Ok(services) => { + let mut services: Vec = services.into_iter().map(|s| s.0).collect(); services.sort(); JsonRpcResponse::success(id, json!({ "services": services })) } @@ -434,7 +422,7 @@ mod tests { use super::*; use agentkeys_core::backend::BackendError; use agentkeys_types::{ - AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, + AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; @@ -448,7 +436,6 @@ mod tests { async fn create_child_session(&self, _: &Session, _: Scope) -> Result<(Session, WalletAddress), BackendError> { unimplemented!() } async fn store_credential(&self, _: &Session, _: &WalletAddress, _: &ServiceName, _: &[u8]) -> Result<(), BackendError> { Ok(()) } async fn read_credential(&self, _: &Session, _: &WalletAddress, _: &ServiceName) -> Result, BackendError> { Err(BackendError::NotFound("none".into())) } - async fn query_audit(&self, _: &Session, _: AuditFilter) -> Result, BackendError> { unimplemented!() } async fn revoke_session(&self, _: &Session, _: &Session) -> Result<(), BackendError> { unimplemented!() } async fn revoke_by_wallet(&self, _: &Session, _: &WalletAddress) -> Result<(), BackendError> { unimplemented!() } async fn teardown_agent(&self, _: &Session, _: &WalletAddress) -> Result<(), BackendError> { unimplemented!() } @@ -462,7 +449,6 @@ mod tests { async fn await_auth_decision(&self, _: &AuthRequestId) -> Result { unimplemented!() } async fn recover_session(&self, _: &agentkeys_types::AgentIdentity, _: &agentkeys_types::RecoveryMethod) -> Result<(Session, WalletAddress), BackendError> { unimplemented!() } async fn list_credentials(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } - async fn resolve_identity(&self, _: &Session, _: &str) -> Result { unimplemented!() } async fn get_scope(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } async fn update_scope(&self, _: &Session, _: &WalletAddress, _: &Scope) -> Result<(), BackendError> { unimplemented!() } async fn provision_inbox(&self, _: &Session, _: &WalletAddress) -> Result { unimplemented!() } diff --git a/crates/agentkeys-mock-server/src/handlers/audit.rs b/crates/agentkeys-mock-server/src/handlers/audit.rs index d13340e..ff079b1 100644 --- a/crates/agentkeys-mock-server/src/handlers/audit.rs +++ b/crates/agentkeys-mock-server/src/handlers/audit.rs @@ -1,96 +1,11 @@ -use axum::{ - extract::{Query, State}, - http::HeaderMap, - Json, -}; -use serde::Deserialize; +use axum::{extract::State, Json}; use serde_json::{json, Value}; use crate::{ - auth::{extract_bearer_token, validate_session}, - error::{AppError, AppResult}, + error::AppResult, state::SharedState, }; -#[derive(Deserialize)] -pub struct AuditQuery { - pub owner: Option, - pub agent: Option, - pub service: Option, -} - -pub async fn query_audit( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> AppResult> { - let token = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(extract_bearer_token) - .ok_or_else(|| AppError::unauthorized("missing Authorization header"))?; - - let session = validate_session(&state, token)?; - - let db = state.db.lock().unwrap(); - - // Restrict results to events where the session has access. - // A session may see events where: - // 1. owner_wallet == session.wallet (they are the owner), OR - // 2. owner_wallet is a direct child of session.wallet (they own the child), OR - // 3. agent_wallet == session.wallet (they are the agent in the event). - // Use ? placeholders sequentially. - let mut sql = String::from( - "SELECT owner_wallet, agent_wallet, service_name, action, result, timestamp FROM audit_log - WHERE (owner_wallet = ? - OR owner_wallet IN ( - SELECT wallet_address FROM sessions - WHERE parent_token IN (SELECT token FROM sessions WHERE wallet_address = ?) - ) - OR agent_wallet = ?)", - ); - // Bind slots: session wallet (owner check), session wallet (child check), session wallet (agent check) - let mut bind_values: Vec = vec![ - session.wallet_address.clone(), - session.wallet_address.clone(), - session.wallet_address.clone(), - ]; - - if let Some(owner) = &query.owner { - sql.push_str(" AND owner_wallet = ?"); - bind_values.push(owner.clone()); - } - if let Some(agent) = &query.agent { - sql.push_str(" AND agent_wallet = ?"); - bind_values.push(agent.clone()); - } - if let Some(service) = &query.service { - sql.push_str(" AND service_name = ?"); - bind_values.push(service.clone()); - } - - sql.push_str(" ORDER BY timestamp DESC"); - - let mut stmt = db.prepare(&sql).map_err(|e| AppError::internal(e.to_string()))?; - - let events: Vec = stmt - .query_map(rusqlite::params_from_iter(bind_values.iter()), |row| { - Ok(json!({ - "owner": row.get::<_, String>(0)?, - "agent": row.get::<_, String>(1)?, - "service": row.get::<_, String>(2)?, - "action": row.get::<_, String>(3)?, - "result": row.get::<_, String>(4)?, - "timestamp": row.get::<_, u64>(5)?, - })) - }) - .map_err(|e| AppError::internal(e.to_string()))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(Json(json!({ "events": events }))) -} - pub async fn shielding_key( State(state): State, ) -> AppResult> { diff --git a/crates/agentkeys-mock-server/src/handlers/identity.rs b/crates/agentkeys-mock-server/src/handlers/identity.rs index cc16edb..5c1bb7c 100644 --- a/crates/agentkeys-mock-server/src/handlers/identity.rs +++ b/crates/agentkeys-mock-server/src/handlers/identity.rs @@ -1,79 +1,4 @@ -use axum::{ - extract::{Query, State}, - http::HeaderMap, - Json, -}; use rusqlite::params; -use serde::Deserialize; -use serde_json::{json, Value}; - -use crate::{ - auth::{extract_bearer_token, now_secs, validate_session}, - error::{AppError, AppResult}, - state::SharedState, -}; - -pub async fn link_identity( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> AppResult> { - let token = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(extract_bearer_token) - .ok_or_else(|| AppError::unauthorized("missing Authorization header"))?; - - let session = validate_session(&state, token)?; - - let identity_type = body - .get("identity_type") - .and_then(|v| v.as_str()) - .ok_or_else(|| AppError::bad_request("identity_type required"))?; - let identity_value = body - .get("identity_value") - .and_then(|v| v.as_str()) - .ok_or_else(|| AppError::bad_request("identity_value required"))?; - let wallet_address = body - .get("wallet_address") - .and_then(|v| v.as_str()) - .unwrap_or(&session.wallet_address); - - let now = now_secs(); - let db = state.db.lock().unwrap(); - - db.execute( - "INSERT OR REPLACE INTO identity_links (wallet_address, identity_type, identity_value, created_at) - VALUES (?1, ?2, ?3, ?4)", - params![wallet_address, identity_type, identity_value, now], - ) - .map_err(|e| AppError::internal(e.to_string()))?; - - Ok(Json(json!({ "ok": true }))) -} - -#[derive(Deserialize)] -pub struct ResolveIdentityQuery { - pub identity_type: String, - pub identity_value: String, -} - -pub fn resolve_identity_to_wallet( - db: &rusqlite::Connection, - identity_type: &str, - identity_value: &str, -) -> Option { - match identity_type { - "WalletAddress" | "wallet_address" => Some(identity_value.to_string()), - _ => db - .query_row( - "SELECT wallet_address FROM identity_links WHERE identity_type = ?1 AND identity_value = ?2", - params![identity_type, identity_value], - |row| row.get(0), - ) - .ok(), - } -} /// Shared typed identity → wallet resolver (Issue #13, CLAUDE.md Backend Design Principles). /// Called from `approve_auth_request` Recover branch and `recover_session` handler. @@ -109,9 +34,6 @@ pub fn resolve_identity_typed( identity_value ))); } - // Wallet existence check: unknown wallets must return 404 here instead - // of triggering a later FK constraint on INSERT INTO sessions (which - // would surface as 500). Codex P2 on PR #21. let exists: bool = db .query_row( "SELECT 1 FROM accounts WHERE wallet_address = ?1", @@ -133,14 +55,3 @@ pub fn resolve_identity_typed( ))), } } - -pub async fn resolve_identity( - State(state): State, - Query(query): Query, -) -> AppResult> { - let db = state.db.lock().unwrap(); - - let wallet = resolve_identity_typed(&db, &query.identity_type, &query.identity_value)?; - - Ok(Json(json!({ "wallet_address": wallet }))) -} diff --git a/crates/agentkeys-mock-server/src/lib.rs b/crates/agentkeys-mock-server/src/lib.rs index e0b91a6..7df7209 100644 --- a/crates/agentkeys-mock-server/src/lib.rs +++ b/crates/agentkeys-mock-server/src/lib.rs @@ -39,8 +39,6 @@ pub fn create_router(state: SharedState) -> Router { .route("/credential/read", get(handlers::credential::read_credential)) .route("/credential/list", get(handlers::credential::list_credentials)) .route("/credential/teardown", delete(handlers::credential::teardown_agent)) - // Audit - .route("/audit/query", get(handlers::audit::query_audit)) // Shielding key .route("/shielding-key", get(handlers::audit::shielding_key)) // Rendezvous @@ -55,9 +53,6 @@ pub fn create_router(state: SharedState) -> Router { // Session scope .route("/session/scope", get(handlers::session::get_session_scope)) .route("/session/scope", put(handlers::session::update_scope)) - // Identity - .route("/identity/link", post(handlers::identity::link_identity)) - .route("/identity/resolve", get(handlers::identity::resolve_identity)) // Inbox .route("/mock/inbox/provision", post(handlers::inbox::provision_inbox)) .route("/mock/inbox/deliver", post(handlers::inbox::deliver_inbox)) diff --git a/crates/agentkeys-mock-server/src/test_client.rs b/crates/agentkeys-mock-server/src/test_client.rs index b445515..b799de9 100644 --- a/crates/agentkeys-mock-server/src/test_client.rs +++ b/crates/agentkeys-mock-server/src/test_client.rs @@ -10,7 +10,7 @@ use tower::ServiceExt; use agentkeys_core::backend::{BackendError, CredentialBackend}; use agentkeys_types::{ - AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, + AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; @@ -18,8 +18,6 @@ use agentkeys_types::{ use crate::{create_router, db, state::{AppState, SharedState}}; /// Percent-encode the unreserved subset of RFC 3986 for query-string values. -/// Used to safely interpolate user-provided identity values (aliases, emails -/// containing '+', etc.) into the `/identity/resolve` URL. fn pct_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.as_bytes() { @@ -335,78 +333,6 @@ impl CredentialBackend for InProcessBackend { Ok(PublicKey(key_bytes)) } - async fn query_audit( - &self, - session: &Session, - filter: AuditFilter, - ) -> Result, BackendError> { - // Query the DB directly so that a child/agent session can see audit events - // about itself even when those events were recorded by the parent session. - // The HTTP handler's SQL only shows events where owner_wallet belongs to the - // caller, which excludes events stored by a parent on behalf of a child agent. - // Direct DB access gives us the full picture while staying within the crate. - let db = self.state.db.lock().unwrap(); - let session_wallet = &session.token; - - // Resolve the wallet address from the session token. - let wallet_address: String = db - .query_row( - "SELECT wallet_address FROM sessions WHERE token = ?1 AND revoked = 0", - rusqlite::params![session_wallet], - |row| row.get(0), - ) - .map_err(|e| BackendError::AuthFailed(format!("session not found: {e}")))?; - - let mut sql = String::from( - "SELECT owner_wallet, agent_wallet, service_name, action, result, timestamp FROM audit_log \ - WHERE (owner_wallet = ? \ - OR owner_wallet IN ( \ - SELECT wallet_address FROM sessions \ - WHERE parent_token IN (SELECT token FROM sessions WHERE wallet_address = ?) \ - ) \ - OR agent_wallet = ?)", - ); - let mut bind_values: Vec = vec![ - wallet_address.clone(), - wallet_address.clone(), - wallet_address.clone(), - ]; - - if let Some(owner) = &filter.owner { - sql.push_str(" AND owner_wallet = ?"); - bind_values.push(owner.0.clone()); - } - if let Some(agent) = &filter.agent { - sql.push_str(" AND agent_wallet = ?"); - bind_values.push(agent.0.clone()); - } - if let Some(service) = &filter.service { - sql.push_str(" AND service_name = ?"); - bind_values.push(service.0.clone()); - } - sql.push_str(" ORDER BY timestamp DESC"); - - let mut stmt = db.prepare(&sql) - .map_err(|e| BackendError::Transport(format!("prepare: {e}")))?; - - let events: Vec = stmt - .query_map(rusqlite::params_from_iter(bind_values.iter()), |row| { - Ok(AuditEvent { - owner: WalletAddress(row.get::<_, String>(0)?), - agent: WalletAddress(row.get::<_, String>(1)?), - service: ServiceName(row.get::<_, String>(2)?), - action: row.get::<_, String>(3)?, - result: row.get::<_, String>(4)?, - timestamp: row.get::<_, u64>(5)?, - }) - }) - .map_err(|e| BackendError::Transport(format!("query: {e}")))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(events) - } - async fn register_rendezvous( &self, daemon_pubkey: &PublicKey, @@ -702,40 +628,13 @@ impl CredentialBackend for InProcessBackend { Ok(services) } - async fn resolve_identity( - &self, - session: &Session, - identifier: &str, - ) -> Result { - let (identity_type, identity_value) = if identifier.contains('@') { - ("email", identifier.to_string()) - } else { - ("alias", identifier.to_string()) - }; - // Percent-encode the value so reserved characters ('+', '&', '=', '%', - // spaces, '@' when embedded in emails) travel through the query string - // correctly. Mirrors MockHttpClient's reqwest `.query()` builder. - let path = format!( - "/identity/resolve?identity_type={}&identity_value={}", - identity_type, - pct_encode(&identity_value), - ); - let body = self.get_with_session(&path, session).await?; - let wallet_str = body["wallet_address"] - .as_str() - .ok_or_else(|| BackendError::Transport("missing wallet_address".into()))? - .to_string(); - Ok(WalletAddress(wallet_str)) - } - async fn get_scope( &self, session: &Session, target_wallet: &WalletAddress, ) -> Result, BackendError> { // Percent-encode the wallet — matches the `.query()` pattern in - // `MockHttpClient::get_scope` and the `pct_encode` usage in - // `resolve_identity` above. Wallet strings are hex today so this is + // `MockHttpClient::get_scope`. Wallet strings are hex today so this is // safe in practice, but the consistency matters for the // `.github/REVIEW_GUIDELINES.md` URL-encoding invariant (pattern #3). let path = format!("/session/scope?wallet={}", pct_encode(&target_wallet.0)); diff --git a/crates/agentkeys-mock-server/tests/integration.rs b/crates/agentkeys-mock-server/tests/integration.rs index c1479c2..5d85ccf 100644 --- a/crates/agentkeys-mock-server/tests/integration.rs +++ b/crates/agentkeys-mock-server/tests/integration.rs @@ -12,10 +12,39 @@ use tower::ServiceExt; // --------------------------------------------------------------------------- fn setup() -> Router { + let (router, _state) = setup_with_state(); + router +} + +fn setup_with_state() -> (Router, Arc) { let conn = rusqlite::Connection::open_in_memory().unwrap(); db::init_schema(&conn).unwrap(); let state = Arc::new(AppState::new(conn)); - create_router(state) + (create_router(state.clone()), state) +} + +/// Direct-DB identity link helper, used after the `/identity/link` endpoint +/// was retired with issue #77. Mirrors `InProcessBackend::link_identity_for_tests`. +fn link_identity_direct( + state: &Arc, + identity_type: &str, + identity_value: &str, + wallet_address: &str, +) { + state + .db + .lock() + .unwrap() + .execute( + "INSERT OR REPLACE INTO identity_links (wallet_address, identity_type, identity_value, created_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + wallet_address, + identity_type, + identity_value, + agentkeys_mock_server::auth::now_secs() + ], + ) + .expect("insert identity_link"); } async fn body_json(body: axum::body::Body) -> Value { @@ -344,7 +373,7 @@ async fn session_revoke_valid() { assert_eq!(revoke_status, StatusCode::OK); // Child session should now fail - let (status, _) = get_json_auth(app, "/audit/query", &child_session).await; + let (status, _) = get_json_auth(app, "/credential/list?agent_id=0xagent", &child_session).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } @@ -803,35 +832,6 @@ async fn auth_request_await_decision() { assert!(await_json["signature"].is_string()); } -#[tokio::test] -async fn identity_link_and_resolve() { - let app = setup(); - let (session, wallet, app) = create_test_session(app).await; - - // Link identity - let (link_status, _) = post_json_auth( - app.clone(), - "/identity/link", - &session, - json!({ "identity_type": "email", "identity_value": "test@example.com", "wallet_address": wallet }), - ) - .await; - assert_eq!(link_status, StatusCode::OK); - - // Resolve identity - let req = Request::builder() - .method(Method::GET) - .uri("/identity/resolve?identity_type=email&identity_value=test%40example.com") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - let status = resp.status(); - let json = body_json(resp.into_body()).await; - - assert_eq!(status, StatusCode::OK, "{json}"); - assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); -} - // --------------------------------------------------------------------------- // Security/property tests (26-37) // --------------------------------------------------------------------------- @@ -1156,7 +1156,7 @@ async fn nonce_uniqueness() { #[tokio::test] async fn recover_flow_e2e() { use base64::Engine; - let app = setup(); + let (app, state) = setup_with_state(); // Create original session and store credential let (_, orig_json) = post_json(app.clone(), "/session/create", json!({ "auth_token": "recover-user" })).await; @@ -1173,13 +1173,7 @@ async fn recover_flow_e2e() { .await; // Link alias so the Recover request can resolve identity → wallet - post_json_auth( - app.clone(), - "/identity/link", - &orig_session, - json!({ "identity_type": "alias", "identity_value": "recover-user-alias", "wallet_address": orig_wallet }), - ) - .await; + link_identity_direct(&state, "alias", "recover-user-alias", &orig_wallet); // Open a Recover request with required typed identity fields let (_, open_json) = post_json( @@ -1223,7 +1217,7 @@ async fn recover_flow_e2e() { #[tokio::test] async fn recover_wrong_session() { - let app = setup(); + let (app, state) = setup_with_state(); // User A let (_, ja) = post_json(app.clone(), "/session/create", json!({ "auth_token": "recover-a" })).await; @@ -1235,13 +1229,8 @@ async fn recover_wrong_session() { let session_b = jb["session"].as_str().unwrap().to_string(); // Link alias for wallet_a so the Recover request has valid typed fields - post_json_auth( - app.clone(), - "/identity/link", - &session_a, - json!({ "identity_type": "alias", "identity_value": "recover-a-alias", "wallet_address": wallet_a }), - ) - .await; + link_identity_direct(&state, "alias", "recover-a-alias", &wallet_a); + let _ = session_a; // Open Recover for wallet_a with typed identity fields let (_, open_json) = post_json( @@ -1541,170 +1530,9 @@ async fn list_credentials_ownership_enforced() { let session_b = json_b["session"].as_str().unwrap().to_string(); let path = format!("/credential/list?agent_id={}", wallet_a); - let (status, _) = get_json_auth(app.clone(), &path, &session_b).await; + let (status, _) = get_json_auth(app, &path, &session_b).await; assert_eq!(status, StatusCode::FORBIDDEN, "user B must not list user A's credentials"); - - // Codex P2 on PR #19: a denied list_credentials must also leave an audit - // trail so cross-agent probing through the new /credential/list endpoint - // is visible. Query the audit log via the existing /audit endpoint - // (filtered by agent=wallet_a; user A can see events where their wallet is - // the agent_wallet, even when owner_wallet is user B). Confirm a DENIED - // 'list' row appears. - let audit_path = format!("/audit/query?agent={}", wallet_a); - let (audit_status, audit_body) = get_json_auth(app, &audit_path, &session_a).await; - assert_eq!(audit_status, StatusCode::OK, "audit query failed: {audit_body}"); - let events = audit_body["events"].as_array().expect("events array"); - assert!( - events - .iter() - .any(|e| e["action"] == "list" && e["result"] == "DENIED"), - "expected a list/DENIED audit row after the cross-agent list attempt, got: {audit_body}" - ); -} - -// --------------------------------------------------------------------------- -// Issue #13: resolve_identity_typed + typed auth-request fields -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn resolve_identity_alias_returns_wallet() { - let app = setup(); - let (session, wallet, app) = create_test_session(app).await; - - let (link_status, _) = post_json_auth( - app.clone(), - "/identity/link", - &session, - json!({ "identity_type": "alias", "identity_value": "my-bot", "wallet_address": wallet }), - ) - .await; - assert_eq!(link_status, StatusCode::OK); - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/identity/resolve?identity_type=alias&identity_value=my-bot") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - let status = resp.status(); - let json = body_json(resp.into_body()).await; - assert_eq!(status, StatusCode::OK, "{json}"); - assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); -} - -#[tokio::test] -async fn resolve_identity_email_returns_wallet() { - let app = setup(); - let (session, wallet, app) = create_test_session(app).await; - - let (link_status, _) = post_json_auth( - app.clone(), - "/identity/link", - &session, - json!({ "identity_type": "email", "identity_value": "bot@example.com", "wallet_address": wallet }), - ) - .await; - assert_eq!(link_status, StatusCode::OK); - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/identity/resolve?identity_type=email&identity_value=bot%40example.com") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - let status = resp.status(); - let json = body_json(resp.into_body()).await; - assert_eq!(status, StatusCode::OK, "{json}"); - assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); -} - -#[tokio::test] -async fn resolve_identity_wallet_passthrough() { - // Wallet passthrough requires the wallet to exist in `accounts` (codex P2 - // on PR #21: prevents 500 on later FK constraint). Use a wallet created - // via /session/create so the accounts row is present. - let app = setup(); - let (_session, wallet, app) = create_test_session(app).await; - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri(format!("/identity/resolve?identity_type=wallet&identity_value={wallet}")) - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - let status = resp.status(); - let json = body_json(resp.into_body()).await; - assert_eq!(status, StatusCode::OK, "{json}"); - assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); -} - -#[tokio::test] -async fn resolve_identity_not_found_errors() { - let app = setup(); - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/identity/resolve?identity_type=alias&identity_value=nonexistent-bot") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn resolve_identity_invalid_type_errors() { - let app = setup(); - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/identity/resolve?identity_type=unknown_type&identity_value=something") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -// Codex P2 on PR #21: ENS identities must resolve through the identity_links -// table, not silently map to "alias" / get rejected as unknown type. -#[tokio::test] -async fn resolve_identity_ens_returns_wallet() { - let app = setup(); - let (session, wallet, app) = create_test_session(app).await; - - let (link_status, _) = post_json_auth( - app.clone(), - "/identity/link", - &session, - json!({ "identity_type": "ens", "identity_value": "mybot.eth", "wallet_address": wallet }), - ) - .await; - assert_eq!(link_status, StatusCode::OK); - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/identity/resolve?identity_type=ens&identity_value=mybot.eth") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - let status = resp.status(); - let json = body_json(resp.into_body()).await; - assert_eq!(status, StatusCode::OK, "{json}"); - assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); -} - -// Codex P2 on PR #21: an unknown wallet address must return 404 from -// /identity/resolve, not flow through and 500 later on the sessions FK. -#[tokio::test] -async fn resolve_identity_wallet_unknown_returns_not_found() { - let app = setup(); - - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/identity/resolve?identity_type=wallet&identity_value=0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let _ = session_a; } #[tokio::test] @@ -1745,19 +1573,12 @@ async fn open_auth_request_pair_rejects_typed_fields() { #[tokio::test] async fn approve_recover_uses_typed_fields() { - let app = setup(); + let (app, state) = setup_with_state(); let (session, wallet, app) = create_test_session(app).await; - // Link alias identity to the session wallet - let (link_status, _) = post_json_auth( - app.clone(), - "/identity/link", - &session, - json!({ "identity_type": "alias", "identity_value": "recovery-bot", "wallet_address": wallet }), - ) - .await; - assert_eq!(link_status, StatusCode::OK); + // Link alias identity to the session wallet (direct-DB after issue #77). + link_identity_direct(&state, "alias", "recovery-bot", &wallet); // Open Recover request with typed fields let (open_status, open_json) = post_json( diff --git a/crates/agentkeys-provisioner/src/orchestrator.rs b/crates/agentkeys-provisioner/src/orchestrator.rs index a4e4c26..fb73eea 100644 --- a/crates/agentkeys-provisioner/src/orchestrator.rs +++ b/crates/agentkeys-provisioner/src/orchestrator.rs @@ -287,7 +287,7 @@ mod orchestrate { use super::*; use agentkeys_core::backend::BackendError; use agentkeys_types::{ - AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, + AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; @@ -371,7 +371,6 @@ mod orchestrate { async fn create_session(&self, _: agentkeys_types::AuthToken) -> Result<(Session, WalletAddress), BackendError> { unimplemented!() } async fn create_child_session(&self, _: &Session, _: Scope) -> Result<(Session, WalletAddress), BackendError> { unimplemented!() } - async fn query_audit(&self, _: &Session, _: AuditFilter) -> Result, BackendError> { unimplemented!() } async fn revoke_session(&self, _: &Session, _: &Session) -> Result<(), BackendError> { unimplemented!() } async fn revoke_by_wallet(&self, _: &Session, _: &WalletAddress) -> Result<(), BackendError> { unimplemented!() } async fn teardown_agent(&self, _: &Session, _: &WalletAddress) -> Result<(), BackendError> { unimplemented!() } @@ -385,7 +384,6 @@ mod orchestrate { async fn await_auth_decision(&self, _: &AuthRequestId) -> Result { unimplemented!() } async fn recover_session(&self, _: &agentkeys_types::AgentIdentity, _: &agentkeys_types::RecoveryMethod) -> Result<(Session, WalletAddress), BackendError> { unimplemented!() } async fn list_credentials(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } - async fn resolve_identity(&self, _: &Session, _: &str) -> Result { unimplemented!() } async fn get_scope(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } async fn update_scope(&self, _: &Session, _: &WalletAddress, _: &Scope) -> Result<(), BackendError> { unimplemented!() } async fn provision_inbox(&self, _: &Session, _: &WalletAddress) -> Result { unimplemented!() } diff --git a/scripts/broker.env b/scripts/broker.env index d8e89e4..2b952e2 100644 --- a/scripts/broker.env +++ b/scripts/broker.env @@ -27,10 +27,6 @@ # Keep mode 0600 if you ever fill in real secrets. The file as committed # contains no secrets — only the public role ARN and hostnames. -# Loopback to the colocated mock-server (legacy session-validation backend -# for /v1/auth/exchange + /v1/mint-oidc-jwt; broker calls /healthz here too). -BROKER_BACKEND_URL=http://127.0.0.1:8090 - # AWS account that owns agentkeys-data-role. Set explicitly so a fork # operator only edits one line; BROKER_DATA_ROLE_ARN below derives from it. ACCOUNT_ID=429071895007 diff --git a/scripts/setup-broker-host.sh b/scripts/setup-broker-host.sh index 931d463..dac51f1 100755 --- a/scripts/setup-broker-host.sh +++ b/scripts/setup-broker-host.sh @@ -882,7 +882,6 @@ Environment=HOME=/var/lib/agentkeys Environment=ACCOUNT_ID=$ACCOUNT_ID Environment=REGION=$REGION Environment=BROKER_AWS_REGION=$REGION -Environment=BROKER_BACKEND_URL=http://127.0.0.1:8090 Environment=BROKER_OIDC_ISSUER=$ISSUER_URL # Email-link auth (Pass 2 of Option B — see crates/agentkeys-broker-server # /src/plugins/auth/email_link.rs). Comma-separated method list now includes From 8bb713a5f7fcad48b0b2edae485a6869068bd28e Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 08:27:18 +0800 Subject: [PATCH 2/3] operator-workstation.env: refresh /v1/mint-aws-creds comment after #72 retirement --- scripts/operator-workstation.env | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/operator-workstation.env b/scripts/operator-workstation.env index 7e2ec10..fe64d87 100644 --- a/scripts/operator-workstation.env +++ b/scripts/operator-workstation.env @@ -45,9 +45,10 @@ OIDC_ISSUER=https://${BROKER_HOST} OIDC_PROVIDER_ARN=arn:aws:iam::${ACCOUNT_ID}:oidc-provider/${BROKER_HOST} # Federated role ARN — used by the daemon-side -# `aws sts assume-role-with-web-identity` calls in the demo. Same as -# what the broker hands AssumeRoleWithWebIdentity internally for -# /v1/mint-aws-creds callers. +# `aws sts assume-role-with-web-identity` calls in the demo. The daemon +# fetches an OIDC JWT from /v1/mint-oidc-jwt and does +# AssumeRoleWithWebIdentity client-side (issue #71 Option A; issue #72 +# retired the broker-side /v1/mint-aws-creds aggregator). # # Stage-1 v2 split per arch.md §17.2 (per-bucket IAM role): # - DATA_ROLE_ARN → email subsystem (inbound/sent paths). Legacy name From 4243434d97262877debab7a8bd141820d28e4136 Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 09:08:52 +0800 Subject: [PATCH 3/3] mock-server: retire audit_log table + 8 INSERT sites (codex #96 followup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After this PR deleted GET /audit/query, the 8 INSERT INTO audit_log writes in mock-server credential/session handlers became write-only dead code — nothing reads them now and nothing ever will. Production audit lives at broker plugin_mint_log (today) → agentkeys-worker-audit + Heima CredentialAudit contract (post-#97). Mock-server never was on that path. Removed: - credential.rs: store/read/list audit INSERTs (6 sites covering ok, DENIED, DENIED_SCOPE, NOT_FOUND outcomes) - session.rs: scope_update/scope_read audit INSERTs on cross-agent probes (2 sites) - db.rs: CREATE TABLE audit_log schema Tests still green: 48 mock-server, 176 broker, 41 cli, full workspace (30 test-result groups, 0 failed). Resolves codex adversarial-review finding [high] from PR #96 review. --- crates/agentkeys-mock-server/src/db.rs | 10 ---- .../src/handlers/credential.rs | 54 ++----------------- .../src/handlers/session.rs | 18 ------- 3 files changed, 3 insertions(+), 79 deletions(-) diff --git a/crates/agentkeys-mock-server/src/db.rs b/crates/agentkeys-mock-server/src/db.rs index c34dc12..587893e 100644 --- a/crates/agentkeys-mock-server/src/db.rs +++ b/crates/agentkeys-mock-server/src/db.rs @@ -33,16 +33,6 @@ pub fn init_schema(conn: &Connection) -> Result<()> { PRIMARY KEY (wallet_address, service_name) ); - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_wallet TEXT NOT NULL, - agent_wallet TEXT NOT NULL, - service_name TEXT NOT NULL, - action TEXT NOT NULL, - result TEXT NOT NULL, - timestamp INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS rendezvous_registrations ( pair_code TEXT PRIMARY KEY, registration_token TEXT NOT NULL, diff --git a/crates/agentkeys-mock-server/src/handlers/credential.rs b/crates/agentkeys-mock-server/src/handlers/credential.rs index d04f825..38e07f5 100644 --- a/crates/agentkeys-mock-server/src/handlers/credential.rs +++ b/crates/agentkeys-mock-server/src/handlers/credential.rs @@ -73,14 +73,6 @@ pub async fn store_credential( ) .map_err(|e| AppError::internal(e.to_string()))?; - // Audit log - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'store', 'ok', ?4)", - params![session.wallet_address, agent_id, service, now], - ) - .map_err(|e| AppError::internal(e.to_string()))?; - Ok(Json(json!({ "ok": true }))) } @@ -110,13 +102,6 @@ pub async fn read_credential( // Ownership check: caller must own or be the parent of the agent if !is_owner_of(&db, &session.wallet_address, agent_id) { - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'read', 'DENIED', ?4)", - params![session.wallet_address, agent_id, service, now], - ) - .ok(); return Err(AppError::forbidden(format!( "session does not own agent {}", agent_id @@ -130,13 +115,6 @@ pub async fn read_credential( let service_name = agentkeys_types::ServiceName(service.clone()); if !scope.services.contains(&service_name) { - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'read', 'DENIED_SCOPE', ?4)", - params![session.wallet_address, agent_id, service, now], - ) - .ok(); return Err(AppError::forbidden(format!( "Agent {} does not have scope for service {}", session.wallet_address, service @@ -151,24 +129,10 @@ pub async fn read_credential( ); match result { - Err(_) => { - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'read', 'NOT_FOUND', ?4)", - params![session.wallet_address, agent_id, service, now], - ) - .ok(); - Err(AppError::not_found(format!("credential not found for agent={agent_id} service={service}"))) - } + Err(_) => Err(AppError::not_found(format!( + "credential not found for agent={agent_id} service={service}" + ))), Ok(ciphertext) => { - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'read', 'ok', ?4)", - params![session.wallet_address, agent_id, service, now], - ) - .ok(); let encoded = base64::Engine::encode( &base64::engine::general_purpose::STANDARD, &ciphertext, @@ -201,18 +165,6 @@ pub async fn list_credentials( let db = state.db.lock().unwrap(); if !is_owner_of(&db, &session.wallet_address, agent_id) { - // Audit the DENIED list attempt so cross-agent probing through the - // new /credential/list path stays visible in the audit log — the - // existing read_credential audit contract guarantees DENIED rows for - // ownership failures, and this endpoint inherits the same use case - // (called from cmd_run for master sessions). Codex P2 on PR #19. - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'list', 'DENIED', ?4)", - params![session.wallet_address, agent_id, "*", now], - ) - .ok(); return Err(AppError::forbidden(format!( "session does not own agent {}", agent_id diff --git a/crates/agentkeys-mock-server/src/handlers/session.rs b/crates/agentkeys-mock-server/src/handlers/session.rs index 14c968a..8c314fe 100644 --- a/crates/agentkeys-mock-server/src/handlers/session.rs +++ b/crates/agentkeys-mock-server/src/handlers/session.rs @@ -355,15 +355,6 @@ pub async fn update_scope( let db = state.db.lock().unwrap(); if !is_owner_of(&db, &session.wallet_address, &target_wallet) { - // Mirror the read_credential / list_credentials audit contract — - // cross-agent probing of scope endpoints must leave a DENIED row. - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'scope_update', 'DENIED', ?4)", - rusqlite::params![session.wallet_address, target_wallet, "*", now], - ) - .ok(); return Err(AppError::forbidden("session does not own the target wallet")); } @@ -420,15 +411,6 @@ pub async fn get_session_scope( // Only the master that owns the target wallet may query its scope. let db = state.db.lock().unwrap(); if !is_owner_of(&db, &session.wallet_address, &query.wallet) { - // Audit cross-agent scope probing to match the DENIED contract on - // other credential-path endpoints (codex PR #29 P1). - let now = now_secs(); - db.execute( - "INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp) - VALUES (?1, ?2, ?3, 'scope_read', 'DENIED', ?4)", - rusqlite::params![session.wallet_address, query.wallet, "*", now], - ) - .ok(); return Err(AppError::forbidden("session does not own the target wallet")); }