Skip to main content

seyd_signal_client/
identity.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Robot identity: an Ed25519 key pair stored as a 32-byte seed file.
4//!
5//! Created on first run (mode 0600 on Unix). The public key is what the cloud
6//! pins to the `robot_id`; the private key never leaves the robot.
7
8use base64::Engine;
9use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
10use std::path::Path;
11
12#[derive(Clone)]
13pub struct Identity {
14    signing: SigningKey,
15}
16
17impl Identity {
18    pub fn generate() -> Self {
19        Self {
20            signing: SigningKey::generate(&mut rand::rngs::OsRng),
21        }
22    }
23
24    pub fn from_seed(seed: [u8; 32]) -> Self {
25        Self {
26            signing: SigningKey::from_bytes(&seed),
27        }
28    }
29
30    /// Load the seed from `path`, creating a new identity there if absent.
31    pub fn load_or_create(path: &Path) -> std::io::Result<Self> {
32        match std::fs::read(path) {
33            Ok(bytes) if bytes.len() == 32 => {
34                let mut seed = [0u8; 32];
35                seed.copy_from_slice(&bytes);
36                Ok(Self::from_seed(seed))
37            }
38            Ok(_) => Err(std::io::Error::new(
39                std::io::ErrorKind::InvalidData,
40                format!("{}: expected a 32-byte Ed25519 seed", path.display()),
41            )),
42            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
43                let id = Self::generate();
44                if let Some(dir) = path.parent() {
45                    std::fs::create_dir_all(dir)?;
46                }
47                std::fs::write(path, id.signing.to_bytes())?;
48                #[cfg(unix)]
49                {
50                    use std::os::unix::fs::PermissionsExt;
51                    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
52                }
53                tracing::info!(path = %path.display(), "created new robot identity");
54                Ok(id)
55            }
56            Err(e) => Err(e),
57        }
58    }
59
60    pub fn verifying_key(&self) -> VerifyingKey {
61        self.signing.verifying_key()
62    }
63
64    pub fn public_key_b64(&self) -> String {
65        base64::engine::general_purpose::STANDARD.encode(self.verifying_key().to_bytes())
66    }
67
68    pub fn sign_b64(&self, msg: &[u8]) -> String {
69        base64::engine::general_purpose::STANDARD.encode(self.signing.sign(msg).to_bytes())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use ed25519_dalek::Verifier;
77
78    #[test]
79    fn signature_verifies_with_public_key() {
80        let id = Identity::generate();
81        let sig = id.sign_b64(b"nonce");
82        let sig_bytes = base64::engine::general_purpose::STANDARD
83            .decode(sig)
84            .unwrap();
85        let sig = ed25519_dalek::Signature::from_slice(&sig_bytes).unwrap();
86        assert!(id.verifying_key().verify(b"nonce", &sig).is_ok());
87    }
88
89    #[test]
90    fn load_or_create_roundtrip() {
91        let dir = std::env::temp_dir().join(format!("seyd-id-{}", std::process::id()));
92        let path = dir.join("robot.key");
93        let a = Identity::load_or_create(&path).unwrap();
94        let b = Identity::load_or_create(&path).unwrap();
95        assert_eq!(a.public_key_b64(), b.public_key_b64());
96        std::fs::remove_dir_all(dir).unwrap();
97    }
98}