kiosk-test-support 0.5.20 → 0.6.28
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/CHANGELOG.md +155 -0
- data/README.md +71 -26
- data/lib/kiosk/story_test.rb +1 -1
- data/lib/kiosk/test_helpers/assistant/events.rb +10 -8
- data/lib/kiosk/test_helpers/assistant.rb +92 -72
- data/lib/kiosk/test_helpers/conformance/checks/routes.rb +1 -1
- data/lib/kiosk/test_helpers/conformance/in_process_origin.rb +1 -1
- data/lib/kiosk/test_helpers/customer.rb +17 -22
- data/lib/kiosk/test_helpers/dpop.rb +59 -0
- data/lib/kiosk/test_helpers/errors.rb +2 -2
- data/lib/kiosk/test_helpers/in_process_wire.rb +31 -17
- data/lib/kiosk/test_helpers/kyc.rb +15 -15
- data/lib/kiosk/test_helpers/live_server.rb +9 -8
- data/lib/kiosk/test_helpers/mandate.rb +46 -0
- data/lib/kiosk/test_helpers/pow_solver.rb +19 -0
- data/lib/kiosk/test_helpers/skill_pin/rspec.rb +17 -0
- data/lib/kiosk/test_helpers/skill_pin.rb +66 -0
- data/lib/kiosk/test_helpers/story.rb +1 -1
- data/lib/kiosk/test_helpers/version.rb +1 -1
- data/lib/kiosk/test_helpers/wire.rb +34 -11
- metadata +26 -19
|
@@ -74,7 +74,7 @@ module Kiosk
|
|
|
74
74
|
routed_name = found[:kiosk_verb].to_s
|
|
75
75
|
return if routed_name == verb.name
|
|
76
76
|
|
|
77
|
-
"#{verb}: #{verb.http_method} #{path} hands the
|
|
77
|
+
"#{verb}: #{verb.http_method} #{path} hands the engine " \
|
|
78
78
|
"kiosk_verb: #{routed_name.inspect} — the path segment and the " \
|
|
79
79
|
'`defaults:` are two spellings of one name and must match'
|
|
80
80
|
end
|
|
@@ -45,7 +45,7 @@ module Kiosk
|
|
|
45
45
|
@wire.call(name, kind: kind, params: params, identity: identity_for(as))
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Uses the
|
|
48
|
+
# Uses the protocol's own validators, so a query's reserved `limit`/`cursor` pass as they do in the protocol.
|
|
49
49
|
def schema_errors(payload, schema:, verb:, kind:, slot:)
|
|
50
50
|
if slot.to_s == 'input_schema'
|
|
51
51
|
Kiosk::Server::RequestValidation.validate_arguments!(payload, input_schema: schema, verb: verb,
|
|
@@ -25,7 +25,7 @@ module Kiosk
|
|
|
25
25
|
# How many proofs of work the assistant solved to be answered.
|
|
26
26
|
def tolls_paid = @response.proofs
|
|
27
27
|
|
|
28
|
-
# The proofs of work that
|
|
28
|
+
# The proofs of work that solve the toll this refusal demands.
|
|
29
29
|
def solved_toll = Wire.solve(self['challenges'])
|
|
30
30
|
|
|
31
31
|
# The `Link` target with `rel="next"`: the rest of a truncated list.
|
|
@@ -40,10 +40,8 @@ module Kiosk
|
|
|
40
40
|
class Customer
|
|
41
41
|
attr_reader :principal
|
|
42
42
|
|
|
43
|
-
# An assistant
|
|
44
|
-
def self.newcomer(assistant)
|
|
45
|
-
new(assistant, Assistant::Principal.new(agent_id: nil, user_id: nil, token: nil, rsa_key: OpenSSL::PKey::RSA.generate(2048)))
|
|
46
|
-
end
|
|
43
|
+
# An assistant that registered its key here and acts for no person yet.
|
|
44
|
+
def self.newcomer(assistant) = new(assistant, assistant.register_client!)
|
|
47
45
|
|
|
48
46
|
def initialize(assistant, principal)
|
|
49
47
|
@assistant = assistant
|
|
@@ -52,7 +50,7 @@ module Kiosk
|
|
|
52
50
|
|
|
53
51
|
def origin = @assistant.base_url
|
|
54
52
|
|
|
55
|
-
#
|
|
53
|
+
# Solves any proof-of-work toll asked for, unless `unpaid:` or carrying its own `proofs:`.
|
|
56
54
|
def asks(query, headers: {}, unpaid: false, proofs: nil, **params)
|
|
57
55
|
headers = headers.merge('Kiosk-PoW' => JSON.generate(proofs)) if proofs
|
|
58
56
|
Answer.new(@assistant.query(principal, name: query.to_s, headers:, pay_tolls: !(unpaid || proofs), **params))
|
|
@@ -60,15 +58,8 @@ module Kiosk
|
|
|
60
58
|
|
|
61
59
|
def does(action, headers: {}, **args) = Answer.new(@assistant.run(principal, name: action.to_s, headers:, **args))
|
|
62
60
|
|
|
63
|
-
# Signs
|
|
64
|
-
def pays(
|
|
65
|
-
Answer.new(@assistant.pay(principal, **@assistant.mandates(principal, total:, scope:, line_items:, currency:)))
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Signs the `cart` an action answered with, unchanged, and pays.
|
|
69
|
-
def pays_cart(cart, scope:, currency: cart['currency'])
|
|
70
|
-
pays(total: cart['total_amount_cents'], scope:, line_items: cart['line_items'], currency:)
|
|
71
|
-
end
|
|
61
|
+
# Signs the checkout and payment mandates for the `checkout_jwt` an action answered, and pays.
|
|
62
|
+
def pays(checkout_jwt) = Answer.new(@assistant.pay(principal, checkout_jwt))
|
|
72
63
|
|
|
73
64
|
# The answer says whether a card is on file, or carries the page where the person saves one.
|
|
74
65
|
def sets_up_payment = does(:payment_setup)
|
|
@@ -92,21 +83,25 @@ module Kiosk
|
|
|
92
83
|
def account = claims['sub']
|
|
93
84
|
def role = claims['role']
|
|
94
85
|
|
|
95
|
-
|
|
86
|
+
# Asks the token endpoint for a new token with the key it holds.
|
|
87
|
+
def signs_back_in = Answer.new(@assistant.token(principal))
|
|
96
88
|
|
|
97
|
-
# This assistant holding the credential a fresh
|
|
89
|
+
# This assistant holding the credential a fresh token request issues.
|
|
98
90
|
def with_a_fresh_credential = holding(signs_back_in)
|
|
99
91
|
|
|
100
92
|
# Redeems the link code a person handed over; the assistant then acts for that person.
|
|
101
|
-
def redeems(link_code)
|
|
93
|
+
def redeems(link_code)
|
|
94
|
+
claimed = Answer.new(@assistant.claim(link_code, principal))
|
|
95
|
+
raise "#{origin} did not redeem #{link_code}: #{claimed}" unless claimed.ok?
|
|
102
96
|
|
|
103
|
-
|
|
104
|
-
def asks_to_be_linked(client_id: 'kiosk-story')
|
|
105
|
-
Answer.new(@assistant.device_authorization(client_id:, public_key: principal.rsa_key.public_key.to_pem))
|
|
97
|
+
with_a_fresh_credential
|
|
106
98
|
end
|
|
107
99
|
|
|
100
|
+
# Asks to be linked to a person's account; the answer carries the code the person approves.
|
|
101
|
+
def asks_to_be_linked = Answer.new(@assistant.device_authorization(principal))
|
|
102
|
+
|
|
108
103
|
# Asks once whether the person has approved `request`.
|
|
109
|
-
def polls(request) = Answer.new(@assistant.device_token(request['device_code'], principal
|
|
104
|
+
def polls(request) = Answer.new(@assistant.device_token(request['device_code'], principal))
|
|
110
105
|
|
|
111
106
|
# This assistant holding the credential the person granted through `request`.
|
|
112
107
|
def collects(request) = holding(polls(request))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'jwt'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'uri'
|
|
9
|
+
require 'kiosk/server/eddsa'
|
|
10
|
+
|
|
11
|
+
module Kiosk
|
|
12
|
+
module TestHelpers
|
|
13
|
+
# The two headers of a call that carries an access token (RFC 9449): the
|
|
14
|
+
# token as `Authorization: DPoP`, and a proof that the token's key signs
|
|
15
|
+
# for this method and URL. A token request has no token yet: it sends
|
|
16
|
+
# only the proof, with no `ath`.
|
|
17
|
+
#
|
|
18
|
+
# Dpop.headers(token, key, method: 'GET', url: 'https://shop.example/kiosk/catalog')
|
|
19
|
+
# Dpop.headers(nil, key, method: 'POST', url: 'https://shop.example/kiosk/oauth/token')
|
|
20
|
+
module Dpop
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# A new assistant key: Ed25519, or P-256 for `ES256`.
|
|
24
|
+
def generate_key(algorithm = 'EdDSA')
|
|
25
|
+
algorithm == 'ES256' ? OpenSSL::PKey::EC.generate('prime256v1') : OpenSSL::PKey.generate_key('ED25519')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The JOSE algorithm the key signs with: EdDSA or ES256.
|
|
29
|
+
def algorithm(key) = Kiosk::Server::Jwk.algorithm(key)
|
|
30
|
+
|
|
31
|
+
def headers(token, key, method:, url:, iat: Time.now.to_i)
|
|
32
|
+
proof = { 'DPoP' => proof(token, key, method:, url:, iat:) }
|
|
33
|
+
token ? { 'Authorization' => "DPoP #{token}", **proof } : proof
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The same two headers as Rack env entries.
|
|
37
|
+
def env(token, key, method:, url:, iat: Time.now.to_i)
|
|
38
|
+
{ 'HTTP_AUTHORIZATION' => "DPoP #{token}", 'HTTP_DPOP' => proof(token, key, method:, url:, iat:) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `url` loses its query and fragment: `htu` names the resource, not the arguments.
|
|
42
|
+
def proof(token, key, method:, url:, iat: Time.now.to_i)
|
|
43
|
+
uri = URI(url.to_s)
|
|
44
|
+
htu = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}#{uri.path}"
|
|
45
|
+
payload = { jti: SecureRandom.uuid, htm: method.to_s.upcase, htu: htu, iat: iat }
|
|
46
|
+
payload[:ath] = ath(token) if token
|
|
47
|
+
JWT.encode(payload, key, algorithm(key), { typ: 'dpop+jwt', jwk: jwk(key) })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ath(token) = Base64.urlsafe_encode64(Digest::SHA256.digest(token.to_s), padding: false)
|
|
51
|
+
|
|
52
|
+
# The RFC 7638 thumbprint of the key: a token's `cnf.jkt`, a rotation's `new_key`.
|
|
53
|
+
def thumbprint(key) = Kiosk::Server::Jwk.thumbprint(key)
|
|
54
|
+
|
|
55
|
+
# The public JWK (RFC 7517) of the key: a proof's `jwk`, a registration's `jwks` member.
|
|
56
|
+
def jwk(key) = Kiosk::Server::Jwk.public_members(key)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -13,7 +13,7 @@ module Kiosk
|
|
|
13
13
|
DEFAULT_MESSAGE = <<~MSG.strip
|
|
14
14
|
Kiosk::TestHelpers has no executor configured.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Configure one in your test helper:
|
|
17
17
|
|
|
18
18
|
# spec/spec_helper.rb or test/test_helper.rb
|
|
19
19
|
require "kiosk/test_helpers/in_process_executor"
|
|
@@ -33,7 +33,7 @@ module Kiosk
|
|
|
33
33
|
DEFAULT_MESSAGE = <<~MSG.strip
|
|
34
34
|
Kiosk::TestHelpers::Conformance has no origin configured.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Configure one in your test helper:
|
|
37
37
|
|
|
38
38
|
# spec/spec_helper.rb or test/test_helper.rb
|
|
39
39
|
require "kiosk/test_helpers/conformance/in_process_origin"
|
|
@@ -9,12 +9,13 @@ require 'rack/mock'
|
|
|
9
9
|
require 'uri'
|
|
10
10
|
require 'kiosk/server'
|
|
11
11
|
require 'kiosk/test_helpers/wire'
|
|
12
|
+
require 'kiosk/test_helpers/dpop'
|
|
12
13
|
|
|
13
14
|
module Kiosk
|
|
14
15
|
module TestHelpers
|
|
15
16
|
# Calls a verb of the booted app through its route, in this process, as an
|
|
16
17
|
# assistant does: one linked to the principal's account, as a person links
|
|
17
|
-
# one, and
|
|
18
|
+
# one, and solving any proof-of-work toll. A caller with no identity sends no
|
|
18
19
|
# token.
|
|
19
20
|
class InProcessWire
|
|
20
21
|
# `app` defaults to the Rails application, middleware included.
|
|
@@ -22,12 +23,12 @@ module Kiosk
|
|
|
22
23
|
@app = app
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
# The verb's answer, or the
|
|
26
|
+
# The verb's answer, or the Kiosk error its refusal names.
|
|
26
27
|
def call(name, kind:, params:, identity:)
|
|
27
|
-
|
|
28
|
-
response = request(name, kind, params,
|
|
28
|
+
credential = credential_for(identity) if identity
|
|
29
|
+
response = request(name, kind, params, credential)
|
|
29
30
|
challenges = toll_challenges(response)
|
|
30
|
-
response = request(name, kind, params,
|
|
31
|
+
response = request(name, kind, params, credential, Wire.solve(challenges)) if challenges
|
|
31
32
|
answer(response)
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -35,27 +36,40 @@ module Kiosk
|
|
|
35
36
|
|
|
36
37
|
def app = @app || ::Rails.application
|
|
37
38
|
|
|
38
|
-
def request(name, kind, params,
|
|
39
|
-
|
|
39
|
+
def request(name, kind, params, credential, proofs = nil)
|
|
40
|
+
query = kind.to_sym == :query
|
|
40
41
|
url = "#{Kiosk.current_issuer.to_s.chomp('/')}#{Kiosk.configuration.mount_path}/#{name}"
|
|
41
|
-
|
|
42
|
+
env = credentials(credential, query ? 'GET' : 'POST', url, proofs)
|
|
43
|
+
return Rack::MockRequest.new(app).get(query_url(url, params), env) if query
|
|
42
44
|
|
|
43
45
|
Rack::MockRequest.new(app).post(url, env.merge(input: JSON.generate(params), 'CONTENT_TYPE' => 'application/json'))
|
|
44
46
|
end
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
# The access token with a DPoP proof its key signs for this request.
|
|
49
|
+
def credentials(credential, method, url, proofs)
|
|
50
|
+
env = credential ? Dpop.env(credential.token, credential.key, method:, url:) : {}
|
|
51
|
+
env['HTTP_KIOSK_POW'] = JSON.generate(proofs) if proofs
|
|
52
|
+
env
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def query_url(url, params) = params.empty? ? url : "#{url}?#{URI.encode_www_form(params)}"
|
|
52
56
|
|
|
53
|
-
# The token of an assistant linked to the principal for this call.
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
# The token and key of an assistant registered and linked to the principal for this call.
|
|
58
|
+
def credential_for(identity)
|
|
59
|
+
key = Dpop.generate_key
|
|
60
|
+
client = Kiosk::Server::AccountBinding.bind!(agent_id: register(key).id, user_id: identity.user_id,
|
|
61
|
+
requested_role: identity.role)
|
|
62
|
+
token = Kiosk::Server::AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: client.id, role: client.role)
|
|
63
|
+
Wire::DpopCredential.new(token:, key:)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Registers the key as a client, and solves the registration toll if the origin asks for one.
|
|
67
|
+
def register(key)
|
|
68
|
+
metadata = { 'jwks' => { 'keys' => [Kiosk::Server::AssistantKey.to_jwk(key)] } }
|
|
69
|
+
Kiosk::Server::ClientRegistration.call(metadata)
|
|
70
|
+
rescue Kiosk::Server::Errors::PowRequired => e
|
|
71
|
+
header = JSON.generate(Wire.solve(JSON.parse(JSON.generate(e.challenges))))
|
|
72
|
+
Kiosk::Server::ClientRegistration.call(metadata, pow: Kiosk::Server::PowGate.proofs_from_header(header))
|
|
59
73
|
end
|
|
60
74
|
|
|
61
75
|
def toll_challenges(response)
|
|
@@ -17,21 +17,23 @@ module Kiosk
|
|
|
17
17
|
# rider.requests_verification
|
|
18
18
|
# the_verification_service_confirms(rider, age_over_18: true)
|
|
19
19
|
module Kyc
|
|
20
|
-
ISSUER
|
|
21
|
-
SETTINGS = %i[kyc_provider kyc_issuer kyc_public_key].freeze
|
|
20
|
+
ISSUER = 'https://kyc.test.invalid'
|
|
22
21
|
|
|
23
|
-
# Opens checks without a network and signs attestations for them
|
|
22
|
+
# Opens checks without a network and signs attestations for them, for
|
|
23
|
+
# the audience of the provider it stands in for.
|
|
24
24
|
class Provider < Kiosk::KycProviders::Base
|
|
25
25
|
Check = Data.define(:subject, :audience, :nonce, :callback_url)
|
|
26
26
|
|
|
27
|
-
def self.key = @key ||= OpenSSL::PKey
|
|
27
|
+
def self.key = @key ||= OpenSSL::PKey.generate_key('ED25519')
|
|
28
28
|
|
|
29
|
-
def initialize
|
|
30
|
-
super
|
|
29
|
+
def initialize(audience:)
|
|
30
|
+
super(issuer: ISSUER, public_key: self.class.key, audience:)
|
|
31
31
|
@checks = {}
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def
|
|
34
|
+
def opens_verifications? = true
|
|
35
|
+
|
|
36
|
+
def open_verification(subject:, callback_url:, **)
|
|
35
37
|
request_id = SecureRandom.uuid
|
|
36
38
|
@checks[request_id] = Check.new(subject:, audience:, nonce: SecureRandom.hex(16), callback_url:)
|
|
37
39
|
{ request_id:, nonce: @checks[request_id].nonce, verification_url: "#{ISSUER}/verify?request=#{request_id}" }
|
|
@@ -47,7 +49,7 @@ module Kiosk
|
|
|
47
49
|
def attest(subject, audience, attributes)
|
|
48
50
|
now = Time.now.to_i
|
|
49
51
|
JWT.encode({ sub: subject.to_s, level: 'verified', iss: ISSUER, aud: audience.to_s, attributes:,
|
|
50
|
-
iat: now, exp: now + 3600 }, self.class.key, '
|
|
52
|
+
iat: now, exp: now + 3600 }, self.class.key, 'EdDSA')
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
|
|
@@ -72,7 +74,7 @@ module Kiosk
|
|
|
72
74
|
# An attestation the provider signed for this principal, as an assistant
|
|
73
75
|
# submits it to `POST <endpoint>/agents/kyc`.
|
|
74
76
|
def kyc_attestation(principal, **attributes)
|
|
75
|
-
@kyc_provider.attest(principal.user_id,
|
|
77
|
+
@kyc_provider.attest(principal.user_id, @kyc_provider.audience, attributes)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
private
|
|
@@ -83,15 +85,13 @@ module Kiosk
|
|
|
83
85
|
|
|
84
86
|
def stand_in_for_kyc_provider
|
|
85
87
|
config = Kiosk.configuration
|
|
86
|
-
@
|
|
87
|
-
@kyc_provider = Provider.new
|
|
88
|
-
config.kyc_provider
|
|
89
|
-
config.kyc_issuer = ISSUER
|
|
90
|
-
config.kyc_public_key = Provider.key.public_key
|
|
88
|
+
@replaced_kyc_provider = config.kyc_provider
|
|
89
|
+
@kyc_provider = Provider.new(audience: @replaced_kyc_provider&.audience)
|
|
90
|
+
config.kyc_provider = @kyc_provider
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def restore_kyc_provider
|
|
94
|
-
|
|
94
|
+
Kiosk.configuration.kyc_provider = @replaced_kyc_provider if @kyc_provider
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
97
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'puma'
|
|
4
3
|
require 'socket'
|
|
4
|
+
begin
|
|
5
|
+
require 'puma'
|
|
6
|
+
rescue LoadError
|
|
7
|
+
raise LoadError, "Kiosk::TestHelpers::LiveServer serves the app with Puma. Add gem 'puma' to the Gemfile."
|
|
8
|
+
end
|
|
5
9
|
require 'kiosk/test_helpers/assistant'
|
|
6
10
|
|
|
7
11
|
module Kiosk
|
|
@@ -18,19 +22,16 @@ module Kiosk
|
|
|
18
22
|
base.teardown { SeededDatabase.empty }
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# Every table on the search path but Rails' own two.
|
|
21
26
|
def self.empty
|
|
22
27
|
connection = ActiveRecord::Base.connection
|
|
23
|
-
|
|
24
|
-
tables
|
|
25
|
-
SELECT format('%I.%I', schemaname, tablename) FROM pg_tables
|
|
26
|
-
WHERE schemaname IN (#{schemas}) AND tablename NOT IN ('schema_migrations', 'ar_internal_metadata')
|
|
27
|
-
SQL
|
|
28
|
-
connection.execute("TRUNCATE #{tables.join(', ')} RESTART IDENTITY CASCADE") if tables.any?
|
|
28
|
+
tables = connection.tables - %w[schema_migrations ar_internal_metadata]
|
|
29
|
+
connection.execute("TRUNCATE #{tables.map { connection.quote_table_name(it) }.join(', ')} RESTART IDENTITY CASCADE") if tables.any?
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
# Serves the app over HTTP from the test process, on a free port that
|
|
33
|
-
# becomes the origin's issuer, so a test drives the
|
|
34
|
+
# becomes the origin's issuer, so a test drives the protocol as an assistant does.
|
|
34
35
|
module LiveServer
|
|
35
36
|
def self.included(base)
|
|
36
37
|
base.include SeededDatabase
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'jwt'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'kiosk/test_helpers/dpop'
|
|
9
|
+
|
|
10
|
+
module Kiosk
|
|
11
|
+
module TestHelpers
|
|
12
|
+
# The two closed AP2 v0.2 mandates an assistant signs to pay an operator's
|
|
13
|
+
# checkout, as compact SD-JWTs: what an assistant following the skill sends.
|
|
14
|
+
module Mandate
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param checkout_jwt [String] the checkout an action answered, unchanged
|
|
18
|
+
# @return [Hash] `checkout:` and `payment:`, each a compact SD-JWT
|
|
19
|
+
def pay(key, checkout_jwt, instrument: 'on_file', lifetime: 600)
|
|
20
|
+
checkout = JWT.decode(checkout_jwt, nil, false).first
|
|
21
|
+
checkout_hash = digest(checkout_jwt)
|
|
22
|
+
now = Time.now.to_i
|
|
23
|
+
times = { iat: now, exp: now + lifetime }
|
|
24
|
+
{ checkout: sign(key, { vct: 'mandate.checkout.1', checkout_jwt:, checkout_hash:, **times }),
|
|
25
|
+
payment: sign(key, { vct: 'mandate.payment.1', transaction_id: checkout_hash, payee: checkout['merchant'],
|
|
26
|
+
payment_amount: { amount: checkout['total_amount_cents'], currency: checkout['currency'] },
|
|
27
|
+
payment_instrument: { id: instrument, type: 'card' }, **times }) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A closed mandate: an SD-JWT whose one disclosure is `content`.
|
|
31
|
+
def sign(key, content)
|
|
32
|
+
disclosure = b64(JSON.generate([b64(SecureRandom.random_bytes(16)), content]))
|
|
33
|
+
jwt = JWT.encode({ _sd_alg: 'sha-256', delegate_payload: [{ '...' => digest(disclosure) }] }, key, Dpop.algorithm(key),
|
|
34
|
+
{ typ: 'dc+sd-jwt' })
|
|
35
|
+
"#{jwt}~#{disclosure}~"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The disclosed mandate of `sd_jwt`, unverified.
|
|
39
|
+
def content(sd_jwt) = JSON.parse(Base64.urlsafe_decode64(sd_jwt.split('~')[1])).last
|
|
40
|
+
|
|
41
|
+
def digest(text) = b64(Digest::SHA256.digest(text))
|
|
42
|
+
|
|
43
|
+
def b64(bytes) = Base64.urlsafe_encode64(bytes, padding: false)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# The solver for a proof-of-work challenge. kiosk-pow-equihash ships the one
|
|
6
|
+
# solver here, and the host's bundle brings that gem.
|
|
7
|
+
module PowSolver
|
|
8
|
+
def self.for(challenge)
|
|
9
|
+
alg = challenge['alg']
|
|
10
|
+
raise ArgumentError, "The origin asks for a #{alg.inspect} proof of work. Wire solves only Equihash." unless alg == 'equihash'
|
|
11
|
+
|
|
12
|
+
require 'kiosk/pow/equihash/solver'
|
|
13
|
+
Kiosk::Pow::Equihash
|
|
14
|
+
rescue LoadError
|
|
15
|
+
raise LoadError, "Wire solves Equihash tolls with kiosk-pow-equihash. Add gem 'kiosk-pow-equihash' to the Gemfile."
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/expectations'
|
|
4
|
+
|
|
5
|
+
require 'kiosk/test_helpers/skill_pin'
|
|
6
|
+
|
|
7
|
+
# `expect(Kiosk.configuration).to pin_the_served_skill(hashed_version: '0.6.28')`
|
|
8
|
+
# The matcher DSL gives the keywords to the block as one Hash.
|
|
9
|
+
RSpec::Matchers.define :pin_the_served_skill do |options|
|
|
10
|
+
match do |configuration|
|
|
11
|
+
@problem = Kiosk::TestHelpers::SkillPin.problem(hashed_version: options.fetch(:hashed_version), configuration:)
|
|
12
|
+
@problem.nil?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
failure_message { @problem }
|
|
16
|
+
description { 'pin the SHA-256 of the skill that kiosk-server gives' }
|
|
17
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/server'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module TestHelpers
|
|
7
|
+
# Checks the skill pin of an origin. kiosk-server gives the skill URL, and
|
|
8
|
+
# each kiosk-server release gives the URL of a new skill version. The
|
|
9
|
+
# operator sets `skill_sha256`, the SHA-256 of that file, and tells this
|
|
10
|
+
# check the skill version that the digest is for. When an update of
|
|
11
|
+
# kiosk-server moves the skill to a different version, the check fails and
|
|
12
|
+
# tells the operator how to make the new digest.
|
|
13
|
+
module SkillPin
|
|
14
|
+
VERSION_IN_URL = /skill-v(\d+\.\d+\.\d+)\.md\z/
|
|
15
|
+
SHA256 = /\A\h{64}\z/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# nil when the pin is correct. Otherwise, what is wrong and what to do.
|
|
20
|
+
def problem(hashed_version:, configuration: Kiosk.configuration)
|
|
21
|
+
served = Kiosk::Configuration.new.skill_url
|
|
22
|
+
version = served[VERSION_IN_URL, 1]
|
|
23
|
+
return override(configuration.skill_url, served) unless configuration.skill_url == served
|
|
24
|
+
return if hashed_version == version && configuration.skill_sha256.to_s.match?(SHA256)
|
|
25
|
+
|
|
26
|
+
stale(served, version, hashed_version, configuration.skill_sha256)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def override(configured, served)
|
|
30
|
+
"The configuration sets skill_url to #{configured}. kiosk-server gives the skill URL " \
|
|
31
|
+
"(#{served}). Remove the line `c.skill_url = ...` from your configuration."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stale(url, version, hashed_version, sha256)
|
|
35
|
+
<<~MESSAGE
|
|
36
|
+
#{cause(version, hashed_version, sha256)}
|
|
37
|
+
Do these steps:
|
|
38
|
+
1. Download the skill #{version} from kiosk.tech and calculate its SHA-256:
|
|
39
|
+
curl -fsS #{url} | shasum -a 256
|
|
40
|
+
2. Set `c.skill_sha256` in config/initializers/kiosk.rb to that digest.
|
|
41
|
+
3. Set `hashed_version: '#{version}'` in this test.
|
|
42
|
+
MESSAGE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cause(version, hashed_version, sha256)
|
|
46
|
+
if hashed_version != version
|
|
47
|
+
"kiosk-server gives the skill #{version}, but skill_sha256 is the digest of the skill #{hashed_version}."
|
|
48
|
+
elsif sha256.nil?
|
|
49
|
+
"kiosk-server gives the skill #{version}, and skill_sha256 is not set."
|
|
50
|
+
else
|
|
51
|
+
"skill_sha256 is not a SHA-256 digest (64 hexadecimal characters): #{sha256.inspect}."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The Minitest assertion:
|
|
56
|
+
# include Kiosk::TestHelpers::SkillPin::Assertions
|
|
57
|
+
# assert_kiosk_skill_pin hashed_version: '0.6.28'
|
|
58
|
+
module Assertions
|
|
59
|
+
def assert_kiosk_skill_pin(hashed_version:)
|
|
60
|
+
problem = SkillPin.problem(hashed_version:)
|
|
61
|
+
assert(problem.nil?, problem)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -18,7 +18,7 @@ module Kiosk
|
|
|
18
18
|
# A newly registered assistant, as `as` (a demo's own Customer subclass).
|
|
19
19
|
def a_customer(as: Customer) = as.new(assistant, register)
|
|
20
20
|
|
|
21
|
-
# An assistant
|
|
21
|
+
# An assistant that registered its key here and acts for no person yet.
|
|
22
22
|
def a_newcomer(as: Customer) = as.newcomer(assistant)
|
|
23
23
|
|
|
24
24
|
# Someone with an account on the operator's site, signed in there.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'uri'
|
|
6
|
-
require 'kiosk/
|
|
6
|
+
require 'kiosk/test_helpers/dpop'
|
|
7
|
+
require 'kiosk/test_helpers/pow_solver'
|
|
7
8
|
|
|
8
9
|
module Kiosk
|
|
9
10
|
module TestHelpers
|
|
@@ -11,7 +12,7 @@ module Kiosk
|
|
|
11
12
|
# `pay_tolls: true` it solves a proof-of-work toll and sends the request once more.
|
|
12
13
|
#
|
|
13
14
|
# wire = Kiosk::TestHelpers::Wire.new(base_url: "http://127.0.0.1:3006")
|
|
14
|
-
# status, problem = wire.post_json("/kiosk/edit_listing", { listing_id: "junk" },
|
|
15
|
+
# status, problem = wire.post_json("/kiosk/edit_listing", { listing_id: "junk" }, wire.dpop(token, key))
|
|
15
16
|
class Wire
|
|
16
17
|
# One answer: a body that is not JSON reads as `{}`, an unreachable origin as status 0.
|
|
17
18
|
Response = Data.define(:status, :headers, :body, :raw_body, :proofs) do
|
|
@@ -19,7 +20,7 @@ module Kiosk
|
|
|
19
20
|
|
|
20
21
|
def [](header) = headers[header.to_s.downcase]
|
|
21
22
|
|
|
22
|
-
# Whether the client
|
|
23
|
+
# Whether the client solved a proof-of-work toll and sent the request again.
|
|
23
24
|
def pow_retried? = proofs.positive?
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -42,10 +43,15 @@ module Kiosk
|
|
|
42
43
|
http
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
# An access token and the key it is bound to. As a header value, the protocol
|
|
47
|
+
# sends it as `Authorization: DPoP <token>` with a proof for that request;
|
|
48
|
+
# with no token, as the proof alone (a token request).
|
|
49
|
+
DpopCredential = Data.define(:token, :key)
|
|
50
|
+
|
|
51
|
+
def self.dpop(token, key) = { 'Authorization' => DpopCredential.new(token:, key:) }
|
|
46
52
|
|
|
47
53
|
# A solved proof of work for each challenge, as the `Kiosk-PoW` header carries them.
|
|
48
|
-
def self.solve(challenges) = challenges.map { { challenge: it, nonce:
|
|
54
|
+
def self.solve(challenges) = challenges.map { { challenge: it, nonce: PowSolver.for(it).solve(it) } }
|
|
49
55
|
|
|
50
56
|
attr_reader :base_url
|
|
51
57
|
|
|
@@ -56,7 +62,7 @@ module Kiosk
|
|
|
56
62
|
@read_timeout = read_timeout
|
|
57
63
|
end
|
|
58
64
|
|
|
59
|
-
def
|
|
65
|
+
def dpop(token, key) = self.class.dpop(token, key)
|
|
60
66
|
|
|
61
67
|
def post_json(path, body = {}, headers = {}) = post(path, body, headers).then { [it.status, it.body] }
|
|
62
68
|
|
|
@@ -89,7 +95,7 @@ module Kiosk
|
|
|
89
95
|
uri
|
|
90
96
|
end
|
|
91
97
|
|
|
92
|
-
# The proof-of-work challenges of a toll this client
|
|
98
|
+
# The proof-of-work challenges of a toll this client solves, or nil.
|
|
93
99
|
def toll_challenges(answer)
|
|
94
100
|
return unless @pay_tolls && answer.status == 402 && answer.body.is_a?(Hash)
|
|
95
101
|
|
|
@@ -97,10 +103,8 @@ module Kiosk
|
|
|
97
103
|
challenges if challenges.is_a?(Array) && challenges.any?
|
|
98
104
|
end
|
|
99
105
|
|
|
100
|
-
def deliver(klass, uri, body, headers)
|
|
101
|
-
request = klass
|
|
102
|
-
request.body = JSON.generate(body) unless body.nil?
|
|
103
|
-
yield request if block_given?
|
|
106
|
+
def deliver(klass, uri, body, headers, &)
|
|
107
|
+
request = build(klass, uri, body, headers, &)
|
|
104
108
|
begin
|
|
105
109
|
answer = self.class.http_for(uri, open_timeout: @open_timeout, read_timeout: @read_timeout).request(request)
|
|
106
110
|
rescue StandardError => e
|
|
@@ -109,6 +113,25 @@ module Kiosk
|
|
|
109
113
|
response(answer)
|
|
110
114
|
end
|
|
111
115
|
|
|
116
|
+
def build(klass, uri, body, headers)
|
|
117
|
+
headers = { 'Content-Type' => 'application/json' }.merge(headers) unless body.nil?
|
|
118
|
+
request = klass.new(uri, signed(headers, klass::METHOD, uri))
|
|
119
|
+
request.body = JSON.generate(body) unless body.nil?
|
|
120
|
+
yield request if block_given?
|
|
121
|
+
request
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Each delivery signs its own DPoP proof, so a retry never reuses one.
|
|
125
|
+
def signed(headers, method, uri)
|
|
126
|
+
headers.each_with_object({}) do |(name, value), out|
|
|
127
|
+
if value.is_a?(DpopCredential)
|
|
128
|
+
out.merge!(Dpop.headers(value.token, value.key, method:, url: uri.to_s))
|
|
129
|
+
else
|
|
130
|
+
out[name] = value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
112
135
|
def response(answer)
|
|
113
136
|
raw_body = answer.body.to_s
|
|
114
137
|
body = begin
|