Skip to main content

openrtc/
native_key_store.rs

1//! Prompt-free native software keys. Owner: native host storage; consumers:
2//! Node service hosts and offline devices. Reviewed 2026-09-05.
3//! This stores identity only; it owns no discovery, admission or peer lifecycle.
4
5use anyhow::{ensure, Context, Result};
6use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
7use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};
8use std::path::Path;
9use zeroize::Zeroizing;
10
11/// Persistent software signer, with no OS keychain prompt. Supports Unix
12/// private directories (including macOS/Linux and native mobile sandboxes).
13/// Other hosts can implement `DeviceSigner` / `OfflineSigner` with their store.
14///
15/// Use separate paths for device proof, authority signing and endpoint keys.
16/// Never copy these files to provision another device: copied software keys
17/// can impersonate their owner. This is not hardware-backed clone resistance.
18/// The signer intentionally has no serialization, clone or private-key API.
19pub struct FileSigner(SigningKey);
20
21impl FileSigner {
22    pub fn load_or_create(path: impl AsRef<Path>) -> Result<Self> {
23        let seed = load_or_create_secret(path.as_ref())?;
24        Ok(Self(SigningKey::from_bytes(&seed)))
25    }
26}
27
28impl crate::native::DeviceSigner for FileSigner {
29    fn public_jwk(&self, _app_tag: &str) -> Result<serde_json::Value> {
30        Ok(serde_json::json!({
31            "kty": "OKP", "crv": "Ed25519",
32            "x": URL_SAFE_NO_PAD.encode(self.0.verifying_key().as_bytes()),
33        }))
34    }
35
36    fn sign(&self, _app_tag: &str, challenge: &[u8]) -> Result<Vec<u8>> {
37        Ok(self.0.sign(challenge).to_bytes().to_vec())
38    }
39}
40
41impl crate::offline::OfflineSigner for FileSigner {
42    fn verifying_key(&self) -> Result<VerifyingKey> {
43        Ok(self.0.verifying_key())
44    }
45    fn sign(&self, message: &[u8]) -> Result<Signature> {
46        Ok(self.0.sign(message))
47    }
48}
49
50/// Reopen the device's private Iroh identity at process construction. Pass it
51/// directly to the native endpoint initializer; never return it through IPC.
52pub fn load_or_create_endpoint_key(path: impl AsRef<Path>) -> Result<iroh::SecretKey> {
53    let seed = load_or_create_secret(path.as_ref())?;
54    Ok(iroh::SecretKey::from_bytes(&seed))
55}
56
57#[cfg(not(unix))]
58fn load_or_create_secret(_path: &Path) -> Result<Zeroizing<[u8; 32]>> {
59    anyhow::bail!(
60        "private file keys require Unix permissions; install a host-owned signer on this platform"
61    )
62}
63
64#[cfg(unix)]
65fn load_or_create_secret(path: &Path) -> Result<Zeroizing<[u8; 32]>> {
66    use std::fs::{self, File, OpenOptions};
67    use std::io::{Read as _, Write as _};
68    use std::os::unix::fs::{
69        DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _,
70    };
71
72    ensure!(path.is_absolute(), "private key path must be absolute");
73    let parent = path
74        .parent()
75        .context("private key path requires a directory")?;
76    fs::DirBuilder::new()
77        .recursive(true)
78        .mode(0o700)
79        .create(parent)
80        .context("create private key directory")?;
81    let metadata = fs::symlink_metadata(parent).context("inspect private key directory")?;
82    // Same-UID malware and whole-disk rollback are outside software assurance.
83    // Validate the opened key too, rather than trusting a pre-open path check.
84    ensure!(
85        metadata.is_dir()
86            && !metadata.file_type().is_symlink()
87            && metadata.permissions().mode() & 0o077 == 0
88            && metadata.uid() == unsafe { libc::geteuid() },
89        "key directory must be private and owned by this user"
90    );
91
92    let read = || -> Result<Option<Zeroizing<[u8; 32]>>> {
93        let mut file = match OpenOptions::new()
94            .read(true)
95            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
96            .open(path)
97        {
98            Ok(file) => file,
99            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
100            Err(error) => return Err(error).context("open private key"),
101        };
102        let metadata = file.metadata()?;
103        ensure!(
104            metadata.is_file()
105                && metadata.len() == 32
106                && metadata.permissions().mode() & 0o077 == 0
107                && metadata.uid() == unsafe { libc::geteuid() },
108            "private key file is invalid or not private"
109        );
110        let mut seed = Zeroizing::new([0u8; 32]);
111        file.read_exact(seed.as_mut()).context("read private key")?;
112        Ok(Some(seed))
113    };
114    if let Some(seed) = read()? {
115        return Ok(seed);
116    }
117
118    let mut seed = Zeroizing::new([0u8; 32]);
119    getrandom::getrandom(seed.as_mut())
120        .map_err(|error| anyhow::anyhow!("generate private key: {error}"))?;
121    let temp = parent.join(format!(".openrtc-key-{}.tmp", uuid::Uuid::new_v4()));
122    // Publish only a complete synced file. hard_link never replaces an existing
123    // key, and simultaneous creators read the same winner instead of a partial
124    // write. A damaged existing key fails closed; it is never regenerated.
125    let mut file = OpenOptions::new()
126        .write(true)
127        .create_new(true)
128        .mode(0o600)
129        .open(&temp)
130        .context("create private key temporary file")?;
131    let result = (|| -> Result<()> {
132        file.write_all(seed.as_ref()).context("write private key")?;
133        file.sync_all().context("sync private key")?;
134        match fs::hard_link(&temp, path) {
135            Ok(()) => (),
136            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (),
137            Err(error) => return Err(error).context("install private key"),
138        }
139        Ok(())
140    })();
141    drop(file);
142    let cleanup = fs::remove_file(&temp);
143    result?;
144    cleanup.context("remove private key temporary file")?;
145    File::open(parent)?
146        .sync_all()
147        .context("sync private key directory")?;
148    read()?.context("private key disappeared during initialization")
149}
150
151#[cfg(all(test, unix))]
152mod tests {
153    use super::*;
154    use crate::offline::OfflineSigner;
155    use ed25519_dalek::Verifier as _;
156    use std::fs;
157    use std::os::unix::fs::{symlink, PermissionsExt as _};
158
159    struct Directory(std::path::PathBuf);
160    impl Directory {
161        fn new() -> Self {
162            Self(std::env::temp_dir().join(format!("openrtc-keys-{}", uuid::Uuid::new_v4())))
163        }
164    }
165    impl Drop for Directory {
166        fn drop(&mut self) {
167            let _ = fs::remove_dir_all(&self.0);
168        }
169    }
170
171    #[test]
172    fn reopen_preserves_separate_proof_and_endpoint_identities() {
173        let directory = Directory::new();
174        let proof = directory.0.join("proof.key");
175        let endpoint = directory.0.join("iroh.key");
176        let first = FileSigner::load_or_create(&proof).unwrap();
177        let public = first.verifying_key().unwrap();
178        let signature = first.sign(b"restart proof").unwrap();
179        drop(first);
180        let second = FileSigner::load_or_create(&proof).unwrap();
181        assert_eq!(second.verifying_key().unwrap(), public);
182        public.verify(b"restart proof", &signature).unwrap();
183        let endpoint_id = load_or_create_endpoint_key(&endpoint).unwrap().public();
184        assert_eq!(
185            load_or_create_endpoint_key(&endpoint).unwrap().public(),
186            endpoint_id
187        );
188        assert_ne!(endpoint_id.as_bytes(), public.as_bytes());
189        assert_eq!(
190            fs::metadata(&proof).unwrap().permissions().mode() & 0o077,
191            0
192        );
193    }
194
195    #[test]
196    fn simultaneous_creators_share_one_complete_key() {
197        let directory = Directory::new();
198        let key = directory.0.join("proof.key");
199        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
200        let tasks: Vec<_> = (0..8)
201            .map(|_| {
202                let key = key.clone();
203                let barrier = barrier.clone();
204                std::thread::spawn(move || {
205                    barrier.wait();
206                    FileSigner::load_or_create(key)
207                        .unwrap()
208                        .verifying_key()
209                        .unwrap()
210                })
211            })
212            .collect();
213        let keys: Vec<_> = tasks.into_iter().map(|task| task.join().unwrap()).collect();
214        assert!(keys.iter().all(|key| key == &keys[0]));
215        assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
216    }
217
218    #[test]
219    fn unsafe_or_damaged_paths_fail_without_rotating_identity() {
220        let directory = Directory::new();
221        let key = directory.0.join("proof.key");
222        FileSigner::load_or_create(&key).unwrap();
223        let original = fs::read(&key).unwrap();
224        let link = directory.0.join("link.key");
225        symlink(&key, &link).unwrap();
226        assert!(FileSigner::load_or_create(&link).is_err());
227        fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
228        assert!(FileSigner::load_or_create(&key).is_err());
229        assert_eq!(fs::read(&key).unwrap(), original);
230        fs::set_permissions(&key, fs::Permissions::from_mode(0o600)).unwrap();
231        fs::write(&key, b"damaged").unwrap();
232        assert!(FileSigner::load_or_create(&key).is_err());
233        assert_eq!(fs::read(&key).unwrap(), b"damaged");
234        assert!(FileSigner::load_or_create("relative.key").is_err());
235    }
236}