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,126 +0,0 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
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 permission: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");
|
|
||||||
install_age_key_at(key, &path)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the age key to `path` with mode 0600, never transiently world-readable.
|
|
||||||
/// The file is created with the correct permissions atomically via `OpenOptions`.
|
|
||||||
pub fn install_age_key_at(key: &str, path: &Path) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(
|
|
||||||
path.parent()
|
|
||||||
.context("age keys.txt path has no parent directory")?,
|
|
||||||
)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::io::Write;
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
let mut f = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)?;
|
|
||||||
f.write_all(key.as_bytes())?;
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
std::fs::write(path, key)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the argument list for `sops --decrypt -- <path>`.
|
|
||||||
/// Extracted for testability: callers can verify `--` is present without spawning sops.
|
|
||||||
pub fn sops_args(sops_file: &Path) -> Result<Vec<String>> {
|
|
||||||
let path_str = sops_file
|
|
||||||
.to_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("sops path is not valid UTF-8"))?
|
|
||||||
.to_string();
|
|
||||||
Ok(vec!["--decrypt".to_string(), "--".to_string(), path_str])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `sops --decrypt -- <sops_file>` and return the decrypted content.
|
|
||||||
pub fn decrypt_sops(sops_file: &Path) -> Result<String> {
|
|
||||||
let args = sops_args(sops_file)?;
|
|
||||||
let output = Command::new("sops")
|
|
||||||
.args(&args)
|
|
||||||
.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)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
/// Issue #3: age key file must never be world-readable, even transiently.
|
|
||||||
#[test]
|
|
||||||
fn install_age_key_never_world_readable() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join(".config/sops/age/keys.txt");
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
|
|
||||||
install_age_key_at("AGE-SECRET-KEY-TEST", &path).unwrap();
|
|
||||||
|
|
||||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
|
||||||
assert_eq!(
|
|
||||||
mode & 0o777,
|
|
||||||
0o600,
|
|
||||||
"key file must be mode 0600, got {:o}",
|
|
||||||
mode & 0o777
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue #2: sops invocation must include -- before the path.
|
|
||||||
#[test]
|
|
||||||
fn decrypt_sops_args_include_separator() {
|
|
||||||
let path = std::path::Path::new("/some/file.sops.env");
|
|
||||||
let args = sops_args(path).unwrap();
|
|
||||||
assert_eq!(args[0], "--decrypt");
|
|
||||||
assert_eq!(args[1], "--");
|
|
||||||
assert_eq!(args[2], "/some/file.sops.env");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue #7: .context() should be used instead of .with_context(closure) for string literals.
|
|
||||||
#[test]
|
|
||||||
fn install_age_key_uses_context_not_with_context() {
|
|
||||||
assert!(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,22 +5,96 @@ pub mod format;
|
|||||||
pub mod identities;
|
pub mod identities;
|
||||||
pub mod recipients;
|
pub mod recipients;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
pub mod _legacy;
|
|
||||||
|
|
||||||
pub use error::AgeError;
|
|
||||||
pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, EncryptedIdentity};
|
|
||||||
pub use decrypt::Decryptor;
|
pub use decrypt::Decryptor;
|
||||||
pub use encrypt::Encryptor;
|
pub use encrypt::Encryptor;
|
||||||
pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient};
|
pub use error::AgeError;
|
||||||
pub use sources::{BitwardenSource, FileSource, PromptSource};
|
pub use identities::{
|
||||||
|
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
|
||||||
// Legacy API — kept for notstrap compatibility until Task 12.
|
SshRsaIdentity, Stanza, X25519Identity,
|
||||||
#[allow(deprecated)]
|
|
||||||
pub use _legacy::{
|
|
||||||
AgeKeySource,
|
|
||||||
install_age_key,
|
|
||||||
install_age_key_at,
|
|
||||||
resolve_age_key,
|
|
||||||
decrypt_sops,
|
|
||||||
sops_args,
|
|
||||||
};
|
};
|
||||||
|
pub use recipients::{
|
||||||
|
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
||||||
|
};
|
||||||
|
pub use sources::{BitwardenSource, FileSource, IdentitySource, PromptSource};
|
||||||
|
|
||||||
|
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
||||||
|
///
|
||||||
|
/// Returns an error only if all sources fail. Partial success is accepted because
|
||||||
|
/// not every source needs to hold the key for the target file.
|
||||||
|
pub fn resolve_identities(
|
||||||
|
sources: Vec<Box<dyn sources::IdentitySource>>,
|
||||||
|
) -> Result<Vec<Box<dyn Identity>>, AgeError> {
|
||||||
|
let mut identities: Vec<Box<dyn Identity>> = Vec::new();
|
||||||
|
let mut last_err: Option<AgeError> = None;
|
||||||
|
|
||||||
|
for source in sources {
|
||||||
|
match source.load() {
|
||||||
|
Ok(identity) => identities.push(identity),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" [{}] {e}", source.name());
|
||||||
|
last_err = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if identities.is_empty() {
|
||||||
|
Err(last_err.unwrap_or(AgeError::SourceError {
|
||||||
|
name: "resolve_identities".to_string(),
|
||||||
|
source: anyhow::anyhow!("no sources provided"),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(identities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::sources::IdentitySource;
|
||||||
|
|
||||||
|
struct AlwaysFailSource;
|
||||||
|
impl IdentitySource for AlwaysFailSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"always-fail"
|
||||||
|
}
|
||||||
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||||
|
Err(AgeError::SourceError {
|
||||||
|
name: "always-fail".to_string(),
|
||||||
|
source: anyhow::anyhow!("intentional failure"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticX25519Source;
|
||||||
|
impl IdentitySource for StaticX25519Source {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"static-x25519"
|
||||||
|
}
|
||||||
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use x25519_dalek::StaticSecret;
|
||||||
|
Ok(Box::new(
|
||||||
|
crate::identities::x25519::X25519Identity::from_static_secret(
|
||||||
|
StaticSecret::random_from_rng(OsRng),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_identities_all_fail_returns_error() {
|
||||||
|
let sources: Vec<Box<dyn IdentitySource>> = vec![Box::new(AlwaysFailSource)];
|
||||||
|
assert!(resolve_identities(sources).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_identities_partial_success_returns_loaded() {
|
||||||
|
let sources: Vec<Box<dyn IdentitySource>> = vec![
|
||||||
|
Box::new(AlwaysFailSource),
|
||||||
|
Box::new(StaticX25519Source),
|
||||||
|
];
|
||||||
|
let ids = resolve_identities(sources).expect("at least one source succeeded");
|
||||||
|
assert_eq!(ids.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::_legacy::AgeKeySource;
|
use crate::error::AgeError;
|
||||||
use anyhow::{Result, bail};
|
use crate::identities::Identity;
|
||||||
|
use crate::identities::x25519::X25519Identity;
|
||||||
|
use crate::sources::IdentitySource;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
pub struct BitwardenSource {
|
pub struct BitwardenSource {
|
||||||
@@ -8,61 +10,101 @@ pub struct BitwardenSource {
|
|||||||
|
|
||||||
impl BitwardenSource {
|
impl BitwardenSource {
|
||||||
pub fn new(item_name: impl Into<String>) -> Self {
|
pub fn new(item_name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self { item_name: item_name.into() }
|
||||||
item_name: item_name.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgeKeySource for BitwardenSource {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"bitwarden"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retrieve(&self) -> Result<String> {
|
fn retrieve_key(&self) -> Result<String, AgeError> {
|
||||||
let bw_available = Command::new("sh")
|
let bw_ok = Command::new("sh")
|
||||||
.args(["-c", "command -v bw"])
|
.args(["-c", "command -v bw"])
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !bw_available {
|
if !bw_ok {
|
||||||
bail!("bw CLI not found in PATH");
|
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 = std::env::var("BW_SESSION").unwrap_or_default();
|
||||||
let session = if session.is_empty() {
|
let session = if session.is_empty() {
|
||||||
let password = rpassword::prompt_password("Bitwarden master password: ")
|
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")
|
let output = Command::new("bw")
|
||||||
.args(["unlock", "--raw", &password])
|
.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() {
|
if !output.status.success() {
|
||||||
bail!(
|
return Err(AgeError::SourceError {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
source: anyhow::anyhow!(
|
||||||
"bw unlock failed: {}",
|
"bw unlock failed: {}",
|
||||||
String::from_utf8_lossy(&output.stderr)
|
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 {
|
} else {
|
||||||
session
|
session
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = Command::new("bw")
|
let output = Command::new("bw")
|
||||||
.args(["get", "notes", &self.item_name, "--session", &session])
|
.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() {
|
if !output.status.success() {
|
||||||
bail!(
|
return Err(AgeError::SourceError {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
source: anyhow::anyhow!(
|
||||||
"bw get notes '{}' failed: {}",
|
"bw get notes '{}' failed: {}",
|
||||||
self.item_name,
|
self.item_name,
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
);
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
let key = String::from_utf8(output.stdout)
|
||||||
let key = String::from_utf8(output.stdout)?.trim().to_string();
|
.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() {
|
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)
|
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 crate::error::AgeError;
|
||||||
use anyhow::Result;
|
use crate::identities::encrypted::EncryptedIdentity;
|
||||||
|
use crate::identities::x25519::X25519Identity;
|
||||||
|
use crate::identities::Identity;
|
||||||
|
use crate::sources::IdentitySource;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub struct FileSource {
|
pub struct FileSource {
|
||||||
@@ -12,13 +15,43 @@ impl FileSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgeKeySource for FileSource {
|
impl IdentitySource for FileSource {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"file"
|
"file"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retrieve(&self) -> Result<String> {
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||||
std::fs::read_to_string(&self.path)
|
let content = std::fs::read(&self.path).map_err(|e| AgeError::SourceError {
|
||||||
.map_err(|e| anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()))
|
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 bitwarden;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
@@ -5,3 +8,10 @@ pub mod prompt;
|
|||||||
pub use bitwarden::BitwardenSource;
|
pub use bitwarden::BitwardenSource;
|
||||||
pub use file::FileSource;
|
pub use file::FileSource;
|
||||||
pub use prompt::PromptSource;
|
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 crate::error::AgeError;
|
||||||
use anyhow::Result;
|
use crate::identities::scrypt::ScryptIdentity;
|
||||||
|
use crate::identities::Identity;
|
||||||
|
use crate::sources::IdentitySource;
|
||||||
|
|
||||||
pub struct PromptSource;
|
pub struct PromptSource;
|
||||||
|
|
||||||
impl AgeKeySource for PromptSource {
|
impl IdentitySource for PromptSource {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"prompt"
|
"prompt"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retrieve(&self) -> Result<String> {
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||||
let key = rpassword::prompt_password("Paste your age private key: ")
|
let passphrase = rpassword::prompt_password("Enter age passphrase: ").map_err(|e| {
|
||||||
.map_err(|e| anyhow::anyhow!("could not read age key from prompt: {e}"))?;
|
AgeError::SourceError {
|
||||||
if key.trim().is_empty() {
|
name: self.name().to_string(),
|
||||||
anyhow::bail!("empty age key entered");
|
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
||||||
}
|
}
|
||||||
Ok(key)
|
})?;
|
||||||
|
if passphrase.trim().is_empty() {
|
||||||
|
return Err(AgeError::SourceError {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
source: anyhow::anyhow!("empty passphrase entered"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Box::new(ScryptIdentity::new(passphrase)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,47 @@
|
|||||||
use notsecrets::{AgeKeySource, FileSource, resolve_age_key};
|
use notsecrets::{FileSource, IdentitySource, X25519Identity, resolve_identities};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn generate_age_key_str() -> String {
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use x25519_dalek::StaticSecret;
|
||||||
|
let identity = X25519Identity::from_static_secret(StaticSecret::random_from_rng(OsRng));
|
||||||
|
identity.to_bech32()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_file_source_reads_key() {
|
fn test_file_source_loads_valid_key() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let key_file = dir.path().join("age.key");
|
let key_file = dir.path().join("age.key");
|
||||||
fs::write(&key_file, "AGE-SECRET-KEY-1ABCDEF\n").unwrap();
|
let key = generate_age_key_str();
|
||||||
|
fs::write(&key_file, format!("{}\n", key)).unwrap();
|
||||||
|
|
||||||
let source = FileSource::new(key_file.clone());
|
let source = FileSource::new(key_file.clone());
|
||||||
let result = source.retrieve();
|
let result = source.load();
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
|
||||||
assert_eq!(result.unwrap().trim(), "AGE-SECRET-KEY-1ABCDEF");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_file_source_missing_returns_err() {
|
fn test_file_source_missing_returns_err() {
|
||||||
let source = FileSource::new("/nonexistent/age.key".into());
|
let source = FileSource::new("/nonexistent/age.key".into());
|
||||||
assert!(source.retrieve().is_err());
|
assert!(source.load().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_age_key_uses_file_fallback() {
|
fn test_resolve_identities_uses_file_source() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let key_file = dir.path().join("age.key");
|
let key_file = dir.path().join("age.key");
|
||||||
fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap();
|
let key = generate_age_key_str();
|
||||||
|
fs::write(&key_file, format!("{}\n", key)).unwrap();
|
||||||
|
|
||||||
let sources: Vec<Box<dyn AgeKeySource>> = vec![Box::new(FileSource::new(key_file))];
|
let sources: Vec<Box<dyn IdentitySource>> = vec![Box::new(FileSource::new(key_file))];
|
||||||
let key = resolve_age_key(sources).unwrap();
|
let ids = resolve_identities(sources).unwrap();
|
||||||
assert_eq!(key.trim(), "AGE-SECRET-KEY-1TEST");
|
assert_eq!(ids.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_age_key_all_fail_returns_err() {
|
fn test_resolve_identities_all_fail_returns_err() {
|
||||||
let sources: Vec<Box<dyn AgeKeySource>> =
|
let sources: Vec<Box<dyn IdentitySource>> =
|
||||||
vec![Box::new(FileSource::new("/nonexistent".into()))];
|
vec![Box::new(FileSource::new("/nonexistent".into()))];
|
||||||
assert!(resolve_age_key(sources).is_err());
|
assert!(resolve_identities(sources).is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user