1#[cfg(unix)]
23use std::os::unix::fs::{MetadataExt as _, 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: std::sync::RwLock<Option<Registration>> = std::sync::RwLock::new(None);
167
168pub async fn load() -> Result<Option<Registration>> {
174 if let Some(reg) = CACHE.read().expect("registration cache poisoned").as_ref() {
175 return Ok(Some(reg.clone()));
176 }
177
178 let dir = default_dir();
179 let path = registration_file(&dir);
180 let reg = if path.exists() {
181 Some(read_and_decrypt(&path).await?)
182 } else if std::env::var_os(DIR_ENV).is_some() {
183 None
184 } else {
185 migrate_from_legacy(&dir).await?
186 };
187
188 if let Some(ref reg) = reg {
189 set_cache(reg.clone());
190 }
191 Ok(reg)
192}
193
194fn set_cache(reg: Registration) {
196 *CACHE.write().expect("registration cache poisoned") = Some(reg);
197}
198
199pub async fn load_from(dir: &Path) -> Result<Option<Registration>> {
201 let path = registration_file(dir);
202 if path.exists() {
203 Ok(Some(read_and_decrypt(&path).await?))
204 } else {
205 Ok(None)
206 }
207}
208
209pub async fn store(reg: &Registration) -> Result<()> {
214 store_in(&default_dir(), reg).await?;
215 set_cache(reg.clone());
216 Ok(())
217}
218
219pub async fn store_in(dir: &Path, reg: &Registration) -> Result<()> {
221 tokio::fs::create_dir_all(dir)
222 .await
223 .into_diagnostic()
224 .wrap_err_with(|| format!("creating {}", dir.display()))?;
225 let plaintext = serde_json::to_vec(reg)
226 .into_diagnostic()
227 .wrap_err("serialising registration")?;
228 let ciphertext = encrypt_bytes(&plaintext, machine_passphrase()?)?;
229 write_atomic(®istration_file(dir), &ciphertext).await
230}
231
232pub async fn delete_in(dir: &Path) -> Result<bool> {
238 let path = registration_file(dir);
239 let existed = remove_if_present(&path).await?;
240 let _ = remove_if_present(&path.with_extension("tmp")).await;
243 Ok(existed)
244}
245
246async fn remove_if_present(path: &Path) -> Result<bool> {
247 match tokio::fs::remove_file(path).await {
248 Ok(()) => Ok(true),
249 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
250 Err(err) => Err(err)
251 .into_diagnostic()
252 .wrap_err_with(|| format!("removing {}", path.display())),
253 }
254}
255
256pub fn encrypt_with_passphrase(reg: &Registration, passphrase: Passphrase) -> Result<Vec<u8>> {
258 let plaintext = serde_json::to_vec(reg)
259 .into_diagnostic()
260 .wrap_err("serialising registration")?;
261 encrypt_bytes(&plaintext, passphrase)
262}
263
264pub fn generate_passphrase() -> Result<String> {
269 let mut bytes = [0u8; 16];
270 getrandom::fill(&mut bytes).map_err(|e| miette!("generating passphrase: {e}"))?;
271 Ok(URL_SAFE_NO_PAD.encode(bytes))
272}
273
274pub fn decrypt_with_passphrase(bytes: &[u8], passphrase: Passphrase) -> Result<Registration> {
276 let plaintext = decrypt_bytes(bytes, passphrase)?;
277 serde_json::from_slice(&plaintext)
278 .into_diagnostic()
279 .wrap_err("parsing registration")
280}
281
282async fn read_and_decrypt(path: &Path) -> Result<Registration> {
283 #[cfg(unix)]
287 if let Ok(meta) = tokio::fs::metadata(path).await
288 && meta.permissions().mode() & 0o777 != REG_FILE_MODE
289 {
290 let _ =
291 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(REG_FILE_MODE)).await;
292 }
293 #[cfg(unix)]
294 inherit_dir_group(path).await;
295
296 let bytes = tokio::fs::read(path)
297 .await
298 .into_diagnostic()
299 .wrap_err_with(|| format!("reading {}", path.display()))?;
300 let plaintext = decrypt_bytes(&bytes, machine_passphrase()?)
301 .wrap_err("decrypting registration (was this disk cloned from another machine?)")?;
302
303 if scrypt_work_factor(&bytes).is_some_and(|log_n| log_n > REG_WORK_FACTOR) {
308 match encrypt_bytes(&plaintext, machine_passphrase()?) {
309 Ok(cheap) => match write_atomic(path, &cheap).await {
310 Ok(()) => {
311 info!(path = %path.display(), "re-encrypted registration with cheap work factor")
312 }
313 Err(err) => debug!(%err, "could not rewrite registration with cheap work factor"),
314 },
315 Err(err) => debug!(%err, "could not re-encrypt registration with cheap work factor"),
316 }
317 }
318
319 serde_json::from_slice(&plaintext)
320 .into_diagnostic()
321 .wrap_err("parsing registration")
322}
323
324fn scrypt_work_factor(ciphertext: &[u8]) -> Option<u8> {
329 ciphertext
330 .split(|&b| b == b'\n')
331 .take(2)
332 .filter_map(|line| std::str::from_utf8(line).ok())
333 .find_map(|line| line.strip_prefix("-> scrypt "))
334 .and_then(|rest| rest.split_ascii_whitespace().nth(1))
335 .and_then(|n| n.parse().ok())
336}
337
338async fn migrate_from_legacy(dir: &Path) -> Result<Option<Registration>> {
339 let sid_path = legacy_server_id_path();
340 let key_path = legacy_device_key_path();
341 let server_id = read_trimmed(&sid_path);
342 let device_key = std::fs::read_to_string(&key_path)
343 .ok()
344 .filter(|s| !s.trim().is_empty());
345
346 if server_id.is_none() && device_key.is_none() {
347 return Ok(None);
348 }
349
350 let reg = Registration {
351 server_id,
352 device_key,
353 ..Registration::default()
354 };
355 info!("migrating canopy registration from legacy /etc/tamanu files");
356
357 if let Err(err) = store_in(dir, ®).await {
361 warn!(%err, "could not write consolidated registration; keeping legacy files");
362 return Ok(Some(reg));
363 }
364 match load_from(dir).await {
365 Ok(Some(roundtrip))
366 if roundtrip.server_id == reg.server_id && roundtrip.device_key == reg.device_key =>
367 {
368 delete_legacy(&sid_path, &key_path);
369 }
370 Ok(_) => warn!("registration did not round-trip; keeping legacy files"),
371 Err(err) => warn!(%err, "could not verify written registration; keeping legacy files"),
372 }
373
374 Ok(Some(reg))
375}
376
377fn delete_legacy(sid_path: &Path, key_path: &Path) {
378 for path in [sid_path, key_path] {
379 match std::fs::remove_file(path) {
380 Ok(()) => debug!(path = %path.display(), "removed migrated legacy file"),
381 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
382 Err(err) => warn!(path = %path.display(), %err, "could not remove legacy file"),
383 }
384 }
385}
386
387fn read_trimmed(path: &Path) -> Option<String> {
388 std::fs::read_to_string(path)
389 .ok()
390 .map(|s| s.trim().to_owned())
391 .filter(|s| !s.is_empty())
392}
393
394fn machine_passphrase() -> Result<Passphrase> {
400 let id =
401 machine_uid::get().map_err(|err| miette!("could not read the host machine id: {err}"))?;
402 Ok(Passphrase::with_work_factor(
403 derive_passphrase(&id).into(),
404 REG_WORK_FACTOR,
405 ))
406}
407
408fn derive_passphrase(machine_id: &str) -> String {
409 let key = blake3::derive_key(KDF_CONTEXT, machine_id.as_bytes());
410 STANDARD_NO_PAD.encode(key)
411}
412
413fn encrypt_bytes(plaintext: &[u8], passphrase: Passphrase) -> Result<Vec<u8>> {
419 futures::executor::block_on(async {
420 let mut out = futures::io::Cursor::new(Vec::new());
421 encrypt_stream(plaintext, &mut out, Box::new(passphrase))
422 .await
423 .wrap_err("encrypting registration")?;
424 Ok(out.into_inner())
425 })
426}
427
428fn decrypt_bytes(ciphertext: &[u8], passphrase: Passphrase) -> Result<Vec<u8>> {
429 futures::executor::block_on(async {
430 let reader = futures::io::Cursor::new(ciphertext.to_vec());
431 let mut out: Vec<u8> = Vec::new();
432 decrypt_stream(reader, &mut out, Box::new(passphrase))
433 .await
434 .wrap_err("decrypting registration")?;
435 Ok(out)
436 })
437}
438
439async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
440 let tmp = path.with_extension("tmp");
441 let mut opts = tokio::fs::OpenOptions::new();
442 opts.write(true).create(true).truncate(true);
443 #[cfg(windows)]
444 {
445 const FILE_ATTRIBUTE_HIDDEN: u32 = 0x0000_0002;
446 opts.attributes(FILE_ATTRIBUTE_HIDDEN);
447 }
448 #[cfg(unix)]
449 {
450 opts.mode(REG_FILE_MODE);
451 }
452 let mut f = opts
453 .open(&tmp)
454 .await
455 .into_diagnostic()
456 .wrap_err_with(|| format!("creating {}", tmp.display()))?;
457 use tokio::io::AsyncWriteExt as _;
458 f.write_all(bytes).await.into_diagnostic()?;
459 f.sync_all().await.into_diagnostic()?;
460 drop(f);
461
462 #[cfg(unix)]
466 tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(REG_FILE_MODE))
467 .await
468 .into_diagnostic()
469 .wrap_err_with(|| format!("setting permissions on {}", tmp.display()))?;
470 #[cfg(unix)]
471 inherit_dir_group(&tmp).await;
472
473 tokio::fs::rename(&tmp, path)
474 .await
475 .into_diagnostic()
476 .wrap_err_with(|| format!("renaming into {}", path.display()))
477}
478
479#[cfg(unix)]
485async fn inherit_dir_group(path: &Path) {
486 let Some(dir) = path.parent() else { return };
487 let (Ok(file), Ok(dir)) = (
488 tokio::fs::metadata(path).await,
489 tokio::fs::metadata(dir).await,
490 ) else {
491 return;
492 };
493
494 if file.gid() != dir.gid()
495 && let Err(err) = std::os::unix::fs::chown(path, None, Some(dir.gid()))
496 {
497 debug!(path = %path.display(), %err, "could not set the registration's group");
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn passphrase(s: &str) -> Passphrase {
506 Passphrase::new(s.to_owned().into())
507 }
508
509 fn sample() -> Registration {
510 Registration {
511 server_id: Some("7deb2793-0425-427e-8a19-7213946fa9be".into()),
512 device_key: Some(
513 "-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY-----\n".into(),
514 ),
515 device_id: Some("11111111-2222-3333-4444-555555555555".into()),
516 api_url: Some("https://canopy.example/".into()),
517 ..Registration::default()
518 }
519 }
520
521 #[test]
522 fn debug_redacts_device_key() {
523 let dbg = format!("{:?}", sample());
524 assert!(dbg.contains("<redacted>"), "{dbg}");
525 assert!(!dbg.contains("BEGIN PRIVATE KEY"), "{dbg}");
526 }
527
528 #[test]
529 fn passphrase_roundtrip() {
530 let reg = sample();
531 let blob = encrypt_with_passphrase(®, passphrase("a-test-passphrase")).unwrap();
532 let back = decrypt_with_passphrase(&blob, passphrase("a-test-passphrase")).unwrap();
533 assert_eq!(back.server_id, reg.server_id);
534 assert_eq!(back.device_key, reg.device_key);
535 assert_eq!(back.device_id, reg.device_id);
536 assert_eq!(back.api_url, reg.api_url);
537 }
538
539 #[test]
540 fn passphrase_decrypt_rejects_wrong_passphrase() {
541 let blob = encrypt_with_passphrase(&sample(), passphrase("right-passphrase")).unwrap();
542 assert!(decrypt_with_passphrase(&blob, passphrase("wrong-passphrase")).is_err());
543 }
544
545 #[test]
546 fn derive_passphrase_is_stable_and_machine_specific() {
547 assert_eq!(
548 derive_passphrase("machine-aaaa"),
549 derive_passphrase("machine-aaaa")
550 );
551 assert_ne!(
552 derive_passphrase("machine-aaaa"),
553 derive_passphrase("machine-bbbb")
554 );
555 }
556
557 #[tokio::test]
558 async fn load_reads_and_set_cache_refreshes_the_process_cache() {
559 let first = Registration {
564 server_id: Some("s".into()),
565 ..Registration::default()
566 };
567 set_cache(first.clone());
568 assert_eq!(load().await.unwrap().unwrap().device_id, None);
569
570 let second = Registration {
571 device_id: Some("d".into()),
572 ..first
573 };
574 set_cache(second);
575 assert_eq!(
576 load().await.unwrap().unwrap().device_id.as_deref(),
577 Some("d")
578 );
579
580 *CACHE.write().expect("registration cache poisoned") = None;
581 }
582
583 #[tokio::test]
584 async fn store_and_load_from_dir_roundtrip() {
585 let dir = tempfile::tempdir().unwrap();
586 assert!(load_from(dir.path()).await.unwrap().is_none());
587
588 let reg = sample();
589 store_in(dir.path(), ®).await.unwrap();
590
591 let back = load_from(dir.path()).await.unwrap().unwrap();
592 assert_eq!(back.server_id, reg.server_id);
593 assert_eq!(back.device_key, reg.device_key);
594
595 let raw = std::fs::read(registration_file(dir.path())).unwrap();
597 assert!(
598 !raw.windows(b"PRIVATE KEY".len())
599 .any(|w| w == b"PRIVATE KEY"),
600 "registration file should be encrypted"
601 );
602 }
603
604 #[tokio::test]
605 async fn delete_in_removes_registration_and_reports_presence() {
606 let dir = tempfile::tempdir().unwrap();
607 assert!(
608 !delete_in(dir.path()).await.unwrap(),
609 "deleting when absent reports nothing removed"
610 );
611
612 store_in(dir.path(), &sample()).await.unwrap();
613 assert!(registration_file(dir.path()).exists());
614
615 assert!(
616 delete_in(dir.path()).await.unwrap(),
617 "deleting an existing registration reports it was removed"
618 );
619 assert!(!registration_file(dir.path()).exists());
620 assert!(load_from(dir.path()).await.unwrap().is_none());
621 }
622
623 #[tokio::test]
624 async fn store_uses_cheap_work_factor() {
625 let dir = tempfile::tempdir().unwrap();
626 store_in(dir.path(), &sample()).await.unwrap();
627
628 let raw = std::fs::read(registration_file(dir.path())).unwrap();
629 assert_eq!(scrypt_work_factor(&raw), Some(REG_WORK_FACTOR));
630 }
631
632 #[tokio::test]
633 async fn load_reencrypts_expensive_files() {
634 let dir = tempfile::tempdir().unwrap();
635 let path = registration_file(dir.path());
636 let reg = sample();
637
638 let machine_id = machine_uid::get().unwrap();
641 let expensive = Passphrase::with_work_factor(
642 derive_passphrase(&machine_id).into(),
643 REG_WORK_FACTOR + 1,
644 );
645 let blob = encrypt_bytes(&serde_json::to_vec(®).unwrap(), expensive).unwrap();
646 write_atomic(&path, &blob).await.unwrap();
647 assert_eq!(scrypt_work_factor(&blob), Some(REG_WORK_FACTOR + 1));
648
649 let back = load_from(dir.path()).await.unwrap().unwrap();
650 assert_eq!(back.server_id, reg.server_id);
651 assert_eq!(back.device_key, reg.device_key);
652
653 let raw = std::fs::read(&path).unwrap();
654 assert_eq!(scrypt_work_factor(&raw), Some(REG_WORK_FACTOR));
655 let again = load_from(dir.path()).await.unwrap().unwrap();
656 assert_eq!(again.server_id, reg.server_id);
657 }
658
659 #[cfg(unix)]
660 #[tokio::test]
661 async fn store_writes_group_readable_file() {
662 let dir = tempfile::tempdir().unwrap();
663 store_in(dir.path(), &sample()).await.unwrap();
664
665 let mode = std::fs::metadata(registration_file(dir.path()))
666 .unwrap()
667 .permissions()
668 .mode() & 0o777;
669 assert_eq!(
670 mode, REG_FILE_MODE,
671 "expected {REG_FILE_MODE:o}, got {mode:o}"
672 );
673 }
674
675 #[cfg(unix)]
678 fn other_gid(exclude: u32) -> Option<u32> {
679 let out = std::process::Command::new("id").arg("-G").output().ok()?;
680 String::from_utf8(out.stdout)
681 .ok()?
682 .split_whitespace()
683 .filter_map(|gid| gid.parse().ok())
684 .find(|gid| *gid != exclude)
685 }
686
687 #[cfg(unix)]
688 #[tokio::test]
689 async fn store_writes_file_with_the_directory_group() {
690 let dir = tempfile::tempdir().unwrap();
691 let dir_gid = std::fs::metadata(dir.path()).unwrap().gid();
692 let Some(shared) = other_gid(dir_gid) else {
693 return;
694 };
695 std::os::unix::fs::chown(dir.path(), None, Some(shared)).unwrap();
696
697 store_in(dir.path(), &sample()).await.unwrap();
698
699 let gid = std::fs::metadata(registration_file(dir.path()))
700 .unwrap()
701 .gid();
702 assert_eq!(gid, shared, "expected group {shared}, got {gid}");
703 }
704
705 #[cfg(unix)]
706 #[tokio::test]
707 async fn load_repairs_group_of_files_written_elsewhere() {
708 let dir = tempfile::tempdir().unwrap();
709 store_in(dir.path(), &sample()).await.unwrap();
710 let path = registration_file(dir.path());
711
712 let dir_gid = std::fs::metadata(dir.path()).unwrap().gid();
713 let Some(other) = other_gid(dir_gid) else {
714 return;
715 };
716 std::os::unix::fs::chown(&path, None, Some(other)).unwrap();
717
718 load_from(dir.path()).await.unwrap().unwrap();
719
720 let gid = std::fs::metadata(&path).unwrap().gid();
721 assert_eq!(gid, dir_gid, "expected group {dir_gid}, got {gid}");
722 }
723
724 #[cfg(unix)]
725 #[tokio::test]
726 async fn load_repairs_mode_of_old_files() {
727 let dir = tempfile::tempdir().unwrap();
728 store_in(dir.path(), &sample()).await.unwrap();
729
730 let path = registration_file(dir.path());
731 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
732
733 load_from(dir.path()).await.unwrap().unwrap();
734 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
735 assert_eq!(
736 mode, REG_FILE_MODE,
737 "expected {REG_FILE_MODE:o}, got {mode:o}"
738 );
739 }
740}