Skip to content

Commit 80bd150

Browse files
jpoehneltjpoehnelt-botgoogleworkspace-bot
authored
feat(auth): use strict OS keychain integration on macOS and Windows (#631)
* fix(auth): implement platform-aware keyring storage * chore: regenerate skills [skip ci] * fix(auth): address PR comments on error handling and TOCTOU vulnerabilities * fix(test): isolate config directory to prevent race conditions during cargo test * refactor(auth): deduplicate key generation logic in credential store * fix(test): execute credential tests serially to prevent environment pollution * fix(auth): sanitize os keyring error strings and handle missing file fallbacks This commit removes the inadvertently included skills, uses output::sanitize_for_terminal to protect the user from escape sequence injection via keyring errors, and ensures removal of the fallback file properly alerts the user when an io error occurs. * chore: regenerate skills [skip ci] --------- Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com> Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
1 parent 674d53a commit 80bd150

6 files changed

Lines changed: 318 additions & 50 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
feat(auth): use strict OS keychain integration on macOS and Windows
6+
7+
Closes #623. The CLI no longer writes a fallback `.encryption_key` text file on macOS and Windows when securely storing credentials. Instead, it strictly uses the native OS keychain (Keychain Access on macOS, Credential Manager on Windows). If an old `.encryption_key` file is found during a successful keychain login, it will be automatically deleted for security.
8+
Linux deployments continue to use a seamless file-based fallback by default to ensure maximum compatibility with headless continuous integration (CI) runners, Docker containers, and SSH environments without desktop DBUS services.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ mod tests {
750750
}
751751

752752
#[tokio::test]
753+
#[serial_test::serial]
753754
async fn test_load_credentials_encrypted_file() {
754755
// Simulate an encrypted credentials file
755756
let json = r#"{
@@ -762,6 +763,9 @@ mod tests {
762763
let dir = tempfile::tempdir().unwrap();
763764
let enc_path = dir.path().join("credentials.enc");
764765

766+
// Isolate global config dir to prevent races with other tests
767+
std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path());
768+
765769
// Encrypt and write
766770
let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap();
767771
std::fs::write(&enc_path, &encrypted).unwrap();

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

Lines changed: 190 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ fn save_key_file_exclusive(path: &std::path::Path, b64_key: &str) -> std::io::Re
6666

6767
/// Persist the base64-encoded encryption key to a local file with restrictive
6868
/// permissions (0600 file, 0700 directory). Overwrites any existing file.
69-
/// Persist the base64-encoded encryption key to a local file with restrictive
70-
/// permissions. Uses atomic_write to prevent TOCTOU/symlink race conditions.
69+
/// Uses atomic_write to prevent TOCTOU/symlink race conditions.
70+
#[allow(dead_code)]
7171
fn save_key_file(path: &std::path::Path, b64_key: &str) -> std::io::Result<()> {
7272
crate::fs_util::atomic_write(path, b64_key.as_bytes())
7373
}
@@ -202,64 +202,122 @@ fn resolve_key(
202202

203203
// --- 1. Try keyring (only when backend = Keyring) --------------------
204204
if backend == KeyringBackend::Keyring {
205-
match provider.get_password() {
206-
Ok(b64_key) => {
207-
if let Ok(decoded) = STANDARD.decode(&b64_key) {
208-
if decoded.len() == 32 {
209-
let mut arr = [0u8; 32];
210-
arr.copy_from_slice(&decoded);
211-
// Ensure file backup stays in sync with keyring so
212-
// credentials survive keyring loss (e.g. after OS
213-
// upgrades, container restarts, daemon changes).
214-
if let Err(err) = save_key_file(key_file, &b64_key) {
215-
eprintln!(
216-
"Warning: failed to sync keyring backup file at '{}': {err}",
217-
key_file.display()
218-
);
205+
#[cfg(any(target_os = "macos", target_os = "windows"))]
206+
{
207+
match provider.get_password() {
208+
Ok(b64_key) => {
209+
if let Ok(decoded) = STANDARD.decode(&b64_key) {
210+
if decoded.len() == 32 {
211+
let mut arr = [0u8; 32];
212+
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 {
217+
eprintln!(
218+
"Warning: failed to remove legacy key file at '{}': {}",
219+
key_file.display(),
220+
e
221+
);
222+
}
223+
}
224+
return Ok(arr);
219225
}
220-
return Ok(arr);
221226
}
227+
// Keyring contained invalid data — fall through to generate new.
228+
}
229+
Err(keyring::Error::NoEntry) => {
230+
// Keyring is empty — fall through to generate new.
231+
}
232+
Err(e) => {
233+
anyhow::bail!("OS keyring failed: {}. Set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file to use file storage.", sanitize_for_terminal(&e.to_string()));
222234
}
223-
// Keyring contained invalid data — fall through to file.
224235
}
225-
Err(keyring::Error::NoEntry) => {
226-
// Keyring is reachable but empty — check file, then generate.
227-
if let Some(key) = read_key_file(key_file) {
228-
// Best-effort: copy file key into keyring for future runs.
229-
let _ = provider.set_password(&STANDARD.encode(key));
230-
return Ok(key);
236+
237+
// Generate a new key if keyring was empty or contained invalid data.
238+
let key = generate_random_key();
239+
let b64_key = STANDARD.encode(key);
240+
if let Err(e) = provider.set_password(&b64_key) {
241+
anyhow::bail!(
242+
"Failed to set key in OS keyring: {}",
243+
sanitize_for_terminal(&e.to_string())
244+
);
245+
}
246+
if let Err(e) = std::fs::remove_file(key_file) {
247+
if e.kind() != std::io::ErrorKind::NotFound {
248+
eprintln!(
249+
"Warning: failed to remove legacy key file at '{}': {}",
250+
key_file.display(),
251+
e
252+
);
231253
}
254+
}
255+
return Ok(key);
256+
}
232257

233-
// Generate a new key.
234-
let key = generate_random_key();
235-
let b64_key = STANDARD.encode(key);
236-
237-
// Best-effort: store in keyring.
238-
let _ = provider.set_password(&b64_key);
239-
240-
// Atomically create the file; if another process raced us,
241-
// use their key instead.
242-
match save_key_file_exclusive(key_file, &b64_key) {
243-
Ok(()) => return Ok(key),
244-
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
245-
if let Some(winner) = read_key_file(key_file) {
246-
// Sync the winner's key into the keyring so both
247-
// backends stay consistent after the race.
248-
let _ = provider.set_password(&STANDARD.encode(winner));
249-
return Ok(winner);
258+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
259+
{
260+
// On Linux, keyring uses a mock store by default without C DBus dependencies,
261+
// so we continue to use the file fallback for reliability.
262+
match provider.get_password() {
263+
Ok(b64_key) => {
264+
if let Ok(decoded) = STANDARD.decode(&b64_key) {
265+
if decoded.len() == 32 {
266+
let mut arr = [0u8; 32];
267+
arr.copy_from_slice(&decoded);
268+
// Ensure file backup stays in sync with keyring so
269+
// credentials survive keyring loss (e.g. after OS
270+
// upgrades, container restarts, daemon changes).
271+
if let Err(err) = save_key_file(key_file, &b64_key) {
272+
eprintln!(
273+
"Warning: failed to sync keyring backup file at '{}': {err}",
274+
key_file.display()
275+
);
276+
}
277+
return Ok(arr);
250278
}
251-
// File exists but is unreadable/corrupt — overwrite.
252-
save_key_file(key_file, &b64_key)?;
279+
}
280+
// Keyring contained invalid data — fall through to file.
281+
}
282+
Err(keyring::Error::NoEntry) => {
283+
// Keyring is reachable but empty — check file, then generate.
284+
if let Some(key) = read_key_file(key_file) {
285+
// Best-effort: copy file key into keyring for future runs.
286+
let _ = provider.set_password(&STANDARD.encode(key));
253287
return Ok(key);
254288
}
255-
Err(e) => return Err(e.into()),
289+
290+
// Generate a new key.
291+
let key = generate_random_key();
292+
let b64_key = STANDARD.encode(key);
293+
294+
// Best-effort: store in keyring.
295+
let _ = provider.set_password(&b64_key);
296+
297+
// Atomically create the file; if another process raced us,
298+
// use their key instead.
299+
match save_key_file_exclusive(key_file, &b64_key) {
300+
Ok(()) => return Ok(key),
301+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
302+
if let Some(winner) = read_key_file(key_file) {
303+
// Sync the winner's key into the keyring so both
304+
// backends stay consistent after the race.
305+
let _ = provider.set_password(&STANDARD.encode(winner));
306+
return Ok(winner);
307+
}
308+
// File exists but is unreadable/corrupt — overwrite.
309+
save_key_file(key_file, &b64_key)?;
310+
return Ok(key);
311+
}
312+
Err(e) => return Err(e.into()),
313+
}
314+
}
315+
Err(e) => {
316+
eprintln!(
317+
"Warning: keyring access failed, falling back to file storage: {}",
318+
sanitize_for_terminal(&e.to_string())
319+
);
256320
}
257-
}
258-
Err(e) => {
259-
eprintln!(
260-
"Warning: keyring access failed, falling back to file storage: {}",
261-
sanitize_for_terminal(&e.to_string())
262-
);
263321
}
264322
}
265323
}
@@ -293,7 +351,11 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> {
293351
return Ok(*key);
294352
}
295353

354+
#[cfg(not(test))]
296355
let backend = KeyringBackend::from_env();
356+
#[cfg(test)]
357+
let backend = KeyringBackend::File; // Force file to avoid native keychain prompts during test execution
358+
297359
// Item 5: log which backend was selected
298360
eprintln!("Using keyring backend: {}", backend.as_str());
299361

@@ -407,6 +469,8 @@ pub fn load_encrypted() -> anyhow::Result<String> {
407469

408470
#[cfg(test)]
409471
mod tests {
472+
#![allow(dead_code)]
473+
410474
use super::*;
411475
use std::cell::RefCell;
412476

@@ -513,7 +577,73 @@ mod tests {
513577
assert_eq!(result, expected);
514578
}
515579

580+
// ---- Backend::Keyring tests (macOS/Windows specific behavior) ----
581+
582+
#[test]
583+
#[cfg(any(target_os = "macos", target_os = "windows"))]
584+
fn keyring_backend_cleans_up_legacy_file_on_success() {
585+
use base64::{engine::general_purpose::STANDARD, Engine as _};
586+
let dir = tempfile::tempdir().unwrap();
587+
let key_file = dir.path().join(".encryption_key");
588+
589+
// Create a legacy fallback file
590+
std::fs::write(&key_file, STANDARD.encode([99u8; 32])).unwrap();
591+
assert!(key_file.exists());
592+
593+
// Keyring has a valid key
594+
let expected = [7u8; 32];
595+
let mock = MockKeyring::with_password(&STANDARD.encode(expected));
596+
597+
let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap();
598+
599+
assert_eq!(result, expected);
600+
assert!(
601+
!key_file.exists(),
602+
"Legacy file must be deleted upon successful keyring read"
603+
);
604+
}
605+
606+
#[test]
607+
#[cfg(any(target_os = "macos", target_os = "windows"))]
608+
fn keyring_backend_cleans_up_legacy_file_on_generation() {
609+
let dir = tempfile::tempdir().unwrap();
610+
let key_file = dir.path().join(".encryption_key");
611+
612+
std::fs::write(&key_file, "legacy-data").unwrap();
613+
assert!(key_file.exists());
614+
615+
let mock = MockKeyring::no_entry();
616+
617+
let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap();
618+
619+
assert_eq!(result.len(), 32);
620+
assert!(
621+
!key_file.exists(),
622+
"Legacy file must be deleted upon successful keyring generation"
623+
);
624+
}
625+
626+
#[test]
627+
#[cfg(any(target_os = "macos", target_os = "windows"))]
628+
fn keyring_backend_returns_error_on_platform_failure() {
629+
let dir = tempfile::tempdir().unwrap();
630+
let key_file = dir.path().join(".encryption_key");
631+
632+
let mock = MockKeyring::platform_error();
633+
634+
let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file);
635+
636+
assert!(result.is_err());
637+
assert!(result
638+
.unwrap_err()
639+
.to_string()
640+
.contains("OS keyring failed"));
641+
}
642+
643+
// ---- Backend::Keyring tests (Linux fallback behavior) ----
644+
516645
#[test]
646+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
517647
fn keyring_backend_creates_file_backup_when_missing() {
518648
use base64::{engine::general_purpose::STANDARD, Engine as _};
519649
let dir = tempfile::tempdir().unwrap();
@@ -535,6 +665,7 @@ mod tests {
535665
}
536666

537667
#[test]
668+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
538669
fn keyring_backend_syncs_file_when_keyring_differs() {
539670
use base64::{engine::general_purpose::STANDARD, Engine as _};
540671
let dir = tempfile::tempdir().unwrap();
@@ -554,6 +685,7 @@ mod tests {
554685
}
555686

556687
#[test]
688+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
557689
fn keyring_backend_no_entry_reads_file() {
558690
let dir = tempfile::tempdir().unwrap();
559691
let (expected, key_file) = write_test_key(dir.path());
@@ -568,6 +700,7 @@ mod tests {
568700
}
569701

570702
#[test]
703+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
571704
fn keyring_backend_no_entry_no_file_generates_and_saves_both() {
572705
let dir = tempfile::tempdir().unwrap();
573706
let key_file = dir.path().join(".encryption_key");
@@ -581,6 +714,7 @@ mod tests {
581714
}
582715

583716
#[test]
717+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
584718
fn keyring_backend_no_entry_no_file_keyring_set_fails() {
585719
let dir = tempfile::tempdir().unwrap();
586720
let key_file = dir.path().join(".encryption_key");
@@ -593,6 +727,7 @@ mod tests {
593727
}
594728

595729
#[test]
730+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
596731
fn keyring_backend_platform_error_falls_back_to_file() {
597732
let dir = tempfile::tempdir().unwrap();
598733
let (expected, key_file) = write_test_key(dir.path());
@@ -602,6 +737,7 @@ mod tests {
602737
}
603738

604739
#[test]
740+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
605741
fn keyring_backend_platform_error_no_file_generates() {
606742
let dir = tempfile::tempdir().unwrap();
607743
let key_file = dir.path().join(".encryption_key");
@@ -612,6 +748,7 @@ mod tests {
612748
}
613749

614750
#[test]
751+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
615752
fn keyring_backend_invalid_keyring_data_uses_file() {
616753
use base64::{engine::general_purpose::STANDARD, Engine as _};
617754
let dir = tempfile::tempdir().unwrap();
@@ -660,6 +797,7 @@ mod tests {
660797
// ---- Stability tests ----
661798

662799
#[test]
800+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
663801
fn key_is_stable_across_calls() {
664802
let dir = tempfile::tempdir().unwrap();
665803
let key_file = dir.path().join(".encryption_key");
@@ -858,6 +996,7 @@ mod tests {
858996
// ---- Race condition tests ----
859997

860998
#[test]
999+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
8611000
fn race_loser_syncs_winner_key_to_keyring() {
8621001
use base64::{engine::general_purpose::STANDARD, Engine as _};
8631002
let dir = tempfile::tempdir().unwrap();
@@ -881,6 +1020,7 @@ mod tests {
8811020
}
8821021

8831022
#[test]
1023+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
8841024
fn race_loser_corrupt_file_overwrites() {
8851025
let dir = tempfile::tempdir().unwrap();
8861026
let key_file = dir.path().join(".encryption_key");

docs/skills.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Core Google Workspace API skills.
2626
| [gws-events](../skills/gws-events/SKILL.md) | Subscribe to Google Workspace events. |
2727
| [gws-modelarmor](../skills/gws-modelarmor/SKILL.md) | Google Model Armor: Filter user-generated content for safety. |
2828
| [gws-workflow](../skills/gws-workflow/SKILL.md) | Google Workflow: Cross-service productivity workflows. |
29+
| [gws-script](../skills/gws-script/SKILL.md) | Manage Google Apps Script projects. |
2930

3031
## Helpers
3132

@@ -57,6 +58,7 @@ Shortcut commands for common operations.
5758
| [gws-workflow-email-to-task](../skills/gws-workflow-email-to-task/SKILL.md) | Google Workflow: Convert a Gmail message into a Google Tasks entry. |
5859
| [gws-workflow-weekly-digest](../skills/gws-workflow-weekly-digest/SKILL.md) | Google Workflow: Weekly summary: this week's meetings + unread email count. |
5960
| [gws-workflow-file-announce](../skills/gws-workflow-file-announce/SKILL.md) | Google Workflow: Announce a Drive file in a Chat space. |
61+
| [gws-script-push](../skills/gws-script-push/SKILL.md) | Google Apps Script: Upload local files to an Apps Script project. |
6062

6163
## Personas
6264

0 commit comments

Comments
 (0)