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 +4 -4
- data/aptible-cli.gemspec +2 -1
- data/lib/aptible/cli/agent.rb +90 -15
- data/lib/aptible/cli/helpers/security_key.rb +138 -19
- data/lib/aptible/cli/version.rb +1 -1
- data/spec/aptible/cli/agent_spec.rb +31 -9
- metadata +20 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f20b71eb3388732ade1e6e53cb1f076761d40a06d460faf08fa3e468f239acc1
|
4
|
+
data.tar.gz: 3e603d02a1dbe3e8168209f9a0f061d29851a48189d28dc549bff3a7d203a5e1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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?
|
data/lib/aptible/cli/agent.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
161
|
-
|
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
|
-
|
164
|
-
|
165
|
-
|
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
|
-
|
170
|
+
puts ''
|
169
171
|
|
170
|
-
|
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
|
-
@
|
21
|
-
|
22
|
-
|
23
|
-
'
|
24
|
-
|
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
|
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,
|
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
|
-
|
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.
|
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.
|
110
|
-
|
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,
|
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) }
|
data/lib/aptible/cli/version.rb
CHANGED
@@ -187,8 +187,16 @@ describe Aptible::CLI::Agent do
|
|
187
187
|
'u2f' => {
|
188
188
|
'challenge' => 'some 123',
|
189
189
|
'devices' => [
|
190
|
-
{
|
191
|
-
|
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('
|
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
|
-
{
|
227
|
-
|
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
|
-
|
247
|
-
|
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.
|
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-
|
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: '
|
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: '
|
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
|