use keyring_core::{
Entry, Error, Result,
api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
attributes::parse_attributes,
};
use std::{
any::Any,
collections::HashMap,
fs,
io::Write,
path::{Path, PathBuf},
sync::Arc,
};
pub type SecretPolicy = fn(&[u8]) -> std::result::Result<(), String>;
const SECRET_EXT: &str = "secret";
pub struct Store {
dir: PathBuf,
policy: Option<SecretPolicy>,
id: String,
}
impl std::fmt::Debug for Store {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileStore")
.field("dir", &self.dir)
.field("policy", &self.policy.is_some())
.finish()
}
}
impl Store {
#[must_use]
pub fn new(dir: PathBuf, policy: Option<SecretPolicy>) -> Arc<Self> {
Arc::new(Store {
id: format!("openvtc file store at {}", dir.display()),
dir,
policy,
})
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
}
fn encode_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
struct Cred {
path: PathBuf,
service: String,
user: String,
policy: Option<SecretPolicy>,
}
impl std::fmt::Debug for Cred {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileCred")
.field("path", &self.path)
.field("service", &self.service)
.field("user", &self.user)
.finish()
}
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
Ok(())
}
fn io_err(e: std::io::Error) -> Error {
Error::PlatformFailure(Box::new(e))
}
impl CredentialApi for Cred {
fn set_secret(&self, secret: &[u8]) -> Result<()> {
if let Some(policy) = self.policy
&& let Err(reason) = policy(secret)
{
return Err(Error::NotSupportedByStore(reason));
}
let dir = self
.path
.parent()
.ok_or_else(|| Error::Invalid("path".to_string(), "no parent directory".to_string()))?;
fs::create_dir_all(dir).map_err(io_err)?;
let _ = set_mode(dir, 0o700);
let tmp = self.path.with_extension(format!("{SECRET_EXT}.tmp"));
{
let mut f = fs::File::create(&tmp).map_err(io_err)?;
set_mode(&tmp, 0o600).map_err(io_err)?;
f.write_all(secret).map_err(io_err)?;
f.sync_all().map_err(io_err)?;
}
fs::rename(&tmp, &self.path).map_err(|e| {
let _ = fs::remove_file(&tmp);
io_err(e)
})?;
Ok(())
}
fn get_secret(&self) -> Result<Vec<u8>> {
match fs::read(&self.path) {
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NoEntry),
Err(e) => Err(io_err(e)),
}
}
fn get_attributes(&self) -> Result<HashMap<String, String>> {
self.get_secret()?;
Ok(HashMap::from([
(
crate::secure_store::BACKEND_ATTR.to_string(),
crate::secure_store::BACKEND_FILE.to_string(),
),
("path".to_string(), self.path.display().to_string()),
]))
}
fn delete_credential(&self) -> Result<()> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NoEntry),
Err(e) => Err(io_err(e)),
}
}
fn get_credential(&self) -> Result<Option<Arc<keyring_core::api::Credential>>> {
if self.path.exists() {
Ok(None)
} else {
Err(Error::NoEntry)
}
}
fn get_specifiers(&self) -> Option<(String, String)> {
Some((self.service.clone(), self.user.clone()))
}
fn as_any(&self) -> &dyn Any {
self
}
fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
impl CredentialStoreApi for Store {
fn vendor(&self) -> String {
"OpenVTC encrypted file store".to_string()
}
fn id(&self) -> String {
self.id.clone()
}
fn build(
&self,
service: &str,
user: &str,
modifiers: Option<&HashMap<&str, &str>>,
) -> Result<Entry> {
parse_attributes(&[], modifiers)?;
let name = format!(
"{}.{}.{SECRET_EXT}",
encode_component(service),
encode_component(user)
);
Ok(Entry::new_with_credential(Arc::new(Cred {
path: self.dir.join(name),
service: service.to_string(),
user: user.to_string(),
policy: self.policy,
})))
}
fn as_any(&self) -> &dyn Any {
self
}
fn persistence(&self) -> CredentialPersistence {
CredentialPersistence::UntilDelete
}
fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
#[cfg(test)]
mod tests {
use super::*;
use keyring_core::api::CredentialStoreApi;
fn refuse_plain(secret: &[u8]) -> std::result::Result<(), String> {
if secret.starts_with(b"plain:") {
Err("unencrypted".to_string())
} else {
Ok(())
}
}
fn tmpdir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("openvtc-filestore-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn round_trips_a_secret() {
let dir = tmpdir("roundtrip");
let store = Store::new(dir.clone(), None);
let entry = store.build("openvtc", "default", None).unwrap();
entry.set_secret(b"hello").unwrap();
assert_eq!(entry.get_secret().unwrap(), b"hello");
entry.set_secret(b"bye").unwrap();
assert_eq!(entry.get_secret().unwrap(), b"bye");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_credential_reports_no_entry() {
let dir = tmpdir("missing");
let store = Store::new(dir.clone(), None);
let entry = store.build("openvtc", "nope", None).unwrap();
assert!(matches!(entry.get_secret(), Err(Error::NoEntry)));
assert!(matches!(entry.delete_credential(), Err(Error::NoEntry)));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn delete_removes_the_file() {
let dir = tmpdir("delete");
let store = Store::new(dir.clone(), None);
let entry = store.build("openvtc", "default", None).unwrap();
entry.set_secret(b"x").unwrap();
entry.delete_credential().unwrap();
assert!(matches!(entry.get_secret(), Err(Error::NoEntry)));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn policy_refusal_is_distinguishable_and_writes_nothing() {
let dir = tmpdir("policy");
let store = Store::new(dir.clone(), Some(refuse_plain));
let entry = store.build("openvtc", "default", None).unwrap();
match entry.set_secret(b"plain:seed") {
Err(Error::NotSupportedByStore(msg)) => assert_eq!(msg, "unencrypted"),
other => panic!("expected a policy refusal, got {other:?}"),
}
assert!(matches!(entry.get_secret(), Err(Error::NoEntry)));
entry.set_secret(b"sealed").unwrap();
assert_eq!(entry.get_secret().unwrap(), b"sealed");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn credential_names_do_not_collide() {
let dir = tmpdir("collide");
let store = Store::new(dir.clone(), None);
let a = store.build("a.b", "c", None).unwrap();
let b = store.build("a", "b.c", None).unwrap();
a.set_secret(b"first").unwrap();
b.set_secret(b"second").unwrap();
assert_eq!(a.get_secret().unwrap(), b"first");
assert_eq!(b.get_secret().unwrap(), b"second");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn secret_file_and_directory_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tmpdir("perms");
let store = Store::new(dir.clone(), None);
let entry = store.build("openvtc", "default", None).unwrap();
entry.set_secret(b"secret").unwrap();
let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
dir_mode, 0o700,
"secrets dir must not be group/world readable"
);
let file = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|e| e == SECRET_EXT))
.expect("secret file written");
let mode = fs::metadata(&file).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "secret file must be owner-only");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn store_reports_itself_durable() {
let store = Store::new(tmpdir("persist"), None);
assert!(matches!(
store.persistence(),
CredentialPersistence::UntilDelete
));
}
#[test]
fn constructing_a_store_touches_no_disk() {
let dir = tmpdir("notouch");
let store = Store::new(dir.clone(), None);
let _ = store.build("openvtc", "default", None).unwrap();
assert!(
!dir.exists(),
"building a store must not create its directory"
);
}
}