Skip to main content

bestool_canopy/
registration.rs

1//! Single, machine-bound, encrypted store for this host's canopy enrollment.
2//!
3//! Everything the agent needs to talk to canopy — the mTLS device key, the
4//! server id, and (once enrolled) the device id and api url — lives in one
5//! encrypted file:
6//!
7//! - Linux: `/etc/bestool/canopy-registration`
8//! - Windows: `%ProgramData%\bestool\canopy-registration`
9//!
10//! Encryption reuses algae (the age/scrypt profile this workspace already uses
11//! for `protect`/`reveal` and the enrollment ticket). The local file is keyed
12//! by a passphrase derived from the host's machine id, so a cloned disk can't
13//! reuse it on a different machine and the device key isn't at rest in
14//! plaintext. The same format is used for `canopy export` blobs, keyed by an
15//! operator passphrase instead — see [`encrypt_with_passphrase`].
16//!
17//! The machine-id binding is a deliberately weak, software-only measure. Where
18//! a TPM is present it could augment this — sealing or deriving the unlock key
19//! in hardware via [`machine_passphrase`] — while hosts without one keep using
20//! the machine id, and neither the file format nor any consumer changes.
21
22#[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
43/// Environment variable overriding the base directory for the registration
44/// file. Set by tests and honoured for ad-hoc relocation; when set, legacy
45/// migration is skipped.
46const DIR_ENV: &str = "BESTOOL_CANOPY_DIR";
47
48/// blake3 KDF context string for the machine-id-derived file passphrase. Bump
49/// the version suffix if the derivation ever changes.
50const KDF_CONTEXT: &str = "bestool canopy-registration v1 (machine-id)";
51
52/// Unix mode for the registration file. Group-readable so unprivileged runs
53/// sharing the daemon's group (e.g. `bestool tamanu doctor` run by hand) read
54/// the same registration instead of falling back to the database and
55/// rewriting the legacy `/etc/tamanu` files.
56#[cfg(unix)]
57const REG_FILE_MODE: u32 = 0o640;
58
59/// scrypt work factor (`N = 2^REG_WORK_FACTOR`) for the registration file.
60///
61/// The machine passphrase is a 256-bit blake3-derived key, so scrypt's
62/// memory-hardness adds no protection; age's default calibrates to ~1 second
63/// of scrypt, which on a fast server is a 512MiB arena — enough to blow
64/// through a service MemoryMax. 2^12 keeps the arena at 4MiB.
65const REG_WORK_FACTOR: u8 = 12;
66
67/// This host's canopy enrollment state.
68///
69/// Every field is optional so a partially-provisioned or migrated host can
70/// still be represented; `canopy register` populates all of them.
71#[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
111/// Default base directory for the registration file (honours [`DIR_ENV`]).
112///
113/// Uses the platform convention for machine-global state: `/etc` on Linux,
114/// `%ProgramData%` on Windows.
115pub 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
134/// Path to the cached canopy tags file, alongside the registration.
135///
136/// Tags aren't secret, so this is a plaintext JSON file rather than part of the
137/// encrypted registration blob; it lives in the same directory ([`default_dir`],
138/// honouring [`DIR_ENV`]) so all per-host canopy state shares one location.
139pub fn default_tags_path() -> PathBuf {
140	default_dir().join("tags.json")
141}
142
143// Legacy plaintext paths, mirroring bestool-tamanu's `standard_*` paths. Kept
144// as literals here because canopy can't depend on the tamanu crate.
145fn 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
161/// Process-wide cache of the registration at the default location, so repeated
162/// reporting reads (e.g. the doctor tick) don't re-run scrypt each time. A
163/// [`store`] at the default location refreshes it, so a writer's update — the
164/// self-heal that recovers a missing identity, say — is seen by the next
165/// in-process read without waiting for a process restart.
166static CACHE: std::sync::RwLock<Option<Registration>> = std::sync::RwLock::new(None);
167
168/// Load the registration from the default location.
169///
170/// If the file is absent, migrates from the legacy `/etc/tamanu` plaintext
171/// files (unless [`DIR_ENV`] is set). Returns `None` when there's nothing to
172/// load.
173pub 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
194/// Replace the process-wide cache of the default-location registration.
195fn set_cache(reg: Registration) {
196	*CACHE.write().expect("registration cache poisoned") = Some(reg);
197}
198
199/// Load the registration from a specific directory, without legacy migration.
200pub 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
209/// Encrypt and store the registration at the default location.
210///
211/// Refreshes the process-wide [`CACHE`] on success so an in-process reader sees
212/// the update on its next [`load`] without a restart.
213pub async fn store(reg: &Registration) -> Result<()> {
214	store_in(&default_dir(), reg).await?;
215	set_cache(reg.clone());
216	Ok(())
217}
218
219/// Encrypt and store the registration in a specific directory.
220pub 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(&registration_file(dir), &ciphertext).await
230}
231
232/// Remove the registration file (and any stale temp file) from `dir`.
233///
234/// Returns whether a registration file was present. A running daemon caches the
235/// registration in memory for its lifetime, so it must be restarted to notice
236/// the removal.
237pub async fn delete_in(dir: &Path) -> Result<bool> {
238	let path = registration_file(dir);
239	let existed = remove_if_present(&path).await?;
240	// Best-effort: a leftover temp file isn't an enrollment, so a failure to
241	// remove it shouldn't fail the unregister.
242	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
256/// Encrypt a registration under an operator passphrase, for `canopy export`.
257pub 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
264/// Generate a fresh random passphrase for `canopy export`.
265///
266/// ~128 bits from a URL-safe base64 of 16 random bytes — enough entropy to make
267/// brute force infeasible, with no wordlist to bloat the binary.
268pub 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
274/// Decrypt a registration from an operator passphrase, for `canopy import`.
275pub 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	// Repair the mode of files written before group read was granted.
284	// Best-effort: only the owner can chmod, and unprivileged readers that get
285	// this far don't need to.
286	#[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	// Files written before the work factor was fixed used age's calibrated
304	// default, which costs hundreds of MiB to decrypt on every load. Re-encrypt
305	// once with the cheap factor. Best-effort: unprivileged readers can't write
306	// here, and the owner will on its next load.
307	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
324/// Extract the scrypt work factor (log_n) from an age file header.
325///
326/// The header is ASCII text even in the binary format: a version line, then
327/// `-> scrypt <salt> <log_n>` for passphrase-encrypted files.
328fn 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	// Write the consolidated file, then prove it reads back from scratch before
358	// removing the only other copy of the device key. Any failure leaves the
359	// legacy files in place so the next run retries.
360	if let Err(err) = store_in(dir, &reg).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
394/// Build the passphrase that unlocks the local registration file from the
395/// host's machine id, read via the `machine-uid` crate (machine-id on Linux,
396/// MachineGuid on Windows, IOPlatformUUID on macOS). A TPM, where one is
397/// present, could augment this by sealing the key in hardware; hosts without a
398/// TPM keep using the machine id.
399fn 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
413// algae's stream API takes `Box<dyn Identity>` (not `Send`), which would poison
414// the `Send` futures the reporting path requires. The payload is tiny and fully
415// in-memory (no tokio reactor needed), so we drive algae to completion on the
416// current thread with `block_on` inside a synchronous helper — nothing
417// non-`Send` is then held across an `.await` in the async callers.
418fn 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	// `mode()` only applies on creation and is filtered by the umask, so set
463	// the permissions explicitly to cover pre-existing tmp files and
464	// restrictive service umasks.
465	#[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/// Give `path` the group of the directory it sits in, so [`REG_FILE_MODE`]'s
480/// group read reaches the group owning the config directory. A setgid
481/// directory confers it already; one without the bit does not. Best-effort,
482/// since chowning needs ownership and a reader that can already open the file
483/// does not need it to have worked.
484#[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(&reg, 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		// load() short-circuits on the process cache; set_cache (which store
560		// calls) replaces it. A refreshed value must be returned rather than
561		// frozen at the first read — that freeze would keep a healed
562		// registration invisible until the daemon restarted.
563		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(), &reg).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		// File must not contain the plaintext key.
596		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		// Simulate a file written before the work factor was fixed (one notch
639		// up, to keep the test fast).
640		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(&reg).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	/// A group this process may chown to, other than `exclude`; `None` when it
676	/// belongs to no other group and so can't set up the mismatch.
677	#[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}