Skip to content

feat: system keychain credential storage with filesystem fallback#4

Open
bowlofarugula wants to merge 2 commits intomasterfrom
feat/keychain-credentials
Open

feat: system keychain credential storage with filesystem fallback#4
bowlofarugula wants to merge 2 commits intomasterfrom
feat/keychain-credentials

Conversation

@bowlofarugula
Copy link
Copy Markdown

@bowlofarugula bowlofarugula commented Apr 11, 2026

Summary

  • Stores OAuth tokens in the OS keychain (macOS Keychain, GNOME Keyring/KDE Wallet, Windows Credential Locker) via the keyring library, mirroring the gh CLI approach
  • Falls back to ~/.murl/credentials/*.json files when keyring is not installed or no secure backend is available
  • Automatic migration: existing file-based credentials are still readable, and re-saving promotes them to the keychain (cleaning up the file)
  • Install keychain support with pip install mcp-curl[keychain]

Test plan

  • Filesystem backend: roundtrip save/load, clear, clear nonexistent
  • Keyring backend: roundtrip save/load, clear
  • Migration: file creds readable after keyring enabled, re-save promotes to keychain and removes file
  • Graceful fallback: keyring set failure falls back to file storage
  • Expiry checks unchanged (expired, not expired, within buffer, missing)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added optional system keychain integration to securely store credentials in your operating system's credential manager instead of files. Enable by installing the keychain extra: pip install murl[keychain].
  • Tests

    • Expanded test coverage for credential storage backends, including fallback and migration scenarios.

Uses the keyring library to store OAuth tokens in the OS keychain
(macOS Keychain, GNOME Keyring/KDE Wallet, Windows Credential Locker),
falling back to ~/.murl/credentials/ JSON files when keyring is not
installed or no secure backend is available. Mirrors the gh CLI approach.

Install with: pip install mcp-curl[keychain]

Migration is automatic — existing file credentials are still readable,
and re-saving promotes them to the keychain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 11, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7e3e3550-2101-4c30-81b1-5f4db76e3beb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The pull request introduces a keyring-based credential storage backend to the token management system while preserving the existing filesystem backend. The implementation includes a priority-based lookup that attempts to retrieve credentials from the system keychain first, then falls back to the filesystem. The public API remains unchanged with identical function signatures. New test coverage includes filesystem and keyring-specific test classes, migration scenarios from filesystem to keychain, and fallback behavior when keyring operations fail. A new optional dependency keyring>=25.0.0 is added to support the keychain functionality.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@murl/token_store.py`:
- Around line 109-113: The _file_delete helper currently raises if path.unlink()
fails, turning a transient filesystem error into a user-visible auth failure
when credentials were already written to the keychain; change _file_delete to
perform legacy-file cleanup as best-effort by catching OSError (and subclasses
like PermissionError) around path.unlink(), suppressing the exception
(optionally logging a debug/warning via the module logger) so save_credentials()
does not fail when unlink() transiently errors; locate and update the
_file_delete function to wrap unlink() in a try/except that only swallows
filesystem-related exceptions.
- Around line 93-105: The file write in _file_set currently creates the
credential file with the process umask and only calls chmod after writing,
leaving a race where the file can be created with weaker permissions; fix by
opening the file with os.open using flags os.O_WRONLY | os.O_CREAT | os.O_TRUNC
and mode 0o600, wrap the returned fd with os.fdopen to write JSON (json.dump) so
the file is created atomically with 0600; ensure CREDENTIALS_DIR is created with
mode 0o700 (mkdir(..., mode=0o700)) and keep the existing try/except chmod
fallback for CREDENTIALS_DIR if desired, but remove reliance on a post-write
chmod of path (or keep it as a no-op safety fallback).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e23d9a64-04bf-4454-9fdc-ada90466195b

📥 Commits

Reviewing files that changed from the base of the PR and between 42f9f80 and 74a6158.

📒 Files selected for processing (3)
  • murl/token_store.py
  • pyproject.toml
  • tests/test_auth.py

- Create credential files with 0600 from the start via os.open() to
  avoid a brief window where tokens are world-readable under permissive umask
- Make _file_delete best-effort so transient unlink errors don't surface
  as auth failures when creds are already safely in the keychain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bowlofarugula
Copy link
Copy Markdown
Author

Addressed both CodeRabbit findings in 37fc9fa:

  1. Atomic file permissions — Credential files are now created with os.open(..., 0o600) from the start, eliminating the race window where tokens could briefly be world-readable under a permissive umask.
  2. Best-effort file cleanup_file_delete() now catches OSError so transient unlink failures don't surface as auth errors when creds are already safely stored in the keychain.

Copy link
Copy Markdown

@turlockmike turlockmike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two-lens review: security, test quality.

Overall: Right design direction — keychain-first with file fallback mirrors gh. The critical improvement: _file_set now uses os.open() with 0o600 from creation, eliminating the old open() + chmod() TOCTOU race. Several items to address.

Key findings:

  1. Silent security tier demotion — Keyring failures (except Exception: return None) silently fall back to file storage without informing the user. If an attacker or faulty environment causes the keychain read to throw, the code uses the weaker backend without any signal. At minimum, log a warning; ideally, distinguish "not found" from "backend error." See inline.

  2. clear_credentials doesn't guarantee deletion_keyring_delete swallows all exceptions. A user who invokes "logout" believes their token is gone; if keychain deletion silently fails, the token remains accessible. Should raise if both deletions fail. See inline.

  3. Test gaps — Missing tests for: (a) fallback round-trip (save via file when keyring fails, then get_credentials retrieves it), (b) corrupt JSON in credential file (json.JSONDecodeError handler is untested), (c) migration-on-get vs migration-on-save distinction.

Additional observations:

  • _keyring_available() called on every credential operation with no caching — creates a TOCTOU window between get and save calls where the backend can change state
  • server_url stored in plaintext inside the credential blob in both backends — not a regression from the file-only design, but worth documenting
  • _mock_keyring creates an unused MagicMock() object that adds confusion — clean up or add a comment
  • Test names (test_roundtrip, test_clear) describe the operation, not the behavioral claim — better: test_credential_stored_in_keychain_not_on_disk

What's good: The _file_set rewrite (using os.open() with 0o600 as the creation mode) is the most important fix and it's done correctly — the file is never world-readable even for a millisecond. SHA-256 of the URL as storage key avoids raw URL as keychain username. The in-memory keyring mock in tests is well-built. test_keyring_failure_falls_back_to_file and test_migration_from_file are exactly the high-value paths most PRs would omit.

— Mike's AI Clone 🧬

if data is None:
return None
return json.loads(data)
except Exception:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] Warning — Silent Security Tier Demotion

_keyring_get catches all exceptions and returns None, which get_credentials treats as "not found" and falls through to the file backend. If a keychain read throws (D-Bus disruption on Linux, corrupt entry, signal), the code silently uses the weaker backend without informing the caller.

On a system where migration is incomplete (file still exists alongside keychain), this means credentials come from the weaker backend without any signal. At minimum, log a warning when a keyring operation throws. The same pattern applies to _keyring_set.

_file_set(key, data_to_save)


def clear_credentials(server_url: str) -> None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] Warningclear_credentials Doesn't Guarantee Deletion

_keyring_delete swallows all exceptions including PasswordDeleteError. clear_credentials calls both backends and returns with no indication of whether either deletion succeeded.

A user who invokes "logout" believes their token is gone. If keychain deletion silently fails, the token remains accessible to any process that can read the keychain. At minimum, raise an exception if both deletions fail so the caller can surface an error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants