secure(api-tokens): one-time token file delivery and redact token_hash in CLI output

- Add --write-token-file option to admin tokens create
- Write token file with restrictive 0600 permissions; print only 'Token written to file: <PATH>'
- Preserve optional one-time stdout display when file not provided (labelled)
- Sanitize token metadata and token list by replacing token_hash with [REDACTED]
- Update CLI tests to use --write-token-file and assert file content & permissions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
thakaresandCopilot committed 2026-08-16 17:09:07 +05:30
1 parent 2ac6c81dfe
commit 3c43b56e09
2 files changed
+129 -12

No files matched your search

+105 -10
View File
@@ -33,6 +33,7 @@ use nx9_wireguard::{
use serde::Serialize; use serde::Serialize;
use std::io::{self, Read}; use std::io::{self, Read};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@@ -335,6 +336,11 @@ enum TokenSubcommands {
name: String, name: String,
#[arg(long, help = "Validity in days (omit for never expiring)")] #[arg(long, help = "Validity in days (omit for never expiring)")]
days: Option<i64>, days: Option<i64>,
#[arg(
long,
help = "Write the plaintext token to a file (restricted mode 0600)"
)]
write_token_file: Option<PathBuf>,
}, },
#[command(about = "List all API tokens")] #[command(about = "List all API tokens")]
List, List,
@@ -1201,14 +1207,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Administrator initialized successfully."); println!("Administrator initialized successfully.");
println!(" Username: {}", res.admin.username); println!(" Username: {}", res.admin.username);
println!(" Source: {}", res.source.description()); println!(" Source: {}", res.source.description());
if let Some(ref pw) = res.generated_plaintext { if let Some(_pw) = res.generated_plaintext {
println!("\n Generated Password (SAVE THIS IMMEDIATELY):"); if let Some(ref path) = opts.write_password_file {
println!(" ========================================"); println!("Generated password written to file: {}", path);
println!(" {pw}"); } else {
println!(" ========================================\n"); 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(_)) => { Err(ApiError::Conflict(_)) => {
if !cli.quiet { if !cli.quiet {
@@ -1375,7 +1393,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Generated password created (redacted)"); 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(_)) => { Err(ApiError::Conflict(_)) => {
eprintln!("Administrator already exists."); eprintln!("Administrator already exists.");
@@ -1433,12 +1461,58 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}, },
AdminSubcommands::Tokens(token_args) => match token_args.subcommand { AdminSubcommands::Tokens(token_args) => match token_args.subcommand {
TokenSubcommands::Create { name, days } => { TokenSubcommands::Create {
name,
days,
write_token_file,
} => {
let exp = days let exp = days
.map(|d| chrono::Utc::now().naive_utc() + chrono::Duration::days(d)); .map(|d| chrono::Utc::now().naive_utc() + chrono::Duration::days(d));
match auth.create_api_token(&name, exp, Some("cli")).await { match auth.create_api_token(&name, exp, Some("cli")).await {
Ok((meta, raw_token)) => { Ok((meta, raw_token)) => {
println!("API Token Created:"); println!("API Token Created:");
// 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!(" ID: {}", meta.id);
println!(" Name: {}", meta.name); println!(" Name: {}", meta.name);
println!(" Expires: {:?}", meta.expires_at); println!(" Expires: {:?}", meta.expires_at);
@@ -1446,7 +1520,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(" ========================================"); println!(" ========================================");
println!(" {raw_token}"); println!(" {raw_token}");
println!(" ========================================\n"); println!(" ========================================\n");
print_output(&meta, format)?; }
// 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) => { Err(e) => {
eprintln!("Error creating token: {e}"); eprintln!("Error creating token: {e}");
@@ -1456,7 +1540,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
TokenSubcommands::List => { TokenSubcommands::List => {
let tokens = store.list_tokens().await?; 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 } => { TokenSubcommands::Revoke { id } => {
auth.revoke_api_token(&id, Some("cli")).await?; auth.revoke_api_token(&id, Some("cli")).await?;
+24 -2
View File
@@ -95,7 +95,13 @@ fn test_cli_admin_init_and_management() {
assert!(ok); assert!(ok);
assert!(out.contains("Administrator password updated successfully")); 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(&[ let (ok, out, _) = runner.run(&[
"admin", "admin",
"tokens", "tokens",
@@ -104,10 +110,26 @@ fn test_cli_admin_init_and_management() {
"ci-deployer", "ci-deployer",
"--days", "--days",
"30", "30",
"--write-token-file",
&token_file,
]); ]);
assert!(ok); assert!(ok);
assert!(out.contains("API Token Created")); 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 // List tokens
let (ok, out, _) = runner.run(&["admin", "tokens", "list", "--format", "json"]); let (ok, out, _) = runner.run(&["admin", "tokens", "list", "--format", "json"]);