use std::{
fmt,
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
};
#[cfg(test)]
use std::{cell::Cell, marker::PhantomData, rc::Rc};
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use thiserror::Error;
const PRIVATE_FILE_MODE: u32 = 0o600;
const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
const TEMPORARY_NAME_ATTEMPTS: usize = 8;
#[cfg(unix)]
#[derive(Clone, Copy, PartialEq, Eq)]
enum OwnershipTarget {
ParentDirectory,
KeyFile,
}
#[cfg(test)]
thread_local! {
static REJECTED_OWNER_TARGET: Cell<Option<OwnershipTarget>> = const { Cell::new(None) };
}
#[cfg(test)]
pub(crate) struct OwnerMismatchGuard {
previous: Option<OwnershipTarget>,
_not_send: PhantomData<Rc<()>>,
}
#[cfg(test)]
impl Drop for OwnerMismatchGuard {
fn drop(&mut self) {
REJECTED_OWNER_TARGET.set(self.previous);
}
}
type PublishKey = fn(&Path, &Path, &Path) -> rustix::io::Result<()>;
#[derive(Clone)]
pub(crate) struct IrohIdentity {
#[allow(dead_code)] secret_key: iroh::SecretKey,
endpoint_id: iroh::EndpointId,
}
impl IrohIdentity {
fn from_secret_key(secret_key: iroh::SecretKey) -> Self {
let endpoint_id = secret_key.public();
Self {
secret_key,
endpoint_id,
}
}
pub(crate) fn endpoint_id(&self) -> iroh::EndpointId {
self.endpoint_id
}
#[allow(dead_code)] pub(crate) fn secret_key(&self) -> &iroh::SecretKey {
&self.secret_key
}
}
impl fmt::Debug for IrohIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("IrohIdentity")
.field("endpoint_id", &self.endpoint_id)
.finish()
}
}
impl fmt::Display for IrohIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.endpoint_id.fmt(formatter)
}
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IrohIdentityError {
#[error("iroh endpoint identity storage is invalid or unavailable")]
Storage,
}
#[derive(Debug, Clone)]
pub(crate) struct IrohIdentityStore {
path: PathBuf,
publish_key: PublishKey,
}
impl IrohIdentityStore {
pub(crate) fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
publish_key: publish_key_no_replace,
}
}
#[cfg(test)]
pub(crate) fn with_unsupported_atomic_publish(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
publish_key: unsupported_atomic_publish,
}
}
#[cfg(test)]
pub(crate) fn reject_key_owner_for_test() -> OwnerMismatchGuard {
reject_owner_for_test(OwnershipTarget::KeyFile)
}
#[cfg(test)]
pub(crate) fn reject_parent_owner_for_test() -> OwnerMismatchGuard {
reject_owner_for_test(OwnershipTarget::ParentDirectory)
}
pub(crate) fn load_or_create(&self) -> Result<IrohIdentity, IrohIdentityError> {
let parent = ensure_private_parent(&self.path)?;
if let Some(secret_key) = read_key(&self.path)? {
return Ok(IrohIdentity::from_secret_key(secret_key));
}
let secret_key = iroh::SecretKey::generate();
let (temporary_path, mut temporary_file) = create_temporary_key(parent)?;
let write_result = (|| -> Result<(), IrohIdentityError> {
temporary_file
.set_permissions(private_file_permissions())
.map_err(|_| IrohIdentityError::Storage)?;
temporary_file
.write_all(&secret_key.to_bytes())
.map_err(|_| IrohIdentityError::Storage)?;
temporary_file
.sync_all()
.map_err(|_| IrohIdentityError::Storage)
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary_path);
return Err(IrohIdentityError::Storage);
}
match (self.publish_key)(parent, &temporary_path, &self.path) {
Ok(()) => {
sync_directory(parent).map_err(|_| IrohIdentityError::Storage)?;
read_key(&self.path)?
.map(IrohIdentity::from_secret_key)
.ok_or(IrohIdentityError::Storage)
}
Err(error) if error == rustix::io::Errno::EXIST => {
let _ = fs::remove_file(&temporary_path);
read_key(&self.path)?
.map(IrohIdentity::from_secret_key)
.ok_or(IrohIdentityError::Storage)
}
Err(_) => {
let _ = fs::remove_file(&temporary_path);
Err(IrohIdentityError::Storage)
}
}
}
pub(crate) fn load_existing(&self) -> Result<Option<IrohIdentity>, IrohIdentityError> {
if ensure_existing_private_parent(&self.path)?.is_none() {
return Ok(None);
}
Ok(read_key(&self.path)?.map(IrohIdentity::from_secret_key))
}
}
#[cfg(test)]
fn reject_owner_for_test(target: OwnershipTarget) -> OwnerMismatchGuard {
OwnerMismatchGuard {
previous: REJECTED_OWNER_TARGET.replace(Some(target)),
_not_send: PhantomData,
}
}
fn ensure_private_parent(path: &Path) -> Result<&Path, IrohIdentityError> {
let parent = match path.parent() {
Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
Some(parent) => parent,
None => return Err(IrohIdentityError::Storage),
};
match fs::symlink_metadata(parent) {
Ok(metadata) => {
if metadata.file_type().is_symlink()
|| !metadata.is_dir()
|| !private_directory(&metadata)
{
return Err(IrohIdentityError::Storage);
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir_all(parent).map_err(|_| IrohIdentityError::Storage)?;
fs::set_permissions(parent, private_directory_permissions())
.map_err(|_| IrohIdentityError::Storage)?;
}
Err(_) => return Err(IrohIdentityError::Storage),
}
Ok(parent)
}
fn ensure_existing_private_parent(path: &Path) -> Result<Option<&Path>, IrohIdentityError> {
let parent = match path.parent() {
Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
Some(parent) => parent,
None => return Err(IrohIdentityError::Storage),
};
match fs::symlink_metadata(parent) {
Ok(metadata) => {
if metadata.file_type().is_symlink()
|| !metadata.is_dir()
|| !private_directory(&metadata)
{
Err(IrohIdentityError::Storage)
} else {
Ok(Some(parent))
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(IrohIdentityError::Storage),
}
}
fn create_temporary_key(parent: &Path) -> Result<(PathBuf, File), IrohIdentityError> {
for _ in 0..TEMPORARY_NAME_ATTEMPTS {
let path = parent.join(format!(".iroh-endpoint-{}.tmp", uuid::Uuid::new_v4()));
let mut options = OpenOptions::new();
options.write(true).create_new(true).mode(PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(file) => return Ok((path, file)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(_) => return Err(IrohIdentityError::Storage),
}
}
Err(IrohIdentityError::Storage)
}
fn publish_key_no_replace(
parent: &Path,
temporary: &Path,
destination: &Path,
) -> rustix::io::Result<()> {
let directory = File::open(parent).map_err(|error| {
rustix::io::Errno::from_io_error(&error).unwrap_or(rustix::io::Errno::IO)
})?;
let temporary_name = temporary.file_name().ok_or(rustix::io::Errno::INVAL)?;
let destination_name = destination.file_name().ok_or(rustix::io::Errno::INVAL)?;
rustix::fs::renameat_with(
&directory,
temporary_name,
&directory,
destination_name,
rustix::fs::RenameFlags::NOREPLACE,
)
}
#[cfg(test)]
fn unsupported_atomic_publish(
_parent: &Path,
_temporary: &Path,
_destination: &Path,
) -> rustix::io::Result<()> {
Err(rustix::io::Errno::NOSYS)
}
fn read_key(path: &Path) -> Result<Option<iroh::SecretKey>, IrohIdentityError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err(IrohIdentityError::Storage),
};
if !valid_key_metadata(&metadata) {
return Err(IrohIdentityError::Storage);
}
let mut options = OpenOptions::new();
options
.read(true)
.custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32);
let mut file = options.open(path).map_err(|_| IrohIdentityError::Storage)?;
let opened = file.metadata().map_err(|_| IrohIdentityError::Storage)?;
if !valid_key_metadata(&opened) {
return Err(IrohIdentityError::Storage);
}
let mut bytes = [0_u8; 32];
file.read_exact(&mut bytes)
.map_err(|_| IrohIdentityError::Storage)?;
let mut extra = [0_u8; 1];
if file
.read(&mut extra)
.map_err(|_| IrohIdentityError::Storage)?
!= 0
{
return Err(IrohIdentityError::Storage);
}
Ok(Some(iroh::SecretKey::from_bytes(&bytes)))
}
fn sync_directory(path: &Path) -> io::Result<()> {
File::open(path)?.sync_all()
}
#[cfg(unix)]
fn private_directory(metadata: &fs::Metadata) -> bool {
metadata.permissions().mode() & 0o777 == PRIVATE_DIRECTORY_MODE
&& owned_by_current_user(metadata, OwnershipTarget::ParentDirectory)
}
#[cfg(not(unix))]
fn private_directory(_metadata: &fs::Metadata) -> bool {
true
}
#[cfg(unix)]
fn valid_key_metadata(metadata: &fs::Metadata) -> bool {
!metadata.file_type().is_symlink()
&& metadata.is_file()
&& metadata.len() == 32
&& metadata.permissions().mode() & 0o777 == PRIVATE_FILE_MODE
&& owned_by_current_user(metadata, OwnershipTarget::KeyFile)
}
#[cfg(unix)]
fn owned_by_current_user(metadata: &fs::Metadata, target: OwnershipTarget) -> bool {
#[cfg(test)]
if REJECTED_OWNER_TARGET.get() == Some(target) {
return false;
}
#[cfg(not(test))]
let _ = target;
metadata.uid() == rustix::process::getuid().as_raw()
}
#[cfg(not(unix))]
fn valid_key_metadata(metadata: &fs::Metadata) -> bool {
!metadata.file_type().is_symlink() && metadata.is_file() && metadata.len() == 32
}
#[cfg(unix)]
fn private_file_permissions() -> fs::Permissions {
fs::Permissions::from_mode(PRIVATE_FILE_MODE)
}
#[cfg(not(unix))]
fn private_file_permissions() -> fs::Permissions {
fs::Permissions::readonly(false)
}
#[cfg(unix)]
fn private_directory_permissions() -> fs::Permissions {
fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE)
}
#[cfg(not(unix))]
fn private_directory_permissions() -> fs::Permissions {
fs::Permissions::readonly(false)
}