aptible-cli 0.19.2 → 0.19.3

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: bdd31b147b7a6e31ec8a563120edfeeb65675e6fc3207a3f7a67dfd36b34c852
4
- data.tar.gz: d984d2e4aa162a24c44acf7a3ea7854a2dd14b7896afbfa7851b1c2d87b2310d
3
+ metadata.gz: f20b71eb3388732ade1e6e53cb1f076761d40a06d460faf08fa3e468f239acc1
4
+ data.tar.gz: 3e603d02a1dbe3e8168209f9a0f061d29851a48189d28dc549bff3a7d203a5e1
5
5
  SHA512:
6
- metadata.gz: 70854f3f9dbdd1af2c90c739e05baa72b127c4cadfea8e1d889f2b00a81fcbc56a2bf779d51e621371e38e3312110a2a1324d3edc4b2514bd944f940f9f1e74c
7
- data.tar.gz: 42a946fb3e79f01f951f480aa788d356560f97887ac1613dd5d3c210311873daaabfa9b30d6ebb542185ad264b5521f7f72a267073958c2adc9bffb2445e8f11
6
+ metadata.gz: e195096e2854788c488477ca6d42fd1beb15ce2b6044f0be32a774e6b3915c387694e4608c7546a94915dcb32b3f0844f4864544e4a64806dca23379c0383598
7
+ data.tar.gz: b2fb1ef99eb836cac0e80419d362277b05877826e8cf4b8f8cb78f937168d4ab658b81f9fea6c7ea0559cd5fe6f926aec896869b7fa8737e4503536d39dfb161
data/aptible-cli.gemspec CHANGED
@@ -25,9 +25,10 @@ Gem::Specification.new do |spec|
25
25
  spec.add_dependency 'aptible-auth', '~> 1.2.4'
26
26
  spec.add_dependency 'aptible-billing', '~> 1.0'
27
27
  spec.add_dependency 'thor', '~> 0.20.0'
28
- spec.add_dependency 'git'
28
+ spec.add_dependency 'git', '< 1.10'
29
29
  spec.add_dependency 'term-ansicolor'
30
30
  spec.add_dependency 'chronic_duration', '~> 0.10.6'
31
+ spec.add_dependency 'cbor'
31
32
 
32
33
  # Temporarily pin ffi until https://github.com/ffi/ffi/issues/868 is fixed
33
34
  spec.add_dependency 'ffi', '<= 1.14.1' if Gem.win_platform?
@@ -145,29 +145,32 @@ module Aptible
145
145
 
146
146
  # If the user has added a security key and their computer supports it,
147
147
  # allow them to use it
148
- if u2f && !which('u2f-host').nil?
148
+ # https://developers.yubico.com/libfido2/Manuals
149
+ # installation: https://github.com/Yubico/libfido2#installation
150
+ if u2f && !which('fido2-assert').nil? && !which('fido2-token').nil?
149
151
  origin = Aptible::Auth::Resource.new.get.href
150
152
  app_id = Aptible::Auth::Resource.new.utf_trusted_facets.href
151
-
152
153
  challenge = u2f.fetch('challenge')
153
154
 
154
- devices = u2f.fetch('devices').map do |dev|
155
- Helpers::SecurityKey::Device.new(
156
- dev.fetch('version'), dev.fetch('key_handle')
157
- )
158
- end
155
+ device_info = security_key_device(u2f, app_id)
159
156
 
160
- puts 'Enter your 2FA token or touch your Security Key once it ' \
161
- 'starts blinking.'
157
+ if device_info[:locations].count > 0 && device_info[:device]
158
+ puts "\nEnter your 2FA token or touch your Security Key " \
159
+ 'once it starts blinking.'
162
160
 
163
- mfa_threads << Thread.new do
164
- token_options[:u2f] = Helpers::SecurityKey.authenticate(
165
- origin, app_id, challenge, devices
166
- )
161
+ mfa_threads << Thread.new do
162
+ token_options[:u2f] = Helpers::SecurityKey.authenticate(
163
+ origin,
164
+ app_id,
165
+ challenge,
166
+ device_info[:device],
167
+ device_info[:locations]
168
+ )
167
169
 
168
- puts ''
170
+ puts ''
169
171
 
170
- q.push(nil)
172
+ q.push(nil)
173
+ end
171
174
  end
172
175
  end
173
176
 
@@ -202,6 +205,78 @@ module Aptible
202
205
 
203
206
  private
204
207
 
208
+ def security_key_device(u2f, app_id)
209
+ devices = u2f.fetch('devices').map do |dev|
210
+ version = dev.fetch('version')
211
+ rp_id =
212
+ if version == 'U2F_V2'
213
+ app_id
214
+ else
215
+ u2f['payload']['rpId']
216
+ end
217
+
218
+ Helpers::SecurityKey::Device.new(
219
+ dev.fetch('version'),
220
+ dev.fetch('key_handle'),
221
+ dev.fetch('name'),
222
+ rp_id
223
+ )
224
+ end
225
+
226
+ result = {
227
+ locations: [],
228
+ device: nil
229
+ }
230
+
231
+ device_locations = Helpers::SecurityKey.device_locations
232
+
233
+ if device_locations.count.zero?
234
+ no_keys = 'WARNING: no security keys detected on machine'
235
+ CLI.logger.warn(no_keys) if device_locations.count.zero?
236
+ else
237
+ result[:locations] = device_locations
238
+ no_creds = 'No credentials associated with user'
239
+ raise Error, no_creds if devices.count.zero?
240
+
241
+ result[:device] = devices[0]
242
+ if devices.count > 1
243
+ credential = security_credential(devices)
244
+ result[:device] = credential
245
+ end
246
+ end
247
+
248
+ result
249
+ end
250
+
251
+ # The name for our backend model is U2FDevice.
252
+ # However, really what we are storing is a security credential.
253
+ # Here we figure out which security credential to pass to fido2-assert.
254
+ def security_credential(devices)
255
+ puts 'There are multiple credentials associated ' \
256
+ 'with this user. Please select the ' \
257
+ "credential you want to use for authentication:\n"
258
+
259
+ device = nil
260
+ while device.nil?
261
+ devices.each_with_index do |dev, index|
262
+ puts "#{index}: #{dev.name}"
263
+ end
264
+
265
+ puts ''
266
+
267
+ device_index = ask(
268
+ 'Enter the credential number you want to use: '
269
+ )
270
+
271
+ # https://stackoverflow.com/a/1235990
272
+ next unless /\A\d+\z/ =~ device_index
273
+
274
+ device = devices[device_index.to_i]
275
+ end
276
+
277
+ device
278
+ end
279
+
205
280
  def deprecated(msg)
206
281
  CLI.logger.warn([
207
282
  "DEPRECATION NOTICE: #{msg}",
@@ -1,3 +1,6 @@
1
+ require 'openssl'
2
+ require 'cbor'
3
+
1
4
  module Aptible
2
5
  module CLI
3
6
  module Helpers
@@ -8,21 +11,32 @@ module Aptible
8
11
 
9
12
  class AuthenticatorParameters
10
13
  attr_reader :origin, :challenge, :app_id, :version, :key_handle
11
- attr_reader :request
14
+ attr_reader :request, :rp_id, :device_location
15
+ attr_reader :client_data, :assert_str, :version
12
16
 
13
- def initialize(origin, challenge, app_id, device)
17
+ def initialize(origin, challenge, app_id, device, device_location)
14
18
  @origin = origin
15
19
  @challenge = challenge
16
20
  @app_id = app_id
17
21
  @version = device.version
18
22
  @key_handle = device.key_handle
19
-
20
- @request = {
21
- 'challenge' => challenge,
22
- 'appId' => app_id,
23
- 'version' => version,
24
- 'keyHandle' => key_handle
25
- }
23
+ @rp_id = device.rp_id
24
+ @version = device.version
25
+ @device_location = device_location
26
+ @client_data = {
27
+ type: 'webauthn.get',
28
+ challenge: challenge,
29
+ origin: origin,
30
+ crossOrigin: false
31
+ }.to_json
32
+ key_handle = Base64.strict_encode64(
33
+ Base64.urlsafe_decode64(device.key_handle)
34
+ )
35
+ client_data_hash = Digest::SHA256.base64digest(@client_data)
36
+ in_str = "#{client_data_hash}\n" \
37
+ "#{device.rp_id}\n" \
38
+ "#{key_handle}"
39
+ @assert_str = in_str
26
40
  end
27
41
  end
28
42
 
@@ -51,9 +65,50 @@ module Aptible
51
65
  end
52
66
  end
53
67
 
54
- class Authenticator
68
+ class DeviceMapper
55
69
  attr_reader :pid
56
70
 
71
+ def initialize(pid, out_read, err_read)
72
+ @pid = pid
73
+ @out_read = out_read
74
+ @err_read = err_read
75
+ end
76
+
77
+ def exited(status)
78
+ out, err = [@out_read, @err_read].map(&:read).map(&:chomp)
79
+
80
+ if status.exitstatus == 0
81
+ U2F_LOGGER.info("#{self.class}: ok: #{out}")
82
+ [nil, out]
83
+ else
84
+ U2F_LOGGER.warn("#{self.class}: err: #{err}")
85
+ [nil, nil]
86
+ end
87
+ ensure
88
+ [@out_read, @err_read].each(&:close)
89
+ end
90
+
91
+ def self.spawn
92
+ out_read, out_write = IO.pipe
93
+ err_read, err_write = IO.pipe
94
+
95
+ pid = Process.spawn(
96
+ 'fido2-token -L',
97
+ out: out_write, err: err_write,
98
+ close_others: true
99
+ )
100
+
101
+ U2F_LOGGER.debug("#{self}: spawned #{pid}")
102
+
103
+ [out_write, err_write].each(&:close)
104
+
105
+ new(pid, out_read, err_read)
106
+ end
107
+ end
108
+
109
+ class Authenticator
110
+ attr_reader :pid, :auth
111
+
57
112
  def initialize(auth, pid, out_read, err_read)
58
113
  @auth = auth
59
114
  @pid = pid
@@ -61,13 +116,55 @@ module Aptible
61
116
  @err_read = err_read
62
117
  end
63
118
 
119
+ def formatted_out(out)
120
+ arr = out.split("\n")
121
+ authenticator_data = arr[2]
122
+ signature = arr[3]
123
+ appid = auth.app_id if auth.version == 'U2F_V2'
124
+ client_data_json = Base64.urlsafe_encode64(auth.client_data)
125
+
126
+ {
127
+ id: auth.key_handle,
128
+ rawId: auth.key_handle,
129
+ clientExtensionResults: { appid: appid },
130
+ type: 'public-key',
131
+ response: {
132
+ clientDataJSON: client_data_json,
133
+ authenticatorData: Base64.urlsafe_encode64(
134
+ CBOR.decode(
135
+ Base64.strict_decode64(authenticator_data)
136
+ )
137
+ ),
138
+ signature: signature
139
+ }
140
+ }
141
+ end
142
+
143
+ def fido_err_msg(err)
144
+ match = err.match(/(FIDO_ERR.+)/)
145
+ return nil unless match
146
+ result = match.captures || []
147
+ no_cred = "\nCredential not found on device, " \
148
+ 'are you sure you selected the right ' \
149
+ 'credential for this device?'
150
+ err_map = {
151
+ 'FIDO_ERR_NO_CREDENTIALS' => no_cred
152
+ }
153
+
154
+ return err_map[result[0]] if result.count > 0
155
+
156
+ nil
157
+ end
158
+
64
159
  def exited(status)
65
160
  out, err = [@out_read, @err_read].map(&:read).map(&:chomp)
66
161
 
67
162
  if status.exitstatus == 0
68
163
  U2F_LOGGER.info("#{self.class} #{@auth.key_handle}: ok: #{out}")
69
- [nil, JSON.parse(out)]
164
+ [nil, out]
70
165
  else
166
+ err_msg = fido_err_msg(err)
167
+ CLI.logger.error(err_msg) if err_msg
71
168
  U2F_LOGGER.warn("#{self.class} #{@auth.key_handle}: err: #{err}")
72
169
  [ThrottledAuthenticator.spawn(@auth), nil]
73
170
  end
@@ -81,7 +178,7 @@ module Aptible
81
178
  err_read, err_write = IO.pipe
82
179
 
83
180
  pid = Process.spawn(
84
- 'u2f-host', '-aauthenticate', '-o', auth.origin,
181
+ "fido2-assert -G #{auth.device_location}",
85
182
  in: in_read, out: out_write, err: err_write,
86
183
  close_others: true
87
184
  )
@@ -90,7 +187,7 @@ module Aptible
90
187
 
91
188
  [in_read, out_write, err_write].each(&:close)
92
189
 
93
- in_write.write(auth.request.to_json)
190
+ in_write.write(auth.assert_str)
94
191
  in_write.close
95
192
 
96
193
  new(auth, pid, out_read, err_read)
@@ -98,18 +195,40 @@ module Aptible
98
195
  end
99
196
 
100
197
  class Device
101
- attr_reader :version, :key_handle
198
+ attr_reader :version, :key_handle, :rp_id, :name
102
199
 
103
- def initialize(version, key_handle)
200
+ def initialize(version, key_handle, name, rp_id)
104
201
  @version = version
105
202
  @key_handle = key_handle
203
+ @name = name
204
+ @rp_id = rp_id
106
205
  end
107
206
  end
108
207
 
109
- def self.authenticate(origin, app_id, challenge, devices)
110
- procs = Hash[devices.map do |device|
208
+ def self.device_locations
209
+ w = DeviceMapper.spawn
210
+ _, status = Process.wait2
211
+ _, out = w.exited(status)
212
+ # parse output and only log device
213
+ matches = out.split("\n").map { |s| s.match(/^(\S+):\s/) }
214
+ results = []
215
+ matches.each do |m|
216
+ capture = m.captures
217
+ results << capture[0] if m && capture.count.positive?
218
+ end
219
+
220
+ results
221
+ end
222
+
223
+ def self.authenticate(origin, app_id, challenge,
224
+ device, device_locations)
225
+ procs = Hash[device_locations.map do |location|
111
226
  params = AuthenticatorParameters.new(
112
- origin, challenge, app_id, device
227
+ origin,
228
+ challenge,
229
+ app_id,
230
+ device,
231
+ location
113
232
  )
114
233
  w = Authenticator.spawn(params)
115
234
  [w.pid, w]
@@ -124,7 +243,7 @@ module Aptible
124
243
  r, out = w.exited(status)
125
244
 
126
245
  procs[r.pid] = r if r
127
- return out if out
246
+ return w.formatted_out(out) if out
128
247
  end
129
248
  ensure
130
249
  procs.values.map(&:pid).each { |p| Process.kill(:SIGTERM, p) }
@@ -1,5 +1,5 @@
1
1
  module Aptible
2
2
  module CLI
3
- VERSION = '0.19.2'.freeze
3
+ VERSION = '0.19.3'.freeze
4
4
  end
5
5
  end
@@ -187,8 +187,16 @@ describe Aptible::CLI::Agent do
187
187
  'u2f' => {
188
188
  'challenge' => 'some 123',
189
189
  'devices' => [
190
- { 'version' => 'U2F_V2', 'key_handle' => '123' },
191
- { 'version' => 'U2F_V2', 'key_handle' => '456' }
190
+ {
191
+ 'version' => 'U2F_V2',
192
+ 'key_handle' => '123',
193
+ 'name' => 'primary'
194
+ },
195
+ {
196
+ 'version' => 'U2F_V2',
197
+ 'key_handle' => '456',
198
+ 'name' => 'secondary'
199
+ }
192
200
  ]
193
201
  }
194
202
  )
@@ -215,16 +223,28 @@ describe Aptible::CLI::Agent do
215
223
  end
216
224
 
217
225
  it 'should call into U2F if supported' do
218
- allow(subject).to receive(:which).and_return('u2f-host')
226
+ allow(subject).to receive(:which).and_return('fido2-token')
227
+ allow(subject).to receive(:which).and_return('fido2-assert')
219
228
  allow(subject).to receive(:ask).with('2FA Token: ') { sleep }
229
+ allow(subject).to receive(:ask).with(
230
+ 'Enter the credential number you want to use: '
231
+ ) { '0' }
220
232
 
221
233
  e = make_oauth2_error(
222
234
  'otp_token_required',
223
235
  'u2f' => {
224
236
  'challenge' => 'some 123',
225
237
  'devices' => [
226
- { 'version' => 'U2F_V2', 'key_handle' => '123' },
227
- { 'version' => 'U2F_V2', 'key_handle' => '456' }
238
+ {
239
+ 'version' => 'U2F_V2',
240
+ 'key_handle' => '123',
241
+ 'name' => 'primary'
242
+ },
243
+ {
244
+ 'version' => 'U2F_V2',
245
+ 'key_handle' => '456',
246
+ 'name' => 'secondary'
247
+ }
228
248
  ]
229
249
  }
230
250
  )
@@ -238,15 +258,17 @@ describe Aptible::CLI::Agent do
238
258
 
239
259
  expect(subject).to receive(:puts).with(/security key/i)
240
260
 
261
+ expect(Aptible::CLI::Helpers::SecurityKey)
262
+ .to receive(:device_locations)
263
+ .and_return(['ioreg://4295020796'])
264
+
241
265
  expect(Aptible::CLI::Helpers::SecurityKey).to receive(:authenticate)
242
266
  .with(
243
267
  'https://auth.aptible.com/',
244
268
  'https://auth.aptible.com/u2f/trusted_facets',
245
269
  'some 123',
246
- array_including(
247
- instance_of(Aptible::CLI::Helpers::SecurityKey::Device),
248
- instance_of(Aptible::CLI::Helpers::SecurityKey::Device)
249
- )
270
+ instance_of(Aptible::CLI::Helpers::SecurityKey::Device),
271
+ ['ioreg://4295020796']
250
272
  ).and_return(u2f)
251
273
 
252
274
  expect(Aptible::Auth::Token).to receive(:create)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aptible-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.2
4
+ version: 0.19.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frank Macreery
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-18 00:00:00.000000000 Z
11
+ date: 2022-03-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aptible-resource
@@ -84,16 +84,16 @@ dependencies:
84
84
  name: git
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
- - - ">="
87
+ - - "<"
88
88
  - !ruby/object:Gem::Version
89
- version: '0'
89
+ version: '1.10'
90
90
  type: :runtime
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
- - - ">="
94
+ - - "<"
95
95
  - !ruby/object:Gem::Version
96
- version: '0'
96
+ version: '1.10'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: term-ansicolor
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: 0.10.6
125
+ - !ruby/object:Gem::Dependency
126
+ name: cbor
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: activesupport
127
141
  requirement: !ruby/object:Gem::Requirement