feat(notsecrets): add age key retrieval with bw/file/prompt sources

This commit is contained in:
Joseph O'Brien
2026-03-31 17:26:10 -04:00
parent ed11aa9bc3
commit 6e78778543
7 changed files with 213 additions and 1 deletions

View File

@@ -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)?)
}

View 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)
}
}

View 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()))
}
}

View 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;

View 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)
}
}