use std::io::{Read as _, Write as _};
use std::path::Path;
#[cfg(feature = "simulation")]
pub mod simulation;
const MAGIC: &[u8] = b"BCODE-CUSTODY\0\x01";
const MAX_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum CustodyStorageError {
#[error("credential custody ownership unavailable")]
Ownership,
#[error("credential custody requires maintenance")]
MaintenanceRequired,
#[error("credential custody changed; reload before retrying")]
Conflict,
#[error("credential custody storage failed")]
Io(#[source] std::io::Error),
}
pub trait AuthCustodyStorage: Send {
fn read(&self) -> Result<Vec<u8>, CustodyStorageError>;
fn compare_and_publish(
&mut self,
expected: &[u8],
ciphertext: &[u8],
) -> Result<(), CustodyStorageError>;
fn ensure_no_provisioning(&self) -> Result<(), CustodyStorageError>;
fn begin_provisioning(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<(), CustodyStorageError>;
fn provisioning_intent(
&self,
) -> Result<crate::operations::AuthProvisioningIntent, CustodyStorageError>;
fn ensure_no_refresh(&self) -> Result<(), CustodyStorageError> {
Err(CustodyStorageError::MaintenanceRequired)
}
fn begin_refresh(&self, _intent: &AuthRefreshIntent) -> Result<(), CustodyStorageError> {
Err(CustodyStorageError::MaintenanceRequired)
}
fn refresh_intent(&self) -> Result<AuthRefreshIntent, CustodyStorageError> {
Err(CustodyStorageError::MaintenanceRequired)
}
fn finish_refresh(&self, _intent: &AuthRefreshIntent) -> Result<(), CustodyStorageError> {
Err(CustodyStorageError::MaintenanceRequired)
}
fn finish_provisioning(&self) -> Result<(), CustodyStorageError>;
}
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthRefreshIntent {
pub version: u16,
pub attempt: String,
pub profile_binding: String,
}
impl AuthRefreshIntent {
fn validate(&self) -> Result<(), CustodyStorageError> {
if self.version != 1
|| self.attempt.len() != 64
|| self.profile_binding.len() != 64
|| !self.attempt.bytes().all(|byte| byte.is_ascii_hexdigit())
|| !self
.profile_binding
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
return Err(CustodyStorageError::MaintenanceRequired);
}
Ok(())
}
}
#[cfg(unix)]
#[derive(Debug)]
pub struct CredentialCustodyStorage {
directory: std::fs::File,
_lock: std::fs::File,
refresh_poisoned: std::cell::Cell<bool>,
}
#[cfg(unix)]
impl CredentialCustodyStorage {
pub fn open(path: &Path) -> Result<Self, CustodyStorageError> {
let store = Self::acquire(path, false)?;
store.read()?;
Ok(store)
}
pub fn create(path: &Path, ciphertext: &[u8]) -> Result<Self, CustodyStorageError> {
use std::os::unix::fs::DirBuilderExt as _;
validate(ciphertext)?;
if !path.is_absolute() {
return Err(CustodyStorageError::Ownership);
}
std::fs::DirBuilder::new()
.mode(0o700)
.create(path)
.map_err(CustodyStorageError::Io)?;
let store = Self::acquire(path, true)?;
store.publish(ciphertext)?;
let parent = path.parent().ok_or(CustodyStorageError::Ownership)?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(CustodyStorageError::Io)?;
Ok(store)
}
pub(crate) fn native_coordinator(vault: &Path) -> Result<Self, CustodyStorageError> {
use std::os::unix::fs::DirBuilderExt as _;
if !vault.is_absolute()
|| std::fs::symlink_metadata(vault)
.map_err(CustodyStorageError::Io)?
.file_type()
.is_symlink()
{
return Err(CustodyStorageError::Ownership);
}
let mut name = vault.as_os_str().to_os_string();
name.push(".refresh");
let path = std::path::PathBuf::from(name);
match std::fs::DirBuilder::new().mode(0o700).create(&path) {
Ok(()) => {
let store = Self::acquire(&path, true)?;
store
.directory
.sync_all()
.map_err(CustodyStorageError::Io)?;
std::fs::File::open(path.parent().ok_or(CustodyStorageError::Ownership)?)
.and_then(|parent| parent.sync_all())
.map_err(CustodyStorageError::Io)?;
Ok(store)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Self::acquire(&path, false)
}
Err(error) => Err(CustodyStorageError::Io(error)),
}
}
fn acquire(path: &Path, create: bool) -> Result<Self, CustodyStorageError> {
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
if !path.is_absolute() {
return Err(CustodyStorageError::Ownership);
}
let directory = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)
.map_err(CustodyStorageError::Io)?;
let metadata = directory.metadata().map_err(CustodyStorageError::Io)?;
if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 {
return Err(CustodyStorageError::Ownership);
}
let lock = crate::store::open_owned_entry(
&directory,
c"owner.lock",
libc::O_RDWR
| if create {
libc::O_CREAT | libc::O_EXCL
} else {
0
},
)
.map_err(CustodyStorageError::Io)?;
lock.try_lock()
.map_err(|_| CustodyStorageError::Ownership)?;
match crate::store::open_owned_entry(&directory, c"pending", libc::O_RDONLY) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(CustodyStorageError::MaintenanceRequired),
}
Ok(Self {
directory,
_lock: lock,
refresh_poisoned: std::cell::Cell::new(false),
})
}
pub fn read(&self) -> Result<Vec<u8>, CustodyStorageError> {
if self.refresh_poisoned.get() {
return Err(CustodyStorageError::MaintenanceRequired);
}
match crate::store::open_owned_entry(&self.directory, c"pending", libc::O_RDONLY) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(CustodyStorageError::MaintenanceRequired),
}
let file = crate::store::open_owned_entry(&self.directory, c"custody", libc::O_RDONLY)
.map_err(CustodyStorageError::Io)?;
let mut bytes = Vec::new();
file.take((MAX_BYTES + MAGIC.len() + 1) as u64)
.read_to_end(&mut bytes)
.map_err(CustodyStorageError::Io)?;
let ciphertext = bytes
.strip_prefix(MAGIC)
.ok_or(CustodyStorageError::MaintenanceRequired)?;
validate(ciphertext)?;
Ok(ciphertext.to_vec())
}
pub fn compare_and_publish(
&mut self,
expected: &[u8],
ciphertext: &[u8],
) -> Result<(), CustodyStorageError> {
validate(ciphertext)?;
if self.read()? != expected {
return Err(CustodyStorageError::Conflict);
}
self.publish(ciphertext)
}
pub(crate) fn ensure_no_provisioning(&self) -> Result<(), CustodyStorageError> {
match crate::store::open_owned_entry(&self.directory, c"provisioning-v1", libc::O_RDONLY) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
_ => Err(CustodyStorageError::MaintenanceRequired),
}
}
pub(crate) fn begin_provisioning(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<(), CustodyStorageError> {
self.ensure_no_provisioning()?;
let bytes =
serde_json::to_vec(intent).map_err(|_| CustodyStorageError::MaintenanceRequired)?;
if intent.version != 2
|| intent.source.is_empty()
|| intent.operation.is_empty()
|| bytes.len() > 65536
{
return Err(CustodyStorageError::MaintenanceRequired);
}
let mut intent = crate::store::open_owned_entry(
&self.directory,
c"provisioning-v1",
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
)
.map_err(CustodyStorageError::Io)?;
intent
.write_all(&bytes)
.and_then(|()| intent.sync_all())
.map_err(CustodyStorageError::Io)?;
self.directory.sync_all().map_err(CustodyStorageError::Io)
}
pub(crate) fn provisioning_intent(
&self,
) -> Result<crate::operations::AuthProvisioningIntent, CustodyStorageError> {
let file =
crate::store::open_owned_entry(&self.directory, c"provisioning-v1", libc::O_RDONLY)
.map_err(CustodyStorageError::Io)?;
let mut bytes = Vec::new();
file.take(65537)
.read_to_end(&mut bytes)
.map_err(CustodyStorageError::Io)?;
if bytes.len() > 65536 {
return Err(CustodyStorageError::MaintenanceRequired);
}
let intent: crate::operations::AuthProvisioningIntent =
serde_json::from_slice(&bytes).map_err(|_| CustodyStorageError::MaintenanceRequired)?;
if intent.version != 2 || intent.source.is_empty() || intent.operation.is_empty() {
return Err(CustodyStorageError::MaintenanceRequired);
}
Ok(intent)
}
pub(crate) fn finish_provisioning(&self) -> Result<(), CustodyStorageError> {
use std::os::fd::AsRawFd as _;
if unsafe { libc::unlinkat(self.directory.as_raw_fd(), c"provisioning-v1".as_ptr(), 0) }
!= 0
{
return Err(CustodyStorageError::Io(std::io::Error::last_os_error()));
}
self.directory.sync_all().map_err(CustodyStorageError::Io)
}
fn publish(&self, ciphertext: &[u8]) -> Result<(), CustodyStorageError> {
use std::os::fd::AsRawFd as _;
let mut pending = crate::store::open_owned_entry(
&self.directory,
c"pending",
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
)
.map_err(CustodyStorageError::Io)?;
pending
.write_all(MAGIC)
.and_then(|()| pending.write_all(ciphertext))
.and_then(|()| pending.sync_all())
.map_err(CustodyStorageError::Io)?;
if unsafe {
libc::renameat(
self.directory.as_raw_fd(),
c"pending".as_ptr(),
self.directory.as_raw_fd(),
c"custody".as_ptr(),
)
} != 0
{
return Err(CustodyStorageError::Io(std::io::Error::last_os_error()));
}
self.directory.sync_all().map_err(CustodyStorageError::Io)
}
}
#[cfg(unix)]
impl AuthCustodyStorage for CredentialCustodyStorage {
fn read(&self) -> Result<Vec<u8>, CustodyStorageError> {
Self::read(self)
}
fn compare_and_publish(
&mut self,
expected: &[u8],
ciphertext: &[u8],
) -> Result<(), CustodyStorageError> {
Self::compare_and_publish(self, expected, ciphertext)
}
fn ensure_no_provisioning(&self) -> Result<(), CustodyStorageError> {
Self::ensure_no_provisioning(self)
}
fn begin_provisioning(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<(), CustodyStorageError> {
Self::begin_provisioning(self, intent)
}
fn provisioning_intent(
&self,
) -> Result<crate::operations::AuthProvisioningIntent, CustodyStorageError> {
Self::provisioning_intent(self)
}
fn ensure_no_refresh(&self) -> Result<(), CustodyStorageError> {
if self.refresh_poisoned.get() {
return Err(CustodyStorageError::MaintenanceRequired);
}
match crate::store::open_owned_entry(&self.directory, c"refresh-v1", libc::O_RDONLY) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
_ => Err(CustodyStorageError::MaintenanceRequired),
}
}
fn begin_refresh(&self, intent: &AuthRefreshIntent) -> Result<(), CustodyStorageError> {
intent.validate()?;
self.ensure_no_refresh()?;
self.ensure_no_provisioning()?;
let bytes =
serde_json::to_vec(intent).map_err(|_| CustodyStorageError::MaintenanceRequired)?;
let mut file = crate::store::open_owned_entry(
&self.directory,
c"refresh-v1",
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
)
.map_err(CustodyStorageError::Io)?;
file.write_all(&bytes).map_err(CustodyStorageError::Io)?;
file.sync_all().map_err(CustodyStorageError::Io)?;
self.directory.sync_all().map_err(CustodyStorageError::Io)
}
fn refresh_intent(&self) -> Result<AuthRefreshIntent, CustodyStorageError> {
if self.refresh_poisoned.get() {
return Err(CustodyStorageError::MaintenanceRequired);
}
let file = crate::store::open_owned_entry(&self.directory, c"refresh-v1", libc::O_RDONLY)
.map_err(CustodyStorageError::Io)?;
let mut bytes = Vec::new();
file.take(1025)
.read_to_end(&mut bytes)
.map_err(CustodyStorageError::Io)?;
if bytes.len() > 1024 {
return Err(CustodyStorageError::MaintenanceRequired);
}
let intent: AuthRefreshIntent =
serde_json::from_slice(&bytes).map_err(|_| CustodyStorageError::MaintenanceRequired)?;
intent.validate()?;
Ok(intent)
}
fn finish_refresh(&self, intent: &AuthRefreshIntent) -> Result<(), CustodyStorageError> {
use std::os::fd::AsRawFd as _;
if self.refresh_intent()? != *intent {
return Err(CustodyStorageError::Conflict);
}
if unsafe { libc::unlinkat(self.directory.as_raw_fd(), c"refresh-v1".as_ptr(), 0) } != 0 {
return Err(CustodyStorageError::Io(std::io::Error::last_os_error()));
}
if let Err(error) = self.directory.sync_all() {
self.refresh_poisoned.set(true);
return Err(CustodyStorageError::Io(error));
}
Ok(())
}
fn finish_provisioning(&self) -> Result<(), CustodyStorageError> {
Self::finish_provisioning(self)
}
}
fn validate(ciphertext: &[u8]) -> Result<(), CustodyStorageError> {
if ciphertext.len() > MAX_BYTES {
return Err(CustodyStorageError::MaintenanceRequired);
}
sshenv_vault::Vault::decode_ciphertext(ciphertext)
.map_err(|_| CustodyStorageError::MaintenanceRequired)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uncertain_publication_blocks_reads_without_clearing_refresh() {
let temp = tempfile::tempdir().unwrap();
#[cfg(not(feature = "custody-simulation"))]
let public =
crate::security::ensure_vault_recipient_key(&temp.path().join("source")).unwrap();
#[cfg(feature = "custody-simulation")]
let public = "sim-age:uncertain-owner".to_owned();
let (vault, key) = sshenv_vault::Vault::create(&public).unwrap();
let mut ciphertext = Vec::new();
vault
.save_with_storage(&key, |bytes, _| {
ciphertext = bytes.to_vec();
Ok(())
})
.unwrap();
let path = temp.path().join("owned");
let mut storage = CredentialCustodyStorage::create(&path, &ciphertext).unwrap();
let intent = AuthRefreshIntent {
version: 1,
attempt: "a".repeat(64),
profile_binding: "b".repeat(64),
};
storage.begin_refresh(&intent).unwrap();
std::fs::write(path.join("pending"), b"interrupted").unwrap();
assert!(
storage
.compare_and_publish(&ciphertext, &ciphertext)
.is_err()
);
assert!(storage.read().is_err());
assert!(storage.ensure_no_refresh().is_err());
assert!(storage.refresh_intent().unwrap() == intent);
drop(storage);
assert!(CredentialCustodyStorage::open(&path).is_err());
assert!(path.join("refresh-v1").exists());
assert_eq!(
std::fs::read(path.join("custody")).unwrap()[MAGIC.len()..],
ciphertext
);
}
#[test]
fn encrypted_owner_conflicts_release_and_incompatible_envelope() {
let temp = tempfile::tempdir().unwrap();
#[cfg(not(feature = "custody-simulation"))]
let public =
crate::security::ensure_vault_recipient_key(&temp.path().join("source")).unwrap();
#[cfg(feature = "custody-simulation")]
let public = "sim-age:storage-owner".to_owned();
let (vault, key) = sshenv_vault::Vault::create(&public).unwrap();
let mut ciphertext = Vec::new();
vault
.save_with_storage(&key, |bytes, _| {
ciphertext = bytes.to_vec();
Ok(())
})
.unwrap();
let path = temp.path().join("owned");
let mut storage = CredentialCustodyStorage::create(&path, &ciphertext).unwrap();
assert!(CredentialCustodyStorage::open(&path).is_err());
assert!(sshenv_vault::Vault::load_ciphertext(&path.join("custody")).is_err());
assert!(matches!(
storage.compare_and_publish(b"stale", &ciphertext),
Err(CustodyStorageError::Conflict)
));
assert_eq!(storage.read().unwrap(), ciphertext);
storage
.compare_and_publish(&ciphertext, &ciphertext)
.unwrap();
let refresh = AuthRefreshIntent {
version: 1,
attempt: "a".repeat(64),
profile_binding: "b".repeat(64),
};
storage.begin_refresh(&refresh).unwrap();
assert!(storage.ensure_no_refresh().is_err());
assert!(storage.begin_refresh(&refresh).is_err());
let stale = AuthRefreshIntent {
attempt: "c".repeat(64),
..refresh.clone()
};
assert!(storage.finish_refresh(&stale).is_err());
drop(storage);
let storage = CredentialCustodyStorage::open(&path).unwrap();
assert!(storage.refresh_intent().unwrap() == refresh);
assert!(storage.ensure_no_refresh().is_err());
storage.finish_refresh(&refresh).unwrap();
storage.ensure_no_refresh().unwrap();
let intent = crate::operations::AuthProvisioningIntent {
version: 2,
source: "test".into(),
operation: "attempt-1".into(),
profile_binding: "test-binding".into(),
};
storage.begin_provisioning(&intent).unwrap();
drop(storage);
let reopened = CredentialCustodyStorage::open(&path).unwrap();
assert!(matches!(
reopened.ensure_no_provisioning(),
Err(CustodyStorageError::MaintenanceRequired)
));
assert!(reopened.provisioning_intent().unwrap() == intent);
assert!(reopened.begin_provisioning(&intent).is_err());
reopened.finish_provisioning().unwrap();
reopened.ensure_no_provisioning().unwrap();
assert_eq!(reopened.read().unwrap(), ciphertext);
drop(reopened);
std::fs::write(path.join("custody"), &ciphertext).unwrap();
assert!(matches!(
CredentialCustodyStorage::open(&path),
Err(CustodyStorageError::MaintenanceRequired)
));
assert_eq!(std::fs::read(path.join("custody")).unwrap(), ciphertext);
}
}