feat(notsecrets): add age key retrieval with bw/file/prompt sources
This commit is contained in:
@@ -2,3 +2,14 @@
|
|||||||
name = "notsecrets"
|
name = "notsecrets"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
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 }
|
||||||
|
|||||||
@@ -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<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try each source in order; return the first success.
|
||||||
|
pub fn resolve_age_key(sources: Vec<Box<dyn AgeKeySource>>) -> Result<String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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 <sops_file>` and return the decrypted content.
|
||||||
|
pub fn decrypt_sops(sops_file: &Path) -> Result<String> {
|
||||||
|
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)?)
|
||||||
|
}
|
||||||
|
|||||||
57
crates/notsecrets/src/sources/bitwarden.rs
Normal file
57
crates/notsecrets/src/sources/bitwarden.rs
Normal file
@@ -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<String>) -> Self {
|
||||||
|
Self { item_name: item_name.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgeKeySource for BitwardenSource {
|
||||||
|
fn name(&self) -> &str { "bitwarden" }
|
||||||
|
|
||||||
|
fn retrieve(&self) -> Result<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/notsecrets/src/sources/file.rs
Normal file
22
crates/notsecrets/src/sources/file.rs
Normal file
@@ -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<String> {
|
||||||
|
std::fs::read_to_string(&self.path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/notsecrets/src/sources/mod.rs
Normal file
7
crates/notsecrets/src/sources/mod.rs
Normal file
@@ -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;
|
||||||
17
crates/notsecrets/src/sources/prompt.rs
Normal file
17
crates/notsecrets/src/sources/prompt.rs
Normal file
@@ -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<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/notsecrets/tests/integration.rs
Normal file
42
crates/notsecrets/tests/integration.rs
Normal file
@@ -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<Box<dyn AgeKeySource>> = 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<Box<dyn AgeKeySource>> = vec![
|
||||||
|
Box::new(FileSource::new("/nonexistent".into())),
|
||||||
|
];
|
||||||
|
assert!(resolve_age_key(sources).is_err());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user