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:
yahya-nusseibehandClaude Opus 5.5 committed 2026-11-07 08:20:48 +02:00
1 parent 4500b9e72b
commit b984398cbc
3 files changed
+202 -57

No files matched your search

+5 -2
View File
@@ -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
View File
@@ -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,
+63 -3
View File
@@ -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);
}