feat(notsecrets): migrate sources to IdentitySource trait, add resolve_identities, remove legacy API
- Replace AgeKeySource+retrieve() with IdentitySource+load() returning Box<dyn Identity> - Rewrite bitwarden/file/prompt sources to implement new trait - Add resolve_identities() public API with partial-success semantics - Delete _legacy.rs and remove legacy re-exports from lib.rs - Update integration tests to use new API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
use crate::_legacy::AgeKeySource;
|
||||
use anyhow::{Result, bail};
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::Identity;
|
||||
use crate::identities::x25519::X25519Identity;
|
||||
use crate::sources::IdentitySource;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct BitwardenSource {
|
||||
@@ -8,61 +10,101 @@ pub struct BitwardenSource {
|
||||
|
||||
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"
|
||||
Self { item_name: item_name.into() }
|
||||
}
|
||||
|
||||
fn retrieve(&self) -> Result<String> {
|
||||
let bw_available = Command::new("sh")
|
||||
fn retrieve_key(&self) -> Result<String, AgeError> {
|
||||
let bw_ok = Command::new("sh")
|
||||
.args(["-c", "command -v bw"])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !bw_available {
|
||||
bail!("bw CLI not found in PATH");
|
||||
if !bw_ok {
|
||||
return Err(AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("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}"))?;
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read password: {e}"),
|
||||
})?;
|
||||
let output = Command::new("bw")
|
||||
.args(["unlock", "--raw", &password])
|
||||
.output()?;
|
||||
.output()
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("bw unlock spawn: {e}"),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"bw unlock failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return Err(AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!(
|
||||
"bw unlock failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
});
|
||||
}
|
||||
String::from_utf8(output.stdout)?.trim().to_string()
|
||||
String::from_utf8(output.stdout)
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
|
||||
})?
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
session
|
||||
};
|
||||
|
||||
let output = Command::new("bw")
|
||||
.args(["get", "notes", &self.item_name, "--session", &session])
|
||||
.output()?;
|
||||
|
||||
.output()
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("bw get spawn: {e}"),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"bw get notes '{}' failed: {}",
|
||||
self.item_name,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return Err(AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!(
|
||||
"bw get notes '{}' failed: {}",
|
||||
self.item_name,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let key = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
let key = String::from_utf8(output.stdout)
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("bw output UTF-8: {e}"),
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
if key.is_empty() {
|
||||
bail!("Bitwarden item '{}' has empty notes", self.item_name);
|
||||
return Err(AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name),
|
||||
});
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentitySource for BitwardenSource {
|
||||
fn name(&self) -> &str {
|
||||
"bitwarden"
|
||||
}
|
||||
|
||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||
let key = self.retrieve_key()?;
|
||||
let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(identity))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::_legacy::AgeKeySource;
|
||||
use anyhow::Result;
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::encrypted::EncryptedIdentity;
|
||||
use crate::identities::x25519::X25519Identity;
|
||||
use crate::identities::Identity;
|
||||
use crate::sources::IdentitySource;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct FileSource {
|
||||
@@ -12,13 +15,43 @@ impl FileSource {
|
||||
}
|
||||
}
|
||||
|
||||
impl AgeKeySource for FileSource {
|
||||
impl IdentitySource 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()))
|
||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||
let content = std::fs::read(&self.path).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()),
|
||||
})?;
|
||||
|
||||
if content.starts_with(b"AGE-SECRET-KEY-1") {
|
||||
let key_str = std::str::from_utf8(&content)
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("key file UTF-8: {e}"),
|
||||
})?
|
||||
.trim();
|
||||
let identity = X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(identity))
|
||||
} else if content.starts_with(b"age-encryption.org/v1") {
|
||||
let passphrase = rpassword::prompt_password(
|
||||
"Enter passphrase for encrypted identity file: ",
|
||||
)
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(EncryptedIdentity::new(content, passphrase)))
|
||||
} else {
|
||||
Err(AgeError::UnsupportedKeyType(format!(
|
||||
"file {} does not contain a recognized age key format",
|
||||
self.path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::Identity;
|
||||
|
||||
pub mod bitwarden;
|
||||
pub mod file;
|
||||
pub mod prompt;
|
||||
@@ -5,3 +8,10 @@ pub mod prompt;
|
||||
pub use bitwarden::BitwardenSource;
|
||||
pub use file::FileSource;
|
||||
pub use prompt::PromptSource;
|
||||
|
||||
/// Infra boundary trait: an identity resolver that loads key material from an
|
||||
/// external source and returns a concrete Identity.
|
||||
pub trait IdentitySource {
|
||||
fn name(&self) -> &str;
|
||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError>;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
use crate::_legacy::AgeKeySource;
|
||||
use anyhow::Result;
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::scrypt::ScryptIdentity;
|
||||
use crate::identities::Identity;
|
||||
use crate::sources::IdentitySource;
|
||||
|
||||
pub struct PromptSource;
|
||||
|
||||
impl AgeKeySource for PromptSource {
|
||||
impl IdentitySource 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");
|
||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||
let passphrase = rpassword::prompt_password("Enter age passphrase: ").map_err(|e| {
|
||||
AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
||||
}
|
||||
})?;
|
||||
if passphrase.trim().is_empty() {
|
||||
return Err(AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("empty passphrase entered"),
|
||||
});
|
||||
}
|
||||
Ok(key)
|
||||
Ok(Box::new(ScryptIdentity::new(passphrase)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user