diff --git a/src/main.rs b/src/main.rs index 63578a3..704a773 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ use nx9_wireguard::{ use serde::Serialize; use std::io::{self, Read}; use std::net::{IpAddr, SocketAddr}; +use std::os::unix::fs::OpenOptionsExt; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -335,6 +336,11 @@ enum TokenSubcommands { name: String, #[arg(long, help = "Validity in days (omit for never expiring)")] days: Option, + #[arg( + long, + help = "Write the plaintext token to a file (restricted mode 0600)" + )] + write_token_file: Option, }, #[command(about = "List all API tokens")] List, @@ -1201,14 +1207,26 @@ async fn main() -> Result<(), Box> { println!("Administrator initialized successfully."); println!(" Username: {}", res.admin.username); println!(" Source: {}", res.source.description()); - if let Some(ref pw) = res.generated_plaintext { - println!("\n Generated Password (SAVE THIS IMMEDIATELY):"); - println!(" ========================================"); - println!(" {pw}"); - println!(" ========================================\n"); + if let Some(_pw) = res.generated_plaintext { + if let Some(ref path) = opts.write_password_file { + println!("Generated password written to file: {}", path); + } else { + println!("Generated password created (redacted)"); + } } } - print_output(&res.admin, format)?; + + // Sanitize admin output to avoid leaking password hashes or secrets + let admin_sanitized = serde_json::json!({ + "id": res.admin.id, + "username": res.admin.username, + "created_at": res.admin.created_at, + "last_login_at": res.admin.last_login_at, + "last_login_ip": res.admin.last_login_ip, + "totp_enabled": res.admin.totp_enabled, + "password_hash": "[REDACTED]" + }); + print_output(&admin_sanitized, format)?; } Err(ApiError::Conflict(_)) => { if !cli.quiet { @@ -1375,7 +1393,17 @@ async fn main() -> Result<(), Box> { println!("Generated password created (redacted)"); } } - print_output(&res.admin, format)?; + // Sanitize admin output to avoid leaking password hashes or secrets + let admin_sanitized = serde_json::json!({ + "id": res.admin.id, + "username": res.admin.username, + "created_at": res.admin.created_at, + "last_login_at": res.admin.last_login_at, + "last_login_ip": res.admin.last_login_ip, + "totp_enabled": res.admin.totp_enabled, + "password_hash": "[REDACTED]" + }); + print_output(&admin_sanitized, format)?; } Err(ApiError::Conflict(_)) => { eprintln!("Administrator already exists."); @@ -1433,20 +1461,76 @@ async fn main() -> Result<(), Box> { } }, AdminSubcommands::Tokens(token_args) => match token_args.subcommand { - TokenSubcommands::Create { name, days } => { + TokenSubcommands::Create { + name, + days, + write_token_file, + } => { let exp = days .map(|d| chrono::Utc::now().naive_utc() + chrono::Duration::days(d)); match auth.create_api_token(&name, exp, Some("cli")).await { Ok((meta, raw_token)) => { println!("API Token Created:"); - println!(" ID: {}", meta.id); - println!(" Name: {}", meta.name); - println!(" Expires: {:?}", meta.expires_at); - println!("\n Secret Token (SAVE THIS NOW):"); - println!(" ========================================"); - println!(" {raw_token}"); - println!(" ========================================\n"); - print_output(&meta, format)?; + // One-time delivery: either write to a restricted file, or display once to stdout + if let Some(path) = write_token_file { + if let Some(parent) = path.parent() + && !parent.exists() + && let Err(e) = std::fs::create_dir_all(parent) + { + eprintln!( + "Failed to create parent directory for token file '{}': {}", + parent.display(), + e + ); + std::process::exit(1); + } + use std::io::Write; + match std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&path) + { + Ok(mut f) => { + if let Err(e) = f.write_all(raw_token.as_bytes()) { + eprintln!( + "Failed to write token to file '{}': {}", + path.display(), + e + ); + std::process::exit(1); + } + } + Err(e) => { + eprintln!( + "Failed to create token file '{}': {}", + path.display(), + e + ); + std::process::exit(1); + } + } + println!("Token written to file: {}", path.display()); + } else { + println!(" ID: {}", meta.id); + println!(" Name: {}", meta.name); + println!(" Expires: {:?}", meta.expires_at); + println!("\n Secret Token (SAVE THIS NOW):"); + println!(" ========================================"); + println!(" {raw_token}"); + println!(" ========================================\n"); + } + + // Sanitize token metadata for output (never expose token_hash) + let mut meta_val = serde_json::to_value(&meta)?; + if let serde_json::Value::Object(ref mut obj) = meta_val { + obj.insert( + "token_hash".to_string(), + serde_json::Value::String("[REDACTED]".to_string()), + ); + } + print_output(&meta_val, format)?; } Err(e) => { eprintln!("Error creating token: {e}"); @@ -1456,7 +1540,18 @@ async fn main() -> Result<(), Box> { } TokenSubcommands::List => { let tokens = store.list_tokens().await?; - print_output(&tokens, format)?; + let mut tokens_val = serde_json::to_value(&tokens)?; + if let serde_json::Value::Array(ref mut arr) = tokens_val { + for item in arr.iter_mut() { + if let serde_json::Value::Object(obj) = item { + obj.insert( + "token_hash".to_string(), + serde_json::Value::String("[REDACTED]".to_string()), + ); + } + } + } + print_output(&tokens_val, format)?; } TokenSubcommands::Revoke { id } => { auth.revoke_api_token(&id, Some("cli")).await?; diff --git a/tests/test_cli_commands.rs b/tests/test_cli_commands.rs index 32d328c..05c72d1 100644 --- a/tests/test_cli_commands.rs +++ b/tests/test_cli_commands.rs @@ -95,7 +95,13 @@ fn test_cli_admin_init_and_management() { assert!(ok); assert!(out.contains("Administrator password updated successfully")); - // Test API token creation + // Test API token creation (write one-time token to a restricted file) + let token_file = runner + ._temp_dir + .path() + .join("admin_token.txt") + .to_string_lossy() + .to_string(); let (ok, out, _) = runner.run(&[ "admin", "tokens", @@ -104,10 +110,26 @@ fn test_cli_admin_init_and_management() { "ci-deployer", "--days", "30", + "--write-token-file", + &token_file, ]); assert!(ok); assert!(out.contains("API Token Created")); - assert!(out.contains("Secret Token")); + assert!(out.contains("Token written to file")); + // Verify token file exists and contains the token once + let token_contents = std::fs::read_to_string(&token_file).expect("read token file"); + assert!(token_contents.starts_with("nx9_")); + // verify restrictive permissions on unix systems + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&token_file) + .expect("stat") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } // List tokens let (ok, out, _) = runner.run(&["admin", "tokens", "list", "--format", "json"]);