mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
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:
1 parent
aeadf92f84
commit
0de577b509
6 files changed
+94
-8
No files matched your search
@@ -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
|
||||
|
||||
Generated
+1
@@ -2671,6 +2671,7 @@ name = "horizon-launcher"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = "";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user