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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
thiserror = "2"
anyhow = "1"
zeroize = { version = "1", features = ["derive"] }
1 change: 1 addition & 0 deletions crates/agentkeys-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ aws-credential-types = "1"
sha2 = "0.10"
hex = "0.4"
thiserror = { workspace = true }
zeroize = { workspace = true }

# Real WebAuthn ceremony (--webauthn flag on `agentkeys k11 enroll/assert`).
# Brings up a localhost axum server that serves the JS calling
Expand Down
41 changes: 29 additions & 12 deletions crates/agentkeys-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ async fn broker_env_for_provision(
use agentkeys_types::{AuthToken, Scope, ServiceName, Session, WalletAddress};
use anyhow::{anyhow, Context, Result};
use serde_json::json;
use zeroize::Zeroizing;

fn format_backend_error(err: &BackendError) -> String {
match err {
Expand Down Expand Up @@ -389,7 +390,7 @@ impl CommandContext {
.ok_or_else(|| anyhow!(
"--credential-backend=s3 requires --omni-account or AGENTKEYS_OMNI_ACCOUNT env (until issue #74 step 2 persists omni in the session JWT)"
))?;
let session_token = self.load_session().ok().map(|s| s.token);
let session_token = self.load_session().ok().map(|s| s.token.clone());
let mut signer = HttpSignerClient::new(&signer_url);
if let Some(ref tok) = session_token {
signer = signer.with_session_jwt(tok.clone());
Expand Down Expand Up @@ -517,6 +518,21 @@ pub enum InitMode {
}

pub async fn cmd_init(ctx: &CommandContext, mode: InitMode) -> Result<(String, Session)> {
cmd_init_with_force(ctx, mode, false).await
}

pub async fn cmd_init_with_force(
ctx: &CommandContext,
mode: InitMode,
force: bool,
) -> Result<(String, Session)> {
if !force {
if let Ok(session) = ctx.load_session() {
let output = format!("Already initialized as {}", session.wallet.0);
return Ok((output, session));
}
}

match mode {
InitMode::ImportLegacyMock(token) => init_legacy_mock(ctx, token).await,
InitMode::Email {
Expand Down Expand Up @@ -755,13 +771,13 @@ pub async fn cmd_read(ctx: &CommandContext, agent: Option<&str>, service: &str)
.await
.map_err(wrap_backend_error)?;

let value = String::from_utf8_lossy(&bytes).to_string();
let value = Zeroizing::new(String::from_utf8_lossy(bytes.as_slice()).into_owned());

if ctx.json_output {
let obj = json!({ "agent": agent_id.0, "service": service, "credential": value });
let obj = json!({ "agent": agent_id.0, "service": service, "credential": value.as_str() });
Ok(serde_json::to_string_pretty(&obj).unwrap())
} else {
Ok(value)
Ok(value.to_string())
}
}

Expand Down Expand Up @@ -823,8 +839,9 @@ pub async fn cmd_run(
// The --env loop below reuses these values instead of issuing a second
// read_credential for the same service, which would double-count audit
// events and rate-limit decrements (codex P2 on PR #19).
let mut fetched: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut env_vars: Vec<(String, String)> = Vec::new();
let mut fetched: std::collections::HashMap<String, Zeroizing<String>> =
std::collections::HashMap::new();
let mut env_vars: Vec<(String, Zeroizing<String>)> = Vec::new();
let mut credential_errors: Vec<String> = Vec::new();
for service in &services_to_try {
let service_name = ServiceName(service.clone());
Expand All @@ -833,7 +850,7 @@ pub async fn cmd_run(
.await
{
Ok(bytes) => {
let value = String::from_utf8_lossy(&bytes).to_string();
let value = Zeroizing::new(String::from_utf8_lossy(bytes.as_slice()).into_owned());
let env_key = format!("{}_API_KEY", service.to_uppercase().replace('-', "_"));
fetched.insert(service.clone(), value.clone());
env_vars.push((env_key, value));
Expand Down Expand Up @@ -872,7 +889,7 @@ pub async fn cmd_run(
.read_credential(&session, &agent_id, &service_name)
.await
.map_err(wrap_backend_error)?;
let v = String::from_utf8_lossy(&bytes).to_string();
let v = Zeroizing::new(String::from_utf8_lossy(bytes.as_slice()).into_owned());
fetched.insert(service.to_string(), v.clone());
v
};
Expand All @@ -894,7 +911,7 @@ pub async fn cmd_run(
let mut child = std::process::Command::new(&cmd[0]);
child.args(&cmd[1..]);
for (k, v) in &env_vars {
child.env(k, v);
child.env(k, v.as_str());
}

let status = child.status().with_context(|| format!("exec {}", cmd[0]))?;
Expand Down Expand Up @@ -1417,7 +1434,7 @@ pub async fn cmd_signer_derive(
let session = ctx
.load_session()
.context("load session (run `agentkeys init` first)")?;
let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token);
let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token.clone());
let derived = client
.derive_address(omni_account)
.await
Expand Down Expand Up @@ -1452,7 +1469,7 @@ pub async fn cmd_signer_sign(
let session = ctx
.load_session()
.context("load session (run `agentkeys init` first)")?;
let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token);
let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token.clone());
let signed = client
.sign_eip191(omni_account, message.as_bytes())
.await
Expand Down Expand Up @@ -1509,7 +1526,7 @@ pub async fn cmd_signer_sign_typed_data(
}
}

let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token);
let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token.clone());
let signed = client
.sign_eip712(omni_account, &typed_data)
.await
Expand Down
17 changes: 12 additions & 5 deletions crates/agentkeys-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use agentkeys_cli::{
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_preview_7730,
cmd_signer_sign, cmd_signer_sign_typed_data, cmd_store, cmd_teardown, cmd_whoami,
CommandContext, CredentialBackendKind, EnvelopeVersionFlag, InitMode,
cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init_with_force,
cmd_provision, cmd_read, cmd_revoke, cmd_run, cmd_scope, cmd_signer_derive,
cmd_signer_preview_7730, cmd_signer_sign, cmd_signer_sign_typed_data, cmd_store, cmd_teardown,
cmd_whoami, CommandContext, CredentialBackendKind, EnvelopeVersionFlag, InitMode,
};

use clap::{Parser, Subcommand};
Expand Down Expand Up @@ -120,6 +120,10 @@ enum Commands {
/// click or OAuth2 callback before failing the init.
#[arg(long, default_value_t = 300)]
poll_timeout_seconds: u64,

/// Ignore an existing saved session and run the init flow again.
#[arg(long)]
force: bool,
},

#[command(
Expand Down Expand Up @@ -722,6 +726,7 @@ async fn main() {
signer_url,
chain_id,
poll_timeout_seconds,
force,
} => {
let broker_opt = broker_url.clone().or_else(|| ctx.broker_url.clone());
let signer = signer_url
Expand Down Expand Up @@ -759,7 +764,9 @@ async fn main() {
)),
};
match mode_result {
Ok(mode) => cmd_init(&ctx, mode).await.map(|(msg, _session)| msg),
Ok(mode) => cmd_init_with_force(&ctx, mode, *force)
.await
.map(|(msg, _session)| msg),
Err(e) => Err(e),
}
}
Expand Down
60 changes: 55 additions & 5 deletions crates/agentkeys-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::sync::Arc;

use agentkeys_cli::{
cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_provision, cmd_read, cmd_revoke, cmd_run,
cmd_scope, cmd_store, cmd_teardown, CommandContext, InitMode,
cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_init_with_force, 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;
Expand Down Expand Up @@ -93,6 +93,56 @@ async fn cli_init_creates_session() {
);
}

#[tokio::test(flavor = "multi_thread")]
async fn cli_init_is_idempotent_when_session_exists() {
let (store, _tmp) = test_store();
let backend = create_test_backend();
let ctx = CommandContext::new("unused", false, false)
.with_backend(backend as Arc<dyn CredentialBackend>)
.with_session_store(store);

let (_first_output, first_session) =
cmd_init(&ctx, InitMode::ImportLegacyMock("first-token".to_string()))
.await
.unwrap();

let (second_output, second_session) =
cmd_init(&ctx, InitMode::ImportLegacyMock("second-token".to_string()))
.await
.unwrap();

assert_eq!(second_session, first_session);
assert_eq!(
second_output,
format!("Already initialized as {}", first_session.wallet.0)
);
}

#[tokio::test(flavor = "multi_thread")]
async fn cli_init_force_reinitializes_existing_session() {
let (store, _tmp) = test_store();
let backend = create_test_backend();
let ctx = CommandContext::new("unused", false, false)
.with_backend(backend as Arc<dyn CredentialBackend>)
.with_session_store(store);

let (_first_output, first_session) =
cmd_init(&ctx, InitMode::ImportLegacyMock("first-token".to_string()))
.await
.unwrap();

let (_second_output, second_session) = cmd_init_with_force(
&ctx,
InitMode::ImportLegacyMock("second-token".to_string()),
true,
)
.await
.unwrap();

assert_ne!(second_session.token, first_session.token);
assert_eq!(ctx.load_session().unwrap(), second_session);
}

// Test 2: store then read returns the same key
#[tokio::test(flavor = "multi_thread")]
async fn cli_store_and_read() {
Expand Down Expand Up @@ -837,7 +887,7 @@ async fn start_scope_test_server() -> (String, String, String, SessionStore, tem
.unwrap();
let child_wallet = child_resp["wallet"].as_str().unwrap().to_string();

(base_url, _session.token, child_wallet, store, tmp)
(base_url, _session.token.clone(), child_wallet, store, tmp)
}

// Test 15: --add appends a service
Expand Down Expand Up @@ -1149,9 +1199,9 @@ impl CredentialBackend for ProvisionTestBackend {
_: &Session,
_: &agentkeys_types::WalletAddress,
_: &agentkeys_types::ServiceName,
) -> Result<Vec<u8>, agentkeys_core::backend::BackendError> {
) -> Result<agentkeys_types::SecretBytes, agentkeys_core::backend::BackendError> {
match &self.existing_credential {
Some(b) => Ok(b.clone()),
Some(b) => Ok(agentkeys_types::SecretBytes::new(b.clone())),
None => Err(agentkeys_core::backend::BackendError::NotFound(
"none".into(),
)),
Expand Down
6 changes: 3 additions & 3 deletions crates/agentkeys-core/src/backend.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use agentkeys_types::{
AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload,
InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope,
ServiceName, Session, SignedAuthDecision, WalletAddress,
SecretBytes, ServiceName, Session, SignedAuthDecision, WalletAddress,
};
use async_trait::async_trait;
use thiserror::Error;
Expand Down Expand Up @@ -52,7 +52,7 @@ pub trait CredentialBackend: Send + Sync {
session: &Session,
agent_id: &WalletAddress,
service: &ServiceName,
) -> Result<Vec<u8>, BackendError>;
) -> Result<SecretBytes, BackendError>;

async fn revoke_session(&self, session: &Session, target: &Session)
-> Result<(), BackendError>;
Expand Down Expand Up @@ -191,7 +191,7 @@ mod tests {
_session: &Session,
_agent_id: &WalletAddress,
_service: &ServiceName,
) -> Result<Vec<u8>, BackendError> {
) -> Result<SecretBytes, BackendError> {
unimplemented!()
}

Expand Down
6 changes: 3 additions & 3 deletions crates/agentkeys-core/src/mock_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::backend::{BackendError, CredentialBackend};
use agentkeys_types::{
AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload,
InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope,
ServiceName, Session, SignedAuthDecision, WalletAddress,
SecretBytes, ServiceName, Session, SignedAuthDecision, WalletAddress,
};

pub struct MockHttpClient {
Expand Down Expand Up @@ -174,7 +174,7 @@ impl CredentialBackend for MockHttpClient {
session: &Session,
agent_id: &WalletAddress,
service: &ServiceName,
) -> Result<Vec<u8>, BackendError> {
) -> Result<SecretBytes, BackendError> {
let url = format!(
"/credential/read?agent_id={}&service={}",
agent_id.0, service.0
Expand Down Expand Up @@ -202,7 +202,7 @@ impl CredentialBackend for MockHttpClient {
let bytes = base64::engine::general_purpose::STANDARD
.decode(ct_b64)
.map_err(|e| BackendError::Internal(format!("base64 decode: {e}")))?;
Ok(bytes)
Ok(SecretBytes::new(bytes))
}

async fn revoke_session(
Expand Down
Loading
Loading