123456789_123456789_123456789_123456789_123456789_

Class: Gem::CredentialStore::MacOSBackend

Relationships & Source Files
Inherits: Object
Defined in: lib/rubygems/credential_store/native/macos.rb

Overview

Stores credentials in the macOS Keychain via the security command line tool. security has no way to read a password from stdin as raw bytes for add-generic-password, so #set uses security -i (batch/interactive mode, one tokenized command per stdin line) to keep the secret off argv and out of ps output.

A newline would start a second command in the security -i batch, and security find-generic-password -w prints any non-printable byte back as a hex string rather than the original value, so a non-ASCII secret would round-trip corrupted. Those are the limits #set enforces for every backend, so the caller falls back to file storage rather than storing something that cannot be read back.

Constant Summary

Class Method Summary

Class Method Details

.delete(service, account)

[ GitHub ]

  
# File 'lib/rubygems/credential_store/native/macos.rb', line 63

def self.delete(service, )
  _out, status = Open3.capture2(
    "security", "delete-generic-password", "-a", , "-s", service,
    err: File::NULL
  )
  status.success? || status.exitstatus == NOT_FOUND_STATUS
end

.delete_all(service)

security deletes one entry per call, so keep going until it reports there is nothing left (exit 44). Other services are untouched.

[ GitHub ]

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

def self.delete_all(service)
  loop do
    _out, status = Open3.capture2(
      "security", "delete-generic-password", "-s", service,
      err: File::NULL
    )
    return true if status.exitstatus == NOT_FOUND_STATUS
    return false unless status.success?
  end
end

.get(service, account)

[ GitHub ]

  
# File 'lib/rubygems/credential_store/native/macos.rb', line 24

def self.get(service, )
  out, err, status = Open3.capture3(
    "security", "find-generic-password", "-a", , "-s", service, "-w"
  )
  # A locked keychain and an absent entry both yield no secret, but only the
  # second one is ordinary.
  unless status.success?
    return nil if status.exitstatus == NOT_FOUND_STATUS

    raise "security exited with #{status.exitstatus}: #{err.strip}"
  end

  secret = out.chomp
  secret.empty? ? nil : secret
end

.list(service)

security has no "list by service" subcommand, so this reads the dump, which never reports the secrets.

[ GitHub ]

  
# File 'lib/rubygems/credential_store/native/macos.rb', line 52

def self.list(service)
  out, status = Open3.capture2("security", "dump-keychain", err: File::NULL)
  return [] unless status.success?

  out.split(/^keychain: /).filter_map do |entry|
    next unless entry[/^\s*"svce"<blob>="(.*)"$/, 1] == service

    entry[/^\s*"acct"<blob>="(.*)"$/, 1]
  end.uniq
end

.quote(value) (private)

[ GitHub ]

  
# File 'lib/rubygems/credential_store/native/macos.rb', line 84

def self.quote(value)
  %("#{value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')}")
end

.set(service, account, secret)

[ GitHub ]

  
# File 'lib/rubygems/credential_store/native/macos.rb', line 40

def self.set(service, , secret)
  command = "add-generic-password -U -a #{quote()} -s #{quote(service)} -w #{quote(secret)}\n"
  _out, err, status = Open3.capture3("security", "-i", stdin_data: command)
  return true if status.success?

  # Raise rather than return false so the reason reaches the user. The
  # wrapper turns it back into false after reporting it.
  raise "security exited with #{status.exitstatus}: #{err.strip}"
end