Bind a stored token to the Mac it was stored on

The machine identifier mixed into the key was read from /etc/machine-id,
which does not exist on macOS, so it was the empty string there. The salt
sits next to the ciphertext and the key context is a constant in a public
binary, which makes that identifier the only thing standing between a copied
home directory and a readable refresh token. Empty is not a neutral
contribution of no entropy, it is the absence of the binding: until now a
home directory lifted off a Mac decrypted anywhere. The file has claimed
otherwise since it was written.

macOS now reads IOPlatformUUID from ioreg, a stock binary called by absolute
path because an app launched from Finder inherits a minimal environment. The
result is cached, since a process spawn is real money when key() runs on
every load and every store. A failure yields an empty id and a working key
rather than an error, because refusing here would lock a user out of a token
that is perfectly good.

iOS and Android stay empty deliberately. The sandbox is the real boundary
there, and every identifier those platforms offer is reset by a reinstall,
which would strand a token that was never in any danger.

No migration, and that is a decision rather than an oversight. Nothing has
been released, so the only install this can orphan is a development machine,
and doing it now costs one reconnect instead of costing every Mac user one
later.

For the same reason the keychain-fallback.* paths are gone. They migrated
nobody, and they were subtly wrong anyway: they carried the old salt across
under the new name while deriving with the new key context, which silently
produced a token that could not be opened. A migration that looks like it
works and does not is worse than no migration.

key() also no longer treats every failure to read the salt as absence. A
missing file and a file too short to be a salt both take a fresh one, since
neither has anything left to lose, but a salt that exists and will not read
is now an error. Writing over it turned one transient read failure into the
permanent loss of every stored token.
This commit is contained in:
pj committed 2026-08-12 20:16:29 +05:30
1 parent 590506eb92
commit 99bbe1113a
1 file changed
+85 -33
+85 -33
View File
@@ -45,12 +45,6 @@ const SALT_NAME: &str = "tokens.salt";
const KEY_CONTEXT: &str = "margin-calendar token store v1"; const KEY_CONTEXT: &str = "margin-calendar token store v1";
const NONCE_LEN: usize = 24; const NONCE_LEN: usize = 24;
/// The names the encrypted store used while it was still the fallback behind a credential store.
/// Read once and migrated, so an existing install does not silently lose its accounts and ask the
/// user to reconnect for no reason they can see.
const LEGACY_BLOB_NAME: &str = "keychain-fallback.enc";
const LEGACY_SALT_NAME: &str = "keychain-fallback.salt";
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Called from `auth::init_sessions` during setup, before any command can run, because this needs /// Called from `auth::init_sessions` during setup, before any command can run, because this needs
@@ -114,23 +108,15 @@ fn data_dir() -> Result<PathBuf, String> {
fn key() -> Result<[u8; 32], String> { fn key() -> Result<[u8; 32], String> {
let dir = data_dir()?; let dir = data_dir()?;
let path = dir.join(SALT_NAME); let path = dir.join(SALT_NAME);
let legacy = dir.join(LEGACY_SALT_NAME);
let salt = match std::fs::read(&path) { let salt = match std::fs::read(&path) {
Ok(bytes) if bytes.len() == 32 => bytes, Ok(bytes) if bytes.len() == 32 => bytes,
_ => match std::fs::read(&legacy) { // Absent, or too short to be a salt because a previous write was cut off. Neither case has
// Same salt, new name. Rewritten rather than renamed so an interrupted migration // a salt worth keeping, so there is nothing for a fresh one to destroy.
// leaves both files readable rather than neither. Err(e) if e.kind() == std::io::ErrorKind::NotFound => fresh_salt(&path)?,
Ok(bytes) if bytes.len() == 32 => { Ok(_) => fresh_salt(&path)?,
write_private(&path, &bytes)?; // A salt that exists and will not read is a different thing entirely, and writing over it
bytes // would turn one transient failure into the permanent loss of every stored token. Refuse.
} Err(e) => return Err(format!("could not read {}: {e}", path.display())),
_ => {
let mut bytes = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
write_private(&path, &bytes)?;
bytes
}
},
}; };
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(KEY_CONTEXT.as_bytes()); hasher.update(KEY_CONTEXT.as_bytes());
@@ -139,11 +125,31 @@ fn key() -> Result<[u8; 32], String> {
Ok(hasher.finalize().into()) Ok(hasher.finalize().into())
} }
/// Mixed into the key so a copied home directory does not decrypt on another machine. Empty on fn fresh_salt(path: &Path) -> Result<Vec<u8>, String> {
/// macOS, iOS and Android, where no such file exists and the sandbox or the file mode is doing the let mut bytes = vec![0u8; 32];
/// work instead. An empty contribution is not a weakness here: the salt is already random and rand::thread_rng().fill_bytes(&mut bytes);
/// per install, and this only ever adds entropy. write_private(path, &bytes)?;
fn machine_id() -> String { Ok(bytes)
}
/// Mixed into the key so a copied home directory does not decrypt on another machine. The salt
/// sits next to the ciphertext and the context below is a constant in a public binary, so this is
/// the only thing binding a stored token to the machine it was stored on. An empty one is not a
/// neutral contribution of no entropy, it is the absence of the binding, and a home directory
/// lifted off that machine decrypts anywhere.
///
/// Deliberately empty on iOS and Android even so. The sandbox is the real boundary there, and the
/// identifiers those platforms offer are reset by a reinstall, which would lock a user out of a
/// token that was never in any danger.
///
/// Read once. On macOS it costs a process, and `key()` runs on every load and every store.
fn machine_id() -> &'static str {
static ID: OnceLock<String> = OnceLock::new();
ID.get_or_init(read_machine_id).as_str()
}
#[cfg(target_os = "linux")]
fn read_machine_id() -> String {
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] { for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(id) = std::fs::read_to_string(path) { if let Ok(id) = std::fs::read_to_string(path) {
let id = id.trim(); let id = id.trim();
@@ -155,18 +161,46 @@ fn machine_id() -> String {
String::new() String::new()
} }
/// `ioreg` rather than an IOKit binding, because it is a stock binary at a fixed path and one
/// process at first use is a better trade than a core-foundation dependency for one string. The
/// absolute path is not decoration: an app launched from Finder inherits a minimal environment.
///
/// `IOPlatformUUID` is stable across reboots and OS upgrades and changes with the logic board, so
/// a repaired Mac reconnects its accounts. That is the same cost as losing the salt, and the right
/// answer for the case this defends: a home directory restored onto different hardware.
#[cfg(target_os = "macos")]
fn read_machine_id() -> String {
let output = match std::process::Command::new("/usr/sbin/ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
{
Ok(output) if output.status.success() => output.stdout,
// An empty id still derives a working key, it only stops binding the token to this Mac.
// Failing here instead would lock the user out of a token that is perfectly good.
_ => return String::new(),
};
String::from_utf8_lossy(&output)
.lines()
.find_map(|line| {
let (_, rest) = line.split_once("\"IOPlatformUUID\"")?;
let (_, rest) = rest.split_once('"')?;
let (uuid, _) = rest.split_once('"')?;
Some(uuid.to_string())
})
.unwrap_or_default()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn read_machine_id() -> String {
String::new()
}
fn read_blob() -> Result<BTreeMap<String, String>, String> { fn read_blob() -> Result<BTreeMap<String, String>, String> {
let dir = data_dir()?; let dir = data_dir()?;
let path = dir.join(BLOB_NAME); let path = dir.join(BLOB_NAME);
let text = match std::fs::read_to_string(&path) { let text = match std::fs::read_to_string(&path) {
Ok(text) => text, Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
match std::fs::read_to_string(dir.join(LEGACY_BLOB_NAME)) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(e.to_string()),
}
}
Err(e) => return Err(e.to_string()), Err(e) => return Err(e.to_string()),
}; };
serde_json::from_str(&text).map_err(|e| e.to_string()) serde_json::from_str(&text).map_err(|e| e.to_string())
@@ -272,4 +306,22 @@ mod tests {
fn a_reference_names_the_entry_and_never_the_secret() { fn a_reference_names_the_entry_and_never_the_secret() {
assert_eq!(reference("1234"), "studio.margin.calendar/1234"); assert_eq!(reference("1234"), "studio.margin.calendar/1234");
} }
/// The point of the machine id is that a copied home directory does not decrypt elsewhere, and
/// the salt travels with the copy. An empty id on a desktop means that protection is not there
/// at all, which is what macOS did before it learned to read `IOPlatformUUID`.
#[test]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn a_desktop_binds_the_key_to_the_machine() {
assert!(
!machine_id().is_empty(),
"no machine id, so a copied home directory would decrypt anywhere"
);
}
/// Reading it twice must agree, or a token sealed early in a run stops opening later in it.
#[test]
fn the_machine_id_is_stable_within_a_run() {
assert_eq!(machine_id(), machine_id());
}
} }