Skip to content

Commit 95faafd

Browse files
committed
feat: Implement OS keyring-backed encrypted token storage for OAuth2 credentials.
1 parent 98b44f6 commit 95faafd

9 files changed

Lines changed: 293 additions & 39 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,5 @@ ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears th
6161
## Environment Variables
6262

6363
- `GOOGLE_WORKSPACE_CLI_TOKEN` — Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading)
64-
- `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` — Path to OAuth credentials JSON (no default; if unset, falls back to encrypted then plaintext credentials in `~/.config/gws/`)
64+
- `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` — Path to OAuth credentials JSON (no default; if unset, falls back to credentials secured by the OS Keyring and encrypted in `~/.config/gws/`)
6565
- Supports `.env` files via `dotenvy`

‎Cargo.lock‎

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ derive_builder = "0.20.2"
5050
ratatui = "0.30.0"
5151
crossterm = "0.29.0"
5252
chrono = "0.4.44"
53+
keyring = "3.6.3"
54+
async-trait = "0.1.89"
5355

5456

5557
# The profile that 'cargo dist' will build with

‎README.md‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ gws schema drive.files.list
7979
# Dynamic help for any resource
8080
gws drive files --help
8181
gws drive files list --help
82+
83+
# Preview a request without sending it
84+
gws chat spaces messages create \
85+
--params '{"parent": "spaces/xyz"}' \
86+
--json '{"text": "Hello world"}' \
87+
--dry-run
8288
```
8389

8490
## Authentication
@@ -87,20 +93,18 @@ The CLI supports three primary authentication workflows depending on your enviro
8793

8894
### 1. Interactive Auth (Local Desktop)
8995

90-
For interactive use on your personal machine where a web browser is available. By default, credentials are encrypted at rest using AES-256-GCM.
96+
For interactive use on your personal machine where a web browser is available.
97+
98+
**Security**: By default, credentials and access tokens are encrypted at rest using AES-256-GCM. The encryption key is stored securely in your OS Keyring (Apple Keychain, Secret Service, or Windows Credential Manager). If a keyring is unavailable (e.g., headless Linux), it falls back to a strictly permissioned (`0600`) local key file.
9199

92-
**Google Cloud Setup:**
93-
1. Create a project: `gcloud projects create my-gws-cli`
94-
2. Enable Workspace APIs: `gcloud services enable --project my-gws-cli drive.googleapis.com ...`
95-
3. Create an OAuth consent screen at [Credentials/Consent](https://console.cloud.google.com/apis/credentials/consent).
96-
4. Create an OAuth **Desktop app** Client ID at [Credentials](https://console.cloud.google.com/apis/credentials).
100+
**Google Cloud Setup & Login:**
101+
The CLI includes a built-in setup wizard to help you configure your Google Cloud Project, enable APIs, and generate the necessary OAuth credentials. Note that this requires the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install) to be installed and authenticated (`gcloud auth login`).
97102

98-
**Login:**
99103
```bash
100-
export GOOGLE_WORKSPACE_CLI_CLIENT_ID=your_client_id.apps.googleusercontent.com
101-
export GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=your_client_secret
104+
# Run the interactive setup and login wizard
105+
gws setup
102106

103-
# Opens browser for OAuth2 consent
107+
# Or login directly if you already have client_secret.json configured
104108
gws auth login
105109

106110
# Or login with custom scopes
@@ -163,7 +167,7 @@ The CLI evaluates authentication sources in the following strict order:
163167
|----------|--------|------------|
164168
| 1 (highest) | Raw access token | `GOOGLE_WORKSPACE_CLI_TOKEN` env var |
165169
| 2 | Credentials file (user or service account) | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var |
166-
| 3 | Encrypted credentials | `~/.config/gws/credentials.enc` (created by `gws auth login`) |
170+
| 3 | Encrypted credentials & token cache | `~/.config/gws/credentials.enc` and `token_cache.json` (created by `gws auth login`, secured via OS Keyring) |
167171
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
168172
| — | No auth | Proceeds unauthenticated; shows error if the API rejects |
169173

‎src/auth.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ async fn get_token_inner(
7070
Credential::AuthorizedUser(secret) => {
7171
let token_cache = config_dir.join("token_cache.json");
7272
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret)
73-
.persist_tokens_to_disk(&token_cache)
73+
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
74+
token_cache,
75+
)))
7476
.build()
7577
.await
7678
.context("Failed to build authorized user authenticator")?;
@@ -83,8 +85,10 @@ async fn get_token_inner(
8385
}
8486
Credential::ServiceAccount(key) => {
8587
let token_cache = config_dir.join("service_account_token_cache.json");
86-
let mut builder = yup_oauth2::ServiceAccountAuthenticator::builder(key)
87-
.persist_tokens_to_disk(&token_cache);
88+
let mut builder =
89+
yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(Box::new(
90+
crate::token_storage::EncryptedTokenStorage::new(token_cache),
91+
));
8892

8993
// Check for impersonation
9094
if let Some(user) = impersonated_user {

‎src/auth_commands.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
155155
secret,
156156
yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect,
157157
)
158-
.persist_tokens_to_disk(&temp_path)
158+
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
159+
temp_path.clone(),
160+
)))
159161
.force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token
160162
.flow_delegate(Box::new(CliFlowDelegate))
161163
.build()
@@ -202,7 +204,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
202204
"status": "success",
203205
"message": "Authentication successful. Encrypted credentials saved.",
204206
"credentials_file": enc_path.display().to_string(),
205-
"encryption": "AES-256-GCM (key derived from hostname + username)",
207+
"encryption": "AES-256-GCM (key secured by OS Keyring or local `.encryption_key`)",
206208
"scopes": scopes,
207209
});
208210
println!(

‎src/credential_store.rs‎

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,111 @@ use std::path::PathBuf;
1616

1717
use aes_gcm::aead::{Aead, KeyInit, OsRng};
1818
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
19-
use sha2::{Digest, Sha256};
2019

21-
/// Derives an AES-256 key from hostname + username.
22-
fn derive_key() -> [u8; 32] {
23-
let hostname = hostname::get()
24-
.map(|h| h.to_string_lossy().to_string())
25-
.unwrap_or_else(|_| "unknown-host".to_string());
20+
use keyring::Entry;
21+
use rand::RngCore;
22+
use std::sync::OnceLock;
23+
24+
/// Returns the encryption key derived from the OS keyring, or falls back to a local file.
25+
/// Generates a random 256-bit key and stores it securely if it doesn't exist.
26+
fn get_or_create_key() -> anyhow::Result<[u8; 32]> {
27+
static KEY: OnceLock<[u8; 32]> = OnceLock::new();
28+
29+
if let Some(key) = KEY.get() {
30+
return Ok(*key);
31+
}
2632

2733
let username = std::env::var("USER")
2834
.or_else(|_| std::env::var("USERNAME"))
2935
.unwrap_or_else(|_| "unknown-user".to_string());
3036

31-
let mut hasher = Sha256::new();
32-
hasher.update(format!("gws-cli:{hostname}:{username}"));
33-
hasher.finalize().into()
37+
let entry = Entry::new("gws-cli", &username);
38+
39+
if let Ok(entry) = entry {
40+
match entry.get_password() {
41+
Ok(b64_key) => {
42+
use base64::{engine::general_purpose::STANDARD, Engine as _};
43+
if let Ok(decoded) = STANDARD.decode(&b64_key) {
44+
if decoded.len() == 32 {
45+
let mut arr = [0u8; 32];
46+
arr.copy_from_slice(&decoded);
47+
let _ = KEY.set(arr);
48+
return Ok(arr);
49+
}
50+
}
51+
}
52+
Err(keyring::Error::NoEntry) => {
53+
// Generate a random 32-byte key
54+
let mut key = [0u8; 32];
55+
rand::thread_rng().fill_bytes(&mut key);
56+
57+
use base64::{engine::general_purpose::STANDARD, Engine as _};
58+
let b64_key = STANDARD.encode(key);
59+
60+
if entry.set_password(&b64_key).is_ok() {
61+
let _ = KEY.set(key);
62+
return Ok(key);
63+
}
64+
}
65+
Err(_) => {} // Fallthrough to file storage
66+
}
67+
}
68+
69+
// Fallback: Local file `.encryption_key`
70+
let key_file = crate::auth_commands::config_dir().join(".encryption_key");
71+
if key_file.exists() {
72+
if let Ok(b64_key) = std::fs::read_to_string(&key_file) {
73+
use base64::{engine::general_purpose::STANDARD, Engine as _};
74+
if let Ok(decoded) = STANDARD.decode(b64_key.trim()) {
75+
if decoded.len() == 32 {
76+
let mut arr = [0u8; 32];
77+
arr.copy_from_slice(&decoded);
78+
let _ = KEY.set(arr);
79+
return Ok(arr);
80+
}
81+
}
82+
}
83+
}
84+
85+
// Generate new key and save to local file
86+
let mut key = [0u8; 32];
87+
rand::thread_rng().fill_bytes(&mut key);
88+
89+
use base64::{engine::general_purpose::STANDARD, Engine as _};
90+
let b64_key = STANDARD.encode(key);
91+
92+
if let Some(parent) = key_file.parent() {
93+
let _ = std::fs::create_dir_all(parent);
94+
#[cfg(unix)]
95+
{
96+
use std::os::unix::fs::PermissionsExt;
97+
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
98+
}
99+
}
100+
101+
#[cfg(unix)]
102+
{
103+
use std::os::unix::fs::OpenOptionsExt;
104+
let mut options = std::fs::OpenOptions::new();
105+
options.write(true).create(true).truncate(true).mode(0o600);
106+
if let Ok(mut file) = options.open(&key_file) {
107+
use std::io::Write;
108+
let _ = file.write_all(b64_key.as_bytes());
109+
}
110+
}
111+
#[cfg(not(unix))]
112+
{
113+
let _ = std::fs::write(&key_file, b64_key);
114+
}
115+
116+
let _ = KEY.set(key);
117+
Ok(key)
34118
}
35119

36120
/// Encrypts plaintext bytes using AES-256-GCM with a machine-derived key.
37121
/// Returns nonce (12 bytes) || ciphertext.
38122
pub fn encrypt(plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
39-
let key = derive_key();
123+
let key = get_or_create_key()?;
40124
let cipher = Aes256Gcm::new_from_slice(&key)
41125
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?;
42126

@@ -57,7 +141,7 @@ pub fn decrypt(data: &[u8]) -> anyhow::Result<Vec<u8>> {
57141
anyhow::bail!("Encrypted data too short");
58142
}
59143

60-
let key = derive_key();
144+
let key = get_or_create_key()?;
61145
let cipher = Aes256Gcm::new_from_slice(&key)
62146
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?;
63147

@@ -82,16 +166,27 @@ pub fn save_encrypted(json: &str) -> anyhow::Result<PathBuf> {
82166
let path = encrypted_credentials_path();
83167
if let Some(parent) = path.parent() {
84168
std::fs::create_dir_all(parent)?;
169+
#[cfg(unix)]
170+
{
171+
use std::os::unix::fs::PermissionsExt;
172+
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
173+
}
85174
}
86175

87176
let encrypted = encrypt(json.as_bytes())?;
88-
std::fs::write(&path, encrypted)?;
89177

90-
// Set file permissions to 600 on Unix
91178
#[cfg(unix)]
92179
{
93-
use std::os::unix::fs::PermissionsExt;
94-
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
180+
use std::os::unix::fs::OpenOptionsExt;
181+
let mut options = std::fs::OpenOptions::new();
182+
options.write(true).create(true).truncate(true).mode(0o600);
183+
let mut file = options.open(&path)?;
184+
use std::io::Write;
185+
file.write_all(&encrypted)?;
186+
}
187+
#[cfg(not(unix))]
188+
{
189+
std::fs::write(&path, encrypted)?;
95190
}
96191

97192
Ok(path)
@@ -205,15 +300,15 @@ mod tests {
205300
}
206301

207302
#[test]
208-
fn derive_key_is_deterministic() {
209-
let key1 = derive_key();
210-
let key2 = derive_key();
303+
fn get_or_create_key_is_deterministic() {
304+
let key1 = get_or_create_key().unwrap();
305+
let key2 = get_or_create_key().unwrap();
211306
assert_eq!(key1, key2);
212307
}
213308

214309
#[test]
215-
fn derive_key_produces_256_bits() {
216-
let key = derive_key();
310+
fn get_or_create_key_produces_256_bits() {
311+
let key = get_or_create_key().unwrap();
217312
assert_eq!(key.len(), 32);
218313
}
219314
}

‎src/main.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
//! interactive prompts, and integration with Model Armor.
2121
2222
mod auth;
23-
mod auth_commands;
23+
pub(crate) mod auth_commands;
2424
mod client;
2525
mod commands;
26-
mod credential_store;
26+
pub(crate) mod credential_store;
2727
mod discovery;
2828
mod error;
2929
mod executor;
@@ -35,6 +35,7 @@ mod schema;
3535
mod services;
3636
mod setup;
3737
mod setup_tui;
38+
mod token_storage;
3839

3940
use error::{print_error_json, GwsError};
4041

0 commit comments

Comments
 (0)