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:
1 parent
2ac6c81dfe
commit
3c43b56e09
2 files changed
+136
-19
No files matched your search
+112
-17
@@ -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<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")]
|
||||
List,
|
||||
@@ -1201,14 +1207,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
},
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
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?;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
Reference in new issue
Block a user