Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
113437c
Add the Gem::CredentialStore wrapper
hsbt Jul 2, 2026
628837e
Add the macOS Keychain backend
hsbt Jul 2, 2026
d72845b
Add the Linux Secret Service backend
hsbt Jul 2, 2026
06ee029
Add the Windows Credential Manager backend
hsbt Jul 2, 2026
0c093cc
Store gem credentials through the credential store in Gem::ConfigFile
hsbt Jul 2, 2026
d922bc0
Read the gem API key from the credential store when enabled
hsbt Jul 2, 2026
376e013
Clear the credential store on gem signout
hsbt Jul 2, 2026
42a0433
Note credential store use in gem signin and push help
hsbt Jul 2, 2026
2e41685
Store bundler host credentials in the credential store when enabled
hsbt Jul 2, 2026
4916753
Document the credential_store setting in bundle-config(1)
hsbt Jul 2, 2026
f29390c
Support pluggable credential store backends by name
hsbt Jul 2, 2026
dc94ee9
Accept a credential store backend name in Gem::ConfigFile
hsbt Jul 2, 2026
6448618
Accept a credential store backend name in bundler settings
hsbt Jul 2, 2026
e837418
Document selecting a credential store backend by name
hsbt Jul 2, 2026
98f0bda
Move the native backends under credential_store/native
hsbt Jul 2, 2026
e422eb9
Normalize credential_store keys like the bundler config file
hsbt Jul 2, 2026
0525b26
Fall back when the paired RubyGems has no credential store
hsbt Jul 2, 2026
6bb372e
Spell out what gem signout removes from the credential store
hsbt Jul 2, 2026
8a6a58c
Reject macOS keychain secrets the security CLI cannot round-trip
hsbt Jul 2, 2026
baa0dd1
Keep the config file and the credential store consistent on write
hsbt Jul 2, 2026
b307d4c
Explain credential store selection, migration, and API key precedence
hsbt Jul 2, 2026
0c0b1b1
Clear every registry from the credential store on gem signout
hsbt Jul 2, 2026
481355e
Fix credential store tests under ruby-core and JRuby
hsbt Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@ lib/rubygems/core_ext/kernel_gem.rb
lib/rubygems/core_ext/kernel_require.rb
lib/rubygems/core_ext/kernel_warn.rb
lib/rubygems/core_ext/tcpsocket_init.rb
lib/rubygems/credential_store.rb
lib/rubygems/credential_store/native/linux.rb
lib/rubygems/credential_store/native/macos.rb
lib/rubygems/credential_store/native/windows.rb
lib/rubygems/defaults.rb
lib/rubygems/dependency.rb
lib/rubygems/dependency_installer.rb
Expand Down
2 changes: 2 additions & 0 deletions lib/bundler/man/bundle-config.1
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ The CLI flag and this setting apply uniformly to every source, including ones de
.IP
Cooldown filtering depends on the gem server providing a per\-version \fBcreated_at\fR timestamp in the v2 compact\-index format\. Versions without that metadata \- older gem servers, historical entries that predate the v2 cutover on \fBrubygems\.org\fR, or private registries that still emit the v1 format \- are treated as outside the cooldown window and remain resolvable\. If you rely on cooldown for supply\-chain protection, confirm that the gem server emits \fBcreated_at\fR in its \fB/info/<gem>\fR responses\.
.IP "\(bu" 4
\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set <host> <user:pass>\fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails, warning that the credential was written in plain text\. Defaults to false\. Credentials already written to the config file are not migrated automatically; re\-run \fBbundle config set <host> <user:pass>\fR with the setting enabled to move each one into the store\. Being experimental, the name and behavior of this setting may change in a future release\.
.IP "\(bu" 4
\fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR): The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\.
.IP "\(bu" 4
\fBdeployment\fR (\fBBUNDLE_DEPLOYMENT\fR): Equivalent to setting \fBfrozen\fR to \fBtrue\fR and \fBpath\fR to \fBvendor/bundle\fR\.
Expand Down
13 changes: 13 additions & 0 deletions lib/bundler/man/bundle-config.1.ronn
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html).
window and remain resolvable. If you rely on cooldown for
supply-chain protection, confirm that the gem server emits
`created_at` in its `/info/<gem>` responses.
* `credential_store` (`BUNDLE_CREDENTIAL_STORE`):
Experimental: store and read host credentials (the values otherwise set
via `bundle config set <host> <user:pass>`) in a credential store instead
of the plain text config file. Set it to `true` to use the operating
system's native store (macOS Keychain, Linux Secret Service, Windows
Credential Manager) when one is available on this platform, or to the name
of a backend provided by a third-party gem, such as `1password`. Falls
back to the config file when the store is unavailable or fails, warning
that the credential was written in plain text. Defaults to false.
Credentials already written to the config file are not migrated
automatically; re-run `bundle config set <host> <user:pass>` with the
setting enabled to move each one into the store. Being experimental, the
name and behavior of this setting may change in a future release.
* `default_cli_command` (`BUNDLE_DEFAULT_CLI_COMMAND`):
The command that running `bundle` without arguments should run. Defaults to
`cli_help` since Bundler 4, but can also be `install` which was the previous
Expand Down
94 changes: 91 additions & 3 deletions lib/bundler/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Settings
bin
cache_path
console
credential_store
default_cli_command
gem.ci
gem.github_username
Expand Down Expand Up @@ -185,7 +186,7 @@ def mirror_for(uri)
end

def credentials_for(uri)
self[uri.to_s] || self[uri.host]
credentials_from_store(uri) || self[uri.to_s] || self[uri.host]
end

def gem_mirrors
Expand Down Expand Up @@ -389,6 +390,79 @@ def is_userinfo(value)
value.include?(":")
end

##
# The Gem::CredentialStore instance to use, or nil when the
# `credential_store` setting is off. The value is `true`/`"true"` for this
# platform's native backend or a backend name such as `"1password"`.
# Guarded by a cheap lookup so reading and writing settings costs nothing
# extra when the setting is disabled.

# The account namespace Bundler uses in the shared native store, kept
# separate from RubyGems so that gem signout does not remove Bundler's
# host credentials.
CREDENTIAL_STORE_SERVICE = "bundler"

def active_credential_store
spec = credential_store_spec
return nil unless spec

store_class = credential_store_class
return nil unless store_class

store_class.for(spec, service: CREDENTIAL_STORE_SERVICE)
end

def credential_store_spec
case value = self[:credential_store]
when nil, "", "false", false then nil
when "true", true then true
else value.to_s
end
end

# The Gem::CredentialStore class, or nil when the paired RubyGems is too
# old to ship one. In that case the setting is honored as a no-op with a
# one-time warning so a bundle keeps using the config file instead of
# raising. Bundler can run on an older RubyGems than it was released with.
def credential_store_class
return @credential_store_class if defined?(@credential_store_class)

@credential_store_class =
begin
require "rubygems/credential_store"
Gem::CredentialStore if Gem::CredentialStore.respond_to?(:for)
rescue LoadError
nil
end

if @credential_store_class.nil?
Bundler.ui.warn "The `credential_store` setting is set but this RubyGems does not provide a credential store. Falling back to the Bundler config file."
end

@credential_store_class
end

##
# True for keys that aren't reserved for a known bool/number/array/
# string setting (or the gem.push_key signing key path), i.e. keys
# that are eligible to be a host credential like the ones set via
# `bundle config set gems.example.com user:pass`. This mirrors the
# heuristic #printable_value already uses to decide whether to redact
# a value for display.

def credential_store_key?(raw_key)
!(is_bool(raw_key) || is_num(raw_key) || is_array(raw_key) || is_string(raw_key) || is_credential(raw_key))
end

def credentials_from_store(uri)
return nil unless store = active_credential_store

# Normalize with key_for so a value stored under, say,
# "https://host/" is found when the source URI is "https://host",
# exactly as the config-file path already matches via self[].
store.get(key_for(uri.to_s)) || store.get(key_for(uri.host))
end

def to_array(value)
return [] unless value
value.tr(" ", ":").split(":").map(&:to_sym)
Expand All @@ -402,10 +476,24 @@ def array_to_s(array)

def set_key(raw_key, value, hash, file)
raw_key = self.class.key_to_s(raw_key)
value = array_to_s(value) if is_array(raw_key)

key = key_for(raw_key)

if (store = active_credential_store) && credential_store_key?(raw_key)
if value.nil?
store.delete(key)
elsif value.is_a?(String) && is_userinfo(value)
if store.set(key, value)
# Stored in the credential store, so drop any plaintext copy left
# in this config file by falling through with a nil value.
value = nil
else
Bundler.ui.warn "Could not write the credential to the credential store, so it was written to the Bundler config file in plain text."
end
end
end

value = array_to_s(value) if is_array(raw_key)

return if hash[key] == value

hash[key] = value
Expand Down
4 changes: 3 additions & 1 deletion lib/rubygems/commands/push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def description # :nodoc:
The gem can be removed from the index and deleted from the server using the yank
command. For further discussion see the help for the yank command.

The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate.
The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the API key is stored in and read from the credential store it selects instead of ~/.gem/credentials.

The API key to send is resolved in this order: the GEM_HOST_API_KEY environment variable, the --key option, the credential store (when :credential_store: is set), then ~/.gem/credentials. The first one found is used.
EOF
end

Expand Down
4 changes: 3 additions & 1 deletion lib/rubygems/commands/signin_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ def description # :nodoc:
"The signin command executes host sign in for a push server (the default is"\
" https://rubygems.org). The host can be provided with the host flag or can"\
" be inferred from the provided gem. Host resolution matches the resolution"\
" strategy for the push command."
" strategy for the push command. If the :credential_store: gemrc option (or"\
" RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the resulting API key is"\
" stored in the credential store it selects instead of ~/.gem/credentials."
end

def usage # :nodoc:
Expand Down
12 changes: 8 additions & 4 deletions lib/rubygems/commands/signout_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ def initialize

def description # :nodoc:
"The `signout` command is used to sign out from all current sessions,"\
" allowing you to sign in using a different set of credentials."
" allowing you to sign in using a different set of credentials. It removes"\
" the ~/.gem/credentials file. If the :credential_store: gemrc option is"\
" set, it also removes every RubyGems key from the credential store,"\
" including keys saved for other hosts with `gem signin --host`."
end

def usage # :nodoc:
Expand All @@ -18,15 +21,16 @@ def usage # :nodoc:

def execute
credentials_path = Gem.configuration.credentials_path
credentials_file_exists = File.exist?(credentials_path)

if !File.exist?(credentials_path)
if !credentials_file_exists && !Gem.configuration.credential_store
alert_error "You are not currently signed in."
elsif !File.writable?(credentials_path)
elsif credentials_file_exists && !File.writable?(credentials_path)
alert_error "File '#{Gem.configuration.credentials_path}' is read-only."\
" Please make sure it is writable."
else
Gem.configuration.unset_api_key!
say "You have successfully signed out from all sessions."
say "You have successfully signed out of every registry, including RubyGems.org."
end
end
end
Loading