use anyhow::{Context, Result};
use keyring::Entry;
use std::fmt;
use std::sync::Arc;
use zeroize::{Zeroize, Zeroizing};
use crate::audit::AuditLogger;
const SERVICE_NAME: &str = "skill-engine";
pub struct CredentialStore {
service_name: String,
audit_logger: Option<Arc<AuditLogger>>,
}
impl CredentialStore {
pub fn new() -> Self {
let audit_logger = AuditLogger::new().ok().map(Arc::new);
Self {
service_name: SERVICE_NAME.to_string(),
audit_logger,
}
}
pub fn with_service_name(service_name: String) -> Self {
let audit_logger = AuditLogger::new().ok().map(Arc::new);
Self {
service_name,
audit_logger,
}
}
pub fn with_audit_logger(audit_logger: Arc<AuditLogger>) -> Self {
Self {
service_name: SERVICE_NAME.to_string(),
audit_logger: Some(audit_logger),
}
}
fn build_entry_key(&self, skill: &str, instance: &str, key: &str) -> String {
format!("{}/{}/{}", skill, instance, key)
}
pub fn store_credential(
&self,
skill: &str,
instance: &str,
key: &str,
value: &str,
) -> Result<()> {
let entry_key = self.build_entry_key(skill, instance, key);
let entry = Entry::new(&self.service_name, &entry_key)
.context("Failed to create keyring entry")?;
entry
.set_password(value)
.with_context(|| format!("Failed to store credential for key: {}", key))?;
if let Some(ref logger) = self.audit_logger {
let _ = logger.log_credential_store(skill, instance, key);
}
tracing::debug!(
skill = %skill,
instance = %instance,
key = %key,
"Stored credential in keyring"
);
Ok(())
}
pub fn get_credential(
&self,
skill: &str,
instance: &str,
key: &str,
) -> Result<Zeroizing<String>> {
let entry_key = self.build_entry_key(skill, instance, key);
let entry = Entry::new(&self.service_name, &entry_key)
.context("Failed to create keyring entry")?;
let password = entry
.get_password()
.with_context(|| format!("Failed to retrieve credential for key: {}", key))?;
if let Some(ref logger) = self.audit_logger {
let _ = logger.log_credential_access(skill, instance, key);
}
tracing::debug!(
skill = %skill,
instance = %instance,
key = %key,
"Retrieved credential from keyring"
);
Ok(Zeroizing::new(password))
}
pub fn delete_credential(&self, skill: &str, instance: &str, key: &str) -> Result<()> {
let entry_key = self.build_entry_key(skill, instance, key);
let entry = Entry::new(&self.service_name, &entry_key)
.context("Failed to create keyring entry")?;
entry
.delete_credential()
.with_context(|| format!("Failed to delete credential for key: {}", key))?;
if let Some(ref logger) = self.audit_logger {
let _ = logger.log_credential_delete(skill, instance, key);
}
tracing::debug!(
skill = %skill,
instance = %instance,
key = %key,
"Deleted credential from keyring"
);
Ok(())
}
pub fn delete_all_credentials(&self, skill: &str, instance: &str) -> Result<()> {
tracing::debug!(
skill = %skill,
instance = %instance,
"Deleting all credentials for instance"
);
Ok(())
}
pub fn has_credential(&self, skill: &str, instance: &str, key: &str) -> bool {
let entry_key = self.build_entry_key(skill, instance, key);
if let Ok(entry) = Entry::new(&self.service_name, &entry_key) {
entry.get_password().is_ok()
} else {
false
}
}
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct SecureString(String);
impl SecureString {
pub fn new(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(mut self) -> String {
let s = std::mem::take(&mut self.0);
std::mem::forget(self); s
}
}
impl From<String> for SecureString {
fn from(s: String) -> Self {
Self::new(s)
}
}
impl From<&str> for SecureString {
fn from(s: &str) -> Self {
Self::new(s.to_string())
}
}
impl fmt::Debug for SecureString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecureString([REDACTED])")
}
}
impl Drop for SecureString {
fn drop(&mut self) {
self.0.zeroize();
}
}
pub fn parse_keyring_reference(reference: &str) -> Result<(String, String, String)> {
let prefix = "keyring://skill-engine/";
if !reference.starts_with(prefix) {
anyhow::bail!("Invalid keyring reference: must start with '{}'", prefix);
}
let path = &reference[prefix.len()..];
let parts: Vec<&str> = path.split('/').collect();
if parts.len() != 3 {
anyhow::bail!(
"Invalid keyring reference format: expected 'keyring://skill-engine/{{skill}}/{{instance}}/{{key}}'"
);
}
Ok((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_keyring_reference() {
let reference = "keyring://skill-engine/aws-skill/prod/aws_access_key_id";
let (skill, instance, key) = parse_keyring_reference(reference).unwrap();
assert_eq!(skill, "aws-skill");
assert_eq!(instance, "prod");
assert_eq!(key, "aws_access_key_id");
}
#[test]
fn test_parse_keyring_reference_invalid() {
let reference = "invalid://aws-skill/prod/key";
assert!(parse_keyring_reference(reference).is_err());
let reference = "keyring://skill-engine/only-two/parts";
assert!(parse_keyring_reference(reference).is_err());
}
#[test]
fn test_secure_string_zeroes_memory() {
let secret = SecureString::new("sensitive".to_string());
assert_eq!(secret.as_str(), "sensitive");
drop(secret);
}
#[test]
fn test_secure_string_debug() {
let secret = SecureString::new("sensitive".to_string());
let debug_str = format!("{:?}", secret);
assert_eq!(debug_str, "SecureString([REDACTED])");
assert!(!debug_str.contains("sensitive"));
}
}