Skip to content

Bug: Encryption key lost during keyring migration on macOS #375

@jzumwalt

Description

@jzumwalt

Summary

get_or_create_key() in credential_store.rs deletes the file-based encryption key after "migrating" it to the OS keyring, but the keyring entry is not readable by subsequent processes. This causes all encrypted credentials to become permanently undecryptable until the user discovers the workaround.

Affected versions

  • gws 0.9.1 (npm-installed binary, ad-hoc signed)
  • macOS (Darwin 24.5.0, Apple Silicon)

Symptoms

gws drive files list --params '{"pageSize": 10}'
{
  "error": {
    "code": 401,
    "message": "Authentication failed: Failed to decrypt credentials: Decryption failed. Credentials may have been created on a different machine. Run `gws auth logout` and `gws auth login` to re-authenticate.",
    "reason": "authError"
  }
}
  • gws auth login succeeds, but any subsequent command fails with the above error
  • gws auth status shows encryption_valid: false
  • Running gws auth logout + gws auth login does not fix the issue (it recreates the same broken cycle)

Root cause

In src/credential_store.rs lines 104-115, when the keyring has no entry but a .encryption_key file exists:

// If keyring is empty, prefer a persisted local key first.
if key_file.exists() {
    if let Ok(b64_key) = std::fs::read_to_string(&key_file) {
        if let Ok(decoded) = STANDARD.decode(b64_key.trim()) {
            if decoded.len() == 32 {
                let mut arr = [0u8; 32];
                arr.copy_from_slice(&decoded);
                // Migrate file key into keyring; remove the
                // file if the keyring store succeeds.
                if entry.set_password(b64_key.trim()).is_ok() {
                    let _ = std::fs::remove_file(&key_file);  // <-- deletes the only working copy
                }
                return Ok(cache_key(arr));
            }
        }
    }
}

The problem:

  1. entry.set_password() returns Ok(()) — the write appears to succeed
  2. The file is deleted because set_password returned Ok
  3. The key lives in the OnceLock for the remainder of this process (login works)
  4. On the next process invocation, entry.get_password() fails to find the keyring entry (likely due to macOS Security framework ACL restrictions on ad-hoc signed binaries)
  5. The file is gone, so a new random key is generated
  6. The new key cannot decrypt the existing credentials.enc

This creates an unrecoverable loop: every login encrypts with a key that is lost when the process exits.

Reproduction steps

  1. Install gws via npm on macOS
  2. Run gws auth login --account user@example.com — succeeds
  3. Run gws drive files list --params '{"pageSize": 10}' — fails with 401 decryption error
  4. Observe that ~/.config/gws/.encryption_key does not exist
  5. Observe that security find-generic-password -s "gws-cli" -a "$USER" -w returns "item not found"

Workaround

Create the encryption key file and make it immutable so the migration cannot delete it:

gws auth logout
python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())" > ~/.config/gws/.encryption_key
chmod 600 ~/.config/gws/.encryption_key
chflags uchg ~/.config/gws/.encryption_key
gws auth login --account user@example.com

Suggested fix

Verify the key is actually readable from the keyring before deleting the file:

if entry.set_password(b64_key.trim()).is_ok() {
    // Verify the roundtrip before removing the file fallback
    if entry.get_password().is_ok() {
        let _ = std::fs::remove_file(&key_file);
    }
}

The same pattern should be applied to the new-key generation path (lines 127-131) where set_password success skips the file fallback entirely — it should also verify readability before relying on keyring-only storage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions