Skip to content

Add an opt-in OS credential store for gem and bundler credentials#9671

Open
hsbt wants to merge 23 commits into
masterfrom
claude/relaxed-chatterjee-5fedd7
Open

Add an opt-in OS credential store for gem and bundler credentials#9671
hsbt wants to merge 23 commits into
masterfrom
claude/relaxed-chatterjee-5fedd7

Conversation

@hsbt

@hsbt hsbt commented Jul 2, 2026

Copy link
Copy Markdown
Member

RubyGems keeps the push API key in ~/.gem/credentials and 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, the security command on macOS, secret-tool on Linux, and the Windows Credential Manager through PowerShell. Secrets are passed over stdin or the environment so they never appear in argv. 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: true in ~/.gemrc or export RUBYGEMS_CREDENTIAL_STORE=true. For Bundler it is the credential_store setting (BUNDLE_CREDENTIAL_STORE).

$ gem signin                                         # API key goes to the OS store, not ~/.gem/credentials
$ bundle config set credential_store true
$ bundle config set rubygems.example.com user:pass   # stored in the OS store, read back at auth time

Existing plain-text credentials are not migrated automatically. Re-run gem signin or bundle 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_store to 1password requires rubygems/credential_store/backends/1password, which registers itself:

# lib/rubygems/credential_store/backends/1password.rb, shipped in a third-party gem
class OnePasswordBackend
  def get(service, account) = ...
  def set(service, account, secret) = ...
  def delete(service, account) = ...
  def delete_all(service) = ...
end

Gem::CredentialStore.register_backend("1password", OnePasswordBackend.new)

Backend names are restricted to /\A[a-z0-9_-]+\z/ and only ever feed that fixed require prefix, 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.

hsbt and others added 16 commits July 2, 2026 10:20
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>
Copilot AI review requested due to automatic review settings July 2, 2026 03:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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::CredentialStore with native backends for macOS (Keychain), Linux (Secret Service via secret-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:pass credentials 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_store enabled unset_api_key! only deletes the default rubygems_api_key entry 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('"', '\\"')}")
hsbt and others added 7 commits July 2, 2026 12:30
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>
@nekketsuuu

Copy link
Copy Markdown

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 1password).

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, get would involve calling the AWS API on the spot and delete could be a no-op (or call the default backend), but I am unsure how to handle set. Since returning an error causes a fallback to plain text, the implementation would likely involve either making it a no-op or ensuring that explicitly set credentials take precedence.

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.

3 participants