1use std::fmt;
21use std::path::{Path, PathBuf};
22
23use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28use super::MeshError;
29
30#[derive(
33 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
34)]
35#[serde(transparent)]
36pub struct NodeId(String);
37
38impl NodeId {
39 pub fn of(key: &VerifyingKey) -> Self {
41 let mut hasher = Sha256::new();
42 hasher.update(key.as_bytes());
43 let digest = hasher.finalize();
44 let mut hex = String::with_capacity(32);
45 for byte in digest.iter().take(16) {
46 hex.push_str(&format!("{byte:02x}"));
47 }
48 NodeId(hex)
49 }
50
51 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57impl fmt::Display for NodeId {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(transparent)]
68pub struct InstanceId(String);
69
70impl InstanceId {
71 pub fn fresh() -> Result<Self, MeshError> {
73 let mut bytes = [0u8; 8];
74 getrandom::getrandom(&mut bytes)
75 .map_err(|e| MeshError::Identity(format!("no OS entropy for an instance id: {e}")))?;
76 Ok(InstanceId(hex(&bytes)))
77 }
78
79 pub fn as_str(&self) -> &str {
81 &self.0
82 }
83}
84
85impl fmt::Display for InstanceId {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 f.write_str(&self.0)
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
94pub struct PublicIdentity {
95 pub node_id: NodeId,
97 pub public_key: String,
100 pub display_name: String,
103 pub instance_id: InstanceId,
105}
106
107pub struct NodeIdentity {
109 pub public: PublicIdentity,
111 signing: SigningKey,
112}
113
114impl fmt::Debug for NodeIdentity {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.debug_struct("NodeIdentity")
118 .field("public", &self.public)
119 .finish_non_exhaustive()
120 }
121}
122
123#[derive(Debug, Serialize, Deserialize)]
126struct IdentityFile {
127 schema: String,
128 secret_key: String,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 display_name: Option<String>,
131 created_at: String,
132}
133
134const SCHEMA: &str = "majordomus-mesh-identity/v1";
136
137pub fn default_identity_path() -> Option<PathBuf> {
141 if let Some(state) = std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) {
142 return Some(PathBuf::from(state).join("majordomus").join("node.json"));
143 }
144 std::env::var_os("HOME")
145 .filter(|v| !v.is_empty())
146 .map(|home| {
147 PathBuf::from(home)
148 .join(".local")
149 .join("state")
150 .join("majordomus")
151 .join("node.json")
152 })
153}
154
155impl NodeIdentity {
156 pub fn load_or_create(path: &Path) -> Result<Self, MeshError> {
160 if path.is_file() {
161 return Self::load(path);
162 }
163 let identity = Self::create()?;
164 identity.persist(path)?;
165 Ok(identity)
166 }
167
168 fn load(path: &Path) -> Result<Self, MeshError> {
169 let text = std::fs::read_to_string(path)
170 .map_err(|e| MeshError::Identity(format!("{}: {e}", path.display())))?;
171 let file: IdentityFile = serde_json::from_str(&text).map_err(|e| {
172 MeshError::Identity(format!("{}: not an identity file: {e}", path.display()))
173 })?;
174 if file.schema != SCHEMA {
175 return Err(MeshError::Identity(format!(
176 "{}: schema {} is not {SCHEMA}",
177 path.display(),
178 file.schema
179 )));
180 }
181 let seed = unhex(&file.secret_key).ok_or_else(|| {
182 MeshError::Identity(format!("{}: the secret key is not hex", path.display()))
183 })?;
184 let seed: [u8; 32] = seed.try_into().map_err(|_| {
185 MeshError::Identity(format!(
186 "{}: the secret key is not 32 bytes",
187 path.display()
188 ))
189 })?;
190 let signing = SigningKey::from_bytes(&seed);
191 Self::assemble(signing, file.display_name)
192 }
193
194 pub fn ephemeral() -> Result<Self, MeshError> {
197 Self::create()
198 }
199
200 fn create() -> Result<Self, MeshError> {
201 let mut seed = [0u8; 32];
202 getrandom::getrandom(&mut seed)
203 .map_err(|e| MeshError::Identity(format!("no OS entropy for a node key: {e}")))?;
204 let signing = SigningKey::from_bytes(&seed);
205 Self::assemble(signing, None)
206 }
207
208 fn assemble(signing: SigningKey, display_name: Option<String>) -> Result<Self, MeshError> {
209 let verifying = signing.verifying_key();
210 let node_id = NodeId::of(&verifying);
211 let display_name =
212 display_name.unwrap_or_else(|| node_id.as_str().chars().take(8).collect());
213 Ok(NodeIdentity {
214 public: PublicIdentity {
215 node_id,
216 public_key: hex(verifying.as_bytes()),
217 display_name,
218 instance_id: InstanceId::fresh()?,
219 },
220 signing,
221 })
222 }
223
224 fn persist(&self, path: &Path) -> Result<(), MeshError> {
225 if let Some(dir) = path.parent() {
226 std::fs::create_dir_all(dir)
227 .map_err(|e| MeshError::Identity(format!("{}: {e}", dir.display())))?;
228 }
229 let file = IdentityFile {
230 schema: SCHEMA.into(),
231 secret_key: hex(&self.signing.to_bytes()),
232 display_name: Some(self.public.display_name.clone()),
233 created_at: crate::peers::rfc3339(std::time::SystemTime::now()),
234 };
235 let text =
236 serde_json::to_string_pretty(&file).map_err(|e| MeshError::Identity(e.to_string()))?;
237 write_private(path, &text)
238 }
239
240 pub fn sign(&self, message: &[u8]) -> String {
242 hex(&self.signing.sign(message).to_bytes())
243 }
244}
245
246pub fn verify(public_key: &str, message: &[u8], signature: &str) -> bool {
254 let Some(key) = unhex(public_key).and_then(|b| <[u8; 32]>::try_from(b).ok()) else {
255 return false;
256 };
257 let Ok(key) = VerifyingKey::from_bytes(&key) else {
258 return false;
259 };
260 let Some(sig) = unhex(signature).and_then(|b| <[u8; 64]>::try_from(b).ok()) else {
261 return false;
262 };
263 key.verify(message, &Signature::from_bytes(&sig)).is_ok()
264}
265
266pub fn node_id_of_key(public_key: &str) -> Option<NodeId> {
268 let key = unhex(public_key).and_then(|b| <[u8; 32]>::try_from(b).ok())?;
269 let key = VerifyingKey::from_bytes(&key).ok()?;
270 Some(NodeId::of(&key))
271}
272
273fn hex(bytes: &[u8]) -> String {
274 let mut out = String::with_capacity(bytes.len() * 2);
275 for byte in bytes {
276 out.push_str(&format!("{byte:02x}"));
277 }
278 out
279}
280
281fn unhex(text: &str) -> Option<Vec<u8>> {
282 if text.len() % 2 != 0 {
283 return None;
284 }
285 (0..text.len())
286 .step_by(2)
287 .map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok())
288 .collect()
289}
290
291pub fn relabel(path: &Path, label: &str) -> Result<NodeIdentity, MeshError> {
307 let valid = !label.is_empty()
308 && label.len() <= 64
309 && label
310 .chars()
311 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
312 if !valid {
313 return Err(MeshError::Identity(format!(
314 "`{label}` is not a device label: 1 to 64 letters, digits, `.`, `_` or `-`"
315 )));
316 }
317 let identity = NodeIdentity::load_or_create(path)?;
319 let created_at = std::fs::read_to_string(path)
321 .ok()
322 .and_then(|text| serde_json::from_str::<IdentityFile>(&text).ok())
323 .map(|file| file.created_at)
324 .unwrap_or_default();
325 let file = IdentityFile {
326 schema: SCHEMA.into(),
327 secret_key: hex(&identity.signing.to_bytes()),
328 display_name: Some(label.to_string()),
329 created_at,
330 };
331 let text = serde_json::to_string_pretty(&file).expect("an identity file is plain data");
332 write_private(path, &text)?;
333 NodeIdentity::load(path)
334}
335
336fn write_private(path: &Path, text: &str) -> Result<(), MeshError> {
344 use std::io::Write;
345 let tmp = path.with_extension("tmp");
346 let fail = |p: &Path, e: std::io::Error| MeshError::Identity(format!("{}: {e}", p.display()));
347 match std::fs::remove_file(&tmp) {
348 Ok(()) => {}
349 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
350 Err(e) => return Err(fail(&tmp, e)),
351 }
352 let mut open = std::fs::OpenOptions::new();
353 open.write(true).create_new(true);
354 #[cfg(unix)]
355 {
356 use std::os::unix::fs::OpenOptionsExt;
357 open.mode(0o600);
358 }
359 let mut file = open.open(&tmp).map_err(|e| fail(&tmp, e))?;
360 file.write_all(text.as_bytes())
361 .and_then(|()| file.sync_all())
362 .map_err(|e| fail(&tmp, e))?;
363 drop(file);
364 std::fs::rename(&tmp, path).map_err(|e| fail(path, e))
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[cfg(unix)]
374 #[test]
375 fn a_node_key_is_never_readable_by_others() {
376 use std::os::unix::fs::PermissionsExt;
377 let dir = tempfile::tempdir().unwrap();
378 let path = dir.path().join("node.json");
379 let tmp = path.with_extension("tmp");
380 std::fs::write(&tmp, "stale").unwrap();
381 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
382 NodeIdentity::load_or_create(&path).unwrap();
383 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
384 assert_eq!(mode, 0o600, "the key file is {mode:o}");
385 assert!(
386 !tmp.exists(),
387 "the temp file is renamed, not left beside the key"
388 );
389 write_private(&path, "{}").unwrap();
391 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
392 assert_eq!(mode, 0o600, "the rewritten key file is {mode:o}");
393 }
394
395 #[test]
396 fn a_label_is_a_short_name_and_changes_nothing_but_itself() {
397 let dir = tempfile::tempdir().unwrap();
398 let path = dir.path().join("node.json");
399 let before = NodeIdentity::load_or_create(&path).unwrap();
400 let created = |p: &Path| {
401 let text = std::fs::read_to_string(p).unwrap();
402 serde_json::from_str::<IdentityFile>(&text)
403 .unwrap()
404 .created_at
405 };
406 let made = created(&path);
407 let after = relabel(&path, "mac-mini_2.local").unwrap();
408 assert_eq!(after.public.display_name, "mac-mini_2.local");
409 assert_eq!(after.public.public_key, before.public.public_key);
410 assert_eq!(created(&path), made, "the key is as old as it was");
411
412 for label in ["", "two words", "semi;colon", &"x".repeat(65)] {
413 let err = relabel(&path, label).unwrap_err().to_string();
414 assert!(err.contains("is not a device label"), "{label}: {err}");
415 }
416 let fresh = dir.path().join("deeper/node.json");
418 assert_eq!(relabel(&fresh, "new").unwrap().public.display_name, "new");
419 }
420
421 #[test]
422 fn a_file_that_is_not_an_identity_is_never_relabelled_into_one() {
423 let dir = tempfile::tempdir().unwrap();
424 let path = dir.path().join("node.json");
425 std::fs::write(&path, "not an identity").unwrap();
426 assert!(relabel(&path, "mac").is_err());
427 assert_eq!(std::fs::read_to_string(&path).unwrap(), "not an identity");
428
429 let path = dir.path().join("other.json");
431 NodeIdentity::load_or_create(&path).unwrap();
432 std::fs::create_dir(path.with_extension("tmp")).unwrap();
433 assert!(relabel(&path, "mac").is_err());
434 }
435
436 #[test]
437 fn an_identity_is_created_once_and_loaded_stable() {
438 let dir = tempfile::tempdir().unwrap();
439 let path = dir.path().join("node.json");
440 let first = NodeIdentity::load_or_create(&path).unwrap();
441 let second = NodeIdentity::load_or_create(&path).unwrap();
442 assert_eq!(
443 first.public.node_id, second.public.node_id,
444 "the node id survives a reload"
445 );
446 assert_ne!(
447 first.public.instance_id, second.public.instance_id,
448 "every run is a new instance"
449 );
450 }
451
452 #[test]
453 fn a_signature_verifies_and_a_tampered_message_does_not() {
454 let dir = tempfile::tempdir().unwrap();
455 let identity = NodeIdentity::load_or_create(&dir.path().join("node.json")).unwrap();
456 let sig = identity.sign(b"hello mesh");
457 assert!(verify(&identity.public.public_key, b"hello mesh", &sig));
458 assert!(!verify(&identity.public.public_key, b"hello mess", &sig));
459 assert!(
460 !verify("zz", b"hello mesh", &sig),
461 "malformed key material is false, not a panic"
462 );
463 assert!(!verify(&identity.public.public_key, b"hello mesh", "zz"));
464 }
465
466 #[test]
467 fn the_node_id_is_the_digest_of_the_key() {
468 let dir = tempfile::tempdir().unwrap();
469 let identity = NodeIdentity::load_or_create(&dir.path().join("node.json")).unwrap();
470 assert_eq!(
471 node_id_of_key(&identity.public.public_key),
472 Some(identity.public.node_id.clone())
473 );
474 assert_eq!(identity.public.node_id.as_str().len(), 32);
475 }
476
477 #[test]
478 fn a_malformed_identity_file_is_an_error_not_a_replacement() {
479 let dir = tempfile::tempdir().unwrap();
480 let path = dir.path().join("node.json");
481 std::fs::write(&path, "not json").unwrap();
482 assert!(NodeIdentity::load_or_create(&path).is_err());
483 assert_eq!(
484 std::fs::read_to_string(&path).unwrap(),
485 "not json",
486 "the file is untouched"
487 );
488 }
489
490 #[test]
491 fn debug_shows_no_secret() {
492 let dir = tempfile::tempdir().unwrap();
493 let identity = NodeIdentity::load_or_create(&dir.path().join("node.json")).unwrap();
494 let rendered = format!("{identity:?}");
495 let secret = hex(&identity.signing.to_bytes());
496 assert!(
497 !rendered.contains(&secret),
498 "Debug must not leak the signing key"
499 );
500 }
501}