openrtc/
native_key_store.rs1use 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
11pub 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
50pub 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 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 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}