diff --git a/crates/notsecrets/Cargo.toml b/crates/notsecrets/Cargo.toml index bccbc1c..11fcd0b 100644 --- a/crates/notsecrets/Cargo.toml +++ b/crates/notsecrets/Cargo.toml @@ -2,3 +2,14 @@ name = "notsecrets" version = "0.1.0" edition = "2024" + +[dependencies] +notcore = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +which = { workspace = true } +rpassword = { workspace = true } +dirs = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index ff7bd09..38d28e2 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -1 +1,57 @@ -// placeholder +pub mod sources; + +pub use sources::{BitwardenSource, FileSource, PromptSource}; + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Trait for retrieving an age private key from some source. +pub trait AgeKeySource { + fn name(&self) -> &str; + fn retrieve(&self) -> Result; +} + +/// Try each source in order; return the first success. +pub fn resolve_age_key(sources: Vec>) -> Result { + let mut last_err = String::new(); + for source in sources { + match source.retrieve() { + Ok(key) => return Ok(key), + Err(e) => { + eprintln!(" [{}] {e}", source.name()); + last_err = format!("{e}"); + } + } + } + bail!("all age key sources failed; last error: {last_err}") +} + +/// Write the age key to `~/.config/sops/age/keys.txt` (mode 0600). +pub fn install_age_key(key: &str) -> Result { + let path = dirs::home_dir() + .context("cannot find home directory")? + .join(".config/sops/age/keys.txt"); + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(&path, key)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(path) +} + +/// Run `sops --decrypt ` and return the decrypted content. +pub fn decrypt_sops(sops_file: &Path) -> Result { + let output = Command::new("sops") + .args(["--decrypt", sops_file.to_str().unwrap()]) + .output() + .context("failed to run sops")?; + + if !output.status.success() { + bail!("sops decrypt failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(String::from_utf8(output.stdout)?) +} diff --git a/crates/notsecrets/src/sources/bitwarden.rs b/crates/notsecrets/src/sources/bitwarden.rs new file mode 100644 index 0000000..23b3a02 --- /dev/null +++ b/crates/notsecrets/src/sources/bitwarden.rs @@ -0,0 +1,57 @@ +use anyhow::{bail, Result}; +use std::process::Command; +use which::which; +use crate::AgeKeySource; + +pub struct BitwardenSource { + pub item_name: String, +} + +impl BitwardenSource { + pub fn new(item_name: impl Into) -> Self { + Self { item_name: item_name.into() } + } +} + +impl AgeKeySource for BitwardenSource { + fn name(&self) -> &str { "bitwarden" } + + fn retrieve(&self) -> Result { + if which("bw").is_err() { + bail!("bw CLI not found in PATH"); + } + + let session = std::env::var("BW_SESSION").unwrap_or_default(); + let session = if session.is_empty() { + let password = rpassword::prompt_password("Bitwarden master password: ") + .map_err(|e| anyhow::anyhow!("could not read password: {e}"))?; + let output = Command::new("bw") + .args(["unlock", "--raw", &password]) + .output()?; + if !output.status.success() { + bail!("bw unlock failed: {}", String::from_utf8_lossy(&output.stderr)); + } + String::from_utf8(output.stdout)?.trim().to_string() + } else { + session + }; + + let output = Command::new("bw") + .args(["get", "notes", &self.item_name, "--session", &session]) + .output()?; + + if !output.status.success() { + bail!( + "bw get notes '{}' failed: {}", + self.item_name, + String::from_utf8_lossy(&output.stderr) + ); + } + + let key = String::from_utf8(output.stdout)?.trim().to_string(); + if key.is_empty() { + bail!("Bitwarden item '{}' has empty notes", self.item_name); + } + Ok(key) + } +} diff --git a/crates/notsecrets/src/sources/file.rs b/crates/notsecrets/src/sources/file.rs new file mode 100644 index 0000000..e4fe166 --- /dev/null +++ b/crates/notsecrets/src/sources/file.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; +use anyhow::Result; +use crate::AgeKeySource; + +pub struct FileSource { + path: PathBuf, +} + +impl FileSource { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl AgeKeySource for FileSource { + fn name(&self) -> &str { "file" } + + fn retrieve(&self) -> Result { + std::fs::read_to_string(&self.path) + .map_err(|e| anyhow::anyhow!("cannot read key file {}: {e}", self.path.display())) + } +} diff --git a/crates/notsecrets/src/sources/mod.rs b/crates/notsecrets/src/sources/mod.rs new file mode 100644 index 0000000..59fc105 --- /dev/null +++ b/crates/notsecrets/src/sources/mod.rs @@ -0,0 +1,7 @@ +pub mod bitwarden; +pub mod file; +pub mod prompt; + +pub use bitwarden::BitwardenSource; +pub use file::FileSource; +pub use prompt::PromptSource; diff --git a/crates/notsecrets/src/sources/prompt.rs b/crates/notsecrets/src/sources/prompt.rs new file mode 100644 index 0000000..05e85bd --- /dev/null +++ b/crates/notsecrets/src/sources/prompt.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use crate::AgeKeySource; + +pub struct PromptSource; + +impl AgeKeySource for PromptSource { + fn name(&self) -> &str { "prompt" } + + fn retrieve(&self) -> Result { + let key = rpassword::prompt_password("Paste your age private key: ") + .map_err(|e| anyhow::anyhow!("could not read age key from prompt: {e}"))?; + if key.trim().is_empty() { + anyhow::bail!("empty age key entered"); + } + Ok(key) + } +} diff --git a/crates/notsecrets/tests/integration.rs b/crates/notsecrets/tests/integration.rs new file mode 100644 index 0000000..1ac8fdc --- /dev/null +++ b/crates/notsecrets/tests/integration.rs @@ -0,0 +1,42 @@ +use std::fs; +use tempfile::TempDir; +use notsecrets::{resolve_age_key, AgeKeySource, FileSource}; + +#[test] +fn test_file_source_reads_key() { + let dir = TempDir::new().unwrap(); + let key_file = dir.path().join("age.key"); + fs::write(&key_file, "AGE-SECRET-KEY-1ABCDEF\n").unwrap(); + + let source = FileSource::new(key_file.clone()); + let result = source.retrieve(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().trim(), "AGE-SECRET-KEY-1ABCDEF"); +} + +#[test] +fn test_file_source_missing_returns_err() { + let source = FileSource::new("/nonexistent/age.key".into()); + assert!(source.retrieve().is_err()); +} + +#[test] +fn test_resolve_age_key_uses_file_fallback() { + let dir = TempDir::new().unwrap(); + let key_file = dir.path().join("age.key"); + fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap(); + + let sources: Vec> = vec![ + Box::new(FileSource::new(key_file)), + ]; + let key = resolve_age_key(sources).unwrap(); + assert_eq!(key.trim(), "AGE-SECRET-KEY-1TEST"); +} + +#[test] +fn test_resolve_age_key_all_fail_returns_err() { + let sources: Vec> = vec![ + Box::new(FileSource::new("/nonexistent".into())), + ]; + assert!(resolve_age_key(sources).is_err()); +}