mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Seal the launcher's secrets file with a per-install secret
Without a keychain, account-secrets.json was sealed under SHA-256 of a constant and /etc/machine-id, which every local user can read; on a system with none of the machine-id files the key was a constant built into every launcher. Only the file's 0600 mode protected the Microsoft refresh token inside, so a copy restored with wider permissions, or a shared home, gave it away. Format 2 (HZSECv2) adds a random 32-byte install secret to the key, kept beside the file in account-secrets.key (0600, made at the first write through a temporary file and a rename, under the file's lock). The key is never the bare constant any more, and a v2 file whose secret is missing or belongs to another install is refused like any unreadable file, never written over. A file sealed by an older launcher (HZSECv1) is still read with the old key and sealed again under the new one at once. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
This commit is contained in:
1 parent
4500b9e72b
commit
b984398cbc
3 files changed
+202
-57
No files matched your search
@@ -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
|
||||
|
||||
+134
-52
@@ -711,22 +711,30 @@ impl FileStore {
|
||||
fn read(&self) -> Result<std::collections::BTreeMap<String, String>, 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<Option<[u8; 32]>, 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<Vec<u8>, 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<Option<(Vec<u8>, 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<Vec<u8>> {
|
||||
["/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<Vec<u8>, 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<Vec<u8>> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user