security 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d010aca28c455caae65e0063ab5ee35336eace64fd8049012c66f77edc634f20
4
- data.tar.gz: a46b316771d171579e241b7c875b99bdaaa0f0cc6e7f9312d1627767b90c3c9e
3
+ metadata.gz: 9c1d9c484b22dfdf437dbd6af09ccb5b1c197c94c1d396496cd93b35c93ae9fc
4
+ data.tar.gz: 72f35ca3c02f14e83e75aed6b10048bf85f78b4f95176302a34af671a3807567
5
5
  SHA512:
6
- metadata.gz: 106ba3c86bc5254bd1d589d1de99ea992b4cb12731761ff79faeed1cbe48f495451520b6ebbf91c50f6fb41b838ecd01de11eec9ca08d97e39c853cc457844e5
7
- data.tar.gz: 636bfaf6e7a5b88eb38112008d3a46ed13da71cd13e8e16bb9891a24b5fbd8fca85dd58017f505d1c0df45264a1be3773f9bf15e973b688f0069135463407cf6
6
+ metadata.gz: 40675d74911733f6316286730b6fe68dd4e28905f3dc00f7d260645de0707b905c0be386e42062601d8226b1e1240179c6607660dc349c92d08d982c83007f7a
7
+ data.tar.gz: 98ef0545f2155fc4c5e55d4969c62b2ed1ddb63634b633f7fec30cadb1726159be3cd7823a0ee0fa11cdc0b75210c7deb2df78bd24fa9a3a4d2b8f95ac1fcbbd
data/Gemfile CHANGED
@@ -3,8 +3,8 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  gem 'rake', '~> 13.4'
6
- gem 'rspec'
7
- gem 'rubocop'
8
- gem 'simplecov'
6
+ gem 'rspec', '~> 3.13'
7
+ gem 'rubocop', '~> 1.92'
8
+ gem 'simplecov', '~> 1.2'
9
9
 
10
10
  gemspec
data/Gemfile.lock CHANGED
@@ -1,17 +1,17 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- security (0.2.0)
4
+ security (0.4.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  ast (2.4.3)
10
10
  diff-lcs (1.6.2)
11
- json (2.21.2)
11
+ json (3.1.0)
12
12
  language_server-protocol (3.17.0.6)
13
13
  lint_roller (1.1.0)
14
- parallel (2.1.0)
14
+ parallel (1.28.0)
15
15
  parser (3.3.12.0)
16
16
  ast (~> 2.4.1)
17
17
  racc
@@ -19,7 +19,7 @@ GEM
19
19
  racc (1.8.1)
20
20
  rainbow (3.1.1)
21
21
  rake (13.4.2)
22
- regexp_parser (2.12.0)
22
+ regexp_parser (2.13.1)
23
23
  rspec (3.13.2)
24
24
  rspec-core (~> 3.13.0)
25
25
  rspec-expectations (~> 3.13.0)
@@ -33,14 +33,14 @@ GEM
33
33
  diff-lcs (>= 1.2.0, < 2.0)
34
34
  rspec-support (~> 3.13.0)
35
35
  rspec-support (3.13.7)
36
- rubocop (1.89.0)
37
- json (~> 2.3)
36
+ rubocop (1.92.0)
37
+ json (>= 2.3)
38
38
  language_server-protocol (~> 3.17.0.2)
39
39
  lint_roller (~> 1.1.0)
40
40
  parallel (>= 1.10)
41
41
  parser (>= 3.3.0.2)
42
42
  rainbow (>= 2.2.2, < 4.0)
43
- regexp_parser (>= 2.9.3, < 3.0)
43
+ regexp_parser (>= 2.11.3, < 3.0)
44
44
  rubocop-ast (>= 1.49.0, < 2.0)
45
45
  ruby-progressbar (~> 1.7)
46
46
  unicode-display_width (>= 2.4.0, < 4.0)
@@ -48,21 +48,20 @@ GEM
48
48
  parser (>= 3.3.7.2)
49
49
  prism (~> 1.7)
50
50
  ruby-progressbar (1.13.0)
51
- simplecov (1.1.1)
52
- unicode-display_width (3.2.0)
53
- unicode-emoji (~> 4.1)
54
- unicode-emoji (4.2.0)
51
+ simplecov (1.2.0)
52
+ unicode-display_width (3.3.0)
53
+ unicode-emoji (~> 4.3)
54
+ unicode-emoji (4.3.0)
55
55
 
56
56
  PLATFORMS
57
57
  ruby
58
- x86_64-linux
59
58
 
60
59
  DEPENDENCIES
61
60
  rake (~> 13.4)
62
- rspec
63
- rubocop
61
+ rspec (~> 3.13)
62
+ rubocop (~> 1.92)
64
63
  security!
65
- simplecov
64
+ simplecov (~> 1.2)
66
65
 
67
66
  BUNDLED WITH
68
- 2.6.9
67
+ 2.7.2
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Security
2
2
 
3
- [![Build Status][build status badge]][build status]
3
+ [![CI](https://github.com/fastlane-community/security/actions/workflows/ci.yml/badge.svg)](https://github.com/fastlane-community/security/actions/workflows/ci.yml)
4
4
  [![Gem](https://img.shields.io/gem/v/security.svg?style=flat)](https://rubygems.org/gems/security)
5
5
  [![License](https://img.shields.io/badge/license-MIT-green.svg?style=flat)](https://github.com/fastlane-community/security/blob/main/LICENSE.md)
6
6
 
@@ -14,14 +14,81 @@
14
14
  ```ruby
15
15
  require 'security'
16
16
 
17
- Security::Keychain::default_keychain.filename #=> "/Users/jappleseed/Library/Keychains/login.keychain"
17
+ Security::Keychain.default_keychain.filename #=> "/Users/jappleseed/Library/Keychains/login.keychain-db"
18
18
 
19
- Security::InternetPassword.find(server: "itunesconnect.apple.com").password #=> "p4ssw0rd"
19
+ item = Security::InternetPassword.find(server: "itunesconnect.apple.com")
20
+ item&.password #=> "p4ssw0rd"
20
21
  ```
21
22
 
22
- ## License
23
+ ## Keychains
24
+
25
+ `find`, `add` and `delete` all take an optional `keychain:`, naming the keychain
26
+ to act on. It accepts a `Security::Keychain` or a path. Without one, `security`
27
+ adds to the default keychain and searches the default search list.
28
+
29
+ ```ruby
30
+ keychain = Security::Keychain.new("/path/to/build.keychain-db")
31
+
32
+ Security::InternetPassword.add("example.com", "jappleseed", "p4ssw0rd", keychain: keychain)
33
+ Security::InternetPassword.find(server: "example.com", keychain: keychain)
34
+ Security::InternetPassword.delete(server: "example.com", keychain: keychain)
35
+ ```
36
+
37
+ Keychains themselves:
23
38
 
24
- MIT
39
+ ```ruby
40
+ keychain = Security::Keychain.create("/path/to/build.keychain-db", "p4ssw0rd")
41
+ keychain.update_settings(timeout: 3600, lock_when_sleeping: true)
42
+ keychain.set_key_partition_list("p4ssw0rd")
43
+
44
+ Security::Keychain.set_search_list(Security::Keychain.list + [keychain])
45
+ Security::Keychain.set_default_keychain(keychain)
46
+ ```
47
+
48
+ ## Certificates and identities
49
+
50
+ ```ruby
51
+ Security::Certificate.import("/path/to/identity.p12", keychain: keychain, password: "p12 password")
52
+ Security::Certificate.find(name: "Developer ID Installer", keychain: keychain).map(&:sha1)
53
+ Security::Identity.find(keychain: keychain).map(&:name) #=> ["Apple Development: Jane Appleseed (TEAMID)"]
54
+ ```
55
+
56
+ ## Errors
57
+
58
+ The `security` command line tool reports failures through its exit status, and
59
+ this library distinguishes the two cases a caller needs to tell apart:
60
+
61
+ - **Nothing matched.** `find` returns `nil`. The keychain answered, and it holds
62
+ no such item.
63
+ - **The question could not be answered.** `find` raises `Security::Error`,
64
+ carrying the tool's exit `status` and its `output`. A locked keychain, a
65
+ keychain this process is not allowed to read, or a malformed request all
66
+ land here.
67
+
68
+ ```ruby
69
+ begin
70
+ item = Security::InternetPassword.find(server: "itunesconnect.apple.com")
71
+ rescue Security::Error => e
72
+ warn "could not read the keychain: #{e.message}"
73
+ item = nil
74
+ end
75
+ ```
76
+
77
+ `Keychain.list`, `Keychain.default_keychain`, `Keychain.login_keychain`,
78
+ `Keychain.create`, `Certificate.find` and `Identity.find` raise `Security::Error`
79
+ on failure in the same way. `Certificate.find` and `Identity.find` return an
80
+ empty array when nothing matched.
81
+
82
+ The methods that change the keychain — `add`, `delete` and the `Keychain`
83
+ setters — return `true` or `false` and print what the tool reported, the way
84
+ `Kernel#system` does. Two raise instead, because the caller needs to know why
85
+ they failed:
86
+
87
+ - `Certificate.import` raises `Security::DuplicateItemError` when the keychain
88
+ already holds the item, and `Security::Error` for any other failure.
89
+ - `Keychain#set_key_partition_list` raises `Security::Error`, most often for a
90
+ wrong keychain password.
91
+
92
+ ## License
25
93
 
26
- [build status]: https://github.com/mattt/Security/actions?query=workflow%3ACI
27
- [build status badge]: https://github.com/mattt/Security/workflows/CI/badge.svg
94
+ [MIT](https://github.com/fastlane-community/security/blob/main/LICENSE.md)
data/UPGRADE.md ADDED
@@ -0,0 +1,25 @@
1
+ # Upgrade Guide
2
+
3
+ ## Upgrading from 0.3 to 0.4
4
+
5
+ Ruby 3.2 is now the minimum.
6
+
7
+ `Security::Command.run` and `Security::Command.relay` no longer go through a shell. They take the program and its arguments separately, so nothing needs escaping:
8
+
9
+ ```ruby
10
+ # 0.3
11
+ Security::Command.run("security show-keychain-info #{path.shellescape}")
12
+
13
+ # 0.4
14
+ Security::Command.run("security", "show-keychain-info", path)
15
+ ```
16
+
17
+ A single string is now the name of the program to run, so a 0.3 command line fails as a missing command, with exit status 127.
18
+
19
+ Passwords no longer appear in the arguments of the `security` process, where any local user could read them with `ps`: the commands that take one (adding a password, creating, unlocking or setting the partition list of a keychain, importing with a passphrase) are sent to `security -i` on stdin. A password or other argument containing a line break now raises `ArgumentError`, and so does a command longer than `security -i` reads (4096 bytes, 32 arguments).
20
+
21
+ ## Upgrading from 0.2 to 0.3
22
+
23
+ In previous versions, `find` attempted to detect failures by searching for a `security: ` prefix in the output. This approach was unreliable: certain errors (like ACL issues) produced no output, while others (like malformed requests) returned a usage banner. These cases would incorrectly result in a `Password` object with `nil` attributes, making it impossible for callers to distinguish a failed request from a successful one. Additionally, failures that *did* have the prefix returned `nil`, which was indistinguishable from a missing item.
24
+
25
+ Starting with version 0.3, both cases now raise a `Security::Error`. If your code relies on `nil` to handle missing keychain items, you should now rescue `Security::Error` to prevent a locked or misconfigured keychain from being treated as empty.
@@ -1,10 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  module Security
4
6
  # :nodoc:
5
7
  class Certificate
8
+ # The tools allowed to use an imported item without asking the user. A
9
+ # keychain prompt in the middle of a build is a hang rather than a failure,
10
+ # which is why these are named rather than left to the default.
11
+ DEFAULT_TRUSTED_APPLICATIONS = [
12
+ '/usr/bin/codesign',
13
+ '/usr/bin/security',
14
+ '/usr/bin/productbuild',
15
+ '/usr/bin/productsign'
16
+ ].freeze
17
+
18
+ # What `security import` reports for an item the keychain already holds.
19
+ ALREADY_EXISTS = 'The specified item already exists in the keychain'
20
+
21
+ PEM = /-----BEGIN CERTIFICATE-----\n.*?-----END CERTIFICATE-----\n/m
22
+
23
+ attr_reader :sha1, :sha256, :pem
24
+
6
25
  private_class_method :new
7
26
 
27
+ def initialize(sha1:, sha256:, pem:)
28
+ @sha1 = sha1
29
+ @sha256 = sha256
30
+ @pem = pem
31
+ end
32
+
33
+ # The subject's common name, which is what the keychain shows as its name.
34
+ def name
35
+ OpenSSL::X509::Certificate.new(pem).subject.to_a.find { |key, _value, _type| key == 'CN' }&.at(1)
36
+ end
37
+
8
38
  def delete!
9
39
  raise NotImplementedError
10
40
  end
@@ -14,8 +44,46 @@ module Security
14
44
  end
15
45
 
16
46
  class << self
17
- def find
18
- raise NotImplementedError
47
+ # Every certificate whose name contains `name`. Finding none is not a
48
+ # failure: `security` prints nothing and exits 0.
49
+ def find(name:, keychain: nil)
50
+ command = ['security', 'find-certificate', '-a', '-c', name.to_s, '-Z', '-p']
51
+ command << filename_for(keychain) if keychain
52
+
53
+ result = Command.run(*command)
54
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
55
+
56
+ certificates_from_output(result.stdout)
57
+ end
58
+
59
+ # Imports a certificate or identity file into `keychain`. `password` is
60
+ # the one protecting the file, not the keychain's. Raises
61
+ # DuplicateItemError when the keychain already holds it.
62
+ def import(path, keychain:, password: nil, format: nil,
63
+ trusted_applications: DEFAULT_TRUSTED_APPLICATIONS)
64
+ command = ['security', 'import', path.to_s, '-k', filename_for(keychain)]
65
+ command += ['-P', password.to_s] if password
66
+ command += ['-f', format.to_s] if format
67
+ trusted_applications.each { |application| command += ['-T', application] }
68
+
69
+ result = password ? Command.run_interactive(*command.drop(1)) : Command.run(*command)
70
+ return true if result.success?
71
+
72
+ # Every failure exits 1, so only the message tells a duplicate apart.
73
+ error = result.stderr.include?(ALREADY_EXISTS) ? DuplicateItemError : Error
74
+ raise error.new(result.exitstatus, result.stderr)
75
+ end
76
+
77
+ private
78
+
79
+ def certificates_from_output(output)
80
+ output.scan(/^SHA-256 hash: (\h+)\nSHA-1 hash: (\h+)\n(#{PEM})/m).map do |sha256, sha1, pem|
81
+ new(sha1: sha1, sha256: sha256, pem: pem)
82
+ end
83
+ end
84
+
85
+ def filename_for(keychain)
86
+ keychain.respond_to?(:filename) ? keychain.filename : keychain.to_s
19
87
  end
20
88
  end
21
89
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'open3'
5
+
6
+ module Security
7
+ # :nodoc:
8
+ module Command
9
+ # TODO: replace with Data.define once Ruby 3.1 support is dropped.
10
+ # :nodoc:
11
+ Result = Struct.new(:stdout, :stderr, :status) do
12
+ def success?
13
+ !status.nil? && status.success?
14
+ end
15
+
16
+ def exitstatus
17
+ status&.exitstatus
18
+ end
19
+
20
+ # `security` splits what it has to say across both streams: find-*-password
21
+ # prints the attributes on stdout and the password on stderr.
22
+ def output
23
+ stdout + stderr
24
+ end
25
+ end
26
+
27
+ # What `security -i` reads at most: bytes per line, and arguments per command.
28
+ INTERACTIVE_LINE_LENGTH = 4096
29
+ INTERACTIVE_ARGUMENTS = 32
30
+
31
+ module_function
32
+
33
+ # Runs a `security` subcommand and captures what it produced. A missing
34
+ # `security` is a failed result rather than an exception: the tool is only
35
+ # present on macOS, and callers elsewhere should see the same failure they
36
+ # would get from a keychain that could not answer.
37
+ #
38
+ # Never through a shell, so a path containing a space or a quote needs no
39
+ # escaping:
40
+ #
41
+ # run("security", "show-keychain-info", path)
42
+ def run(program, *, stdin: nil)
43
+ # [program, program] rather than program: Ruby hands a lone string with
44
+ # shell metacharacters to a shell.
45
+ # stdin_data only when there is some, so a command without a password is
46
+ # run, and can be stubbed, exactly as before.
47
+ Result.new(*Open3.capture3([program, program], *, **(stdin ? { stdin_data: stdin } : {})))
48
+ rescue Errno::ENOENT => e
49
+ # Nothing ran, but the child Ruby forked exited 127 before exec, which is
50
+ # what a shell reports for a missing command, and what this library
51
+ # produced while it still went through one.
52
+ Result.new('', "#{e.message}\n", $CHILD_STATUS)
53
+ end
54
+
55
+ # Runs a `security` subcommand and lets the caller see what it said. The
56
+ # tool reports on stderr whether or not it succeeded: show-keychain-info
57
+ # prints its result there on exit 0.
58
+ def relay(*command)
59
+ relayed(run(*command))
60
+ end
61
+
62
+ # Runs a `security` subcommand that takes a password: through `security -i`,
63
+ # which reads the command from stdin, so the password never shows in the
64
+ # arguments, which any local user can read with `ps`. Without -p (or -P),
65
+ # the tool would instead read it from the terminal when there is one, or
66
+ # import would ask through a dialog.
67
+ #
68
+ # run_interactive("unlock-keychain", "-p", password, path)
69
+ def run_interactive(subcommand, *)
70
+ result = run('security', '-i', stdin: interactive_input(subcommand, *))
71
+ # -i adds a line naming the command and its status after any error.
72
+ result.stderr = result.stderr.sub(/^#{Regexp.escape(subcommand)}: returned -?\d+\n\z/, '')
73
+ result
74
+ end
75
+
76
+ def relay_interactive(*command)
77
+ relayed(run_interactive(*command))
78
+ end
79
+
80
+ # One command line for `security -i`: every argument double quoted, with
81
+ # backslashes and double quotes escaped. A line break would end the command,
82
+ # and a longer line or more arguments than the tool reads would be cut.
83
+ def interactive_input(*command)
84
+ if command.size > INTERACTIVE_ARGUMENTS
85
+ raise ArgumentError, "security -i takes at most #{INTERACTIVE_ARGUMENTS} arguments"
86
+ end
87
+
88
+ line = command.map do |argument|
89
+ argument = argument.to_s
90
+ raise ArgumentError, 'An argument for security -i cannot contain a line break' if argument.match?(/[\r\n]/)
91
+
92
+ %("#{argument.gsub(/[\\"]/) { |character| "\\#{character}" }}")
93
+ end.join(' ')
94
+ if line.bytesize >= INTERACTIVE_LINE_LENGTH - 1
95
+ raise ArgumentError, "A command for security -i must be shorter than #{INTERACTIVE_LINE_LENGTH} bytes"
96
+ end
97
+
98
+ "#{line}\n"
99
+ end
100
+
101
+ def relayed(result)
102
+ warn result.stderr.chomp unless result.stderr.empty?
103
+
104
+ result
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Security
4
+ # :nodoc:
5
+ class Error < StandardError
6
+ attr_reader :status, :output
7
+
8
+ def initialize(status, output)
9
+ @status = status
10
+ @output = output
11
+
12
+ details = output.strip
13
+ super(details.empty? ? "`security` exited with status #{status}" : "#{details} (status #{status})")
14
+ end
15
+ end
16
+
17
+ # Raised when the keychain already holds the item being added.
18
+ class DuplicateItemError < Error; end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Security
4
+ # :nodoc:
5
+ class Identity
6
+ attr_reader :sha1, :name, :status
7
+
8
+ private_class_method :new
9
+
10
+ # `status` is what `security` prints after the name for an identity it
11
+ # considers invalid, such as CSSMERR_TP_CERT_REVOKED, or nil.
12
+ def initialize(sha1, name, status = nil)
13
+ @sha1 = sha1
14
+ @name = name
15
+ @status = status
16
+ end
17
+
18
+ class << self
19
+ # The identities `security` can see, newest tool output shaped like:
20
+ #
21
+ # 1) A1B2... "Apple Development: someone (TEAM)"
22
+ # 1 valid identities found
23
+ #
24
+ # `valid_only` drops the ones the tool marks unusable. `keychain` limits
25
+ # the search to one; without it `security` searches the user's default,
26
+ # which is what `find-identity` does and why callers that do not name a
27
+ # keychain get whatever happens to be in the login one.
28
+ def find(policy: 'codesigning', valid_only: true, keychain: nil)
29
+ command = %w[security find-identity]
30
+ command << '-v' if valid_only
31
+ command += ['-p', policy.to_s] if policy
32
+ command << filename_for(keychain) if keychain
33
+
34
+ result = Command.run(*command)
35
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
36
+
37
+ identities_from_output(result.stdout)
38
+ end
39
+
40
+ private
41
+
42
+ # Finding none is not a failure: `security` prints "0 valid identities
43
+ # found" and exits 0.
44
+ def identities_from_output(output)
45
+ output.scan(/^\s*\d+\)\s+(\h+)\s+"(.*)"(?:\s+\(([^()]*)\))?\s*$/).map do |sha1, name, status|
46
+ new(sha1, name, status)
47
+ end
48
+ end
49
+
50
+ def filename_for(keychain)
51
+ keychain.respond_to?(:filename) ? keychain.filename : keychain.to_s
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'shellwords'
4
-
5
3
  module Security
6
4
  # :nodoc:
7
5
  class Keychain
8
6
  DOMAINS = %i[user system common dynamic].freeze
9
7
 
8
+ # The partition IDs that let Apple's signing tools use an imported key
9
+ # without a keychain prompt.
10
+ DEFAULT_PARTITION_IDS = %w[apple-tool: apple: codesign:].freeze
11
+
10
12
  attr_reader :filename
11
13
 
12
14
  def initialize(filename)
@@ -14,50 +16,102 @@ module Security
14
16
  end
15
17
 
16
18
  def info
17
- system %(security show-keychain-info #{@filename.shellescape})
19
+ Command.relay('security', 'show-keychain-info', @filename).success?
18
20
  end
19
21
 
20
22
  def lock
21
- system %(security lock-keychain #{@filename.shellescape})
23
+ Command.relay('security', 'lock-keychain', @filename).success?
22
24
  end
23
25
 
24
26
  def unlock(password)
25
- system %(security unlock-keychain -p #{password.shellescape} #{@filename.shellescape})
27
+ Command.relay_interactive('unlock-keychain', '-p', password.to_s, @filename).success?
26
28
  end
27
29
 
28
30
  def delete
29
- system %(security delete-keychain #{@filename.shellescape})
31
+ Command.relay('security', 'delete-keychain', @filename).success?
32
+ end
33
+
34
+ # With no options the keychain never locks on its own.
35
+ def update_settings(timeout: nil, lock_when_sleeping: false, lock_after_timeout: false)
36
+ command = %w[security set-keychain-settings]
37
+ command += ['-t', timeout.to_s] if timeout
38
+ command << '-l' if lock_when_sleeping
39
+ command << '-u' if lock_after_timeout
40
+
41
+ Command.relay(*command, @filename).success?
42
+ end
43
+
44
+ # Raises rather than returning false: a wrong keychain password is the usual
45
+ # failure, and the caller needs the output to tell it from the others.
46
+ def set_key_partition_list(password, partition_ids: DEFAULT_PARTITION_IDS)
47
+ result = Command.run_interactive('set-key-partition-list', '-S', partition_ids.join(','), '-s',
48
+ '-k', password.to_s, @filename)
49
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
50
+
51
+ true
30
52
  end
31
53
 
32
54
  class << self
55
+ # Raises on failure, like the other class methods that answer with a keychain.
33
56
  def create(filename, password)
34
- raise NotImplementedError
57
+ result = Command.run_interactive('create-keychain', '-p', password.to_s, filename.to_s)
58
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
59
+
60
+ new(filename.to_s)
35
61
  end
36
62
 
37
63
  def list(domain = :user)
38
- raise ArgumentError "Invalid domain #{domain}, expected one of: #{DOMAINS}" unless DOMAINS.include?(domain)
64
+ keychains_from_command('security', 'list-keychains', '-d', domain_name(domain))
65
+ end
39
66
 
40
- keychains_from_output(`security list-keychains -d #{domain}`)
67
+ def set_search_list(keychains, domain: :user)
68
+ Command.relay('security', 'list-keychains', '-d', domain_name(domain), '-s',
69
+ *keychains.map { |keychain| filename_for(keychain) }).success?
41
70
  end
42
71
 
43
72
  def lock
44
- system %(security lock-keychain -a)
73
+ Command.relay('security', 'lock-keychain', '-a').success?
45
74
  end
46
75
 
47
76
  def unlock(password)
48
- system %(security unlock-keychain -p #{password.shellescape})
77
+ Command.relay_interactive('unlock-keychain', '-p', password.to_s).success?
49
78
  end
50
79
 
51
80
  def default_keychain
52
- keychains_from_output(`security default-keychain`).first
81
+ keychains_from_command('security', 'default-keychain').first
82
+ end
83
+
84
+ def set_default_keychain(keychain, domain: :user)
85
+ Command.relay('security', 'default-keychain', '-d', domain_name(domain), '-s', filename_for(keychain)).success?
53
86
  end
54
87
 
55
88
  def login_keychain
56
- keychains_from_output(`security login-keychain`).first
89
+ keychains_from_command('security', 'login-keychain').first
90
+ end
91
+
92
+ def supports_key_partition_list?
93
+ Command.run('security', '-h').stdout.include?('set-key-partition-list')
57
94
  end
58
95
 
59
96
  private
60
97
 
98
+ def domain_name(domain)
99
+ raise ArgumentError, "Invalid domain #{domain}, expected one of: #{DOMAINS}" unless DOMAINS.include?(domain)
100
+
101
+ domain.to_s
102
+ end
103
+
104
+ def filename_for(keychain)
105
+ keychain.respond_to?(:filename) ? keychain.filename : keychain.to_s
106
+ end
107
+
108
+ def keychains_from_command(*command)
109
+ result = Command.run(*command)
110
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
111
+
112
+ keychains_from_output(result.stdout)
113
+ end
114
+
61
115
  def keychains_from_output(output)
62
116
  output.split("\n").collect { |line| new(line.strip.gsub(/^"|"$/, '')) }
63
117
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'shellwords'
4
-
5
3
  module Security
6
4
  # :nodoc:
7
5
  class Password
6
+ # `security` reports a missing item with this exit status. Every other
7
+ # non-zero status is a failure the caller needs to know about.
8
+ ITEM_NOT_FOUND = 44
9
+
8
10
  attr_reader :keychain, :attributes, :password
9
11
 
10
12
  private_class_method :new
@@ -18,9 +20,15 @@ module Security
18
20
  class << self
19
21
  private
20
22
 
21
- def password_from_output(output)
22
- return nil if output.match?(/^security: /)
23
+ def password_from_command(*command)
24
+ result = Command.run(*command)
25
+ return nil if result.exitstatus == ITEM_NOT_FOUND
26
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
27
+
28
+ password_from_output(result.output)
29
+ end
23
30
 
31
+ def password_from_output(output)
24
32
  keychain = nil
25
33
  attributes = {}
26
34
  password = nil
@@ -44,6 +52,7 @@ module Security
44
52
 
45
53
  def flags_for_options(options = {})
46
54
  flags = options.dup
55
+ keychain = flags.delete(:keychain)
47
56
  flags[:a] ||= flags.delete(:account)
48
57
  flags[:c] ||= flags.delete(:creator)
49
58
  flags[:C] ||= flags.delete(:type)
@@ -51,7 +60,16 @@ module Security
51
60
  flags[:G] ||= flags.delete(:value)
52
61
  flags[:j] ||= flags.delete(:comment)
53
62
 
54
- flags.delete_if { |_k, v| v.nil? }.collect { |k, v| "-#{k} #{v.shellescape}".strip }.join(' ')
63
+ arguments = flags.compact.flat_map { |k, v| ["-#{k}", v.to_s] }
64
+ arguments << filename_for_keychain(keychain) if keychain
65
+
66
+ arguments
67
+ end
68
+
69
+ # `security` takes the keychain to act on as a trailing argument. Without
70
+ # one it uses the default keychain, or the default search list.
71
+ def filename_for_keychain(keychain)
72
+ keychain.is_a?(Keychain) ? keychain.filename : keychain.to_s
55
73
  end
56
74
 
57
75
  def decode_hex_blob(string)
@@ -68,15 +86,15 @@ module Security
68
86
  options[:s] = service
69
87
  options[:w] = password
70
88
 
71
- system "security add-generic-password #{flags_for_options(options)}"
89
+ Command.relay_interactive('add-generic-password', *flags_for_options(options)).success?
72
90
  end
73
91
 
74
92
  def find(options)
75
- password_from_output(`security 2>&1 find-generic-password -g #{flags_for_options(options)}`)
93
+ password_from_command('security', 'find-generic-password', '-g', *flags_for_options(options))
76
94
  end
77
95
 
78
96
  def delete(options)
79
- system "security delete-generic-password #{flags_for_options(options)}"
97
+ Command.run('security', 'delete-generic-password', *flags_for_options(options)).success?
80
98
  end
81
99
 
82
100
  private
@@ -95,16 +113,15 @@ module Security
95
113
  options[:a] = account
96
114
  options[:s] = server
97
115
  options[:w] = password
98
-
99
- system "security add-internet-password #{flags_for_options(options)}"
116
+ Command.relay_interactive('add-internet-password', *flags_for_options(options)).success?
100
117
  end
101
118
 
102
119
  def find(options)
103
- password_from_output(`security 2>&1 find-internet-password -g #{flags_for_options(options)}`)
120
+ password_from_command('security', 'find-internet-password', '-g', *flags_for_options(options))
104
121
  end
105
122
 
106
123
  def delete(options)
107
- system "security delete-internet-password #{flags_for_options(options)}"
124
+ Command.run('security', 'delete-internet-password', *flags_for_options(options)).success?
108
125
  end
109
126
 
110
127
  private
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module Security
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
data/lib/security.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'security/version'
4
+ require 'security/error'
5
+ require 'security/command'
4
6
  require 'security/keychain'
5
7
  require 'security/certificate'
8
+ require 'security/identity'
6
9
  require 'security/password'
data/security.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.files = Dir['./**/*'].grep_v(%r{\./(bin|log|pkg|script|spec|test|vendor)})
16
16
  s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
17
17
  s.require_paths = ['lib']
18
- s.required_ruby_version = '>= 3.0.0'
18
+ s.required_ruby_version = '>= 3.2.0'
19
19
 
20
20
  s.metadata['rubygems_mfa_required'] = 'true'
21
21
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: security
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Holtz
@@ -21,8 +21,12 @@ files:
21
21
  - "./LICENSE.md"
22
22
  - "./README.md"
23
23
  - "./Rakefile"
24
+ - "./UPGRADE.md"
24
25
  - "./lib/security.rb"
25
26
  - "./lib/security/certificate.rb"
27
+ - "./lib/security/command.rb"
28
+ - "./lib/security/error.rb"
29
+ - "./lib/security/identity.rb"
26
30
  - "./lib/security/keychain.rb"
27
31
  - "./lib/security/password.rb"
28
32
  - "./lib/security/version.rb"
@@ -39,7 +43,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
39
43
  requirements:
40
44
  - - ">="
41
45
  - !ruby/object:Gem::Version
42
- version: 3.0.0
46
+ version: 3.2.0
43
47
  required_rubygems_version: !ruby/object:Gem::Requirement
44
48
  requirements:
45
49
  - - ">="