Launcher secrets file: encrypted at rest (ChaCha20-Poly1305, a key of the computer's)

The file kept the Microsoft refresh token and the Minecraft access token in
plain JSON. It is now sealed with a key derived from /etc/machine-id
(/etc/hostid on FreeBSD), so no token is on disk in plain text and a copy
taken off the computer can't be read. A file that doesn't open is refused
without quoting it and never written over, as a broken one was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 14:41:02 +01:00
1 parent aeadf92f84
commit 0de577b509
6 files changed
+94 -8

No files matched your search

+5 -1
View File
@@ -138,7 +138,11 @@ one is selected. "Sign out" deletes the account's secrets from the keychain.
account is selected. Never a token.
- Linux and FreeBSD with no keychain answering (no Secret Service, a locked keychain, #5095): the
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), as most launchers keep them. The
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
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
goes to the keychain and to the file only when it refuses. Each change to the file holds
+1
View File
@@ -2671,6 +2671,7 @@ name = "horizon-launcher"
version = "0.2.0"
dependencies = [
"base64",
"chacha20poly1305",
"chrono",
"clap",
"clap_complete",
+2
View File
@@ -19,6 +19,8 @@ serde_json = "1"
sha1 = "0.10"
# The mod browser checks Modrinth's SHA-512 before a pack reaches the game folder (browser/download.rs).
sha2 = "0.10"
# The secrets file is encrypted where no keychain answers (account.rs, FileStore).
chacha20poly1305 = "0.10"
pulldown-cmark = { version = "0.12", default-features = false }
flate2 = "1"
tar = { version = "0.4", default-features = false }
+1 -1
View File
@@ -431,7 +431,7 @@ play LAN and offline-mode servers beside it. The game binary started directly (`
`horizon-ctl launch`), the harnesses and the servers don't go through this check. Tokens live in the system keychain (Keychain,
Credential Manager, or the Secret Service on Linux), never in a log or the command line;
`accounts.json` in the data folder keeps only names and UUIDs. On Linux without a keychain they go
to `account-secrets.json` in the data folder, readable by you only, and back to the keychain once it
to `account-secrets.json` in the data folder, encrypted and readable by you only, and back to the keychain once it
answers; on macOS and Windows without one you stay signed in until the launcher closes. The
Tailscale access token never goes to that file: without a keychain it lasts until the launcher
closes. When the game starts, it gets the session in a private file it
+56 -4
View File
@@ -657,8 +657,8 @@ impl SecretStore for MemoryStore {
/// The secrets file in the launcher's data folder, when no keychain answers (#5095): many Linux
/// systems run no Secret Service, and sign-ins would otherwise last one run. One JSON object, key
/// to secret, readable and writable by the player only (0600 on Unix), replaced whole on every
/// write. A lock file beside it keeps each read-modify-replace whole across every launcher and CLI
/// to secret, encrypted with a key of this computer's ([`seal`]), readable and writable by the
/// player only (0600 on Unix), replaced whole on every write. A lock file beside it keeps each read-modify-replace whole across every launcher and CLI
/// process. The keychain takes its secrets back at the first start it answers ([`Layered`]).
pub struct FileStore {
path: PathBuf,
@@ -711,7 +711,14 @@ 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(&bytes).map_err(|e| {
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(),
@@ -740,12 +747,13 @@ 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 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, &json)?;
std::io::Write::write_all(&mut file, &sealed)?;
file.sync_all()
});
if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, &self.path)) {
@@ -798,6 +806,50 @@ impl FileStore {
}
}
/// The secrets file's first bytes: format 1, 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";
/// 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"]
.iter()
.find_map(|path| std::fs::read(path).ok())
{
hash.update(id.trim_ascii());
}
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()
}
/// A file entry for an account signed out while the keychain was away: the keychain's copy, if any,
/// counts as gone, and goes at the first start it answers.
const SIGNED_OUT: &str = "";
+29 -2
View File
@@ -1154,13 +1154,40 @@ fn a_broken_secrets_file_is_an_error_that_never_shows_it() {
"[\"leaked-secret\"]",
] {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, broken).unwrap();
let sealed = super::seal(broken.as_bytes()).unwrap();
std::fs::write(&path, &sealed).unwrap();
let said = store.get("a").unwrap_err();
assert!(!said.contains("leaked-secret"), "{said}");
assert!(said.contains("line 1"), "{said}");
// Never written over: the secrets in it aren't lost in silence.
assert!(store.set("c", "four").is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
assert_eq!(std::fs::read(&path).unwrap(), sealed);
}
remove_secrets_dir(&path);
}
#[test]
fn the_secrets_file_is_never_plain_text() {
let path = secrets_file("sealed");
let store = FileStore::new(path.clone());
store.set("a", "kept-secret").unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(super::SEALED));
assert!(
!bytes.windows(11).any(|w| w == b"kept-secret"),
"the secret is encrypted"
);
assert_eq!(store.get("a").unwrap().as_deref(), Some("kept-secret"));
// Plain JSON, or one byte changed in the sealed file: refused, never quoted, never written over.
let mut changed = bytes.clone();
*changed.last_mut().unwrap() ^= 1;
for other in [b"{\"a\":\"leaked-secret\"}".to_vec(), changed] {
std::fs::write(&path, &other).unwrap();
let said = store.get("a").unwrap_err();
assert!(!said.contains("leaked-secret"), "{said}");
assert!(said.contains("Remove it and sign in again"), "{said}");
assert!(store.set("c", "four").is_err());
assert_eq!(std::fs::read(&path).unwrap(), other);
}
remove_secrets_dir(&path);
}