Add an opt-in OS credential store for gem and bundler credentials#9671
Add an opt-in OS credential store for gem and bundler credentials#9671hsbt wants to merge 23 commits into
Conversation
An opt-in facade for storing authentication secrets (API keys, host credentials) in a platform credential store instead of a plain text file. Every operation traps its own errors and returns nil/false so callers can fall back to file storage, and get() results are memoized per process since the platform backends landing next all shell out. No platform backend is registered yet; #default_backend returns nil until macOS, Linux, and Windows support land in follow-up commits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shells out to /usr/bin/security. Reads never put the secret on argv (the security CLI's own -w flag takes it there safely), but writes have no such option for add-generic-password, so #set drives security -i (batch mode) and writes a single tokenized command line to its stdin instead, keeping the secret out of ps output. A secret containing a literal newline can't be represented in that batch syntax and is rejected up front. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shells out to secret-tool from libsecret (GNOME Keyring, KWallet, ...). Unlike security on macOS, secret-tool store reads the secret from stdin natively for both reads and writes, so no batch-command workaround is needed. #available? scans PATH directly with File.executable? rather than shelling out to `which`, since a headless session without a keyring daemon still has secret-tool installed but every call to it fails at runtime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Drives Windows.Security.Credentials.PasswordVault from PowerShell, using powershell.exe (Windows PowerShell 5.1) rather than pwsh since the WinRT projection this relies on isn't reliably available there. Account, service, and secret are passed as environment variables and read back with $env:RUBYGEMS_CRED_* inside the script, so nothing needs escaping and nothing can break out of the script through quoting. PasswordVault has no overwrite-in-place semantics, so #set removes any existing entry before adding the new one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a :credential_store: gemrc option (also RUBYGEMS_CREDENTIAL_STORE) that routes the API key reads, writes, and removal in ConfigFile to the OS credential store, falling back to ~/.gem/credentials whenever the store is disabled or fails. credential_store_signed_in? lets callers detect a key that lives only in the store, with no credentials file present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
api_key now checks the credential store between the GEM_HOST_API_KEY environment variable and the credentials file, so gem push and friends authenticate with a stored key without any file present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
signout now removes the API key from the credential store as well as the credentials file, and recognizes a store-only session so it no longer reports "not currently signed in" when only the store holds the key. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
With the credential_store setting on, bundle config set for a host credential writes user:password to the OS credential store instead of the plain text config file, credentials_for reads it back ahead of the file, and unset removes it. Non-credential keys and the gem.push_key path are untouched, and with the setting off credentials_for adds no subprocess so bundle exec stays unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a registry so the credential store can use a backend other than this platform's native one. Gem::CredentialStore.for accepts either true, still meaning the native backend, or a backend name such as "1password", which is resolved by requiring rubygems/credential_store/backends/<name> and reading what that file registered. A third party can therefore ship a backend as its own gem that RubyGems loads only when the name is actually selected. Names are restricted to a small charset and only ever feed a fixed require prefix, so the setting stays data and never becomes a path or a command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The :credential_store: gemrc option and RUBYGEMS_CREDENTIAL_STORE now take a backend name in addition to true and false, so gem push and signin can store their key in a third-party backend such as 1password rather than the native one. true still means the native backend and false still keeps the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move credential_store from the boolean keys to the string keys so its value survives as text, and interpret it as true for the native backend or a backend name such as 1password. This lets a bundle authenticate to a private source through a third-party backend the same way gem does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps the backends directory exclusively for third-party backends selected by name, so a credential_store value from configuration can only ever require a file under backends. RubyGems' own platform backends now live in native and are reached only through require_relative from default_backend, which no configuration string can redirect. The short native/macos names also mirror the backends/<name> layout instead of the odd macos_backend spelling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The credential_store path stored and looked up host credentials under the raw key, so a value set as "https://host/" was not found when the source URI was "https://host", even though the config file path matches them. Run both the store write and the read through key_for, the same normalization self[] already applies, so keychain lookups behave identically to file lookups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an opt-in shared credential store for RubyGems and Bundler, allowing secrets (RubyGems API keys and Bundler host credentials) to be stored in the OS-native credential manager instead of plaintext files, with transparent fallback to existing file-based behavior when unavailable.
Changes:
- Add
Gem::CredentialStorewith native backends for macOS (Keychain), Linux (Secret Service viasecret-tool), and Windows (Credential Manager via PowerShell). - Wire RubyGems commands/config to read/write API keys via the credential store when enabled (precedence over credentials file).
- Wire Bundler settings to read/write host
user:passcredentials via the credential store when enabled, and document the new setting.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/rubygems/test_gem_gemcutter_utilities.rb | Adds tests asserting credential-store API key precedence and fallback behavior for gem push. |
| test/rubygems/test_gem_credential_store.rb | New unit tests for Gem::CredentialStore behavior (memoization, safe no-ops, warnings, backend resolution). |
| test/rubygems/test_gem_credential_store_windows_backend.rb | New tests verifying Windows backend behavior and that secrets are not passed via argv. |
| test/rubygems/test_gem_credential_store_macos_backend.rb | New tests for macOS Keychain backend behavior and stdin-based secret passing. |
| test/rubygems/test_gem_credential_store_linux_backend.rb | New tests for Linux secret-tool backend availability detection and stdin handling. |
| test/rubygems/test_gem_config_file.rb | Adds tests for :credential_store: config/env parsing and credential-store routing/fallback for API keys. |
| test/rubygems/test_gem_commands_signout_command.rb | Adds coverage for signing out when only a credential-store key exists. |
| test/rubygems/helper.rb | Adds with_fake_credential_store helper for RubyGems tests. |
| test/rubygems/fake_credential_backend.rb | New in-memory backend for RubyGems test suite. |
| spec/support/fake_credential_backend.rb | New in-memory backend for Bundler specs. |
| spec/bundler/settings_spec.rb | Adds specs covering Bundler credential_store behavior for storing/reading/unsetting host credentials. |
| Manifest.txt | Adds new credential store files to the manifest. |
| lib/rubygems/gemcutter_utilities.rb | Prefers credential-store API keys over file keys when enabled. |
| lib/rubygems/credential_store/native/windows.rb | Implements Windows Credential Manager backend via PowerShell + env vars. |
| lib/rubygems/credential_store/native/macos.rb | Implements macOS Keychain backend via security (including -i mode for writes). |
| lib/rubygems/credential_store/native/linux.rb | Implements Linux Secret Service backend via secret-tool with availability detection. |
| lib/rubygems/credential_store.rb | Adds core credential store API, backend loading/validation, caching, and error swallowing/warnings. |
| lib/rubygems/config_file.rb | Adds credential_store setting, routes API key reads/writes/unset to the store when enabled. |
| lib/rubygems/commands/signout_command.rb | Updates signout to consider credential-store sign-in state and remove stored key on signout. |
| lib/rubygems/commands/signin_command.rb | Documents that signin will store API key in the credential store when enabled. |
| lib/rubygems/commands/push_command.rb | Documents credential store behavior for push authentication. |
| lib/bundler/settings.rb | Adds Bundler credential_store setting and routes host credential storage/read through Gem::CredentialStore. |
| lib/bundler/man/bundle-config.1.ronn | Documents credential_store / BUNDLE_CREDENTIAL_STORE. |
| lib/bundler/man/bundle-config.1 | Generated manpage update for the new credential_store setting. |
Comments suppressed due to low confidence (1)
lib/rubygems/commands/signout_command.rb:33
- The success message claims "signed out from all sessions", but with
credential_storeenabledunset_api_key!only deletes the defaultrubygems_api_keyentry from the credential store (and cannot remove other per-host entries). Consider making the message accurate so users don’t assume all stored credentials were removed.
else
Gem.configuration.unset_api_key!
say "You have successfully signed out from all sessions."
end
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @ipv4_fallback_enabled = @hash[:ipv4_fallback_enabled] if @hash.key? :ipv4_fallback_enabled | ||
| @global_gem_cache = @hash[:global_gem_cache] if @hash.key? :global_gem_cache | ||
| @use_psych = @hash[:use_psych] if @hash.key? :use_psych | ||
| @credential_store = @hash[:credential_store] if @hash.key? :credential_store |
| end | ||
|
|
||
| def self.quote(value) | ||
| %("#{value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')}") |
Bundler can run on an older RubyGems than it shipped with, one that does not provide rubygems/credential_store. Requiring it unconditionally made the credential_store setting raise on those pairs. Guard the require so an unsupported RubyGems warns once and keeps reading and writing the config file as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
signout deletes the credentials file and the default RubyGems.org key, but a key saved for another host with `gem signin --host` stays in the store because the native backends expose no way to enumerate entries. Say so in the help instead of implying every session is cleared. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
security find-generic-password -w prints any non-printable byte back as a hex string, so a non-ASCII secret would be read back corrupted, and a newline in the batched security -i command would start a second command. Verified against a throwaway keychain that printable ASCII secrets with spaces, quotes, backslashes, and shell metacharacters round-trip intact while a non-ASCII secret comes back as hex. Limit the secret to printable ASCII and refuse a newline in the account or service so #set fails cleanly and the caller falls back to file storage instead of storing an unreadable value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a credential moves into the store, its plaintext copy is now removed from the config file (both the bundler config and ~/.gem/credentials) instead of being left behind, so enabling the store actually gets the secret off disk. When the store write does not take, the credential still falls back to the file as before, but now with a warning so the user knows it landed in plain text rather than the store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Point the missing-backend warning at the gem that must be installed to provide it. Spell out in gem push help the order the API key is resolved from. Note in bundle-config(1) that existing plaintext credentials are not migrated automatically and that the experimental setting may still change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gem signin --host stores a per-host key, but the store is shared with Bundler under one service name, so signout could not just wipe everything without taking Bundler's credentials with it. Give Bundler its own service namespace, add delete_all to remove one service's entries (a delete-by- service loop on macOS, secret-tool clear on Linux, FindAllByResource on Windows), and have signout clear the whole RubyGems service instead of only the default account. It now reports signing out of every registry, including RubyGems.org, so a user who used --host is not left with a key they cannot see. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The config file test referenced Gem::CredentialStore before anything required it, which only worked when another test loaded the constant first, so it failed under ruby-core's test-all where the load order differs. Require it directly. The macOS backend test expected Open3 to raise Errno::ENOENT for a missing security binary, but JRuby reports a failure status instead of raising, so assert only that no credential comes back either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
When using a third-party backend gem in conjunction with Bundler, how is the third-party gem itself expected to be installed? Is it intended to be installed as an individual gem without being managed by Bundler? Also, what would be the best way to handle falling back from a third-party gem to the default backend? I am wondering how to implement a process where only specific credentials are handled by the custom backend, while the rest are passed through to the default backend (or any other backend like For example, in the use case at #8501 (comment), I want to handle only the credentials for the AWS CodeArtifact source myself and delegate the rest to the default backend. Furthermore, in the case of CodeArtifact credentials, |
RubyGems keeps the push API key in
~/.gem/credentialsand Bundler keeps host credentials in.bundle/config, both as plain text on disk. This adds an opt-in credential store, shared by both, that keeps those secrets in the operating system's native store instead. It shells out to the platform's own tools with no new dependency or C extension, thesecuritycommand on macOS,secret-toolon Linux, and the Windows Credential Manager through PowerShell. Secrets are passed over stdin or the environment so they never appear inargv. Every operation falls back to the existing file storage with a warning when the native store is missing, locked, or otherwise fails.Nothing changes unless you turn it on. For
gem, set:credential_store: truein~/.gemrcor exportRUBYGEMS_CREDENTIAL_STORE=true. For Bundler it is thecredential_storesetting (BUNDLE_CREDENTIAL_STORE).Existing plain-text credentials are not migrated automatically. Re-run
gem signinorbundle config set <host> <user:pass>with the setting enabled to move each one into the store.The setting also accepts a backend name so a third party can ship an alternative store as its own gem, loaded only when selected. Setting
credential_storeto1passwordrequiresrubygems/credential_store/backends/1password, which registers itself:Backend names are restricted to
/\A[a-z0-9_-]+\z/and only ever feed that fixedrequireprefix, so a value from configuration stays data and never becomes a path or a command. RubyGems' own platform backends live outside the directory a name can reach.This is the first, fully opt-in step. Making the store the default and dropping the dual writes are deliberately left for later changes.