#[cfg(feature = "test-trust-overrides")]
use std::path::Path;
use std::path::PathBuf;
use nono::{NonoError, Result};
use zeroize::Zeroizing;
#[cfg(feature = "test-trust-overrides")]
pub(crate) const TEST_KEYSTORE_DIR_ENV: &str = "NONO_TRUST_TEST_KEYSTORE_DIR";
const KEYSTORE_URI_PREFIX: &str = "keystore://";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TrustKeyRef {
Keystore(String),
File(PathBuf),
}
impl TrustKeyRef {
pub(crate) fn parse(uri: &str) -> Result<Self> {
if let Some(name) = uri.strip_prefix(KEYSTORE_URI_PREFIX) {
if name.is_empty() {
return Err(NonoError::ConfigParse(
"keystore:// URI has empty key name".to_string(),
));
}
if let Some(bad) = name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
{
return Err(NonoError::ConfigParse(format!(
"keystore:// key name contains invalid character {bad:?}: {uri}"
)));
}
Ok(Self::Keystore(name.to_string()))
} else if nono::is_file_uri(uri) {
nono::validate_file_uri(uri)?;
let path_str = uri
.strip_prefix("file://")
.ok_or_else(|| NonoError::ConfigParse(format!("invalid file URI: {uri}")))?;
Ok(Self::File(PathBuf::from(path_str)))
} else {
Err(NonoError::ConfigParse(format!(
"unrecognized key reference scheme: {uri} \
(expected keystore://<name> or file:///path)"
)))
}
}
pub(crate) fn key_id(&self) -> Result<String> {
match self {
Self::Keystore(name) => Ok(name.clone()),
Self::File(path) => {
let path_str = path.to_str().ok_or_else(|| {
NonoError::ConfigParse(format!(
"key path is not valid UTF-8: {}",
path.display()
))
})?;
Ok(format!("file://{path_str}"))
}
}
}
pub(crate) fn from_id(id: &str) -> Self {
Self::Keystore(id.to_string())
}
pub(crate) fn resolve_id(keyref: Option<&str>, id: &str) -> Result<Self> {
match keyref {
Some(uri) => Self::parse(uri),
None => Ok(Self::from_id(id)),
}
}
pub(crate) fn resolve_key(keyref: Option<&str>, key: Option<&str>) -> Result<Self> {
match keyref {
Some(uri) => Self::parse(uri),
None => Ok(Self::from_id(key.unwrap_or("default"))),
}
}
}
impl std::fmt::Display for TrustKeyRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Keystore(name) => write!(f, "keystore://{name}"),
Self::File(path) => {
debug_assert!(
path.is_absolute(),
"TrustKeyRef::File must hold an absolute path"
);
write!(f, "file://{}", path.display())
}
}
}
}
enum TrustKeyStore {
System,
#[cfg(feature = "test-trust-overrides")]
Directory(PathBuf),
DirectFile(PathBuf),
}
impl TrustKeyStore {
fn from_ref(key_ref: &TrustKeyRef) -> Self {
match key_ref {
TrustKeyRef::Keystore(_) => {
#[cfg(feature = "test-trust-overrides")]
if let Some(dir) = std::env::var_os(TEST_KEYSTORE_DIR_ENV).filter(|d| !d.is_empty())
{
return Self::Directory(PathBuf::from(dir));
}
Self::System
}
TrustKeyRef::File(path) => Self::DirectFile(path.clone()),
}
}
fn selected() -> Self {
#[cfg(feature = "test-trust-overrides")]
match std::env::var_os(TEST_KEYSTORE_DIR_ENV) {
Some(dir) if !dir.is_empty() => return Self::Directory(PathBuf::from(dir)),
_ => {}
}
Self::System
}
fn description(&self, service: &str) -> String {
match self {
Self::System => format!("system keystore (service: {service})"),
#[cfg(feature = "test-trust-overrides")]
Self::Directory(root) => format!("file keystore directory ({})", root.display()),
Self::DirectFile(path) => format!("file ({})", path.display()),
}
}
fn contains(&self, service: &str, account: &str) -> Result<bool> {
match self {
Self::System => {
#[cfg(not(feature = "system-keyring"))]
{
let _ = (service, account);
return Err(NonoError::KeystoreAccess(
"system keyring is not available (built without system-keyring feature)"
.to_string(),
));
}
#[cfg(feature = "system-keyring")]
{
let entry = keyring::Entry::new(service, account).map_err(|e| {
NonoError::KeystoreAccess(format!("failed to access keystore: {e}"))
})?;
match entry.get_password() {
Ok(_) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(other) => Err(NonoError::KeystoreAccess(format!(
"failed to access key '{account}': {other}"
))),
}
}
}
#[cfg(feature = "test-trust-overrides")]
Self::Directory(root) => Ok(directory_path(root, service, account).exists()),
Self::DirectFile(path) => Ok(path.exists()),
}
}
fn load(&self, service: &str, account: &str) -> Result<Zeroizing<String>> {
match self {
Self::System => {
#[cfg(not(feature = "system-keyring"))]
{
let _ = (service, account);
return Err(NonoError::KeystoreAccess(
"system keyring is not available (built without system-keyring feature)"
.to_string(),
));
}
#[cfg(feature = "system-keyring")]
{
let entry = keyring::Entry::new(service, account).map_err(|e| {
NonoError::KeystoreAccess(format!("failed to access keystore: {e}"))
})?;
entry
.get_password()
.map(Zeroizing::new)
.map_err(|e| match e {
keyring::Error::NoEntry => NonoError::SecretNotFound(format!(
"key '{account}' not found in keystore"
)),
other => NonoError::KeystoreAccess(format!(
"failed to load key '{account}': {other}"
)),
})
}
}
#[cfg(feature = "test-trust-overrides")]
Self::Directory(root) => {
let path = directory_path(root, service, account);
nono::load_secret_file(&path).map_err(|e| match e {
NonoError::SecretNotFound(_) => NonoError::SecretNotFound(format!(
"key '{account}' not found in file keystore"
)),
NonoError::KeystoreAccess(_) => NonoError::KeystoreAccess(format!(
"failed to load key '{account}' from {}",
path.display()
)),
other => other,
})
}
Self::DirectFile(path) => nono::load_secret_file(path).map_err(|e| match e {
NonoError::SecretNotFound(_) => {
NonoError::SecretNotFound(format!("key not found at {}", path.display()))
}
NonoError::KeystoreAccess(_) => {
NonoError::KeystoreAccess(format!("failed to load key from {}", path.display()))
}
other => other,
}),
}
}
fn store(&self, service: &str, account: &str, secret: &str) -> Result<()> {
match self {
Self::System => {
#[cfg(not(feature = "system-keyring"))]
{
let _ = (service, account, secret);
return Err(NonoError::KeystoreAccess(
"system keyring is not available (built without system-keyring feature)"
.to_string(),
));
}
#[cfg(feature = "system-keyring")]
{
let entry = keyring::Entry::new(service, account).map_err(|e| {
NonoError::KeystoreAccess(format!("failed to access keystore: {e}"))
})?;
entry
.set_password(secret)
.map_err(|e| NonoError::KeystoreAccess(format!("failed to store key: {e}")))
}
}
#[cfg(feature = "test-trust-overrides")]
Self::Directory(root) => {
let path = directory_path(root, service, account);
nono::store_secret_file(&path, secret).map_err(|e| match e {
NonoError::KeystoreAccess(_) => NonoError::KeystoreAccess(format!(
"failed to store key '{account}' at {}",
path.display()
)),
other => other,
})
}
Self::DirectFile(path) => nono::store_secret_file(path, secret).map_err(|e| match e {
NonoError::KeystoreAccess(_) => {
NonoError::KeystoreAccess(format!("failed to store key at {}", path.display()))
}
other => other,
}),
}
}
}
#[cfg(feature = "test-trust-overrides")]
fn directory_path(root: &Path, service: &str, account: &str) -> PathBuf {
root.join(hex_component(service))
.join(hex_component(account))
}
#[cfg(feature = "test-trust-overrides")]
fn hex_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len().saturating_mul(2));
for byte in value.as_bytes() {
encoded.push_str(&format!("{byte:02x}"));
}
encoded
}
pub(crate) fn backend_description_for_ref(key_ref: &TrustKeyRef, service: &str) -> String {
TrustKeyStore::from_ref(key_ref).description(service)
}
pub(crate) fn contains_secret_for_ref(
key_ref: &TrustKeyRef,
service: &str,
account: &str,
) -> Result<bool> {
TrustKeyStore::from_ref(key_ref).contains(service, account)
}
pub(crate) fn store_secret_for_ref(
key_ref: &TrustKeyRef,
service: &str,
account: &str,
secret: &str,
) -> Result<()> {
TrustKeyStore::from_ref(key_ref).store(service, account, secret)
}
pub(crate) fn load_secret(service: &str, account: &str) -> Result<Zeroizing<String>> {
TrustKeyStore::selected().load(service, account)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_keystore_uri() {
let key_ref = TrustKeyRef::parse("keystore://default").ok();
assert_eq!(key_ref, Some(TrustKeyRef::Keystore("default".to_string())));
}
#[test]
fn parse_keystore_uri_with_hyphens_and_underscores() {
let key_ref = TrustKeyRef::parse("keystore://my-key_2").ok();
assert_eq!(key_ref, Some(TrustKeyRef::Keystore("my-key_2".to_string())));
}
#[test]
fn parse_keystore_uri_empty_name_rejected() {
assert!(TrustKeyRef::parse("keystore://").is_err());
}
#[test]
fn parse_keystore_uri_bad_chars_rejected() {
assert!(TrustKeyRef::parse("keystore://foo/bar").is_err());
assert!(TrustKeyRef::parse("keystore://foo bar").is_err());
assert!(TrustKeyRef::parse("keystore://foo;bar").is_err());
}
#[test]
fn parse_file_uri() {
let key_ref = TrustKeyRef::parse("file:///home/user/.config/nono/trust/key.pem").ok();
assert_eq!(
key_ref,
Some(TrustKeyRef::File(PathBuf::from(
"/home/user/.config/nono/trust/key.pem"
)))
);
}
#[test]
fn parse_file_uri_relative_rejected() {
assert!(TrustKeyRef::parse("file://relative/path").is_err());
}
#[test]
fn parse_file_uri_traversal_rejected() {
assert!(TrustKeyRef::parse("file:///home/user/../etc/passwd").is_err());
}
#[test]
fn parse_file_uri_empty_path_rejected() {
assert!(TrustKeyRef::parse("file:///").is_err());
}
#[test]
fn parse_unknown_scheme_rejected() {
assert!(TrustKeyRef::parse("s3://bucket/key").is_err());
assert!(TrustKeyRef::parse("just-a-name").is_err());
}
#[test]
fn from_id_produces_keystore_ref() {
assert_eq!(
TrustKeyRef::from_id("default"),
TrustKeyRef::Keystore("default".to_string())
);
}
#[test]
fn key_id_returns_name_for_keystore() {
let key_ref = TrustKeyRef::Keystore("default".to_string());
assert_eq!(key_ref.key_id().ok().as_deref(), Some("default"));
}
#[test]
fn key_id_returns_file_uri_for_file() {
let key_ref = TrustKeyRef::File(PathBuf::from("/home/user/key.pem"));
assert_eq!(
key_ref.key_id().ok().as_deref(),
Some("file:///home/user/key.pem")
);
}
#[test]
fn display_roundtrip_keystore() {
let key_ref = TrustKeyRef::Keystore("default".to_string());
assert_eq!(key_ref.to_string(), "keystore://default");
}
#[test]
fn display_roundtrip_file() {
let key_ref = TrustKeyRef::File(PathBuf::from("/tmp/key.pem"));
assert_eq!(key_ref.to_string(), "file:///tmp/key.pem");
}
#[test]
#[cfg(feature = "test-trust-overrides")]
fn directory_backend_roundtrips_secret() {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => panic!("failed to create tempdir: {e}"),
};
let store = TrustKeyStore::Directory(dir.path().to_path_buf());
assert!(!store.contains("service", "account").unwrap_or(true));
assert!(store.store("service", "account", "secret-value").is_ok());
assert!(store.contains("service", "account").unwrap_or(false));
let loaded = match store.load("service", "account") {
Ok(loaded) => loaded,
Err(e) => panic!("failed to load test secret: {e}"),
};
assert_eq!(loaded.as_str(), "secret-value");
}
#[test]
#[cfg(feature = "test-trust-overrides")]
fn directory_backend_missing_secret_is_not_found() {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => panic!("failed to create tempdir: {e}"),
};
let store = TrustKeyStore::Directory(dir.path().to_path_buf());
match store.load("service", "missing") {
Err(NonoError::SecretNotFound(msg)) => {
assert!(msg.contains("missing"));
}
Err(e) => panic!("unexpected error: {e}"),
Ok(_) => panic!("expected missing secret to fail"),
}
}
#[test]
#[cfg(feature = "test-trust-overrides")]
fn directory_backend_separates_service_namespaces() {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => panic!("failed to create tempdir: {e}"),
};
let store = TrustKeyStore::Directory(dir.path().to_path_buf());
assert!(store.store("service-a", "account", "secret-a").is_ok());
assert!(store.store("service-b", "account", "secret-b").is_ok());
let a = match store.load("service-a", "account") {
Ok(value) => value,
Err(e) => panic!("failed to load service-a secret: {e}"),
};
let b = match store.load("service-b", "account") {
Ok(value) => value,
Err(e) => panic!("failed to load service-b secret: {e}"),
};
assert_eq!(a.as_str(), "secret-a");
assert_eq!(b.as_str(), "secret-b");
}
#[test]
fn direct_file_backend_roundtrips_secret() {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => panic!("failed to create tempdir: {e}"),
};
let path = dir.path().join("key.pem");
let store = TrustKeyStore::DirectFile(path.clone());
assert!(!store.contains("ignored", "ignored").unwrap_or(true));
assert!(store.store("ignored", "ignored", "my-secret-key").is_ok());
assert!(store.contains("ignored", "ignored").unwrap_or(false));
let loaded = match store.load("ignored", "ignored") {
Ok(loaded) => loaded,
Err(e) => panic!("failed to load secret: {e}"),
};
assert_eq!(loaded.as_str(), "my-secret-key");
}
#[test]
fn direct_file_backend_missing_file_is_not_found() {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => panic!("failed to create tempdir: {e}"),
};
let path = dir.path().join("nonexistent.pem");
let store = TrustKeyStore::DirectFile(path);
match store.load("ignored", "ignored") {
Err(NonoError::SecretNotFound(_)) => {}
Err(e) => panic!("unexpected error: {e}"),
Ok(_) => panic!("expected missing file to fail"),
}
}
}