recall_worker/
identity.rs1use 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
23pub const FILE_NAME: &str = "worker-identity.json";
25
26#[derive(Clone)]
28pub struct Identity {
29 key: SigningKey,
30 pub server: Option<String>,
33 pub device_id: Option<String>,
35 pub enrollment_id: Option<String>,
38 pub user_code: Option<String>,
40}
41
42impl std::fmt::Debug for Identity {
43 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#[derive(Serialize, Deserialize)]
57struct Stored {
58 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 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 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 pub fn path(dir: &Path) -> PathBuf {
91 dir.join(FILE_NAME)
92 }
93
94 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 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 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 pub fn key(&self) -> &SigningKey {
177 &self.key
178 }
179
180 pub fn public_key(&self) -> String {
182 encode_public_key(&self.key.verifying_key())
183 }
184
185 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 #[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 #[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}