use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use super::CIPHER_SIZE;
pub(crate) const NODE_KEK_FILENAME: &str = "node_kek";
pub(crate) const KEK_BACKEND_MARKER_FILENAME: &str = "kek_backend";
pub(crate) const SYSTEMD_CRED_NAME: &str = "freenet-kek";
#[cfg(not(target_os = "linux"))]
pub(crate) const KEYRING_SERVICE: &str = "freenet-core";
#[cfg(not(target_os = "linux"))]
pub(crate) const KEYRING_USER: &str = "node-kek";
pub const KEK_SIZE: usize = CIPHER_SIZE;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum KekBackendKind {
Keyring,
Systemd,
File,
}
impl KekBackendKind {
pub fn as_str(&self) -> &'static str {
match self {
KekBackendKind::Keyring => "keyring",
KekBackendKind::Systemd => "systemd",
KekBackendKind::File => "file",
}
}
}
impl std::fmt::Display for KekBackendKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, thiserror::Error)]
pub enum KekError {
#[error("KEK file I/O error: {0}")]
Io(#[from] io::Error),
#[error("OS keyring backend unavailable or denied: {0}")]
Keyring(String),
#[error("Systemd credential `{name}` not found (CREDENTIALS_DIRECTORY={dir:?})")]
SystemdMissing { name: String, dir: Option<PathBuf> },
#[error("KEK on disk is {actual} bytes; expected {expected}")]
InvalidLength { actual: usize, expected: usize },
#[error("KEK already exists in backend; refusing to overwrite without explicit rotation")]
AlreadyExists,
#[error("No KEK backend available — keyring/systemd/file all failed")]
NoBackend,
}
pub trait KekBackend: Send + Sync {
fn kind(&self) -> KekBackendKind;
fn load(&self) -> Result<Option<Zeroizing<[u8; KEK_SIZE]>>, KekError>;
fn store(&self, kek: &[u8; KEK_SIZE]) -> Result<(), KekError>;
fn delete(&self) -> Result<(), KekError>;
}
pub struct KeyringKek {
entry: keyring::Entry,
}
impl KeyringKek {
pub fn new() -> Result<Self, KekError> {
#[cfg(target_os = "linux")]
{
Err(KekError::Keyring(
"keyring backend not supported on Linux in this build (the workspace ships \
`keyring` without `linux-native`/`sync-secret-service` to avoid the libdbus \
build dep; the crate would fall back to an in-process mock that orphans the \
KEK on process exit). Use `--backend systemd` (LoadCredentialEncrypted=...) \
or `--backend file`."
.to_string(),
))
}
#[cfg(not(target_os = "linux"))]
{
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
.map_err(|e| KekError::Keyring(format!("Entry::new failed: {e}")))?;
Ok(Self { entry })
}
}
}
impl KekBackend for KeyringKek {
fn kind(&self) -> KekBackendKind {
KekBackendKind::Keyring
}
fn load(&self) -> Result<Option<Zeroizing<[u8; KEK_SIZE]>>, KekError> {
match self.entry.get_secret() {
Ok(bytes) => {
if bytes.len() != KEK_SIZE {
return Err(KekError::InvalidLength {
actual: bytes.len(),
expected: KEK_SIZE,
});
}
let mut buf = Zeroizing::new([0u8; KEK_SIZE]);
buf.copy_from_slice(&bytes);
Ok(Some(buf))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(KekError::Keyring(format!("get_secret failed: {e}"))),
}
}
fn store(&self, kek: &[u8; KEK_SIZE]) -> Result<(), KekError> {
if self.entry.get_secret().is_ok() {
return Err(KekError::AlreadyExists);
}
self.entry
.set_secret(kek)
.map_err(|e| KekError::Keyring(format!("set_secret failed: {e}")))
}
fn delete(&self) -> Result<(), KekError> {
match self.entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(KekError::Keyring(format!("delete_credential failed: {e}"))),
}
}
}
pub struct SystemdCredentialKek {
path: PathBuf,
}
impl SystemdCredentialKek {
pub fn new() -> Option<Self> {
let dir = std::env::var_os("CREDENTIALS_DIRECTORY")?;
Some(Self {
path: PathBuf::from(dir).join(SYSTEMD_CRED_NAME),
})
}
}
impl KekBackend for SystemdCredentialKek {
fn kind(&self) -> KekBackendKind {
KekBackendKind::Systemd
}
fn load(&self) -> Result<Option<Zeroizing<[u8; KEK_SIZE]>>, KekError> {
match std::fs::read(&self.path) {
Ok(bytes) => {
if bytes.len() != KEK_SIZE {
return Err(KekError::InvalidLength {
actual: bytes.len(),
expected: KEK_SIZE,
});
}
let mut buf = Zeroizing::new([0u8; KEK_SIZE]);
buf.copy_from_slice(&bytes);
Ok(Some(buf))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Err(KekError::SystemdMissing {
name: SYSTEMD_CRED_NAME.to_string(),
dir: self.path.parent().map(|p| p.to_path_buf()),
}),
Err(e) => Err(KekError::Io(e)),
}
}
fn store(&self, _kek: &[u8; KEK_SIZE]) -> Result<(), KekError> {
Err(KekError::Io(io::Error::other(
"systemd credentials are populated by the service manager; freenet cannot \
write them. Generate the KEK out-of-band and configure \
`LoadCredentialEncrypted=freenet-kek:/path` on the unit.",
)))
}
fn delete(&self) -> Result<(), KekError> {
Ok(())
}
}
pub struct FileKek {
path: PathBuf,
}
impl FileKek {
pub fn new(secrets_dir: &Path) -> Self {
Self {
path: secrets_dir.join(NODE_KEK_FILENAME),
}
}
}
impl KekBackend for FileKek {
fn kind(&self) -> KekBackendKind {
KekBackendKind::File
}
fn load(&self) -> Result<Option<Zeroizing<[u8; KEK_SIZE]>>, KekError> {
match std::fs::read(&self.path) {
Ok(bytes) => {
if bytes.len() != KEK_SIZE {
return Err(KekError::InvalidLength {
actual: bytes.len(),
expected: KEK_SIZE,
});
}
let mut buf = Zeroizing::new([0u8; KEK_SIZE]);
buf.copy_from_slice(&bytes);
Ok(Some(buf))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(KekError::Io(e)),
}
}
fn store(&self, kek: &[u8; KEK_SIZE]) -> Result<(), KekError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&self.path).map_err(|e| {
if e.kind() == io::ErrorKind::AlreadyExists {
KekError::AlreadyExists
} else {
KekError::Io(e)
}
})?;
use std::io::Write;
file.write_all(kek)?;
file.sync_all()?;
Ok(())
}
fn delete(&self) -> Result<(), KekError> {
match std::fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(KekError::Io(e)),
}
}
}
pub fn resolve_first_start(
secrets_dir: &Path,
mut kek_supplier: impl FnMut() -> Zeroizing<[u8; KEK_SIZE]>,
) -> Result<(KekBackendKind, Zeroizing<[u8; KEK_SIZE]>), KekError> {
let backends: Vec<Box<dyn KekBackend>> = build_chain(secrets_dir);
for backend in &backends {
match backend.load() {
Ok(Some(kek)) => {
tracing::info!(
backend = %backend.kind(),
"Loaded existing KEK from backend"
);
return Ok((backend.kind(), kek));
}
Ok(None) => continue,
Err(e) => {
tracing::debug!(
backend = %backend.kind(),
"Backend load failed: {e}"
);
continue;
}
}
}
let new_kek = kek_supplier();
for backend in &backends {
match backend.store(&new_kek) {
Ok(()) => {
let kind = backend.kind();
if kind == KekBackendKind::File {
tracing::warn!(
"Provisioned KEK to the FILE backend ({}/{NODE_KEK_FILENAME}). \
This is the weakest option — anyone with read access to the \
secrets directory can decrypt all delegate secrets. Configure \
a stronger backend (OS keyring or systemd credential) and \
migrate with `freenet secrets kek-migrate`.",
secrets_dir.display()
);
} else {
tracing::info!(backend = %kind, "Provisioned new KEK to backend");
}
return Ok((kind, new_kek));
}
Err(e) => {
tracing::debug!(
backend = %backend.kind(),
"Backend store failed: {e}; falling through"
);
continue;
}
}
}
Err(KekError::NoBackend)
}
pub fn load_from_backend(
kind: KekBackendKind,
secrets_dir: &Path,
) -> Result<Zeroizing<[u8; KEK_SIZE]>, KekError> {
let backend = build_backend_for(kind, secrets_dir)?;
match backend.load()? {
Some(kek) => Ok(kek),
None => Err(KekError::NoBackend),
}
}
pub fn read_backend_marker(secrets_dir: &Path) -> Result<Option<KekBackendKind>, KekError> {
let path = secrets_dir.join(KEK_BACKEND_MARKER_FILENAME);
match std::fs::read_to_string(&path) {
Ok(s) => match s.trim() {
"keyring" => Ok(Some(KekBackendKind::Keyring)),
"systemd" => Ok(Some(KekBackendKind::Systemd)),
"file" => Ok(Some(KekBackendKind::File)),
other => Err(KekError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{KEK_BACKEND_MARKER_FILENAME} contains unrecognized backend `{other}`; \
expected one of: keyring, systemd, file"
),
))),
},
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(KekError::Io(e)),
}
}
pub fn write_backend_marker(secrets_dir: &Path, kind: KekBackendKind) -> Result<(), KekError> {
std::fs::create_dir_all(secrets_dir)?;
let path = secrets_dir.join(KEK_BACKEND_MARKER_FILENAME);
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&path)?;
writeln!(file, "{}", kind.as_str())?;
file.sync_all()?;
Ok(())
}
pub fn replace_backend_marker(secrets_dir: &Path, kind: KekBackendKind) -> Result<(), KekError> {
std::fs::create_dir_all(secrets_dir)?;
let tmp = secrets_dir.join(format!("{KEK_BACKEND_MARKER_FILENAME}.tmp"));
let path = secrets_dir.join(KEK_BACKEND_MARKER_FILENAME);
use std::io::Write;
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&tmp)?;
writeln!(file, "{}", kind.as_str())?;
file.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn ensure_kek_loaded(
secrets_dir: &Path,
kek_supplier: impl FnMut() -> Zeroizing<[u8; KEK_SIZE]>,
) -> Result<(KekBackendKind, Zeroizing<[u8; KEK_SIZE]>), KekError> {
match read_backend_marker(secrets_dir)? {
Some(kind) => {
let kek = load_from_backend(kind, secrets_dir)?;
tracing::debug!(
backend = %kind,
"Loaded KEK from previously-recorded backend"
);
Ok((kind, kek))
}
None => {
let (kind, kek) = resolve_first_start(secrets_dir, kek_supplier)?;
write_backend_marker(secrets_dir, kind)?;
tracing::info!(
backend = %kind,
"Recorded chosen KEK backend in {KEK_BACKEND_MARKER_FILENAME}"
);
Ok((kind, kek))
}
}
}
fn build_chain(secrets_dir: &Path) -> Vec<Box<dyn KekBackend>> {
let mut chain: Vec<Box<dyn KekBackend>> = Vec::new();
if let Some(b) = SystemdCredentialKek::new() {
chain.push(Box::new(b));
}
chain.push(Box::new(FileKek::new(secrets_dir)));
chain
}
pub fn build_backend_for(
kind: KekBackendKind,
secrets_dir: &Path,
) -> Result<Box<dyn KekBackend>, KekError> {
match kind {
KekBackendKind::Keyring => Ok(Box::new(KeyringKek::new()?)),
KekBackendKind::Systemd => {
SystemdCredentialKek::new()
.map(|b| Box::new(b) as _)
.ok_or(KekError::SystemdMissing {
name: SYSTEMD_CRED_NAME.to_string(),
dir: None,
})
}
KekBackendKind::File => Ok(Box::new(FileKek::new(secrets_dir))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_kek(byte: u8) -> Zeroizing<[u8; KEK_SIZE]> {
Zeroizing::new([byte; KEK_SIZE])
}
#[test]
fn file_kek_roundtrip_and_0o600() {
let dir = tempfile::tempdir().expect("tempdir");
let backend = FileKek::new(dir.path());
assert!(backend.load().expect("load").is_none(), "fresh dir empty");
let kek = fresh_kek(0x42);
backend.store(&kek).expect("store");
let loaded = backend.load().expect("load").expect("present");
assert_eq!(*loaded, *kek);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(NODE_KEK_FILENAME))
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "KEK file must be 0o600, got {mode:o}");
}
}
#[test]
fn file_kek_store_twice_refuses_to_overwrite() {
let dir = tempfile::tempdir().expect("tempdir");
let backend = FileKek::new(dir.path());
backend.store(&fresh_kek(1)).expect("first store");
let err = backend
.store(&fresh_kek(2))
.expect_err("second store must fail");
assert!(
matches!(err, KekError::AlreadyExists),
"expected AlreadyExists, got {err:?}"
);
let loaded = backend.load().expect("load").expect("present");
assert_eq!(*loaded, *fresh_kek(1));
}
#[test]
fn file_kek_invalid_length_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(NODE_KEK_FILENAME);
std::fs::write(&path, b"too-short").expect("seed garbage");
let backend = FileKek::new(dir.path());
let err = backend.load().expect_err("load must fail");
assert!(
matches!(err, KekError::InvalidLength { .. }),
"expected InvalidLength, got {err:?}"
);
}
#[test]
fn file_kek_delete_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let backend = FileKek::new(dir.path());
backend.delete().expect("missing delete is no-op");
backend.store(&fresh_kek(7)).expect("store");
backend.delete().expect("present delete ok");
assert!(backend.load().expect("load").is_none());
}
#[test]
fn resolver_provisions_file_when_others_fail() {
let dir = tempfile::tempdir().expect("tempdir");
let supplied = fresh_kek(0xAA);
let (kind, kek) = resolve_first_start(dir.path(), || supplied.clone()).expect("resolve");
match kind {
KekBackendKind::File => assert_eq!(*kek, *supplied),
KekBackendKind::Keyring | KekBackendKind::Systemd => {
}
}
}
#[test]
fn marker_roundtrip_and_unknown_value_errors() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(read_backend_marker(dir.path()).expect("read").is_none());
write_backend_marker(dir.path(), KekBackendKind::File).expect("write");
assert_eq!(
read_backend_marker(dir.path()).expect("read"),
Some(KekBackendKind::File)
);
let path = dir.path().join(KEK_BACKEND_MARKER_FILENAME);
std::fs::write(&path, b"oops").expect("overwrite");
let err = read_backend_marker(dir.path()).expect_err("garbage marker must error");
assert!(
matches!(err, KekError::Io(_)),
"expected Io InvalidData, got {err:?}"
);
}
#[test]
fn ensure_kek_loaded_first_start_writes_marker_and_returns_kek() {
let dir = tempfile::tempdir().expect("tempdir");
let supplied = fresh_kek(0xCC);
let (kind, kek) =
ensure_kek_loaded(dir.path(), || supplied.clone()).expect("first-start ensure");
let marker_kind = read_backend_marker(dir.path())
.expect("read")
.expect("present");
assert_eq!(marker_kind, kind);
if kind == KekBackendKind::File {
assert_eq!(*kek, *supplied);
}
}
#[test]
fn ensure_kek_loaded_second_start_uses_recorded_backend_only() {
let dir = tempfile::tempdir().expect("tempdir");
FileKek::new(dir.path())
.store(&fresh_kek(0x11))
.expect("seed file backend");
write_backend_marker(dir.path(), KekBackendKind::File).expect("write marker");
let (kind, kek) = ensure_kek_loaded(dir.path(), || {
unreachable!("second start must not invoke kek_supplier")
})
.expect("second-start ensure");
assert_eq!(kind, KekBackendKind::File);
assert_eq!(*kek, *fresh_kek(0x11));
}
#[test]
fn ensure_kek_loaded_second_start_errors_when_recorded_backend_empty() {
let dir = tempfile::tempdir().expect("tempdir");
write_backend_marker(dir.path(), KekBackendKind::File).expect("write marker");
let err = ensure_kek_loaded(dir.path(), || fresh_kek(0xFF))
.expect_err("missing KEK behind recorded backend must error");
assert!(
matches!(err, KekError::NoBackend | KekError::Io(_)),
"expected NoBackend or Io, got {err:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn keyring_kek_new_refuses_on_linux() {
match KeyringKek::new() {
Ok(_) => panic!("KeyringKek::new() must refuse on Linux but returned Ok"),
Err(KekError::Keyring(msg)) => assert!(
msg.contains("not supported on Linux"),
"expected Linux-refusal message, got: {msg}"
),
Err(other) => panic!("expected KekError::Keyring, got {other:?}"),
}
}
#[test]
fn replace_backend_marker_overwrites_existing_atomically() {
let dir = tempfile::tempdir().expect("tempdir");
write_backend_marker(dir.path(), KekBackendKind::File).expect("seed marker");
assert_eq!(
read_backend_marker(dir.path()).expect("read"),
Some(KekBackendKind::File)
);
replace_backend_marker(dir.path(), KekBackendKind::Keyring).expect("replace");
assert_eq!(
read_backend_marker(dir.path()).expect("read"),
Some(KekBackendKind::Keyring)
);
replace_backend_marker(dir.path(), KekBackendKind::Systemd).expect("replace 2");
assert_eq!(
read_backend_marker(dir.path()).expect("read"),
Some(KekBackendKind::Systemd)
);
let tmp = dir
.path()
.join(format!("{KEK_BACKEND_MARKER_FILENAME}.tmp"));
assert!(
!tmp.exists(),
"replace_backend_marker must rename the tmp file, not leave it behind"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEK_BACKEND_MARKER_FILENAME))
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o600,
"kek_backend marker must be 0o600 after replace, got {mode:o}"
);
}
}
#[test]
fn replace_backend_marker_creates_fresh_when_absent() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(read_backend_marker(dir.path()).expect("read").is_none());
replace_backend_marker(dir.path(), KekBackendKind::File).expect("replace into empty dir");
assert_eq!(
read_backend_marker(dir.path()).expect("read"),
Some(KekBackendKind::File)
);
}
#[test]
fn resolver_loads_existing_file_kek_on_second_call() {
let dir = tempfile::tempdir().expect("tempdir");
FileKek::new(dir.path())
.store(&fresh_kek(0x55))
.expect("seed file backend");
let supplied = fresh_kek(0x99);
let (_kind, kek) = resolve_first_start(dir.path(), || supplied.clone()).expect("resolve");
let file_kek = FileKek::new(dir.path())
.load()
.expect("load")
.expect("present");
assert_eq!(
*file_kek,
*fresh_kek(0x55),
"file KEK must not be overwritten"
);
assert_ne!(*kek, *supplied);
}
}