Skip to content

Commit b9c028a

Browse files
alvin-changCole
andcommitted
fix(credential-store): mirror .encryption_key instead of deleting it
The keyring backend in resolve_key() was auto-deleting the .encryption_key fallback file when a key was found in the OS keyring. The function docstring explicitly stated the file should never be deleted ('it always serves as a durable fallback for environments where the keyring is ephemeral'), but the code contradicted the doc. This caused a TOCTOU bug: when a user switched backends (e.g. from GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file to default keyring), the keyring would return a stale entry from before the file backend was used, then delete the .encryption_key file (which contained the actual current key). This made credentials.enc undecryptable, triggering a second cleanup that deleted the credentials themselves — silently wiping the user's authenticated session. The fix mirrors the keyring key to the .encryption_key file (keeping the file in sync with the keyring) instead of deleting it. Per Gemini code review, the mirror uses the existing save_key_file (atomic_write with 0o600 permissions + fsync) and ensure_key_dir (0o700 on parent) helpers to keep file permissions restrictive and the write race-free. A second optimization (also per Gemini) only invokes the fsync when the file content does not already match the keyring key, eliminating per-invocation disk wear on the common case. Error messages are sanitized via sanitize_for_terminal. This matches the function docstring's design intent and prevents the silent-wipe bug. Tests (29/29 pass): - Renamed 'cleans_up_legacy_file_*' to 'mirrors_legacy_file_*' to reflect the new behavior - Updated assertions: file now exists after the operation and contains the keyring key - New: 'keyring_backend_skips_write_when_file_already_has_correct_key' verifies the fsync is skipped when the file matches (via mtime check) - New: 'keyring_backend_writes_when_file_has_stale_key' verifies the file IS updated when the content doesn't match - Linux fallback tests already mirrored this behavior; macOS/Windows tests now match Reported-by: Alvin Chang (alvin@goodciso.org) Original-patch-author: Cole (AI closer agent, goodciso.org) Tested-by: Cole (Cole, 2026-06-12) Co-authored-by: Cole <cole-closer@goodciso.org> Co-authored-by: Alvin Chang <alvin.chang@gmail.com>
1 parent a3768d0 commit b9c028a

2 files changed

Lines changed: 130 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
Fix silent auth wipe: the keyring backend in `resolve_key()` was auto-deleting the `.encryption_key` fallback file when a key was found in the OS keyring, contradicting the function's own docstring. The fix mirrors the keyring key to the file (via `save_key_file` + `ensure_key_dir` for atomic write with 0o600 permissions and 0o700 parent) instead of deleting it. Switching backends mid-session no longer wipes credentials.

crates/google-workspace-cli/src/credential_store.rs

Lines changed: 125 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,32 @@ fn resolve_key(
210210
if decoded.len() == 32 {
211211
let mut arr = [0u8; 32];
212212
arr.copy_from_slice(&decoded);
213-
// Cleanup insecure file fallback if it still exists.
214-
// TOCTOU race condition is a known limitation.
215-
if let Err(e) = std::fs::remove_file(key_file) {
216-
if e.kind() != std::io::ErrorKind::NotFound {
213+
// Mirror the keyring's key to the .encryption_key file
214+
// so the file stays a durable fallback for headless/CI envs.
215+
// Per the function docstring, the file is never deleted.
216+
// Uses save_key_file (atomic_write with 0o600 + fsync) and
217+
// ensure_key_dir (0o700 on parent) to keep file permissions
218+
// restrictive and the write race-free.
219+
// Per Gemini code review, only write when the existing file
220+
// content does not already match the keyring key — avoids
221+
// unnecessary fsync + disk wear on every CLI invocation.
222+
let needs_write = match read_key_file(key_file) {
223+
Some(existing) => existing != arr,
224+
None => true,
225+
};
226+
if needs_write {
227+
if let Err(e) = ensure_key_dir(key_file) {
217228
eprintln!(
218-
"Warning: failed to remove legacy key file at '{}': {}",
229+
"Warning: failed to set up key directory '{}': {}",
219230
key_file.display(),
220-
e
231+
sanitize_for_terminal(&e.to_string())
232+
);
233+
}
234+
if let Err(e) = save_key_file(key_file, b64_key.trim()) {
235+
eprintln!(
236+
"Warning: failed to mirror keyring key to '{}': {}",
237+
key_file.display(),
238+
sanitize_for_terminal(&e.to_string())
221239
);
222240
}
223241
}
@@ -243,12 +261,32 @@ fn resolve_key(
243261
sanitize_for_terminal(&e.to_string())
244262
);
245263
}
246-
if let Err(e) = std::fs::remove_file(key_file) {
247-
if e.kind() != std::io::ErrorKind::NotFound {
264+
// Mirror the new key to the .encryption_key file as a durable fallback
265+
// for headless/CI environments. Per the function docstring, the file
266+
// is never deleted. Uses save_key_file (atomic_write with 0o600 + fsync)
267+
// and ensure_key_dir (0o700 on parent) to keep file permissions
268+
// restrictive and the write race-free.
269+
// Per Gemini code review, only write when the existing file content does
270+
// not already match — avoids unnecessary fsync + disk wear on every
271+
// CLI invocation. (For a freshly generated key, the file should never
272+
// match, so this is a defensive check that costs only one file read.)
273+
let needs_write = match read_key_file(key_file) {
274+
Some(existing) => existing != key,
275+
None => true,
276+
};
277+
if needs_write {
278+
if let Err(e) = ensure_key_dir(key_file) {
248279
eprintln!(
249-
"Warning: failed to remove legacy key file at '{}': {}",
280+
"Warning: failed to set up key directory '{}': {}",
250281
key_file.display(),
251-
e
282+
sanitize_for_terminal(&e.to_string())
283+
);
284+
}
285+
if let Err(e) = save_key_file(key_file, b64_key.trim()) {
286+
eprintln!(
287+
"Warning: failed to mirror keyring key to '{}': {}",
288+
key_file.display(),
289+
sanitize_for_terminal(&e.to_string())
252290
);
253291
}
254292
}
@@ -581,31 +619,39 @@ mod tests {
581619

582620
#[test]
583621
#[cfg(any(target_os = "macos", target_os = "windows"))]
584-
fn keyring_backend_cleans_up_legacy_file_on_success() {
622+
fn keyring_backend_mirrors_legacy_file_on_success() {
585623
use base64::{engine::general_purpose::STANDARD, Engine as _};
586624
let dir = tempfile::tempdir().unwrap();
587625
let key_file = dir.path().join(".encryption_key");
588626

589-
// Create a legacy fallback file
590-
std::fs::write(&key_file, STANDARD.encode([99u8; 32])).unwrap();
627+
// Create a legacy fallback file with a stale key
628+
let stale_key = [99u8; 32];
629+
std::fs::write(&key_file, STANDARD.encode(stale_key)).unwrap();
591630
assert!(key_file.exists());
592631

593-
// Keyring has a valid key
632+
// Keyring has a valid (different) key
594633
let expected = [7u8; 32];
634+
assert_ne!(stale_key, expected, "stale key must differ for this test");
595635
let mock = MockKeyring::with_password(&STANDARD.encode(expected));
596636

597637
let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap();
598638

599639
assert_eq!(result, expected);
600640
assert!(
601-
!key_file.exists(),
602-
"Legacy file must be deleted upon successful keyring read"
641+
key_file.exists(),
642+
"Legacy file must NOT be deleted — it serves as a durable fallback"
643+
);
644+
let synced = read_key_file(&key_file).unwrap();
645+
assert_eq!(
646+
synced, expected,
647+
"Legacy file must be updated to mirror the keyring key"
603648
);
604649
}
605650

606651
#[test]
607652
#[cfg(any(target_os = "macos", target_os = "windows"))]
608-
fn keyring_backend_cleans_up_legacy_file_on_generation() {
653+
fn keyring_backend_mirrors_legacy_file_on_generation() {
654+
use base64::{engine::general_purpose::STANDARD, Engine as _};
609655
let dir = tempfile::tempdir().unwrap();
610656
let key_file = dir.path().join(".encryption_key");
611657

@@ -618,8 +664,13 @@ mod tests {
618664

619665
assert_eq!(result.len(), 32);
620666
assert!(
621-
!key_file.exists(),
622-
"Legacy file must be deleted upon successful keyring generation"
667+
key_file.exists(),
668+
"Legacy file must NOT be deleted — it serves as a durable fallback"
669+
);
670+
let synced = read_key_file(&key_file).unwrap();
671+
assert_eq!(
672+
synced, result,
673+
"Legacy file must be updated to mirror the newly-generated keyring key"
623674
);
624675
}
625676

@@ -642,6 +693,61 @@ mod tests {
642693

643694
// ---- Backend::Keyring tests (Linux fallback behavior) ----
644695

696+
697+
#[test]
698+
#[cfg(any(target_os = "macos", target_os = "windows"))]
699+
fn keyring_backend_skips_write_when_file_already_has_correct_key() {
700+
use base64::{engine::general_purpose::STANDARD, Engine as _};
701+
let dir = tempfile::tempdir().unwrap();
702+
let key_file = dir.path().join(".encryption_key");
703+
704+
// Pre-populate the file with the SAME key the keyring will return
705+
let expected = [42u8; 32];
706+
std::fs::write(&key_file, STANDARD.encode(expected)).unwrap();
707+
let original_mtime = std::fs::metadata(&key_file).unwrap().modified().unwrap();
708+
709+
// Keyring has the same key
710+
let mock = MockKeyring::with_password(&STANDARD.encode(expected));
711+
712+
// Brief sleep to ensure mtime would change if the file were rewritten
713+
std::thread::sleep(std::time::Duration::from_millis(10));
714+
715+
let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap();
716+
assert_eq!(result, expected);
717+
718+
// File mtime should be unchanged (no write happened)
719+
let new_mtime = std::fs::metadata(&key_file).unwrap().modified().unwrap();
720+
assert_eq!(
721+
original_mtime, new_mtime,
722+
"File should not be rewritten when its content already matches the keyring key"
723+
);
724+
// File content should be unchanged
725+
let synced = read_key_file(&key_file).unwrap();
726+
assert_eq!(synced, expected);
727+
}
728+
729+
#[test]
730+
#[cfg(any(target_os = "macos", target_os = "windows"))]
731+
fn keyring_backend_writes_when_file_has_stale_key() {
732+
use base64::{engine::general_purpose::STANDARD, Engine as _};
733+
let dir = tempfile::tempdir().unwrap();
734+
let key_file = dir.path().join(".encryption_key");
735+
736+
// Pre-populate the file with a DIFFERENT (stale) key
737+
let stale_key = [99u8; 32];
738+
std::fs::write(&key_file, STANDARD.encode(stale_key)).unwrap();
739+
740+
// Keyring has a different valid key
741+
let expected = [7u8; 32];
742+
assert_ne!(stale_key, expected);
743+
let mock = MockKeyring::with_password(&STANDARD.encode(expected));
744+
745+
let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap();
746+
assert_eq!(result, expected);
747+
// File should be updated to the new key
748+
let synced = read_key_file(&key_file).unwrap();
749+
assert_eq!(synced, expected, "file should be updated when stale");
750+
}
645751
#[test]
646752
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
647753
fn keyring_backend_creates_file_backup_when_missing() {

0 commit comments

Comments
 (0)