123456789_123456789_123456789_123456789_123456789_

Class: Gem::CredentialStore

Overview

CredentialStore is opt-in storage for authentication secrets (API keys, host credentials) in the operating system's native secret store instead of a plain text file:

  • macOS: Keychain, via the security command line tool.
  • Linux: the Secret Service API (GNOME Keyring, KWallet, ...), via secret-tool.
  • Windows: Credential Manager, via the Windows.Security.Credentials.PasswordVault API from PowerShell.

A third party can add another backend (1Password, pass, HashiCorp Vault, ...) by shipping a gem that provides rubygems/credential_store/backends/ and calls .register_backend from it. Users then select it by name instead of true (see .resolve_backend).

Every public method traps all errors and returns nil+/+false instead of raising, so that callers can transparently fall back to their existing file-based storage when the native store is unavailable or fails (a locked keychain over SSH, a headless Linux session without a keyring daemon, ...).

Constant Summary

Class Attribute Summary

Class Method Summary

Instance Attribute Summary

Instance Method Summary

Constructor Details

.new(backend: self.class.default_backend, service: SERVICE_NAME) ⇒ CredentialStore

backend is only used by tests to inject a fake backend regardless of the platform the test suite happens to run on. service is the account namespace this store reads and writes under.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 183

def initialize(backend: self.class.default_backend, service: SERVICE_NAME)
  @backend = backend
  @service = service
  @cache = {}
end

Class Attribute Details

.backend=(backend) (writeonly)

Installs a shared backend that .for wraps for every spec and service. Intended for tests that need RubyGems and Bundler credentials to land in one backend under their own service names.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 73

def self.backend=(backend)
  @override_backend = backend
end

.instance (rw)

The default-backed store for this platform, i.e. for(true). Kept for callers and tests that only care about the native backend.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 56

def self.instance
  self.for(true)
end

.instance=(store) (rw)

Installs a stand-in store that .for returns for any enabled setting. Intended for tests that inject a fake backend.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 64

def self.instance=(store)
  @override = store
end

.warn_handler=(handler) (writeonly)

Sends warnings to handler (anything responding to #call) instead of Gem.ui. Bundler sets this because it replaces Gem.ui with a subclass of SilentUI, which discards alert_warning entirely, so a credential store failure would otherwise be silent for the whole bundle command.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 113

def self.warn_handler=(handler)
  @warn_handler = handler
end

Class Method Details

.backend_for(spec) (private)

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 160

def self.backend_for(spec)
  spec == true ? default_backend : resolve_backend(spec)
end

.default_backend

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 165

def self.default_backend
  if Gem.win_platform?
    require_relative "credential_store/native/windows"
    WindowsBackend
  elsif RUBY_PLATFORM.include?("darwin")
    require_relative "credential_store/native/macos"
    MacOSBackend
  elsif RUBY_PLATFORM.include?("linux")
    require_relative "credential_store/native/linux"
    LinuxBackend if LinuxBackend.available?
  end
end

.for(spec, service: SERVICE_NAME)

Returns the store to use for spec, or nil when the credential store is off. spec is either true (use this platform's native backend) or the name of a registered backend such as "1password". service names the account namespace within the backend, so RubyGems and Bundler keep separate credentials in one native store. The store is memoized per spec and service for the life of the process, so the read cache and any expensive backend startup are shared across callers. A test may install a stand-in via #instance= that is returned here for any enabled spec, or inject a shared backend via #backend=.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 44

def self.for(spec, service: SERVICE_NAME)
  return nil unless spec
  return @override if defined?(@override) && @override

  backend = defined?(@override_backend) && @override_backend ? @override_backend : backend_for(spec)
  (@instances ||= {})[[spec, service]] ||= new(backend: backend, service: service)
end

.register_backend(name, backend)

Registers backend under name so it can be selected with credential_store = . A third-party backend gem calls this from the file RubyGems loads for that name (see .resolve_backend).

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 122

def self.register_backend(name, backend)
  (@backends ||= {})[name.to_s] = backend
end

.reset!

Clears the memoized stores, the injected overrides, and the warned messages. Intended for tests only.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 81

def self.reset!
  @override = nil
  @override_backend = nil
  @instances = nil
  @warned = nil
  @warn_handler = nil
end

.resolve_backend(name)

Resolves a registered backend by name, requiring rubygems/credential_store/backends/ on first use so a backend shipped as its own gem loads only when actually selected. Returns nil (warning once) when the name is malformed or no gem provides it, which makes callers fall back to file storage. The fixed require prefix and the restricted name charset keep the setting a piece of data, never a path or a command.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 137

def self.resolve_backend(name)
  # The setting can carry bytes Regexp#match? would reject. A name that gets
  # past the match is ASCII only, so the require path it builds stays sound.
  name = name.to_s.b
  unless BACKEND_NAME.match?(name)
    warn_once "Ignoring invalid credential store backend name #{name.inspect}."
    return nil
  end

  return @backends[name] if @backends&.key?(name)

  begin
    require "rubygems/credential_store/backends/#{name}"
  rescue LoadError
    warn_once "Credential store backend #{name.inspect} is not installed. " \
              "Install a gem that provides rubygems/credential_store/backends/#{name}, " \
              "or unset the credential_store setting. Falling back to file storage."
    return nil
  end

  @backends && @backends[name]
end

.warn_once(message)

Warns once per distinct message. A single flag for every message would let an early warning about, say, a misspelled backend name suppress the later warning that a secret was written in plain text.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 94

def self.warn_once(message)
  @warned ||= {}
  return if @warned.key?(message)

  @warned[message] = true

  if defined?(@warn_handler) && @warn_handler
    @warn_handler.call(message)
  else
    Gem.ui.alert_warning message
  end
end

Instance Attribute Details

#available?Boolean (readonly)

True if a native credential backend is usable on this platform.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 192

def available?
  !@backend.nil?
end

Instance Method Details

#delete(account)

Removes the secret stored for account. Returns true if the entry is gone, whether or not it existed beforehand.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 250

def delete()
  return false unless @backend

  result = @backend.delete(@service, )
  @cache.delete()
  @failed&.delete()
  invalidate_list
  result
rescue StandardError => e
  warn_failure(:remove, e)
  false
end

#delete_all

Removes every entry this store owns (all accounts under its service). Returns true when the store is now clear. Used by gem signout to end every session at once, mirroring deletion of the whole credentials file.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 286

def delete_all
  return false unless @backend

  result = @backend.delete_all(@service)
  @cache.clear
  @failed = nil
  invalidate_list
  result
rescue StandardError => e
  warn_failure(:remove, e)
  false
end

#get(account)

Returns the secret stored for account, or nil if there is none or the backend is unavailable/fails.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 200

def get()
  return nil unless @backend
  return @cache[] if @cache.key?()

  @cache[] = @backend.get(@service, )
rescue StandardError => e
  warn_failure(:read, e)
  # Retrying means another subprocess and, on some platforms, another
  # authorization prompt. #read_failed? keeps this apart from an absent one.
  (@failed ||= {})[] = true
  @cache[] = nil
end

#invalidate_list (private)

The listing is memoized, so a write has to drop it.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 302

def invalidate_list
  remove_instance_variable(:@list) if defined?(@list)
end

#list

The accounts this store holds, or nil when the backend cannot enumerate them. Listing is optional in the backend protocol: the native backends implement it, but a third-party backend that only resolves credentials on demand has nothing to enumerate. Callers must treat nil as "unknown", not as "empty". Secrets are never returned.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 270

def list
  return nil unless @backend.respond_to?(:list)
  return @list if defined?(@list)

  @list = @backend.list(@service)
rescue StandardError => e
  warn_failure(:list, e)
  # Remembered for the same reason #get remembers a failed read.
  @list = nil
end

#read_failed?(account) ⇒ Boolean

True when #get returned nil for account because the backend could not answer, rather than because nothing is stored under it. Callers that would otherwise fall back to a different credential need the difference: a missing entry means "use something else", an unreadable one does not.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 219

def read_failed?()
  return false unless defined?(@failed) && @failed

  @failed.key?()
end

#set(account, secret)

Stores secret for account. Returns true on success.

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 228

def set(, secret)
  return false unless @backend

  validate_credential(, secret)

  if @backend.set(@service, , secret)
    @cache[] = secret
    @failed&.delete()
    invalidate_list
    true
  else
    false
  end
rescue StandardError => e
  warn_failure(:write, e)
  false
end

#validate_credential(account, secret) (private)

A newline in an account would start a second command in the macOS batch input. #set turns the raise back into a warning and a false.

Raises:

  • (ArgumentError)
[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 322

def validate_credential(, secret)
  raise ArgumentError, "credential secret must be printable ASCII" unless secret.to_s.b.match?(PRINTABLE_ASCII)
  raise ArgumentError, "credential account must not contain a newline" if .to_s.include?("\n")
  raise ArgumentError, "credential service must not contain a newline" if @service.to_s.include?("\n")
end

#warn_failure(operation, error) (private)

[ GitHub ]

  
# File 'lib/rubygems/credential_store.rb', line 328

def warn_failure(operation, error)
  self.class.warn_once "Credential store #{operation} failed for #{@service}" \
                       " (#{error.class}: #{error.message}); #{OUTCOMES[operation]}."
end