accessgrid 0.2.0 → 0.5.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 +4 -4
- data/README.md +282 -25
- data/lib/accessgrid/access_cards.rb +28 -17
- data/lib/accessgrid/console.rb +354 -23
- data/lib/accessgrid/error.rb +27 -0
- data/lib/accessgrid/request.rb +112 -0
- data/lib/accessgrid/smart_tap_reveal_crypto.rb +78 -0
- data/lib/accessgrid/version.rb +4 -2
- data/lib/accessgrid.rb +65 -136
- metadata +13 -52
data/lib/accessgrid/console.rb
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/accessgrid/console.rb
|
|
2
4
|
module AccessGrid
|
|
5
|
+
# Manages enterprise template and logging operations.
|
|
3
6
|
class Console
|
|
7
|
+
attr_reader :webhooks, :hid, :credential_profiles
|
|
8
|
+
|
|
4
9
|
def initialize(client)
|
|
5
10
|
@client = client
|
|
11
|
+
@webhooks = Webhooks.new(client)
|
|
12
|
+
@hid = HID.new(client)
|
|
13
|
+
@credential_profiles = CredentialProfiles.new(client)
|
|
6
14
|
end
|
|
7
15
|
|
|
8
16
|
def create_template(params)
|
|
@@ -24,15 +32,13 @@ module AccessGrid
|
|
|
24
32
|
|
|
25
33
|
def get_logs(template_id, params = {})
|
|
26
34
|
response = @client.make_request(:get, "/v1/console/card-templates/#{template_id}/logs", nil, params)
|
|
27
|
-
|
|
35
|
+
|
|
28
36
|
# Return full response to match Python's behavior
|
|
29
|
-
if response['logs']
|
|
30
|
-
|
|
31
|
-
end
|
|
32
|
-
|
|
37
|
+
response['logs'] = response['logs'].map { |log| Event.new(log) } if response['logs']
|
|
38
|
+
|
|
33
39
|
response
|
|
34
40
|
end
|
|
35
|
-
|
|
41
|
+
|
|
36
42
|
# Keep event_log for backwards compatibility
|
|
37
43
|
def event_log(params)
|
|
38
44
|
template_id = params.delete(:card_template_id)
|
|
@@ -40,29 +46,98 @@ module AccessGrid
|
|
|
40
46
|
response['logs'] || []
|
|
41
47
|
end
|
|
42
48
|
|
|
49
|
+
def list_pass_template_pairs(params = {})
|
|
50
|
+
response = @client.make_request(:get, '/v1/console/card-template-pairs', nil, params)
|
|
51
|
+
|
|
52
|
+
if response['card_template_pairs']
|
|
53
|
+
response['card_template_pairs'] = response['card_template_pairs'].map { |pair| PassTemplatePair.new(pair) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
response
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create_pass_template_pair(params)
|
|
60
|
+
response = @client.make_request(:post, '/v1/console/card-template-pairs', params)
|
|
61
|
+
PassTemplatePair.new(response)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def list_ledger_items(params = {})
|
|
65
|
+
response = @client.make_request(:get, '/v1/console/ledger-items', nil, params)
|
|
66
|
+
|
|
67
|
+
if response['ledger_items']
|
|
68
|
+
response['ledger_items'] = response['ledger_items'].map { |item| LedgerItem.new(item) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
response
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
alias ledger_items list_ledger_items
|
|
75
|
+
|
|
76
|
+
def publish_template(template_id)
|
|
77
|
+
response = @client.make_request(:post, "/v1/console/card-templates/#{template_id}/publish")
|
|
78
|
+
PublishTemplateResponse.new(response)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reveal the SmartTap private key for a card template, decrypted client-side.
|
|
82
|
+
#
|
|
83
|
+
# The SDK generates a fresh ephemeral P-256 keypair per call, submits the
|
|
84
|
+
# public half, and decrypts the server's response. The returned
|
|
85
|
+
# RevealTemplatePrivateKey carries the plaintext PEM in #private_key;
|
|
86
|
+
# the encrypted envelope is consumed internally and not exposed.
|
|
87
|
+
def reveal_smart_tap(template_id)
|
|
88
|
+
keypair = SmartTapRevealCrypto.generate_keypair
|
|
89
|
+
response = @client.make_request(
|
|
90
|
+
:post,
|
|
91
|
+
"/v1/console/card-templates/#{template_id}/smart-tap/reveal",
|
|
92
|
+
{ client_public_key: keypair[:pub_pem] }
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
plaintext = SmartTapRevealCrypto.decrypt_envelope(response['encrypted_private_key'], keypair[:priv])
|
|
96
|
+
RevealTemplatePrivateKey.new(response.merge('private_key' => plaintext))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ios_preflight(card_template_id:, access_pass_ex_id:)
|
|
100
|
+
data = { access_pass_ex_id: access_pass_ex_id }
|
|
101
|
+
response = @client.make_request(:post, "/v1/console/card-templates/#{card_template_id}/ios_preflight", data)
|
|
102
|
+
IosPreflight.new(response)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def list_landing_pages
|
|
106
|
+
response = @client.make_request(:get, '/v1/console/landing-pages')
|
|
107
|
+
pages = response.is_a?(Array) ? response : response.fetch('landing_pages', [])
|
|
108
|
+
pages.map { |page| LandingPage.new(page) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def create_landing_page(**params)
|
|
112
|
+
response = @client.make_request(:post, '/v1/console/landing-pages', params)
|
|
113
|
+
LandingPage.new(response)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def update_landing_page(landing_page_id:, **params)
|
|
117
|
+
response = @client.make_request(:put, "/v1/console/landing-pages/#{landing_page_id}", params)
|
|
118
|
+
LandingPage.new(response)
|
|
119
|
+
end
|
|
120
|
+
|
|
43
121
|
private
|
|
44
122
|
|
|
45
123
|
def transform_template_params(params)
|
|
46
|
-
design = params.delete(:design)
|
|
47
|
-
support_info = params.delete(:support_info)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
support_phone_number: support_info[:support_phone_number],
|
|
55
|
-
support_email: support_info[:support_email],
|
|
56
|
-
privacy_policy_url: support_info[:privacy_policy_url],
|
|
57
|
-
terms_and_conditions_url: support_info[:terms_and_conditions_url]
|
|
58
|
-
)
|
|
124
|
+
design = params.delete(:design)
|
|
125
|
+
support_info = params.delete(:support_info)
|
|
126
|
+
|
|
127
|
+
# Only merge nested keys if they were provided (backward compat)
|
|
128
|
+
params.merge!(design) if design
|
|
129
|
+
params.merge!(support_info) if support_info
|
|
130
|
+
|
|
131
|
+
params
|
|
59
132
|
end
|
|
60
133
|
end
|
|
61
134
|
|
|
135
|
+
# Represents a card template configuration.
|
|
62
136
|
class Template
|
|
63
|
-
attr_reader :id, :name, :platform, :protocol, :use_case, :created_at,
|
|
137
|
+
attr_reader :id, :name, :platform, :protocol, :use_case, :created_at,
|
|
64
138
|
:last_published_at, :issued_keys_count, :active_keys_count,
|
|
65
|
-
:allowed_device_counts, :support_settings, :terms_settings, :style_settings
|
|
139
|
+
:allowed_device_counts, :support_settings, :terms_settings, :style_settings,
|
|
140
|
+
:metadata
|
|
66
141
|
|
|
67
142
|
def initialize(data)
|
|
68
143
|
@id = data['id']
|
|
@@ -78,19 +153,275 @@ module AccessGrid
|
|
|
78
153
|
@support_settings = data['support_settings']
|
|
79
154
|
@terms_settings = data['terms_settings']
|
|
80
155
|
@style_settings = data['style_settings']
|
|
156
|
+
@metadata = data['metadata'] || {}
|
|
81
157
|
end
|
|
82
158
|
end
|
|
83
159
|
|
|
160
|
+
# Represents a template activity log event.
|
|
84
161
|
class Event
|
|
85
162
|
attr_reader :type, :timestamp, :user_id, :ip_address, :user_agent, :metadata
|
|
86
163
|
|
|
87
164
|
def initialize(data)
|
|
165
|
+
metadata = data['metadata']
|
|
88
166
|
@type = data['event']
|
|
89
167
|
@timestamp = data['created_at']
|
|
90
|
-
@user_id =
|
|
168
|
+
@user_id = metadata['user_id'] if metadata && metadata['user_id']
|
|
91
169
|
@ip_address = data['ip_address']
|
|
92
170
|
@user_agent = data['user_agent']
|
|
171
|
+
@metadata = metadata
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Represents a paired iOS and Android template configuration.
|
|
176
|
+
class PassTemplatePair
|
|
177
|
+
attr_reader :id, :name, :created_at, :android_template, :ios_template
|
|
178
|
+
|
|
179
|
+
def initialize(data)
|
|
180
|
+
android_template = data['android_template']
|
|
181
|
+
ios_template = data['ios_template']
|
|
182
|
+
@id = data['id']
|
|
183
|
+
@name = data['name']
|
|
184
|
+
@created_at = data['created_at']
|
|
185
|
+
@android_template = android_template ? TemplateInfo.new(android_template) : nil
|
|
186
|
+
@ios_template = ios_template ? TemplateInfo.new(ios_template) : nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Minimal template info used within PassTemplatePair.
|
|
191
|
+
class TemplateInfo
|
|
192
|
+
attr_reader :id, :name, :platform
|
|
193
|
+
|
|
194
|
+
def initialize(data)
|
|
195
|
+
@id = data['id']
|
|
196
|
+
@name = data['name']
|
|
197
|
+
@platform = data['platform']
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Represents an iOS In-App Provisioning preflight response.
|
|
202
|
+
class IosPreflight
|
|
203
|
+
attr_reader :provisioning_credential_identifier, :sharing_instance_identifier,
|
|
204
|
+
:card_template_identifier, :environment_identifier
|
|
205
|
+
|
|
206
|
+
def initialize(data)
|
|
207
|
+
@provisioning_credential_identifier = data['provisioningCredentialIdentifier']
|
|
208
|
+
@sharing_instance_identifier = data['sharingInstanceIdentifier']
|
|
209
|
+
@card_template_identifier = data['cardTemplateIdentifier']
|
|
210
|
+
@environment_identifier = data['environmentIdentifier']
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Result of publishing a card template.
|
|
215
|
+
class PublishTemplateResponse
|
|
216
|
+
attr_reader :id, :status
|
|
217
|
+
|
|
218
|
+
def initialize(data)
|
|
219
|
+
@id = data['id']
|
|
220
|
+
@status = data['status']
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Result of revealing a SmartTap private key. #private_key is the plaintext
|
|
225
|
+
# PEM, decrypted client-side by the SDK; the encrypted envelope is consumed
|
|
226
|
+
# internally and not exposed.
|
|
227
|
+
class RevealTemplatePrivateKey
|
|
228
|
+
attr_reader :key_version, :collector_id, :fingerprint, :private_key
|
|
229
|
+
|
|
230
|
+
def initialize(data)
|
|
231
|
+
@key_version = data['key_version']
|
|
232
|
+
@collector_id = data['collector_id']
|
|
233
|
+
@fingerprint = data['fingerprint']
|
|
234
|
+
@private_key = data['private_key']
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Represents a billing ledger item.
|
|
239
|
+
class LedgerItem
|
|
240
|
+
attr_reader :created_at, :amount, :id, :kind, :metadata, :access_pass
|
|
241
|
+
|
|
242
|
+
def initialize(data)
|
|
243
|
+
@created_at = data['created_at']
|
|
244
|
+
@amount = data['amount']
|
|
245
|
+
@id = data['id']
|
|
246
|
+
@kind = data['kind']
|
|
247
|
+
@metadata = data['metadata']
|
|
248
|
+
@access_pass = data['access_pass'] ? LedgerItemAccessPass.new(data['access_pass']) : nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Represents an access pass reference within a ledger item.
|
|
253
|
+
class LedgerItemAccessPass
|
|
254
|
+
attr_reader :id, :full_name, :state, :metadata, :unified_access_pass_ex_id, :pass_template
|
|
255
|
+
|
|
256
|
+
def initialize(data)
|
|
257
|
+
@id = data['id']
|
|
258
|
+
@full_name = data['full_name']
|
|
259
|
+
@state = data['state']
|
|
93
260
|
@metadata = data['metadata']
|
|
261
|
+
@unified_access_pass_ex_id = data['unified_access_pass_ex_id']
|
|
262
|
+
@pass_template = data['pass_template'] ? LedgerItemPassTemplate.new(data['pass_template']) : nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Represents a pass template reference within a ledger item's access pass.
|
|
267
|
+
class LedgerItemPassTemplate
|
|
268
|
+
attr_reader :id, :name, :protocol, :platform, :use_case
|
|
269
|
+
|
|
270
|
+
def initialize(data)
|
|
271
|
+
@id = data['id']
|
|
272
|
+
@name = data['name']
|
|
273
|
+
@protocol = data['protocol']
|
|
274
|
+
@platform = data['platform']
|
|
275
|
+
@use_case = data['use_case']
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Represents a landing page configuration.
|
|
280
|
+
class LandingPage
|
|
281
|
+
attr_reader :id, :name, :created_at, :kind, :password_protected, :logo_url
|
|
282
|
+
|
|
283
|
+
def initialize(data)
|
|
284
|
+
@id = data['id']
|
|
285
|
+
@name = data['name']
|
|
286
|
+
@created_at = data['created_at']
|
|
287
|
+
@kind = data['kind']
|
|
288
|
+
@password_protected = data['password_protected']
|
|
289
|
+
@logo_url = data['logo_url']
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Represents a credential profile configuration.
|
|
294
|
+
class CredentialProfile
|
|
295
|
+
attr_reader :id, :aid, :name, :apple_id, :created_at, :card_storage, :keys, :files
|
|
296
|
+
|
|
297
|
+
def initialize(data)
|
|
298
|
+
@id = data['id']
|
|
299
|
+
@aid = data['aid']
|
|
300
|
+
@name = data['name']
|
|
301
|
+
@apple_id = data['apple_id']
|
|
302
|
+
@created_at = data['created_at']
|
|
303
|
+
@card_storage = data['card_storage']
|
|
304
|
+
@keys = data['keys'] || []
|
|
305
|
+
@files = data['files'] || []
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Manages credential profile operations.
|
|
310
|
+
class CredentialProfiles
|
|
311
|
+
def initialize(client)
|
|
312
|
+
@client = client
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def create(**params)
|
|
316
|
+
response = @client.make_request(:post, '/v1/console/credential-profiles', params)
|
|
317
|
+
CredentialProfile.new(response)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def list
|
|
321
|
+
response = @client.make_request(:get, '/v1/console/credential-profiles')
|
|
322
|
+
profiles = response.is_a?(Array) ? response : response.fetch('credential_profiles', [])
|
|
323
|
+
profiles.map { |profile| CredentialProfile.new(profile) }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Manages webhook operations.
|
|
328
|
+
class Webhooks
|
|
329
|
+
def initialize(client)
|
|
330
|
+
@client = client
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def create(name:, url:, subscribed_events:, auth_method: 'bearer_token')
|
|
334
|
+
data = {
|
|
335
|
+
name: name,
|
|
336
|
+
url: url,
|
|
337
|
+
subscribed_events: subscribed_events,
|
|
338
|
+
auth_method: auth_method
|
|
339
|
+
}
|
|
340
|
+
response = @client.make_request(:post, '/v1/console/webhooks', data)
|
|
341
|
+
Webhook.new(response)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def list(**params)
|
|
345
|
+
response = @client.make_request(:get, '/v1/console/webhooks', nil, params)
|
|
346
|
+
(response['webhooks'] || []).map { |wh| Webhook.new(wh) }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def delete(webhook_id)
|
|
350
|
+
@client.make_request(:delete, "/v1/console/webhooks/#{webhook_id}")
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Represents a webhook configuration.
|
|
355
|
+
class Webhook
|
|
356
|
+
attr_reader :id, :name, :url, :auth_method, :subscribed_events,
|
|
357
|
+
:created_at, :private_key, :client_cert, :cert_expires_at
|
|
358
|
+
|
|
359
|
+
def initialize(data)
|
|
360
|
+
@id = data['id']
|
|
361
|
+
@name = data['name']
|
|
362
|
+
@url = data['url']
|
|
363
|
+
@auth_method = data['auth_method']
|
|
364
|
+
@subscribed_events = data['subscribed_events']
|
|
365
|
+
@created_at = data['created_at']
|
|
366
|
+
@private_key = data['private_key']
|
|
367
|
+
@client_cert = data['client_cert']
|
|
368
|
+
@cert_expires_at = data['cert_expires_at']
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Provides access to HID-related services.
|
|
373
|
+
class HID
|
|
374
|
+
attr_reader :orgs
|
|
375
|
+
|
|
376
|
+
def initialize(client)
|
|
377
|
+
@orgs = HIDOrgs.new(client)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Manages HID organization operations.
|
|
382
|
+
class HIDOrgs
|
|
383
|
+
def initialize(client)
|
|
384
|
+
@client = client
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def create(name:, full_address:, phone:, first_name:, last_name:)
|
|
388
|
+
data = {
|
|
389
|
+
name: name,
|
|
390
|
+
full_address: full_address,
|
|
391
|
+
phone: phone,
|
|
392
|
+
first_name: first_name,
|
|
393
|
+
last_name: last_name
|
|
394
|
+
}
|
|
395
|
+
response = @client.make_request(:post, '/v1/console/hid/orgs', data)
|
|
396
|
+
HidOrg.new(response)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def list
|
|
400
|
+
response = @client.make_request(:get, '/v1/console/hid/orgs')
|
|
401
|
+
response.map { |org| HidOrg.new(org) }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def activate(email:, password:)
|
|
405
|
+
data = { email: email, password: password }
|
|
406
|
+
response = @client.make_request(:post, '/v1/console/hid/orgs/activate', data)
|
|
407
|
+
HidOrg.new(response)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Represents an HID organization.
|
|
412
|
+
class HidOrg
|
|
413
|
+
attr_reader :id, :name, :slug, :first_name, :last_name, :phone, :full_address, :status, :created_at
|
|
414
|
+
|
|
415
|
+
def initialize(data)
|
|
416
|
+
@id = data['id']
|
|
417
|
+
@name = data['name']
|
|
418
|
+
@slug = data['slug']
|
|
419
|
+
@first_name = data['first_name']
|
|
420
|
+
@last_name = data['last_name']
|
|
421
|
+
@phone = data['phone']
|
|
422
|
+
@full_address = data['full_address']
|
|
423
|
+
@status = data['status']
|
|
424
|
+
@created_at = data['created_at']
|
|
94
425
|
end
|
|
95
426
|
end
|
|
96
|
-
end
|
|
427
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrid
|
|
4
|
+
# base error
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when API credentials are invalid.
|
|
8
|
+
class AuthenticationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a requested resource does not exist.
|
|
11
|
+
class ResourceNotFoundError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when request parameters fail validation.
|
|
14
|
+
class ValidationError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when a SmartTap reveal envelope is missing fields or contains
|
|
17
|
+
# non-base64 / non-PEM data.
|
|
18
|
+
class InvalidEnvelopeError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when AES-GCM auth-tag verification fails while decrypting a
|
|
21
|
+
# SmartTap reveal envelope (wrong key, tampered envelope, or wire-format
|
|
22
|
+
# drift between server and SDK).
|
|
23
|
+
class DecryptError < Error; end
|
|
24
|
+
|
|
25
|
+
# additional error classes to match Python version
|
|
26
|
+
class AccessGridError < Error; end
|
|
27
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module AccessGrid
|
|
6
|
+
# Builds and configures HTTP requests for the AccessGrid API.
|
|
7
|
+
class Request
|
|
8
|
+
PAYLOAD_SIGNATURE_PARAM = :sig_payload
|
|
9
|
+
HTTP_METHODS = {
|
|
10
|
+
get: Net::HTTP::Get,
|
|
11
|
+
post: Net::HTTP::Post,
|
|
12
|
+
put: Net::HTTP::Put,
|
|
13
|
+
patch: Net::HTTP::Patch,
|
|
14
|
+
delete: Net::HTTP::Delete
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :account_id, :body, :http_method, :params, :payload, :uri
|
|
18
|
+
|
|
19
|
+
def initialize(attrs)
|
|
20
|
+
# required attributes
|
|
21
|
+
@http_method = attrs.fetch(:http_method)
|
|
22
|
+
@provided_host = attrs.fetch(:host)
|
|
23
|
+
@provided_path = attrs.fetch(:path)
|
|
24
|
+
|
|
25
|
+
# optional attributes
|
|
26
|
+
@account_id = attrs.fetch(:account_id, nil)
|
|
27
|
+
@body = attrs.fetch(:body, nil)
|
|
28
|
+
@params = attrs.fetch(:params, nil) || {}
|
|
29
|
+
|
|
30
|
+
# computed attributes
|
|
31
|
+
initialize_payload
|
|
32
|
+
initialize_uri
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def net_http_request
|
|
36
|
+
return @net_http_request if defined?(@net_http_request)
|
|
37
|
+
|
|
38
|
+
@net_http_request = generate_net_http_request!.tap do |req|
|
|
39
|
+
req['Content-Type'] = 'application/json'
|
|
40
|
+
req['X-ACCT-ID'] = account_id
|
|
41
|
+
req['User-Agent'] = "accessgrid.rb @ v#{AccessGrid::VERSION}"
|
|
42
|
+
|
|
43
|
+
req.body = body.to_json if body && !get?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def generate_net_http_request!
|
|
50
|
+
klass = HTTP_METHODS.fetch(http_method) do
|
|
51
|
+
raise ArgumentError, "Unsupported HTTP method: #{http_method}"
|
|
52
|
+
end
|
|
53
|
+
klass.new(uri.request_uri)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize_payload
|
|
57
|
+
return @payload if defined?(@payload)
|
|
58
|
+
return @payload = default_payload unless post_without_body_or_get?
|
|
59
|
+
|
|
60
|
+
if resource_id
|
|
61
|
+
@payload = { id: resource_id }.to_json
|
|
62
|
+
@params[PAYLOAD_SIGNATURE_PARAM] = @payload
|
|
63
|
+
else
|
|
64
|
+
@payload = '{}'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@payload
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize_uri
|
|
71
|
+
@uri = URI.parse("#{@provided_host}#{@provided_path}").tap do |parsed_uri|
|
|
72
|
+
parsed_uri.query = URI.encode_www_form(@params) if @params.any?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resource_id
|
|
77
|
+
return @resource_id if defined?(@resource_id)
|
|
78
|
+
return @resource_id = nil unless post_without_body_or_get?
|
|
79
|
+
|
|
80
|
+
parts = @provided_path.strip.split('/')
|
|
81
|
+
return @resource_id = nil unless parts.length >= 2
|
|
82
|
+
|
|
83
|
+
last_part = parts.last
|
|
84
|
+
second_to_last_part = parts[-2]
|
|
85
|
+
@resource_id = %w[suspend resume unlink delete publish].include?(last_part) ? second_to_last_part : last_part
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def get?
|
|
89
|
+
http_method == :get
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def delete?
|
|
93
|
+
http_method == :delete
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def post?
|
|
97
|
+
http_method == :post
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def empty_body?
|
|
101
|
+
body.nil? || body.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def post_without_body_or_get?
|
|
105
|
+
get? || delete? || (post? && empty_body?)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def default_payload
|
|
109
|
+
body&.to_json || ''
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module AccessGrid
|
|
7
|
+
# Internal crypto helpers for the SmartTap reveal flow.
|
|
8
|
+
#
|
|
9
|
+
# Driven by Console#reveal_smart_tap; not part of the public SDK surface.
|
|
10
|
+
# Pure stdlib — no new gem deps.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module SmartTapRevealCrypto
|
|
14
|
+
CURVE = 'prime256v1'
|
|
15
|
+
HKDF_INFO = 'accessgrid-smart-tap-reveal-v1'
|
|
16
|
+
KEY_LEN = 32
|
|
17
|
+
|
|
18
|
+
# Generate a fresh ephemeral P-256 keypair for a reveal call.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash] `{priv: OpenSSL::PKey::EC, pub_pem: String}`
|
|
21
|
+
def self.generate_keypair
|
|
22
|
+
priv = OpenSSL::PKey::EC.generate(CURVE)
|
|
23
|
+
{ priv: priv, pub_pem: priv.public_to_pem }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Decrypt the encrypted_private_key envelope from the reveal endpoint.
|
|
27
|
+
#
|
|
28
|
+
# Performs ECDH(client_priv, server_ephemeral_pub) + HKDF-SHA256 +
|
|
29
|
+
# AES-256-GCM. Must match the server-side encryption parameters exactly.
|
|
30
|
+
#
|
|
31
|
+
# @return [String] the plaintext SmartTap private key PEM.
|
|
32
|
+
# @raise [RuntimeError] on missing/bad envelope or auth-tag verification failure.
|
|
33
|
+
def self.decrypt_envelope(envelope, priv)
|
|
34
|
+
server_pub = parse_ephemeral_public_key(envelope)
|
|
35
|
+
nonce = decode_envelope_bytes(envelope['iv'])
|
|
36
|
+
ciphertext = decode_envelope_bytes(envelope['ciphertext'])
|
|
37
|
+
tag = decode_envelope_bytes(envelope['tag'])
|
|
38
|
+
|
|
39
|
+
aes_key = derive_aes_key(priv, server_pub)
|
|
40
|
+
aes_gcm_decrypt(aes_key, nonce, ciphertext, tag)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @api private
|
|
44
|
+
def self.parse_ephemeral_public_key(envelope)
|
|
45
|
+
pem = envelope['ephemeral_public_key']
|
|
46
|
+
raise InvalidEnvelopeError, 'Invalid ephemeral_public_key in envelope' unless pem.is_a?(String) && !pem.empty?
|
|
47
|
+
|
|
48
|
+
OpenSSL::PKey::EC.new(pem)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @api private
|
|
52
|
+
def self.derive_aes_key(priv, server_pub)
|
|
53
|
+
shared_secret = priv.dh_compute_key(server_pub.public_key)
|
|
54
|
+
OpenSSL::KDF.hkdf(shared_secret, salt: '', info: HKDF_INFO, length: KEY_LEN, hash: 'SHA256')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @api private
|
|
58
|
+
def self.aes_gcm_decrypt(aes_key, nonce, ciphertext, tag)
|
|
59
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
|
|
60
|
+
cipher.key = aes_key
|
|
61
|
+
cipher.iv = nonce
|
|
62
|
+
cipher.auth_tag = tag
|
|
63
|
+
cipher.auth_data = ''
|
|
64
|
+
cipher.update(ciphertext) + cipher.final
|
|
65
|
+
rescue OpenSSL::Cipher::CipherError
|
|
66
|
+
raise DecryptError, 'AES-GCM decryption failed (auth tag verification)'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def self.decode_envelope_bytes(value)
|
|
71
|
+
raise InvalidEnvelopeError, 'Envelope iv/ciphertext/tag must be base64-encoded' unless value.is_a?(String)
|
|
72
|
+
|
|
73
|
+
Base64.strict_decode64(value)
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
raise InvalidEnvelopeError, 'Envelope iv/ciphertext/tag must be base64-encoded'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/accessgrid/version.rb
CHANGED