Skip to main content

recall_worker/
identity.rs

1//! Who the worker is: its Ed25519 key, and the device id the server gave
2//! it once the owner approved it.
3//!
4//! Kept in one file, `worker-identity.json` in the data directory, readable
5//! only by its owner. Whoever reads it can act as the worker, which may
6//! claim jobs and so read the two versions of every conflicting file, so
7//! it lives on the worker's own volume and is never mounted into the API
8//! container.
9//!
10//! It also records the server it was made for. A key enrolled with one
11//! server is never offered to another: pointed elsewhere, the worker stops
12//! and says so rather than asking a second server to approve it.
13
14use std::fs;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use base64::Engine;
20use recall_wire::signature::{encode_public_key, fingerprint, SigningKey};
21use serde::{Deserialize, Serialize};
22
23/// The file's name inside the data directory.
24pub const FILE_NAME: &str = "worker-identity.json";
25
26/// The worker's key, and how far its enrolment has got.
27#[derive(Clone)]
28pub struct Identity {
29    key: SigningKey,
30    /// The server it was made for, as `RECALL_WORKER_SERVER` named it.
31    /// [`None`] only in a file an earlier build wrote.
32    pub server: Option<String>,
33    /// `dev_…`, once approved.
34    pub device_id: Option<String>,
35    /// The enrolment waiting for approval, so a restart keeps polling the
36    /// same one rather than asking the owner to approve a second code.
37    pub enrollment_id: Option<String>,
38    /// That enrolment's code, as the owner types it.
39    pub user_code: Option<String>,
40}
41
42impl std::fmt::Debug for Identity {
43    // Never the private key, not even in a debug print.
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("Identity")
46            .field("fingerprint", &self.fingerprint())
47            .field("server", &self.server)
48            .field("device_id", &self.device_id)
49            .field("enrollment_id", &self.enrollment_id.as_ref().map(|_| "…"))
50            .field("user_code", &self.user_code)
51            .finish()
52    }
53}
54
55/// The file's shape.
56#[derive(Serialize, Deserialize)]
57struct Stored {
58    /// The Ed25519 seed, 32 bytes, base64url without padding.
59    private_key: String,
60    #[serde(default)]
61    server: Option<String>,
62    #[serde(default)]
63    device_id: Option<String>,
64    #[serde(default)]
65    enrollment_id: Option<String>,
66    #[serde(default)]
67    user_code: Option<String>,
68}
69
70impl Identity {
71    /// A fresh key from the operating system's randomness.
72    pub fn generate() -> std::io::Result<Self> {
73        let mut seed = [0u8; 32];
74        getrandom::fill(&mut seed).map_err(|e| std::io::Error::other(e.to_string()))?;
75        Ok(Self::from_seed(seed))
76    }
77
78    /// A key from a known seed; tests use this to be repeatable.
79    pub fn from_seed(seed: [u8; 32]) -> Self {
80        Self {
81            key: SigningKey::from_bytes(&seed),
82            server: None,
83            device_id: None,
84            enrollment_id: None,
85            user_code: None,
86        }
87    }
88
89    /// Where the identity lives inside `dir`.
90    pub fn path(dir: &Path) -> PathBuf {
91        dir.join(FILE_NAME)
92    }
93
94    /// Reads the identity in `dir`, or makes and saves a new one for
95    /// `server` when there is none. A file that exists but does not read is
96    /// an error, never silently replaced: replacing it would enrol a second
97    /// worker and orphan the first.
98    ///
99    /// An identity that exists is answered as it is, whatever server it was
100    /// made for; comparing that with the one configured is the caller's.
101    pub fn load_or_create(dir: &Path, server: &str) -> std::io::Result<Self> {
102        match Self::load(dir)? {
103            Some(id) => Ok(id),
104            None => {
105                let mut id = Self::generate()?;
106                id.server = Some(server.to_string());
107                id.save(dir)?;
108                Ok(id)
109            }
110        }
111    }
112
113    /// Reads the identity in `dir`: [`None`] when there is none yet.
114    pub fn load(dir: &Path) -> std::io::Result<Option<Self>> {
115        let path = Self::path(dir);
116        match fs::read(&path) {
117            Ok(bytes) => Self::parse(&bytes).map(Some).map_err(|why| {
118                std::io::Error::new(
119                    std::io::ErrorKind::InvalidData,
120                    format!("{} does not read: {why}", path.display()),
121                )
122            }),
123            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
124            Err(e) => Err(e),
125        }
126    }
127
128    fn parse(bytes: &[u8]) -> Result<Self, String> {
129        let stored: Stored = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
130        let seed = URL_SAFE_NO_PAD
131            .decode(stored.private_key.trim())
132            .map_err(|e| e.to_string())?;
133        let seed: [u8; 32] = seed
134            .try_into()
135            .map_err(|_| "private_key is not 32 bytes".to_string())?;
136        Ok(Self {
137            server: stored.server,
138            device_id: stored.device_id,
139            enrollment_id: stored.enrollment_id,
140            user_code: stored.user_code,
141            ..Self::from_seed(seed)
142        })
143    }
144
145    /// Writes the identity to `dir`, readable by its owner only, through a
146    /// temporary file renamed over the old one, so a crash mid-write
147    /// leaves the previous identity rather than half of one.
148    pub fn save(&self, dir: &Path) -> std::io::Result<()> {
149        fs::create_dir_all(dir)?;
150        let stored = Stored {
151            private_key: URL_SAFE_NO_PAD.encode(self.key.to_bytes()),
152            server: self.server.clone(),
153            device_id: self.device_id.clone(),
154            enrollment_id: self.enrollment_id.clone(),
155            user_code: self.user_code.clone(),
156        };
157        let body = serde_json::to_vec_pretty(&stored).map_err(std::io::Error::other)?;
158        let tmp = dir.join(format!("{FILE_NAME}.tmp"));
159        let _ = fs::remove_file(&tmp);
160        {
161            let mut opts = fs::OpenOptions::new();
162            opts.write(true).create_new(true);
163            #[cfg(unix)]
164            {
165                use std::os::unix::fs::OpenOptionsExt;
166                opts.mode(0o600);
167            }
168            let mut f = opts.open(&tmp)?;
169            f.write_all(&body)?;
170            f.sync_all()?;
171        }
172        fs::rename(&tmp, Self::path(dir))
173    }
174
175    /// The signing key.
176    pub fn key(&self) -> &SigningKey {
177        &self.key
178    }
179
180    /// The public key, as the server stores it.
181    pub fn public_key(&self) -> String {
182        encode_public_key(&self.key.verifying_key())
183    }
184
185    /// `SHA256:…`, which the owner compares before approving.
186    pub fn fingerprint(&self) -> String {
187        fingerprint(&self.key.verifying_key())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    const S: &str = "http://recall-server:8787";
196
197    #[test]
198    fn a_new_identity_is_saved_and_read_back_the_same() {
199        let dir = tempfile::tempdir().unwrap();
200        let first = Identity::load_or_create(dir.path(), S).unwrap();
201        let mut again = Identity::load_or_create(dir.path(), S).unwrap();
202        assert_eq!(first.public_key(), again.public_key());
203        assert_eq!(again.device_id, None);
204
205        again.device_id = Some("dev_a".into());
206        again.save(dir.path()).unwrap();
207        let third = Identity::load_or_create(dir.path(), S).unwrap();
208        assert_eq!(third.device_id.as_deref(), Some("dev_a"));
209        assert_eq!(third.public_key(), first.public_key());
210    }
211
212    /// The server a key was made for is kept with it, and asking for the
213    /// identity with another server named does not rebind it.
214    #[test]
215    fn an_identity_remembers_its_server() {
216        let dir = tempfile::tempdir().unwrap();
217        assert!(Identity::load(dir.path()).unwrap().is_none());
218        Identity::load_or_create(dir.path(), S).unwrap();
219        let again = Identity::load_or_create(dir.path(), "https://elsewhere.example.com").unwrap();
220        assert_eq!(again.server.as_deref(), Some(S));
221        assert_eq!(
222            Identity::load(dir.path())
223                .unwrap()
224                .unwrap()
225                .server
226                .as_deref(),
227            Some(S)
228        );
229    }
230
231    #[cfg(unix)]
232    #[test]
233    fn only_its_owner_can_read_it() {
234        use std::os::unix::fs::PermissionsExt;
235        let dir = tempfile::tempdir().unwrap();
236        Identity::load_or_create(dir.path(), S).unwrap();
237        let mode = fs::metadata(Identity::path(dir.path()))
238            .unwrap()
239            .permissions()
240            .mode();
241        assert_eq!(mode & 0o777, 0o600);
242    }
243
244    /// A damaged file stops the worker rather than enrolling a new one.
245    #[test]
246    fn a_file_that_does_not_read_is_an_error_not_a_new_identity() {
247        let dir = tempfile::tempdir().unwrap();
248        fs::write(Identity::path(dir.path()), "{").unwrap();
249        assert!(Identity::load_or_create(dir.path(), S).is_err());
250    }
251
252    #[test]
253    fn the_debug_print_holds_no_key() {
254        let id = Identity::from_seed([7; 32]);
255        let printed = format!("{id:?}");
256        assert!(!printed.contains(&URL_SAFE_NO_PAD.encode([7u8; 32])));
257        assert!(printed.contains("SHA256:"));
258    }
259}