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 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,20 +1461,76 @@ 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:");
|
||||||
println!(" ID: {}", meta.id);
|
// One-time delivery: either write to a restricted file, or display once to stdout
|
||||||
println!(" Name: {}", meta.name);
|
if let Some(path) = write_token_file {
|
||||||
println!(" Expires: {:?}", meta.expires_at);
|
if let Some(parent) = path.parent()
|
||||||
println!("\n Secret Token (SAVE THIS NOW):");
|
&& !parent.exists()
|
||||||
println!(" ========================================");
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||||
println!(" {raw_token}");
|
{
|
||||||
println!(" ========================================\n");
|
eprintln!(
|
||||||
print_output(&meta, format)?;
|
"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) => {
|
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?;
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
Reference in new issue
Block a user