diff --git a/docs/microsoft-account.md b/docs/microsoft-account.md index 6ae94eb108..a103d27f65 100644 --- a/docs/microsoft-account.md +++ b/docs/microsoft-account.md @@ -140,8 +140,11 @@ one is selected. "Sign out" deletes the account's secrets from the keychain. secrets the keychain refuses go to `account-secrets.json` in the launcher's data folder instead, readable and writable by the player only (0600 on Unix) and never in plain text: the file is encrypted (ChaCha20-Poly1305) with a key derived from the computer's identity (`/etc/machine-id`, - `/etc/hostid`), so a copy taken off the computer (a backup, a synced home folder) can't be read - elsewhere. On the computer itself the file's mode is what keeps other users out. A file that + `/etc/hostid`) and a random secret of the install's own, `account-secrets.key` beside it (32 + bytes, 0600, made at the first write), so a copy taken off the computer (a backup, a synced home + folder) can't be read elsewhere, and nobody can work the key out from what every user can read. + On the computer itself the files' mode is what keeps other users out. A file sealed by an older + launcher (machine identity only) is read once and sealed again under the new key. A file that doesn't open (another computer's, or changed) is never written over: remove it and sign in again. The launcher never picks one store for a run: every read takes the file's copy first (it was written while the keychain was away, so it is the newer one) and the keychain's otherwise, and every write diff --git a/launcher/src/account.rs b/launcher/src/account.rs index 58b898c4e5..6b9420da2f 100644 --- a/launcher/src/account.rs +++ b/launcher/src/account.rs @@ -711,22 +711,30 @@ impl FileStore { fn read(&self) -> Result, String> { match std::fs::read(&self.path) { // Only where the file breaks: serde's own message can quote what the file holds. - Ok(bytes) => serde_json::from_slice(&unseal(&bytes).ok_or_else(|| { - format!( - "{} can't be read on this computer: it was written on another one, or changed. \ - Remove it and sign in again.", - self.path.display() - ) - })?) - .map_err(|e| { - format!( - "{} can't be read ({:?} error at line {}, column {}).", - self.path.display(), - e.classify(), - e.line(), - e.column() - ) - }), + Ok(bytes) => { + let (json, old) = self.unseal(&bytes)?.ok_or_else(|| { + format!( + "{} can't be read on this computer: it was written on another one, or changed. \ + Remove it and sign in again.", + self.path.display() + ) + })?; + let secrets = serde_json::from_slice(&json).map_err(|e| { + format!( + "{} can't be read ({:?} error at line {}, column {}).", + self.path.display(), + e.classify(), + e.line(), + e.column() + ) + })?; + // Sealed under the old key: sealed again under this install's. Every read runs + // under the lock; a write that fails leaves the old file, read again next time. + if old { + let _ = self.write(&secrets); + } + Ok(secrets) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Default::default()), Err(e) => Err(self.shown(e)), } @@ -747,7 +755,7 @@ impl FileStore { .path .with_extension(format!("json.{}.{n}.tmp", std::process::id())); let json = serde_json::to_vec(secrets).map_err(|e| e.to_string())?; - let sealed = seal(&json)?; + let sealed = self.seal(&json)?; let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -804,50 +812,124 @@ impl FileStore { Ok(secrets.contains_key(key)) }) } + + /// The install's own secret beside the file (`account-secrets.key`): 32 random bytes, readable + /// and writable by the player only, made at the first write when `make`. `None` when it isn't + /// there and not made. + fn install_secret(&self, make: bool) -> Result, String> { + let path = self.path.with_extension("key"); + match std::fs::read(&path) { + Ok(bytes) => <[u8; 32]>::try_from(bytes).map(Some).map_err(|_| { + format!( + "{} is damaged. Remove it and {} and sign in again.", + path.display(), + self.path.display() + ) + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && make => { + let mut secret = [0u8; 32]; + getrandom::fill(&mut secret).map_err(|e| e.to_string())?; + // Whole or not at all, as the secrets file: a temporary file, then a rename. + let tmp = path.with_extension(format!("key.{}.tmp", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let written = options.open(&tmp).and_then(|mut file| { + std::io::Write::write_all(&mut file, &secret)?; + file.sync_all() + }); + if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, &path)) { + let _ = std::fs::remove_file(&tmp); + return Err(format!("{}: {e}", path.display())); + } + Ok(Some(secret)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("{}: {e}", path.display())), + } + } + + /// `json` sealed for the file ([`SEALED`]), under [`file_key`] with this install's secret. + fn seal(&self, json: &[u8]) -> Result, String> { + use chacha20poly1305::{aead::Aead, KeyInit}; + let key = file_key(&self.install_secret(true)?.expect("made")); + let mut nonce = [0u8; 12]; + getrandom::fill(&mut nonce).map_err(|e| e.to_string())?; + let sealed = chacha20poly1305::ChaCha20Poly1305::new(&key) + .encrypt(&nonce.into(), json) + .map_err(|_| "the secrets couldn't be sealed".to_string())?; + Ok([SEALED, &nonce, &sealed].concat()) + } + + /// What [`Self::seal`] sealed, and whether it was sealed in format 1 ([`SEALED_V1`]), to seal + /// again; `None` for a file in another format, changed, sealed on another computer or by + /// another install, or whose install secret is gone. + fn unseal(&self, bytes: &[u8]) -> Result, bool)>, String> { + use chacha20poly1305::{aead::Aead, KeyInit}; + let (key, old, rest) = if let Some(rest) = bytes.strip_prefix(SEALED) { + let Some(secret) = self.install_secret(false)? else { + return Ok(None); + }; + (file_key(&secret), false, rest) + } else if let Some(rest) = bytes.strip_prefix(SEALED_V1) { + (file_key_v1(), true, rest) + } else { + return Ok(None); + }; + let Some((nonce, sealed)) = rest.split_at_checked(12) else { + return Ok(None); + }; + let nonce: [u8; 12] = nonce.try_into().expect("12 bytes"); + Ok(chacha20poly1305::ChaCha20Poly1305::new(&key) + .decrypt(&nonce.into(), sealed) + .ok() + .map(|json| (json, old))) + } } -/// The secrets file's first bytes: format 1, the JSON sealed with ChaCha20-Poly1305 under +/// The secrets file's first bytes: format 2, the JSON sealed with ChaCha20-Poly1305 under /// [`file_key`], then a random 12-byte nonce and the ciphertext with its tag. -const SEALED: &[u8] = b"HZSECv1\n"; +const SEALED: &[u8] = b"HZSECv2\n"; -/// The secrets file's key: this computer's identity (`/etc/machine-id` on Linux, `/etc/hostid` on -/// FreeBSD) hashed with a constant of the launcher's. The tokens are never on disk in plain text, -/// and a copy of the file taken off the computer (a backup, a synced home folder) can't be read -/// elsewhere. On the computer itself, the file's mode (0600) is what keeps other users out. -fn file_key() -> chacha20poly1305::Key { - use sha2::Digest; - let mut hash = sha2::Sha256::new(); - hash.update(b"horizon-launcher account-secrets v1"); - if let Some(id) = ["/etc/machine-id", "/var/lib/dbus/machine-id", "/etc/hostid"] +/// Format 1, sealed under [`file_key_v1`]: still read, and sealed again in format 2 at once. +const SEALED_V1: &[u8] = b"HZSECv1\n"; + +/// This computer's identity: `/etc/machine-id` on Linux, `/etc/hostid` on FreeBSD. +fn machine_id() -> Option> { + ["/etc/machine-id", "/var/lib/dbus/machine-id", "/etc/hostid"] .iter() .find_map(|path| std::fs::read(path).ok()) - { - hash.update(id.trim_ascii()); - } + .map(|id| id.trim_ascii().to_vec()) +} + +/// The secrets file's key: a constant of the launcher's, this computer's identity and the install's +/// own random secret (`account-secrets.key`, 0600). The tokens are never on disk in plain text, +/// and the key is never something every local user can work out (the machine id is readable by +/// all, and some systems have none): it takes the install secret, which only the player can read. +/// The file's mode (0600) is still what keeps other users out; the secret means a copy of the file +/// alone, or one left readable by others while the secret wasn't, can't be read either. +fn file_key(install_secret: &[u8; 32]) -> chacha20poly1305::Key { + use sha2::Digest; + let mut hash = sha2::Sha256::new(); + hash.update(b"horizon-launcher account-secrets v2"); + let id = machine_id().unwrap_or_default(); + hash.update((id.len() as u64).to_le_bytes()); + hash.update(&id); + hash.update(install_secret); hash.finalize() } -/// `json` sealed for the secrets file ([`SEALED`]). -fn seal(json: &[u8]) -> Result, String> { - use chacha20poly1305::{aead::Aead, KeyInit}; - let mut nonce = [0u8; 12]; - getrandom::fill(&mut nonce).map_err(|e| e.to_string())?; - let sealed = chacha20poly1305::ChaCha20Poly1305::new(&file_key()) - .encrypt(&nonce.into(), json) - .map_err(|_| "the secrets couldn't be sealed".to_string())?; - Ok([SEALED, &nonce, &sealed].concat()) -} - -/// What [`seal`] sealed, or `None` for a file in another format, changed, or sealed on another -/// computer. -fn unseal(bytes: &[u8]) -> Option> { - use chacha20poly1305::{aead::Aead, KeyInit}; - let rest = bytes.strip_prefix(SEALED)?; - let (nonce, sealed) = rest.split_at_checked(12)?; - let nonce: [u8; 12] = nonce.try_into().ok()?; - chacha20poly1305::ChaCha20Poly1305::new(&file_key()) - .decrypt(&nonce.into(), sealed) - .ok() +/// Format 1's key, only to read a file sealed before format 2: the constant and the machine id, +/// which anyone on the computer can read (the bare constant where there is none). +fn file_key_v1() -> chacha20poly1305::Key { + use sha2::Digest; + let mut hash = sha2::Sha256::new(); + hash.update(b"horizon-launcher account-secrets v1"); + if let Some(id) = machine_id() { + hash.update(id); + } + hash.finalize() } /// A file entry for an account signed out while the keychain was away: the keychain's copy, if any, diff --git a/launcher/src/account/tests.rs b/launcher/src/account/tests.rs index 0a1c643f0b..3a94e51308 100644 --- a/launcher/src/account/tests.rs +++ b/launcher/src/account/tests.rs @@ -1295,7 +1295,11 @@ fn the_secrets_file_keeps_sign_ins_across_starts() { // No temporary file left behind. assert_eq!( files_beside(&path), - ["account-secrets.json", "account-secrets.lock"] + [ + "account-secrets.json", + "account-secrets.key", + "account-secrets.lock" + ] ); // Signing the last account out removes the file. accounts.remove(&again, UUID).unwrap(); @@ -1316,7 +1320,7 @@ fn a_broken_secrets_file_is_an_error_that_never_shows_it() { "[\"leaked-secret\"]", ] { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let sealed = super::seal(broken.as_bytes()).unwrap(); + let sealed = store.seal(broken.as_bytes()).unwrap(); std::fs::write(&path, &sealed).unwrap(); let said = store.get("a").unwrap_err(); assert!(!said.contains("leaked-secret"), "{said}"); @@ -1354,6 +1358,58 @@ fn the_secrets_file_is_never_plain_text() { remove_secrets_dir(&path); } +#[test] +fn the_key_takes_the_install_secret_beside_the_file() { + let path = secrets_file("install"); + let store = FileStore::new(path.clone()); + store.set("a", "kept-secret").unwrap(); + let secret = path.with_extension("key"); + assert_eq!(std::fs::read(&secret).unwrap().len(), 32); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&secret).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "only the player reads it"); + } + // The same file in another install's folder, with a secret of its own: unreadable there. + let other = secrets_file("install-other"); + let elsewhere = FileStore::new(other.clone()); + elsewhere.set("b", "other-secret").unwrap(); + std::fs::copy(&path, &other).unwrap(); + let said = elsewhere.get("a").unwrap_err(); + assert!(said.contains("Remove it and sign in again"), "{said}"); + // Without its secret, the file can't be read either: no key is left to work out. + std::fs::remove_file(&secret).unwrap(); + assert!(store.get("a").is_err()); + assert!(!secret.exists(), "a read never makes a new secret"); + remove_secrets_dir(&path); + remove_secrets_dir(&other); +} + +#[test] +fn a_file_sealed_under_the_old_key_is_read_and_sealed_again() { + use chacha20poly1305::{aead::Aead, KeyInit}; + let path = secrets_file("format1"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + // As launchers before format 2 wrote it. + let nonce = [7u8; 12]; + let sealed = chacha20poly1305::ChaCha20Poly1305::new(&super::file_key_v1()) + .encrypt(&nonce.into(), &b"{\"a\":\"old-secret\"}"[..]) + .unwrap(); + std::fs::write(&path, [b"HZSECv1\n".as_slice(), &nonce, &sealed].concat()).unwrap(); + let store = FileStore::new(path.clone()); + assert_eq!(store.get("a").unwrap().as_deref(), Some("old-secret")); + // Sealed again at once, under the install's secret, and still read. + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"HZSECv2\n")); + assert!(path.with_extension("key").exists()); + assert_eq!( + FileStore::new(path.clone()).get("a").unwrap().as_deref(), + Some("old-secret") + ); + remove_secrets_dir(&path); +} + const WRITER: &str = "HORIZON_TEST_SECRETS_WRITER"; /// One of the processes [`processes_sharing_the_secrets_file_lose_nothing`] starts: does nothing @@ -1412,7 +1468,11 @@ fn processes_sharing_the_secrets_file_lose_nothing() { assert_eq!(store.get("signed-out").unwrap(), None); assert_eq!( files_beside(&path), - ["account-secrets.json", "account-secrets.lock"] + [ + "account-secrets.json", + "account-secrets.key", + "account-secrets.lock" + ] ); remove_secrets_dir(&path); }