diff --git a/Cargo.lock b/Cargo.lock index e2407cb7..0196505f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tower-service", + "zeroize", ] [[package]] @@ -283,6 +284,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "zeroize", ] [[package]] @@ -5330,6 +5332,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 660e67b3..677a6223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ tokio = { version = "1", features = ["full"] } async-trait = "0.1" thiserror = "2" anyhow = "1" +zeroize = { version = "1", features = ["derive"] } diff --git a/crates/agentkeys-cli/Cargo.toml b/crates/agentkeys-cli/Cargo.toml index 8a87fea2..def7f8ef 100644 --- a/crates/agentkeys-cli/Cargo.toml +++ b/crates/agentkeys-cli/Cargo.toml @@ -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 diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index 5962ce64..afe5ef66 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -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 { @@ -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()); @@ -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 { @@ -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()) } } @@ -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 = std::collections::HashMap::new(); - let mut env_vars: Vec<(String, String)> = Vec::new(); + let mut fetched: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut env_vars: Vec<(String, Zeroizing)> = Vec::new(); let mut credential_errors: Vec = Vec::new(); for service in &services_to_try { let service_name = ServiceName(service.clone()); @@ -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)); @@ -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 }; @@ -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]))?; @@ -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 @@ -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 @@ -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 diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index 7219b2db..d1b5ce4b 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -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}; @@ -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( @@ -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 @@ -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), } } diff --git a/crates/agentkeys-cli/tests/cli_tests.rs b/crates/agentkeys-cli/tests/cli_tests.rs index 6f6f942f..fdffc2af 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_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; @@ -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) + .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) + .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() { @@ -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 @@ -1149,9 +1199,9 @@ impl CredentialBackend for ProvisionTestBackend { _: &Session, _: &agentkeys_types::WalletAddress, _: &agentkeys_types::ServiceName, - ) -> Result, agentkeys_core::backend::BackendError> { + ) -> Result { 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(), )), diff --git a/crates/agentkeys-core/src/backend.rs b/crates/agentkeys-core/src/backend.rs index b5947f48..229fd8f3 100644 --- a/crates/agentkeys-core/src/backend.rs +++ b/crates/agentkeys-core/src/backend.rs @@ -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; @@ -52,7 +52,7 @@ pub trait CredentialBackend: Send + Sync { session: &Session, agent_id: &WalletAddress, service: &ServiceName, - ) -> Result, BackendError>; + ) -> Result; async fn revoke_session(&self, session: &Session, target: &Session) -> Result<(), BackendError>; @@ -191,7 +191,7 @@ mod tests { _session: &Session, _agent_id: &WalletAddress, _service: &ServiceName, - ) -> Result, BackendError> { + ) -> Result { unimplemented!() } diff --git a/crates/agentkeys-core/src/mock_client.rs b/crates/agentkeys-core/src/mock_client.rs index a0778786..edf24f8b 100644 --- a/crates/agentkeys-core/src/mock_client.rs +++ b/crates/agentkeys-core/src/mock_client.rs @@ -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 { @@ -174,7 +174,7 @@ impl CredentialBackend for MockHttpClient { session: &Session, agent_id: &WalletAddress, service: &ServiceName, - ) -> Result, BackendError> { + ) -> Result { let url = format!( "/credential/read?agent_id={}&service={}", agent_id.0, service.0 @@ -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( diff --git a/crates/agentkeys-core/src/s3_backend.rs b/crates/agentkeys-core/src/s3_backend.rs index 8f9b63a2..a7473a52 100644 --- a/crates/agentkeys-core/src/s3_backend.rs +++ b/crates/agentkeys-core/src/s3_backend.rs @@ -70,7 +70,7 @@ use crate::signer_client::{SignerClient, SignerClientError}; use agentkeys_types::{ AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, Scope, - ServiceName, Session, SignedAuthDecision, WalletAddress, + SecretBytes, ServiceName, Session, SignedAuthDecision, WalletAddress, }; /// AEAD wire-format version byte. v1 (wallet-keyed AAD) is the original @@ -386,7 +386,7 @@ impl S3CredentialBackend { wallet: &WalletAddress, service: &ServiceName, envelope: &[u8], - ) -> Result, BackendError> { + ) -> Result { if envelope.len() < 1 + 12 + 16 { return Err(BackendError::Internal(format!( "envelope too short: {} bytes", @@ -404,7 +404,7 @@ impl S3CredentialBackend { let ciphertext = &envelope[13..]; let cipher = Aes256Gcm::new(Key::::from_slice(kek)); let aad = aad_for_version(version, wallet, service)?; - cipher + let plaintext = cipher .decrypt( nonce, Payload { @@ -412,7 +412,8 @@ impl S3CredentialBackend { aad: &aad, }, ) - .map_err(|e| BackendError::Internal(format!("aes-gcm open: {e}"))) + .map_err(|e| BackendError::Internal(format!("aes-gcm open: {e}")))?; + Ok(SecretBytes::new(plaintext)) } } @@ -560,7 +561,7 @@ impl CredentialBackend for S3CredentialBackend { session: &Session, agent_id: &WalletAddress, service: &ServiceName, - ) -> Result, BackendError> { + ) -> Result { enforce_scope_for_service(session, service, false)?; // Dual-path read per issue-v2-stage-1-foundation.md migration step // 10: try v2 (actor_omni-keyed) path first, fall back to v1 @@ -1109,7 +1110,7 @@ mod tests { assert_eq!(envelope[0], ENVELOPE_VERSION_V1); assert!(envelope.len() > 1 + 12 + 16); let opened = S3CredentialBackend::open(&kek, &wallet, &svc, &envelope).unwrap(); - assert_eq!(opened, plaintext); + assert_eq!(opened.as_slice(), plaintext); } #[test] @@ -1123,7 +1124,7 @@ mod tests { S3CredentialBackend::seal(ENVELOPE_VERSION_V2, &kek, &wallet, &svc, plaintext).unwrap(); assert_eq!(envelope[0], ENVELOPE_VERSION_V2); let opened = S3CredentialBackend::open(&kek, &wallet, &svc, &envelope).unwrap(); - assert_eq!(opened, plaintext); + assert_eq!(opened.as_slice(), plaintext); } #[test] @@ -1141,7 +1142,9 @@ mod tests { // Sanity: a v2-shaped envelope decrypted against itself works. let v2 = S3CredentialBackend::seal(ENVELOPE_VERSION_V2, &kek, &wallet, &svc, b"x").unwrap(); assert_eq!( - S3CredentialBackend::open(&kek, &wallet, &svc, &v2).unwrap(), + S3CredentialBackend::open(&kek, &wallet, &svc, &v2) + .unwrap() + .as_slice(), b"x" ); } @@ -1246,7 +1249,7 @@ mod tests { assert_eq!(env[0], ENVELOPE_VERSION_V2); // Round-trip via the public open() — dispatches on version byte. let opened = S3CredentialBackend::open(&kek, &wallet, &svc, &env).unwrap(); - assert_eq!(opened, b"x"); + assert_eq!(opened.as_slice(), b"x"); } #[test] diff --git a/crates/agentkeys-daemon/tests/pair_tests.rs b/crates/agentkeys-daemon/tests/pair_tests.rs index c448a7f3..af5542d3 100644 --- a/crates/agentkeys-daemon/tests/pair_tests.rs +++ b/crates/agentkeys-daemon/tests/pair_tests.rs @@ -515,7 +515,8 @@ async fn recover_full_loop() { .await .unwrap(); assert_eq!( - cred_bytes, b"sk-or-v1-recover-test", + cred_bytes.as_slice(), + b"sk-or-v1-recover-test", "credential should survive recovery" ); } @@ -754,7 +755,8 @@ async fn recover_credentials_intact() { .await .unwrap(); assert_eq!( - or_cred, b"sk-or-v1-original", + or_cred.as_slice(), + b"sk-or-v1-original", "openrouter credential should be intact after recovery" ); @@ -767,7 +769,8 @@ async fn recover_credentials_intact() { .await .unwrap(); assert_eq!( - ant_cred, b"sk-ant-original", + ant_cred.as_slice(), + b"sk-ant-original", "anthropic credential should be intact after recovery" ); } @@ -944,7 +947,8 @@ async fn recover_via_2fa_credentials_intact() { .await .unwrap(); assert_eq!( - or_cred, b"sk-or-v1-2fa-test", + or_cred.as_slice(), + b"sk-or-v1-2fa-test", "openrouter credential should survive 2FA recovery" ); @@ -957,7 +961,8 @@ async fn recover_via_2fa_credentials_intact() { .await .unwrap(); assert_eq!( - ant_cred, b"sk-ant-2fa-test", + ant_cred.as_slice(), + b"sk-ant-2fa-test", "anthropic credential should survive 2FA recovery" ); } diff --git a/crates/agentkeys-mcp/src/lib.rs b/crates/agentkeys-mcp/src/lib.rs index a4f01f4f..0932c75c 100644 --- a/crates/agentkeys-mcp/src/lib.rs +++ b/crates/agentkeys-mcp/src/lib.rs @@ -480,7 +480,7 @@ mod tests { _: &Session, _: &WalletAddress, _: &ServiceName, - ) -> Result, BackendError> { + ) -> Result { Err(BackendError::NotFound("none".into())) } async fn revoke_session(&self, _: &Session, _: &Session) -> Result<(), BackendError> { diff --git a/crates/agentkeys-mock-server/src/test_client.rs b/crates/agentkeys-mock-server/src/test_client.rs index 69e295ea..f1985cd4 100644 --- a/crates/agentkeys-mock-server/src/test_client.rs +++ b/crates/agentkeys-mock-server/src/test_client.rs @@ -12,7 +12,7 @@ use agentkeys_core::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, }; use crate::{ @@ -274,7 +274,7 @@ impl CredentialBackend for InProcessBackend { session: &Session, agent_id: &WalletAddress, service: &ServiceName, - ) -> Result, BackendError> { + ) -> Result { let path = format!( "/credential/read?agent_id={}&service={}", agent_id.0, service.0 @@ -287,7 +287,7 @@ impl CredentialBackend for InProcessBackend { let bytes = base64::engine::general_purpose::STANDARD .decode(ct_b64) .map_err(|e| BackendError::Transport(format!("base64 decode: {e}")))?; - Ok(bytes) + Ok(SecretBytes::new(bytes)) } async fn revoke_session( diff --git a/crates/agentkeys-provisioner/src/orchestrator.rs b/crates/agentkeys-provisioner/src/orchestrator.rs index 1a7d4c03..f19db63d 100644 --- a/crates/agentkeys-provisioner/src/orchestrator.rs +++ b/crates/agentkeys-provisioner/src/orchestrator.rs @@ -364,10 +364,10 @@ mod orchestrate { _session: &Session, _agent_id: &WalletAddress, _service: &ServiceName, - ) -> Result, BackendError> { + ) -> Result { let guard = self.read_result.lock().unwrap(); match guard.as_ref() { - Some(bytes) => Ok(bytes.clone()), + Some(bytes) => Ok(agentkeys_types::SecretBytes::new(bytes.clone())), None => Err(BackendError::NotFound("no credential".to_string())), } } diff --git a/crates/agentkeys-types/Cargo.toml b/crates/agentkeys-types/Cargo.toml index 50065ec9..250b7b40 100644 --- a/crates/agentkeys-types/Cargo.toml +++ b/crates/agentkeys-types/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] serde = { workspace = true } serde_json = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/agentkeys-types/src/lib.rs b/crates/agentkeys-types/src/lib.rs index 74a134ea..e7e24885 100644 --- a/crates/agentkeys-types/src/lib.rs +++ b/crates/agentkeys-types/src/lib.rs @@ -1,6 +1,7 @@ use std::fmt; use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; pub mod provision; @@ -18,15 +19,33 @@ impl fmt::Display for InboxAddress { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub type SecretBytes = Zeroizing>; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] pub struct Session { pub token: String, + #[zeroize(skip)] pub wallet: WalletAddress, + #[zeroize(skip)] pub scope: Option, + #[zeroize(skip)] pub created_at: u64, + #[zeroize(skip)] pub ttl_seconds: u64, } +impl fmt::Debug for Session { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Session") + .field("token", &"") + .field("wallet", &self.wallet) + .field("scope", &self.scope) + .field("created_at", &self.created_at) + .field("ttl_seconds", &self.ttl_seconds) + .finish() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Scope { pub services: Vec, @@ -212,6 +231,20 @@ mod tests { assert_eq!(session, back); } + #[test] + fn session_debug_redacts_token() { + let session = Session { + token: "test-token".into(), + wallet: WalletAddress("0x1234".into()), + scope: None, + created_at: 1000, + ttl_seconds: 3600, + }; + let debug = format!("{session:?}"); + assert!(!debug.contains("test-token")); + assert!(debug.contains("")); + } + #[test] fn recovery_method_serialize_roundtrip() { for method in [ diff --git a/scripts/setup-broker-host.sh b/scripts/setup-broker-host.sh index 166d01c2..a6f52e00 100755 --- a/scripts/setup-broker-host.sh +++ b/scripts/setup-broker-host.sh @@ -1476,15 +1476,21 @@ if [[ "$WITH_NGINX" == "yes" ]]; then write_worker_nginx_site cred "$CRED_HOST" 9094 write_worker_nginx_site memory "$MEMORY_HOST" 9095 fi - # Single point of enabling — one ln -sf per vhost (idempotent), default + # Single point of enabling — replace each vhost symlink idempotently, default # vhost out of the way. Done here (not inside write_nginx_site) so the # symlinks aren't sprinkled across HTTPS / HTTP-only branches. if [[ -d /etc/nginx/sites-enabled ]]; then - sudo ln -sf /etc/nginx/sites-available/agentkeys-broker /etc/nginx/sites-enabled/ - sudo ln -sf /etc/nginx/sites-available/agentkeys-signer /etc/nginx/sites-enabled/ + enable_nginx_site() { + local source_file="$1" + local target_file="/etc/nginx/sites-enabled/$(basename "$source_file")" + sudo rm -f "$target_file" + sudo ln -s "$source_file" "$target_file" + } + enable_nginx_site /etc/nginx/sites-available/agentkeys-broker + enable_nginx_site /etc/nginx/sites-available/agentkeys-signer if [[ "$WITH_WORKERS" == "yes" ]]; then for slug in audit email cred memory; do - sudo ln -sf "/etc/nginx/sites-available/agentkeys-worker-$slug" /etc/nginx/sites-enabled/ + enable_nginx_site "/etc/nginx/sites-available/agentkeys-worker-$slug" done fi sudo rm -f /etc/nginx/sites-enabled/default