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.
@@ -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 wire " \
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 wire's own validators, so a query's reserved `limit`/`cursor` pass as they do on the wire.
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 pay the toll this refusal demands.
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 holding a key and no account at its origin yet.
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
- # Pays any proof-of-work toll asked for, unless `unpaid:` or carrying its own `proofs:`.
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 intent, cart and payment mandates for `total` cents and pays.
64
- def pays(total:, scope:, line_items:, currency: 'eur')
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
- def signs_back_in = Answer.new(@assistant.login(principal.rsa_key))
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 sign-in issues.
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) = holding(Answer.new(@assistant.claim(link_code, principal.rsa_key)))
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
- # Asks to be linked to a person's account; the answer carries the code the person approves.
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.rsa_key))
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
- Wire one in your test helper:
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
- Wire one in your test helper:
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 paying any proof-of-work toll. A caller with no identity sends no
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 wire error its refusal names.
26
+ # The verb's answer, or the Kiosk error its refusal names.
26
27
  def call(name, kind:, params:, identity:)
27
- token = token_for(identity) if identity
28
- response = request(name, kind, params, token)
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, token, Wire.solve(challenges)) if challenges
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, token, proofs = nil)
39
- env = credentials(token, proofs)
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
- return Rack::MockRequest.new(app).get(query_url(url, params), env) if kind.to_sym == :query
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
- def credentials(token, proofs)
47
- { 'HTTP_AUTHORIZATION' => ("Bearer #{token}" if token),
48
- 'HTTP_KIOSK_POW' => (JSON.generate(proofs) if proofs) }.compact
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 token_for(identity)
55
- Kiosk::Server::AccountBinding.bind!(
56
- public_key_pem: OpenSSL::PKey::RSA.generate(2048).public_key.to_pem,
57
- user_id: identity.user_id, requested_role: identity.role
58
- ).fetch(:access_token)
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 = 'https://kyc.test.invalid'
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::RSA.generate(2048)
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 open_verification(subject:, audience:, callback_url:, **)
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, 'RS256')
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, Kiosk.configuration.kyc_audience, attributes)
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
- @replaced_kyc_settings = SETTINGS.to_h { [it, config.public_send(it)] }
87
- @kyc_provider = Provider.new
88
- config.kyc_provider = @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
- @replaced_kyc_settings&.each { |setting, value| Kiosk.configuration.public_send(:"#{setting}=", value) }
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
- schemas = ActiveRecord.dump_schemas.split(',').map { connection.quote(it.strip) }.join(', ')
24
- tables = connection.select_values(<<~SQL)
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 wire as an assistant does.
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 holding a key and no account here yet.
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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kiosk
4
4
  module TestHelpers
5
- VERSION = '0.5.20'
5
+ VERSION = '0.6.28'
6
6
  end
7
7
  end
@@ -3,7 +3,8 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
  require 'uri'
6
- require 'kiosk/pow/equihash/solver'
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" }, Wire.bearer(token))
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 paid a proof-of-work toll and sent the request again.
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
- def self.bearer(token) = { 'Authorization' => "Bearer #{token}" }
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: Kiosk::Pow::Equihash.solve(it) } }
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 bearer(token) = self.class.bearer(token)
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 pays, or nil.
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.new(uri, body.nil? ? headers : { 'Content-Type' => 'application/json' }.merge(headers))
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