aptible-cli 0.19.2 → 0.19.3

Sign up to get free protection for your applications and to get access to all the features.
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