1#[cfg(unix)]
23use std::os::unix::fs::PermissionsExt as _;
24use std::{
25 fmt,
26 path::{Path, PathBuf},
27};
28
29use algae_cli::{
30 passphrases::Passphrase,
31 streams::{decrypt_stream, encrypt_stream},
32};
33use base64::{
34 Engine as _,
35 engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD},
36};
37use miette::{IntoDiagnostic as _, Result, WrapErr as _, miette};
38use serde::{Deserialize, Serialize};
39use tracing::{debug, info, warn};
40
41const VERSION: &str = "registration-1";
42
43const DIR_ENV: &str = "BESTOOL_CANOPY_DIR";
47
48const KDF_CONTEXT: &str = "bestool canopy-registration v1 (machine-id)";
51
52#[cfg(unix)]
57const REG_FILE_MODE: u32 = 0o640;
58
59const REG_WORK_FACTOR: u8 = 12;
66
67#[derive(Clone, Serialize, Deserialize)]
72pub struct Registration {
73 pub v: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub server_id: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub device_key: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub device_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub api_url: Option<String>,
82}
83
84impl Default for Registration {
85 fn default() -> Self {
86 Self {
87 v: VERSION.to_owned(),
88 server_id: None,
89 device_key: None,
90 device_id: None,
91 api_url: None,
92 }
93 }
94}
95
96impl fmt::Debug for Registration {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.debug_struct("Registration")
99 .field("v", &self.v)
100 .field("server_id", &self.server_id)
101 .field(
102 "device_key",
103 &self.device_key.as_ref().map(|_| "<redacted>"),
104 )
105 .field("device_id", &self.device_id)
106 .field("api_url", &self.api_url)
107 .finish()
108 }
109}
110
111pub fn default_dir() -> PathBuf {
116 if let Some(dir) = std::env::var_os(DIR_ENV) {
117 return PathBuf::from(dir);
118 }
119 #[cfg(windows)]
120 {
121 let base = std::env::var_os("ProgramData").unwrap_or_else(|| r"C:\ProgramData".into());
122 PathBuf::from(base).join("bestool")
123 }
124 #[cfg(not(windows))]
125 {
126 PathBuf::from("/etc/bestool")
127 }
128}
129
130fn registration_file(dir: &Path) -> PathBuf {
131 dir.join("canopy-registration")
132}
133
134pub fn default_tags_path() -> PathBuf {
140 default_dir().join("tags.json")
141}
142
143fn legacy_server_id_path() -> PathBuf {
146 if cfg!(windows) {
147 PathBuf::from(r"C:\Tamanu\server-id")
148 } else {
149 PathBuf::from("/etc/tamanu/server-id")
150 }
151}
152
153fn legacy_device_key_path() -> PathBuf {
154 if cfg!(windows) {
155 PathBuf::from(r"C:\Tamanu\device-key.pem")
156 } else {
157 PathBuf::from("/etc/tamanu/device-key.pem")
158 }
159}
160
161static CACHE: tokio::sync::OnceCell<Registration> = tokio::sync::OnceCell::const_new();
166
167pub async fn load() -> Result<Option<Registration>> {
173 if let Some(reg) = CACHE.get() {
174 return Ok(Some(reg.clone()));
175 }
176
177 let dir = default_dir();
178 let path = registration_file(&dir);
179 let reg = if path.exists() {
180 Some(read_and_decrypt(&path).await?)
181 } else if std::env::var_os(DIR_ENV).is_some() {
182 None
183 } else {
184 migrate_from_legacy(&dir).await?
185 };
186
187 if let Some(ref reg) = reg {
188 let _ = CACHE.set(reg.clone());
189 }
190 Ok(reg)
191}
192
193pub async fn load_from(dir: &Path) -> Result<Option<Registration>> {
195 let path = registration_file(dir);
196 if path.exists() {
197 Ok(Some(read_and_decrypt(&path).await?))
198 } else {
199 Ok(None)
200 }
201}
202
203pub async fn store(reg: &Registration) -> Result<()> {
205 store_in(&default_dir(), reg).await
206}
207
208pub async fn store_in(dir: &Path, reg: &Registration) -> Result<()> {
210 tokio::fs::create_dir_all(dir)
211 .await
212 .into_diagnostic()
213 .wrap_err_with(|| format!("creating {}", dir.display()))?;
214 let plaintext = serde_json::to_vec(reg)
215 .into_diagnostic()
216 .wrap_err("serialising registration")?;
217 let ciphertext = encrypt_bytes(&plaintext, machine_passphrase()?)?;
218 write_atomic(®istration_file(dir), &ciphertext).await
219}
220
221pub async fn delete_in(dir: &Path) -> Result<bool> {
227 let path = registration_file(dir);
228 let existed = remove_if_present(&path).await?;
229 let _ = remove_if_present(&path.with_extension("tmp")).await;
232 Ok(existed)
233}
234
235async fn remove_if_present(path: &Path) -> Result<bool> {
236 match tokio::fs::remove_file(path).await {
237 Ok(()) => Ok(true),
238 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
239 Err(err) => Err(err)
240 .into_diagnostic()
241 .wrap_err_with(|| format!("removing {}", path.display())),
242 }
243}
244
245pub fn encrypt_with_passphrase(reg: &Registration, passphrase: Passphrase) -> Result<Vec<u8>> {
247 let plaintext = serde_json::to_vec(reg)
248 .into_diagnostic()
249 .wrap_err("serialising registration")?;
250 encrypt_bytes(&plaintext, passphrase)
251}
252
253pub fn generate_passphrase() -> Result<String> {
258 let mut bytes = [0u8; 16];
259 getrandom::fill(&mut bytes).map_err(|e| miette!("generating passphrase: {e}"))?;
260 Ok(URL_SAFE_NO_PAD.encode(bytes))
261}
262
263pub fn decrypt_with_passphrase(bytes: &[u8], passphrase: Passphrase) -> Result<Registration> {
265 let plaintext = decrypt_bytes(bytes, passphrase)?;
266 serde_json::from_slice(&plaintext)
267 .into_diagnostic()
268 .wrap_err("parsing registration")
269}
270
271async fn read_and_decrypt(path: &Path) -> Result<Registration> {
272 #[cfg(unix)]
276 if let Ok(meta) = tokio::fs::metadata(path).await
277 && meta.permissions().mode() & 0o777 != REG_FILE_MODE
278 {
279 let _ =
280 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(REG_FILE_MODE)).await;
281 }
282
283 let bytes = tokio::fs::read(path)
284 .await
285 .into_diagnostic()
286 .wrap_err_with(|| format!("reading {}", path.display()))?;
287 let plaintext = decrypt_bytes(&bytes, machine_passphrase()?)
288 .wrap_err("decrypting registration (was this disk cloned from another machine?)")?;
289
290 if scrypt_work_factor(&bytes).is_some_and(|log_n| log_n > REG_WORK_FACTOR) {
295 match encrypt_bytes(&plaintext, machine_passphrase()?) {
296 Ok(cheap) => match write_atomic(path, &cheap).await {
297 Ok(()) => {
298 info!(path = %path.display(), "re-encrypted registration with cheap work factor")
299 }
300 Err(err) => debug!(%err, "could not rewrite registration with cheap work factor"),
301 },
302 Err(err) => debug!(%err, "could not re-encrypt registration with cheap work factor"),
303 }
304 }
305
306 serde_json::from_slice(&plaintext)
307 .into_diagnostic()
308 .wrap_err("parsing registration")
309}
310
311fn scrypt_work_factor(ciphertext: &[u8]) -> Option<u8> {
316 ciphertext
317 .split(|&b| b == b'\n')
318 .take(2)
319 .filter_map(|line| std::str::from_utf8(line).ok())
320 .find_map(|line| line.strip_prefix("-> scrypt "))
321 .and_then(|rest| rest.split_ascii_whitespace().nth(1))
322 .and_then(|n| n.parse().ok())
323}
324
325async fn migrate_from_legacy(dir: &Path) -> Result<Option<Registration>> {
326 let sid_path = legacy_server_id_path();
327 let key_path = legacy_device_key_path();
328 let server_id = read_trimmed(&sid_path);
329 let device_key = std::fs::read_to_string(&key_path)
330 .ok()
331 .filter(|s| !s.trim().is_empty());
332
333 if server_id.is_none() && device_key.is_none() {
334 return Ok(None);
335 }
336
337 let reg = Registration {
338 server_id,
339 device_key,
340 ..Registration::default()
341 };
342 info!("migrating canopy registration from legacy /etc/tamanu files");
343
344 if let Err(err) = store_in(dir, ®).await {
348 warn!(%err, "could not write consolidated registration; keeping legacy files");
349 return Ok(Some(reg));
350 }
351 match load_from(dir).await {
352 Ok(Some(roundtrip))
353 if roundtrip.server_id == reg.server_id && roundtrip.device_key == reg.device_key =>
354 {
355 delete_legacy(&sid_path, &key_path);
356 }
357 Ok(_) => warn!("registration did not round-trip; keeping legacy files"),
358 Err(err) => warn!(%err, "could not verify written registration; keeping legacy files"),
359 }
360
361 Ok(Some(reg))
362}
363
364fn delete_legacy(sid_path: &Path, key_path: &Path) {
365 for path in [sid_path, key_path] {
366 match std::fs::remove_file(path) {
367 Ok(()) => debug!(path = %path.display(), "removed migrated legacy file"),
368 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
369 Err(err) => warn!(path = %path.display(), %err, "could not remove legacy file"),
370 }
371 }
372}
373
374fn read_trimmed(path: &Path) -> Option<String> {
375 std::fs::read_to_string(path)
376 .ok()
377 .map(|s| s.trim().to_owned())
378 .filter(|s| !s.is_empty())
379}
380
381fn machine_passphrase() -> Result<Passphrase> {
387 let id =
388 machine_uid::get().map_err(|err| miette!("could not read the host machine id: {err}"))?;
389 Ok(Passphrase::with_work_factor(
390 derive_passphrase(&id).into(),
391 REG_WORK_FACTOR,
392 ))
393}
394
395fn derive_passphrase(machine_id: &str) -> String {
396 let key = blake3::derive_key(KDF_CONTEXT, machine_id.as_bytes());
397 STANDARD_NO_PAD.encode(key)
398}
399
400fn encrypt_bytes(plaintext: &[u8], passphrase: Passphrase) -> Result<Vec<u8>> {
406 futures::executor::block_on(async {
407 let mut out = futures::io::Cursor::new(Vec::new());
408 encrypt_stream(plaintext, &mut out, Box::new(passphrase))
409 .await
410 .wrap_err("encrypting registration")?;
411 Ok(out.into_inner())
412 })
413}
414
415fn decrypt_bytes(ciphertext: &[u8], passphrase: Passphrase) -> Result<Vec<u8>> {
416 futures::executor::block_on(async {
417 let reader = futures::io::Cursor::new(ciphertext.to_vec());
418 let mut out: Vec<u8> = Vec::new();
419 decrypt_stream(reader, &mut out, Box::new(passphrase))
420 .await
421 .wrap_err("decrypting registration")?;
422 Ok(out)
423 })
424}
425
426async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
427 let tmp = path.with_extension("tmp");
428 let mut opts = tokio::fs::OpenOptions::new();
429 opts.write(true).create(true).truncate(true);
430 #[cfg(windows)]
431 {
432 const FILE_ATTRIBUTE_HIDDEN: u32 = 0x0000_0002;
433 opts.attributes(FILE_ATTRIBUTE_HIDDEN);
434 }
435 #[cfg(unix)]
436 {
437 opts.mode(REG_FILE_MODE);
438 }
439 let mut f = opts
440 .open(&tmp)
441 .await
442 .into_diagnostic()
443 .wrap_err_with(|| format!("creating {}", tmp.display()))?;
444 use tokio::io::AsyncWriteExt as _;
445 f.write_all(bytes).await.into_diagnostic()?;
446 f.sync_all().await.into_diagnostic()?;
447 drop(f);
448
449 #[cfg(unix)]
453 tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(REG_FILE_MODE))
454 .await
455 .into_diagnostic()
456 .wrap_err_with(|| format!("setting permissions on {}", tmp.display()))?;
457
458 tokio::fs::rename(&tmp, path)
459 .await
460 .into_diagnostic()
461 .wrap_err_with(|| format!("renaming into {}", path.display()))
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 fn passphrase(s: &str) -> Passphrase {
469 Passphrase::new(s.to_owned().into())
470 }
471
472 fn sample() -> Registration {
473 Registration {
474 server_id: Some("7deb2793-0425-427e-8a19-7213946fa9be".into()),
475 device_key: Some(
476 "-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY-----\n".into(),
477 ),
478 device_id: Some("11111111-2222-3333-4444-555555555555".into()),
479 api_url: Some("https://canopy.example/".into()),
480 ..Registration::default()
481 }
482 }
483
484 #[test]
485 fn debug_redacts_device_key() {
486 let dbg = format!("{:?}", sample());
487 assert!(dbg.contains("<redacted>"), "{dbg}");
488 assert!(!dbg.contains("BEGIN PRIVATE KEY"), "{dbg}");
489 }
490
491 #[test]
492 fn passphrase_roundtrip() {
493 let reg = sample();
494 let blob = encrypt_with_passphrase(®, passphrase("a-test-passphrase")).unwrap();
495 let back = decrypt_with_passphrase(&blob, passphrase("a-test-passphrase")).unwrap();
496 assert_eq!(back.server_id, reg.server_id);
497 assert_eq!(back.device_key, reg.device_key);
498 assert_eq!(back.device_id, reg.device_id);
499 assert_eq!(back.api_url, reg.api_url);
500 }
501
502 #[test]
503 fn passphrase_decrypt_rejects_wrong_passphrase() {
504 let blob = encrypt_with_passphrase(&sample(), passphrase("right-passphrase")).unwrap();
505 assert!(decrypt_with_passphrase(&blob, passphrase("wrong-passphrase")).is_err());
506 }
507
508 #[test]
509 fn derive_passphrase_is_stable_and_machine_specific() {
510 assert_eq!(
511 derive_passphrase("machine-aaaa"),
512 derive_passphrase("machine-aaaa")
513 );
514 assert_ne!(
515 derive_passphrase("machine-aaaa"),
516 derive_passphrase("machine-bbbb")
517 );
518 }
519
520 #[tokio::test]
521 async fn store_and_load_from_dir_roundtrip() {
522 let dir = tempfile::tempdir().unwrap();
523 assert!(load_from(dir.path()).await.unwrap().is_none());
524
525 let reg = sample();
526 store_in(dir.path(), ®).await.unwrap();
527
528 let back = load_from(dir.path()).await.unwrap().unwrap();
529 assert_eq!(back.server_id, reg.server_id);
530 assert_eq!(back.device_key, reg.device_key);
531
532 let raw = std::fs::read(registration_file(dir.path())).unwrap();
534 assert!(
535 !raw.windows(b"PRIVATE KEY".len())
536 .any(|w| w == b"PRIVATE KEY"),
537 "registration file should be encrypted"
538 );
539 }
540
541 #[tokio::test]
542 async fn delete_in_removes_registration_and_reports_presence() {
543 let dir = tempfile::tempdir().unwrap();
544 assert!(
545 !delete_in(dir.path()).await.unwrap(),
546 "deleting when absent reports nothing removed"
547 );
548
549 store_in(dir.path(), &sample()).await.unwrap();
550 assert!(registration_file(dir.path()).exists());
551
552 assert!(
553 delete_in(dir.path()).await.unwrap(),
554 "deleting an existing registration reports it was removed"
555 );
556 assert!(!registration_file(dir.path()).exists());
557 assert!(load_from(dir.path()).await.unwrap().is_none());
558 }
559
560 #[tokio::test]
561 async fn store_uses_cheap_work_factor() {
562 let dir = tempfile::tempdir().unwrap();
563 store_in(dir.path(), &sample()).await.unwrap();
564
565 let raw = std::fs::read(registration_file(dir.path())).unwrap();
566 assert_eq!(scrypt_work_factor(&raw), Some(REG_WORK_FACTOR));
567 }
568
569 #[tokio::test]
570 async fn load_reencrypts_expensive_files() {
571 let dir = tempfile::tempdir().unwrap();
572 let path = registration_file(dir.path());
573 let reg = sample();
574
575 let machine_id = machine_uid::get().unwrap();
578 let expensive = Passphrase::with_work_factor(
579 derive_passphrase(&machine_id).into(),
580 REG_WORK_FACTOR + 1,
581 );
582 let blob = encrypt_bytes(&serde_json::to_vec(®).unwrap(), expensive).unwrap();
583 write_atomic(&path, &blob).await.unwrap();
584 assert_eq!(scrypt_work_factor(&blob), Some(REG_WORK_FACTOR + 1));
585
586 let back = load_from(dir.path()).await.unwrap().unwrap();
587 assert_eq!(back.server_id, reg.server_id);
588 assert_eq!(back.device_key, reg.device_key);
589
590 let raw = std::fs::read(&path).unwrap();
591 assert_eq!(scrypt_work_factor(&raw), Some(REG_WORK_FACTOR));
592 let again = load_from(dir.path()).await.unwrap().unwrap();
593 assert_eq!(again.server_id, reg.server_id);
594 }
595
596 #[cfg(unix)]
597 #[tokio::test]
598 async fn store_writes_group_readable_file() {
599 let dir = tempfile::tempdir().unwrap();
600 store_in(dir.path(), &sample()).await.unwrap();
601
602 let mode = std::fs::metadata(registration_file(dir.path()))
603 .unwrap()
604 .permissions()
605 .mode() & 0o777;
606 assert_eq!(
607 mode, REG_FILE_MODE,
608 "expected {REG_FILE_MODE:o}, got {mode:o}"
609 );
610 }
611
612 #[cfg(unix)]
613 #[tokio::test]
614 async fn load_repairs_mode_of_old_files() {
615 let dir = tempfile::tempdir().unwrap();
616 store_in(dir.path(), &sample()).await.unwrap();
617
618 let path = registration_file(dir.path());
619 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
620
621 load_from(dir.path()).await.unwrap().unwrap();
622 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
623 assert_eq!(
624 mode, REG_FILE_MODE,
625 "expected {REG_FILE_MODE:o}, got {mode:o}"
626 );
627 }
628}