Files
margin-calendar/src-tauri/src/google/secrets.rs
T
pj 99bbe1113a 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.
2026-08-12 20:16:29 +05:30

328 lines
13 KiB
Rust

// Where refresh tokens live: an encrypted file in the app's data directory, on every platform.
// Never plaintext on disk, never in the SQLite store, one entry per account id.
//
// This used to be the OS credential store with this file as a fallback, and that is gone. The
// credential stores did not survive contact with five platforms:
//
// macOS Keychain ties an item's ACL to the code signature, so an ad-hoc signed build gets a
// new identity on every compile and macOS correctly re-asks for authorization every
// single time. An authorization dialog a minute is not something to ask anyone to live
// with, so macOS was already excluded before mobile came up at all.
// Android The keyring crate has no Android backend. There is nothing to fall back from.
// Linux A minimal window manager often runs no Secret Service daemon, so the fallback ran
// anyway on exactly the machines that most wanted the daemon.
//
// That left one real implementation and four ways of reaching it, so the branching went and the
// file stayed. Be clear about what it is worth, because it is not the same everywhere:
//
// iOS, Android The app sandbox is the boundary. Another app cannot read this file, so the
// encryption is defence in depth over a boundary the OS already enforces.
// Desktop Anyone who can read the user's home directory can read the token. The key is
// derived from a salt sitting next to the ciphertext, mixed with a machine
// identifier, so a copied home directory does not decrypt elsewhere, and that is
// the whole of the protection. Better than plaintext, worse than the Secret
// Service, and chosen knowing that.
//
// The token is a Google refresh token scoped to one calendar account, revocable from the user's
// Google account page, and revoked here on disconnect.
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
use rand::RngCore;
use sha2::{Digest, Sha256};
pub const SERVICE: &str = "studio.margin.calendar";
const BLOB_NAME: &str = "tokens.enc";
const SALT_NAME: &str = "tokens.salt";
const KEY_CONTEXT: &str = "margin-calendar token store v1";
const NONCE_LEN: usize = 24;
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Called from `auth::init_sessions` during setup, before any command can run, because this needs
/// a directory and the signatures below deliberately do not carry an AppHandle.
pub fn init(dir: PathBuf) {
let _ = DATA_DIR.set(dir);
}
/// The reference stored in the `accounts` row. It names the entry, never the secret.
///
/// The column is still called `keychain_ref` because renaming it would cost a migration and buy
/// nothing: it has only ever held this string.
pub fn reference(account_id: &str) -> String {
format!("{SERVICE}/{account_id}")
}
pub fn store(account_id: &str, refresh_token: &str) -> Result<(), String> {
let sealed = seal(&key()?, refresh_token.as_bytes())?;
let mut entries = read_blob()?;
entries.insert(account_id.to_string(), sealed);
write_blob(&entries)
}
pub fn load(account_id: &str) -> Result<Option<String>, String> {
let entries = read_blob()?;
let sealed = match entries.get(account_id) {
Some(sealed) => sealed,
None => return Ok(None),
};
let plain = open(&key()?, sealed)?;
String::from_utf8(plain)
.map(Some)
.map_err(|_| "the stored token is not valid UTF-8".to_string())
}
pub fn delete(account_id: &str) -> Result<(), String> {
let mut entries = read_blob()?;
if entries.remove(account_id).is_none() {
return Ok(());
}
write_blob(&entries)
}
fn data_dir() -> Result<PathBuf, String> {
if let Some(dir) = DATA_DIR.get() {
return Ok(dir.clone());
}
// Only reachable before setup has run, which on mobile is never: there is no XDG layout to
// guess at there, so the honest answer is the error rather than a path that does not exist.
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
.ok_or("no app data directory is known")?;
let dir = base.join(SERVICE);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
/// The salt is per install and random. Losing it means losing every stored token, which costs a
/// reconnect and nothing else, so it is written once and never rotated.
fn key() -> Result<[u8; 32], String> {
let dir = data_dir()?;
let path = dir.join(SALT_NAME);
let salt = match std::fs::read(&path) {
Ok(bytes) if bytes.len() == 32 => bytes,
// Absent, or too short to be a salt because a previous write was cut off. Neither case has
// a salt worth keeping, so there is nothing for a fresh one to destroy.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => fresh_salt(&path)?,
Ok(_) => fresh_salt(&path)?,
// A salt that exists and will not read is a different thing entirely, and writing over it
// 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 hasher = Sha256::new();
hasher.update(KEY_CONTEXT.as_bytes());
hasher.update(&salt);
hasher.update(machine_id().as_bytes());
Ok(hasher.finalize().into())
}
fn fresh_salt(path: &Path) -> Result<Vec<u8>, String> {
let mut bytes = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
write_private(path, &bytes)?;
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"] {
if let Ok(id) = std::fs::read_to_string(path) {
let id = id.trim();
if !id.is_empty() {
return id.to_string();
}
}
}
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> {
let dir = data_dir()?;
let path = dir.join(BLOB_NAME);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(e.to_string()),
};
serde_json::from_str(&text).map_err(|e| e.to_string())
}
fn write_blob(entries: &BTreeMap<String, String>) -> Result<(), String> {
let path = data_dir()?.join(BLOB_NAME);
let text = serde_json::to_string(entries).map_err(|e| e.to_string())?;
write_private(&path, text.as_bytes())
}
fn seal(key_bytes: &[u8; 32], plain: &[u8]) -> Result<String, String> {
let cipher = XChaCha20Poly1305::new(Key::from_slice(key_bytes));
let mut nonce = [0u8; NONCE_LEN];
rand::thread_rng().fill_bytes(&mut nonce);
let mut sealed = cipher
.encrypt(XNonce::from_slice(&nonce), plain)
.map_err(|_| "could not encrypt the token".to_string())?;
let mut out = nonce.to_vec();
out.append(&mut sealed);
Ok(BASE64.encode(out))
}
fn open(key_bytes: &[u8; 32], sealed: &str) -> Result<Vec<u8>, String> {
let raw = BASE64
.decode(sealed)
.map_err(|e| format!("the stored token is malformed: {e}"))?;
if raw.len() <= NONCE_LEN {
return Err("the stored token is truncated".to_string());
}
let (nonce, body) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new(Key::from_slice(key_bytes));
cipher
.decrypt(XNonce::from_slice(nonce), body)
.map_err(|_| "the stored token could not be decrypted on this machine".to_string())
}
/// Written 0600 from creation rather than chmodded after the fact, so the ciphertext is never
/// briefly world readable. The mode is a no-op on the mobile sandboxes and costs nothing there.
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let tmp = PathBuf::from(format!("{}.tmp", path.display()));
{
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&tmp).map_err(|e| e.to_string())?;
file.write_all(bytes).map_err(|e| e.to_string())?;
file.sync_all().map_err(|e| e.to_string())?;
}
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sealed_tokens_round_trip() {
let key = [7u8; 32];
let sealed = seal(&key, b"1//refresh-token").expect("seal");
assert_eq!(open(&key, &sealed).expect("open"), b"1//refresh-token");
}
#[test]
fn a_different_key_does_not_open_the_token() {
let sealed = seal(&[7u8; 32], b"1//refresh-token").expect("seal");
assert!(open(&[8u8; 32], &sealed).is_err());
}
#[test]
fn each_seal_uses_a_fresh_nonce() {
let key = [7u8; 32];
assert_ne!(
seal(&key, b"same").expect("seal"),
seal(&key, b"same").expect("seal")
);
}
#[test]
fn a_truncated_blob_is_rejected_rather_than_panicking() {
assert!(open(&[7u8; 32], &BASE64.encode([0u8; 8])).is_err());
}
#[test]
fn a_tampered_blob_is_rejected_rather_than_returning_plaintext() {
let key = [7u8; 32];
let sealed = seal(&key, b"1//refresh-token").expect("seal");
let mut raw = BASE64.decode(&sealed).expect("decode");
// Past the nonce, so it is the ciphertext that changed and the tag that catches it.
let last = raw.len() - 1;
raw[last] ^= 0x01;
assert!(open(&key, &BASE64.encode(raw)).is_err());
}
#[test]
fn a_reference_names_the_entry_and_never_the_secret() {
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());
}
}