use crate::crypto::SigningKey;
use crate::envelope::EncryptionSecretKey;
use crate::ids::AgentId;
pub trait Keyring: Send + Sync {
fn signing_key(&self, id: AgentId) -> Option<SigningKey>;
fn encryption_key(&self, _id: AgentId) -> Option<EncryptionSecretKey> {
None
}
}
const SECRETS_FILE: &str = "secrets.json";
#[derive(Debug, Clone)]
pub struct FsKeyring {
dir: std::path::PathBuf,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Secrets {
signing_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
encryption_key: Option<String>,
}
impl zeroize::Zeroize for Secrets {
fn zeroize(&mut self) {
self.signing_key.zeroize();
self.encryption_key.zeroize();
}
}
impl FsKeyring {
pub fn new(dir: impl Into<std::path::PathBuf>) -> Self {
Self { dir: dir.into() }
}
fn secrets_path(&self, id: AgentId) -> std::path::PathBuf {
self.dir.join(id.to_string()).join(SECRETS_FILE)
}
pub fn insert(&self, id: AgentId, key: &SigningKey) -> std::io::Result<()> {
use std::io::Write;
use zeroize::Zeroize;
let agent_dir = self.dir.join(id.to_string());
std::fs::create_dir_all(&agent_dir)?;
let secrets = zeroize::Zeroizing::new(Secrets {
signing_key: crate::crypto::signing_key_to_hex(key),
encryption_key: None,
});
let mut json = serde_json::to_string_pretty(&*secrets)
.map_err(std::io::Error::other)?;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
options.mode(0o600);
std::fs::set_permissions(
&agent_dir,
std::fs::Permissions::from_mode(0o700),
)?;
}
let result =
options.open(self.secrets_path(id)).and_then(|mut file| {
file.write_all(json.as_bytes())?;
file.sync_all()
});
json.zeroize();
result
}
pub fn ensure_encryption_key(
&self,
id: AgentId,
) -> std::io::Result<EncryptionSecretKey> {
use std::io::Write;
use zeroize::Zeroize;
let path = self.secrets_path(id);
let raw = zeroize::Zeroizing::new(std::fs::read_to_string(&path)?);
let mut secrets: zeroize::Zeroizing<Secrets> = zeroize::Zeroizing::new(
serde_json::from_str(&raw).map_err(std::io::Error::other)?,
);
if let Some(hex_key) = &secrets.encryption_key {
return crate::envelope::encryption_secret_from_hex(hex_key)
.map_err(std::io::Error::other);
}
let (secret, _) = crate::envelope::generate_encryption_keypair();
secrets.encryption_key =
Some(crate::envelope::encryption_secret_to_hex(&secret));
let mut json = serde_json::to_string_pretty(&*secrets)
.map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let result = options
.open(&tmp)
.and_then(|mut file| {
file.write_all(json.as_bytes())?;
file.sync_all()
})
.and_then(|_| std::fs::rename(&tmp, &path));
json.zeroize();
result.map(|_| secret)
}
}
impl Keyring for FsKeyring {
fn signing_key(&self, id: AgentId) -> Option<SigningKey> {
let raw = zeroize::Zeroizing::new(
std::fs::read_to_string(self.secrets_path(id)).ok()?,
);
let secrets: zeroize::Zeroizing<Secrets> = zeroize::Zeroizing::new(
serde_json::from_str(&raw)
.map_err(|e| {
tracing::warn!("unparseable secrets for {id}: {e}");
})
.ok()?,
);
crate::crypto::signing_key_from_hex(&secrets.signing_key)
.map_err(|e| {
tracing::warn!("bad signing key for {id}: {e}");
})
.ok()
}
fn encryption_key(&self, id: AgentId) -> Option<EncryptionSecretKey> {
let raw = zeroize::Zeroizing::new(
std::fs::read_to_string(self.secrets_path(id)).ok()?,
);
let secrets: zeroize::Zeroizing<Secrets> =
zeroize::Zeroizing::new(serde_json::from_str(&raw).ok()?);
crate::envelope::encryption_secret_from_hex(
secrets.encryption_key.as_deref()?,
)
.map_err(|e| {
tracing::warn!("bad encryption key for {id}: {e}");
})
.ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::generate_keypair;
use std::collections::HashMap;
impl Keyring for HashMap<AgentId, SigningKey> {
fn signing_key(&self, id: AgentId) -> Option<SigningKey> {
self.get(&id).cloned()
}
}
#[test]
fn hashmap_ring_resolves() {
let id = AgentId::new();
let (key, _) = generate_keypair();
let ring: HashMap<AgentId, SigningKey> =
[(id, key.clone())].into_iter().collect();
assert_eq!(
ring.signing_key(id).map(|k| k.to_bytes()),
Some(key.to_bytes())
);
assert!(ring.signing_key(AgentId::new()).is_none());
}
#[test]
fn fs_ring_insert_and_resolve_round_trips() {
let dir = tempfile::tempdir().unwrap();
let ring = FsKeyring::new(dir.path());
let id = AgentId::new();
let (key, _) = generate_keypair();
ring.insert(id, &key).unwrap();
assert_eq!(
ring.signing_key(id).map(|k| k.to_bytes()),
Some(key.to_bytes())
);
assert!(ring.signing_key(AgentId::new()).is_none());
}
#[test]
fn fs_ring_backfills_and_round_trips_encryption_key() {
let dir = tempfile::tempdir().unwrap();
let ring = FsKeyring::new(dir.path());
let id = AgentId::new();
let (key, _) = generate_keypair();
ring.insert(id, &key).unwrap();
assert!(Keyring::encryption_key(&ring, id).is_none());
let generated = ring.ensure_encryption_key(id).unwrap();
let again = ring.ensure_encryption_key(id).unwrap();
assert_eq!(generated.to_bytes(), again.to_bytes());
let loaded = Keyring::encryption_key(&ring, id).unwrap();
assert_eq!(generated.to_bytes(), loaded.to_bytes());
assert!(ring.signing_key(id).is_some());
}
#[test]
fn ensure_encryption_key_requires_signing_identity() {
let dir = tempfile::tempdir().unwrap();
let ring = FsKeyring::new(dir.path());
assert!(ring.ensure_encryption_key(AgentId::new()).is_err());
}
#[test]
fn fs_ring_refuses_to_overwrite() {
let dir = tempfile::tempdir().unwrap();
let ring = FsKeyring::new(dir.path());
let id = AgentId::new();
let (key, _) = generate_keypair();
ring.insert(id, &key).unwrap();
let err = ring.insert(id, &key).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
}
#[test]
fn fs_ring_corrupt_secrets_is_none_not_panic() {
let dir = tempfile::tempdir().unwrap();
let ring = FsKeyring::new(dir.path());
let id = AgentId::new();
let agent_dir = dir.path().join(id.to_string());
std::fs::create_dir_all(&agent_dir).unwrap();
std::fs::write(agent_dir.join(SECRETS_FILE), b"not json").unwrap();
assert!(ring.signing_key(id).is_none());
std::fs::write(
agent_dir.join(SECRETS_FILE),
b"{\"signing_key\": \"deadbeef\"}",
)
.unwrap();
assert!(ring.signing_key(id).is_none());
}
#[cfg(unix)]
#[test]
fn fs_ring_writes_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let ring = FsKeyring::new(dir.path());
let id = AgentId::new();
let (key, _) = generate_keypair();
ring.insert(id, &key).unwrap();
let agent_dir = dir.path().join(id.to_string());
let file_mode = std::fs::metadata(agent_dir.join(SECRETS_FILE))
.unwrap()
.permissions()
.mode();
let dir_mode =
std::fs::metadata(&agent_dir).unwrap().permissions().mode();
assert_eq!(file_mode & 0o777, 0o600);
assert_eq!(dir_mode & 0o777, 0o700);
}
}