Skip to main content

majordomus_cli/mesh/
identity.rs

1//! Node identity: who this machine's Majordomus is, independently of any repository,
2//! process, address or port. The identity is an Ed25519 keypair created once per user
3//! and machine and kept under the user's state directory; the node id is a digest of
4//! the public key, so claiming an id means holding the key, and a hostname, an IP
5//! address or a PID is never identity — those are runtime attributes that change.
6//!
7//! The signing key never leaves this module: everything above it sees [`NodeIdentity`],
8//! which signs and exposes public material only. The instance id distinguishes runs of
9//! the same node — a machine that restarts keeps its node id and changes its instance.
10//!
11//! ```
12//! use majordomus_cli::mesh::identity::{verify, NodeIdentity};
13//!
14//! let node = NodeIdentity::ephemeral().unwrap();
15//! let signature = node.sign(b"a majordomus exists here");
16//! assert!(verify(&node.public.public_key, b"a majordomus exists here", &signature));
17//! assert!(!verify(&node.public.public_key, b"something else", &signature));
18//! ```
19
20use 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/// The stable identity of a node: 32 hex characters of the SHA-256 of its Ed25519 public
31/// key. Derived, never chosen: two nodes with one id hold one key.
32#[derive(
33    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
34)]
35#[serde(transparent)]
36pub struct NodeId(String);
37
38impl NodeId {
39    /// The id of a public key.
40    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    /// The id as text: 32 lowercase hex characters, the form listings and allowlists carry.
52    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/// One run of a node: 16 hex characters of OS entropy, new at every process start. A
64/// restart keeps the node id and changes this, which is how a reader tells "the same
65/// node came back" from "the same process is still there".
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(transparent)]
68pub struct InstanceId(String);
69
70impl InstanceId {
71    /// A fresh instance id from OS entropy.
72    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    /// The id as text: 16 hex characters, stable for the life of this process alone.
80    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/// What everyone may know about this node: the id, the public key, a display name and
92/// this run's instance. Everything an advertisement carries; nothing that signs.
93#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
94pub struct PublicIdentity {
95    /// The node id: a digest of the public key.
96    pub node_id: NodeId,
97    /// The Ed25519 public key, 64 hex characters. Whoever holds the matching secret is
98    /// this node; there is no other proof.
99    pub public_key: String,
100    /// A human name for the node, from the identity file; defaults to the first eight
101    /// characters of the node id. Presentation only — never used for trust.
102    pub display_name: String,
103    /// This run of the node.
104    pub instance_id: InstanceId,
105}
106
107/// The identity a running mesh signs with: the public half plus the signing key.
108pub struct NodeIdentity {
109    /// The public half, as an advertisement carries it.
110    pub public: PublicIdentity,
111    signing: SigningKey,
112}
113
114impl fmt::Debug for NodeIdentity {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        // The signing key stays out of every rendering, Debug included.
117        f.debug_struct("NodeIdentity")
118            .field("public", &self.public)
119            .finish_non_exhaustive()
120    }
121}
122
123/// The identity file: schema, the secret seed, an optional display name. Mode 0600,
124/// under the user's state directory, never inside a repository.
125#[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
134/// The identity file's format version.
135const SCHEMA: &str = "majordomus-mesh-identity/v1";
136
137/// Where the identity lives: `$XDG_STATE_HOME/majordomus/node.json`, else
138/// `$HOME/.local/state/majordomus/node.json`. `None` when neither variable is set —
139/// a process with no home has no node identity, and the mesh stays off.
140pub 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    /// Load the identity at `path`, creating it (directories included) when absent.
157    /// A malformed file is an error, never silently replaced: an identity is the one
158    /// thing a node must not lose by accident.
159    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    /// A throwaway identity that touches no disk: what the self-check and tests sign
195    /// with. Never a node's real identity — nothing persists it.
196    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    /// Sign a message with this node's key.
241    pub fn sign(&self, message: &[u8]) -> String {
242        hex(&self.signing.sign(message).to_bytes())
243    }
244}
245
246/// Verify `signature` (hex) over `message` against `public_key` (hex). False on any
247/// malformed material: a verifier never panics on hostile input.
248///
249/// ```
250/// use majordomus_cli::mesh::identity::verify;
251/// assert!(!verify("zz", b"m", "zz"), "garbage material is false, not a panic");
252/// ```
253pub 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
266/// The node id a public key (hex) yields, when the key parses.
267pub 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
291/// Give the identity at `path` a display label, keeping its key: the node id, and every
292/// signature it ever made, are unchanged. The label is presentation — what a person reads
293/// as `macbook-pro` in a list of devices — and is refused unless it is a short name of
294/// letters, digits, `.`, `_` and `-`, because it is printed wherever this node is named.
295///
296/// ```
297/// use majordomus_cli::mesh::identity::{relabel, NodeIdentity};
298/// let dir = tempfile::tempdir().unwrap();
299/// let path = dir.path().join("node.json");
300/// let before = NodeIdentity::load_or_create(&path).unwrap();
301/// let after = relabel(&path, "mac-mini").unwrap();
302/// assert_eq!(after.public.display_name, "mac-mini");
303/// assert_eq!(after.public.node_id, before.public.node_id, "the key is the identity");
304/// assert!(relabel(&path, "two words").is_err());
305/// ```
306pub 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    // a file that is not an identity is refused here, before anything is written
318    let identity = NodeIdentity::load_or_create(path)?;
319    // the moment the key was made stays what the file says it was
320    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
336/// Write `text` to `path` with owner-only permissions, atomically enough for one user's
337/// state directory: a temp file beside the target, then a rename.
338///
339/// The temp file is created owner-only. It was once written at the umask's mode and narrowed
340/// afterwards, with the narrowing's failure ignored, so a secret key stood readable by the
341/// group for as long as the write took, and for good when the narrowing failed. A stale temp
342/// file is removed first, because one that already exists keeps the mode it was created with.
343fn 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    /// A node key is owner-only from the moment it exists, whatever the umask, and a stale
372    /// temp file left by an interrupted write does not lend it its mode.
373    #[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        // rewritten with a label, it stays owner-only
390        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        // an absent identity is made, already labelled
417        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        // the label cannot be written: the temporary file's place is taken
430        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}