Skip to content

Commit efa06e6

Browse files
committed
feat(cli): add OS credential store and bind9 auth subcommand
Integrate keyring, secrecy, and rpassword crates for TSIG key management: - AuthConfig.key_secret uses SecretString (zeroized on drop, redacted in Debug) instead of plain String - Secret resolution order: CLI --key-secret flag > OS credential store (macOS Keychain, Linux Secret Service, Windows Credential Manager) > config file fallback (with tracing::warn!) - New keyring module: get/set/delete using service name "bind9-sdk" - New `bind9 auth set-key` and `bind9 auth delete-key` subcommands - Secret input uses rpassword (no terminal echo) - Server address resolution uses ToSocketAddrs (supports hostnames)
1 parent 8bc8298 commit efa06e6

10 files changed

Lines changed: 413 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 130 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ clap_complete = "4"
101101
toml = "0.8"
102102
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
103103
dirs = "6"
104+
keyring = { version = "3", features = ["apple-native", "linux-native"] }
105+
secrecy = { version = "0.10", features = ["serde"] }
106+
rpassword = "5"
104107

105108
# Testing (consumed via [dev-dependencies] in each crate)
106109
proptest = { version = "1" }

crates/bind9-sdk-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ bind9-sdk = { path = "../../bind9-sdk" }
2121
clap = { workspace = true }
2222
clap_complete = { workspace = true }
2323
dirs = { workspace = true }
24+
keyring = { workspace = true }
25+
rpassword = { workspace = true }
26+
secrecy = { workspace = true }
2427
serde = { workspace = true, features = ["std"] }
2528
serde_json = { workspace = true }
2629
thiserror = { workspace = true }
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-FileCopyrightText: 2026 Sephyi <me@sephy.io>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
4+
5+
//! `bind9 auth` subcommands for managing TSIG key credentials.
6+
7+
use clap::Subcommand;
8+
use secrecy::SecretString;
9+
10+
use crate::error::CliError;
11+
use crate::keyring;
12+
13+
/// Authentication credential management commands.
14+
#[derive(Subcommand, Debug)]
15+
pub enum AuthCommand {
16+
/// Store a TSIG key secret in the OS credential store (macOS Keychain,
17+
/// Linux Secret Service, Windows Credential Manager).
18+
SetKey {
19+
/// Server profile identifier (e.g., `127.0.0.1:953` or a profile name).
20+
/// Must match the server address used in other commands.
21+
#[arg(long)]
22+
profile: String,
23+
24+
/// Base64-encoded TSIG key secret. If omitted, reads from stdin.
25+
#[arg(long)]
26+
secret: Option<String>,
27+
},
28+
29+
/// Remove a TSIG key secret from the OS credential store.
30+
DeleteKey {
31+
/// Server profile identifier to remove.
32+
#[arg(long)]
33+
profile: String,
34+
},
35+
}
36+
37+
pub fn execute(cmd: &AuthCommand) -> Result<(), CliError> {
38+
match cmd {
39+
AuthCommand::SetKey { profile, secret } => {
40+
let secret_value = match secret {
41+
Some(s) => SecretString::from(s.clone()),
42+
None => {
43+
eprint!("Enter base64-encoded TSIG key secret: ");
44+
let line = rpassword::read_password()?;
45+
SecretString::from(line.trim().to_owned())
46+
}
47+
};
48+
49+
keyring::set_secret(profile, &secret_value)?;
50+
eprintln!("Stored TSIG key secret for profile '{profile}' in OS credential store.");
51+
Ok(())
52+
}
53+
AuthCommand::DeleteKey { profile } => {
54+
keyring::delete_secret(profile)?;
55+
eprintln!("Deleted TSIG key secret for profile '{profile}' from OS credential store.");
56+
Ok(())
57+
}
58+
}
59+
}

crates/bind9-sdk-cli/src/commands/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
//! CLI command structure using clap derive.
66
7+
pub mod auth;
78
pub mod dnssec;
89
pub mod record;
910
pub mod stats;
@@ -73,6 +74,10 @@ pub enum Command {
7374
#[command(subcommand)]
7475
Dnssec(dnssec::DnssecCommand),
7576

77+
/// Manage TSIG key credentials in the OS credential store.
78+
#[command(subcommand)]
79+
Auth(auth::AuthCommand),
80+
7681
/// Fetch server statistics from the BIND9 statistics-channel.
7782
Stats,
7883

crates/bind9-sdk-cli/src/config.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
1111
use std::path::PathBuf;
1212

13+
use secrecy::SecretString;
1314
use serde::Deserialize;
1415

1516
use crate::error::CliError;
@@ -38,17 +39,30 @@ pub struct ServerConfig {
3839
}
3940

4041
/// TSIG authentication credentials.
41-
#[derive(Debug, Deserialize)]
42+
///
43+
/// The `key_secret` field is wrapped in `SecretString` to prevent accidental
44+
/// exposure in logs or debug output. The `Debug` impl redacts it.
45+
#[derive(Deserialize)]
4246
pub struct AuthConfig {
4347
/// TSIG key name.
4448
pub key_name: String,
45-
/// Base64-encoded TSIG key secret.
46-
pub key_secret: String,
49+
/// Base64-encoded TSIG key secret (protected in-memory).
50+
pub key_secret: SecretString,
4751
/// TSIG algorithm (default: `hmac-sha256`).
4852
#[serde(default = "default_algorithm")]
4953
pub algorithm: String,
5054
}
5155

56+
impl std::fmt::Debug for AuthConfig {
57+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58+
f.debug_struct("AuthConfig")
59+
.field("key_name", &self.key_name)
60+
.field("key_secret", &"[REDACTED]")
61+
.field("algorithm", &self.algorithm)
62+
.finish()
63+
}
64+
}
65+
5266
fn default_port() -> u16 {
5367
953
5468
}
@@ -90,6 +104,8 @@ impl CliConfig {
90104

91105
#[cfg(test)]
92106
mod tests {
107+
use secrecy::ExposeSecret;
108+
93109
use super::*;
94110

95111
#[test]
@@ -130,7 +146,7 @@ algorithm = "hmac-sha512"
130146
let auth = config.auth.unwrap();
131147
assert_eq!(auth.key_name, "rndc-key");
132148
assert_eq!(
133-
auth.key_secret,
149+
auth.key_secret.expose_secret(),
134150
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
135151
);
136152
assert_eq!(auth.algorithm, "hmac-sha512");
@@ -175,4 +191,20 @@ key_secret = "dGVzdA=="
175191
// We cannot guarantee the file doesn't exist, but this exercises the code path
176192
let _ = result;
177193
}
194+
195+
#[test]
196+
fn auth_config_debug_redacts_secret() {
197+
let toml = r#"
198+
[server]
199+
host = "127.0.0.1"
200+
201+
[auth]
202+
key_name = "rndc-key"
203+
key_secret = "dGVzdA=="
204+
"#;
205+
let config = CliConfig::from_str(toml).unwrap();
206+
let debug_output = format!("{:?}", config.auth.unwrap());
207+
assert!(debug_output.contains("[REDACTED]"));
208+
assert!(!debug_output.contains("dGVzdA=="));
209+
}
178210
}

0 commit comments

Comments
 (0)