openrtc 2.8.2

OpenRTC: a Rust-first P2P runtime for device discovery, signaling, and iroh/QUIC networking.
Documentation
//! Prompt-free native software keys. Owner: native host storage; consumers:
//! Node service hosts and offline devices. Reviewed 2026-09-05.
//! This stores identity only; it owns no discovery, admission or peer lifecycle.

use anyhow::{ensure, Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};
use std::path::Path;
use zeroize::Zeroizing;

/// Persistent software signer, with no OS keychain prompt. Supports Unix
/// private directories (including macOS/Linux and native mobile sandboxes).
/// Other hosts can implement `DeviceSigner` / `OfflineSigner` with their store.
///
/// Use separate paths for device proof, authority signing and endpoint keys.
/// Never copy these files to provision another device: copied software keys
/// can impersonate their owner. This is not hardware-backed clone resistance.
/// The signer intentionally has no serialization, clone or private-key API.
pub struct FileSigner(SigningKey);

impl FileSigner {
    pub fn load_or_create(path: impl AsRef<Path>) -> Result<Self> {
        let seed = load_or_create_secret(path.as_ref())?;
        Ok(Self(SigningKey::from_bytes(&seed)))
    }
}

impl crate::native::DeviceSigner for FileSigner {
    fn public_jwk(&self, _app_tag: &str) -> Result<serde_json::Value> {
        Ok(serde_json::json!({
            "kty": "OKP", "crv": "Ed25519",
            "x": URL_SAFE_NO_PAD.encode(self.0.verifying_key().as_bytes()),
        }))
    }

    fn sign(&self, _app_tag: &str, challenge: &[u8]) -> Result<Vec<u8>> {
        Ok(self.0.sign(challenge).to_bytes().to_vec())
    }
}

impl crate::offline::OfflineSigner for FileSigner {
    fn verifying_key(&self) -> Result<VerifyingKey> {
        Ok(self.0.verifying_key())
    }
    fn sign(&self, message: &[u8]) -> Result<Signature> {
        Ok(self.0.sign(message))
    }
}

/// Reopen the device's private Iroh identity at process construction. Pass it
/// directly to the native endpoint initializer; never return it through IPC.
pub fn load_or_create_endpoint_key(path: impl AsRef<Path>) -> Result<iroh::SecretKey> {
    let seed = load_or_create_secret(path.as_ref())?;
    Ok(iroh::SecretKey::from_bytes(&seed))
}

#[cfg(not(unix))]
fn load_or_create_secret(_path: &Path) -> Result<Zeroizing<[u8; 32]>> {
    anyhow::bail!(
        "private file keys require Unix permissions; install a host-owned signer on this platform"
    )
}

#[cfg(unix)]
fn load_or_create_secret(path: &Path) -> Result<Zeroizing<[u8; 32]>> {
    use std::fs::{self, File, OpenOptions};
    use std::io::{Read as _, Write as _};
    use std::os::unix::fs::{
        DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _,
    };

    ensure!(path.is_absolute(), "private key path must be absolute");
    let parent = path
        .parent()
        .context("private key path requires a directory")?;
    fs::DirBuilder::new()
        .recursive(true)
        .mode(0o700)
        .create(parent)
        .context("create private key directory")?;
    let metadata = fs::symlink_metadata(parent).context("inspect private key directory")?;
    // Same-UID malware and whole-disk rollback are outside software assurance.
    // Validate the opened key too, rather than trusting a pre-open path check.
    ensure!(
        metadata.is_dir()
            && !metadata.file_type().is_symlink()
            && metadata.permissions().mode() & 0o077 == 0
            && metadata.uid() == unsafe { libc::geteuid() },
        "key directory must be private and owned by this user"
    );

    let read = || -> Result<Option<Zeroizing<[u8; 32]>>> {
        let mut file = match OpenOptions::new()
            .read(true)
            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
            .open(path)
        {
            Ok(file) => file,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(error).context("open private key"),
        };
        let metadata = file.metadata()?;
        ensure!(
            metadata.is_file()
                && metadata.len() == 32
                && metadata.permissions().mode() & 0o077 == 0
                && metadata.uid() == unsafe { libc::geteuid() },
            "private key file is invalid or not private"
        );
        let mut seed = Zeroizing::new([0u8; 32]);
        file.read_exact(seed.as_mut()).context("read private key")?;
        Ok(Some(seed))
    };
    if let Some(seed) = read()? {
        return Ok(seed);
    }

    let mut seed = Zeroizing::new([0u8; 32]);
    getrandom::getrandom(seed.as_mut())
        .map_err(|error| anyhow::anyhow!("generate private key: {error}"))?;
    let temp = parent.join(format!(".openrtc-key-{}.tmp", uuid::Uuid::new_v4()));
    // Publish only a complete synced file. hard_link never replaces an existing
    // key, and simultaneous creators read the same winner instead of a partial
    // write. A damaged existing key fails closed; it is never regenerated.
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&temp)
        .context("create private key temporary file")?;
    let result = (|| -> Result<()> {
        file.write_all(seed.as_ref()).context("write private key")?;
        file.sync_all().context("sync private key")?;
        match fs::hard_link(&temp, path) {
            Ok(()) => (),
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (),
            Err(error) => return Err(error).context("install private key"),
        }
        Ok(())
    })();
    drop(file);
    let cleanup = fs::remove_file(&temp);
    result?;
    cleanup.context("remove private key temporary file")?;
    File::open(parent)?
        .sync_all()
        .context("sync private key directory")?;
    read()?.context("private key disappeared during initialization")
}

#[cfg(all(test, unix))]
mod tests {
    use super::*;
    use crate::offline::OfflineSigner;
    use ed25519_dalek::Verifier as _;
    use std::fs;
    use std::os::unix::fs::{symlink, PermissionsExt as _};

    struct Directory(std::path::PathBuf);
    impl Directory {
        fn new() -> Self {
            Self(std::env::temp_dir().join(format!("openrtc-keys-{}", uuid::Uuid::new_v4())))
        }
    }
    impl Drop for Directory {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn reopen_preserves_separate_proof_and_endpoint_identities() {
        let directory = Directory::new();
        let proof = directory.0.join("proof.key");
        let endpoint = directory.0.join("iroh.key");
        let first = FileSigner::load_or_create(&proof).unwrap();
        let public = first.verifying_key().unwrap();
        let signature = first.sign(b"restart proof").unwrap();
        drop(first);
        let second = FileSigner::load_or_create(&proof).unwrap();
        assert_eq!(second.verifying_key().unwrap(), public);
        public.verify(b"restart proof", &signature).unwrap();
        let endpoint_id = load_or_create_endpoint_key(&endpoint).unwrap().public();
        assert_eq!(
            load_or_create_endpoint_key(&endpoint).unwrap().public(),
            endpoint_id
        );
        assert_ne!(endpoint_id.as_bytes(), public.as_bytes());
        assert_eq!(
            fs::metadata(&proof).unwrap().permissions().mode() & 0o077,
            0
        );
    }

    #[test]
    fn simultaneous_creators_share_one_complete_key() {
        let directory = Directory::new();
        let key = directory.0.join("proof.key");
        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
        let tasks: Vec<_> = (0..8)
            .map(|_| {
                let key = key.clone();
                let barrier = barrier.clone();
                std::thread::spawn(move || {
                    barrier.wait();
                    FileSigner::load_or_create(key)
                        .unwrap()
                        .verifying_key()
                        .unwrap()
                })
            })
            .collect();
        let keys: Vec<_> = tasks.into_iter().map(|task| task.join().unwrap()).collect();
        assert!(keys.iter().all(|key| key == &keys[0]));
        assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
    }

    #[test]
    fn unsafe_or_damaged_paths_fail_without_rotating_identity() {
        let directory = Directory::new();
        let key = directory.0.join("proof.key");
        FileSigner::load_or_create(&key).unwrap();
        let original = fs::read(&key).unwrap();
        let link = directory.0.join("link.key");
        symlink(&key, &link).unwrap();
        assert!(FileSigner::load_or_create(&link).is_err());
        fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
        assert!(FileSigner::load_or_create(&key).is_err());
        assert_eq!(fs::read(&key).unwrap(), original);
        fs::set_permissions(&key, fs::Permissions::from_mode(0o600)).unwrap();
        fs::write(&key, b"damaged").unwrap();
        assert!(FileSigner::load_or_create(&key).is_err());
        assert_eq!(fs::read(&key).unwrap(), b"damaged");
        assert!(FileSigner::load_or_create("relative.key").is_err());
    }
}