kiosk-server 0.5.20

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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The Kiosk response headers and the cache policy of a wire response.
6
+ module Headers
7
+ def self.add_to(headers, server_version: Kiosk::Server::VERSION)
8
+ headers[Kiosk::Protocol::HEADER_SERVER_VERSION] = server_version
9
+ headers[Kiosk::Protocol::HEADER_API_VERSION] = Kiosk::Protocol::API_VERSION
10
+ headers[Kiosk::Protocol::HEADER_MIN_CLIENT] = Kiosk::Protocol::MIN_CLIENT
11
+ headers
12
+ end
13
+
14
+ def self.build(server_version: Kiosk::Server::VERSION)
15
+ add_to({}, server_version: server_version)
16
+ end
17
+
18
+ # §3.7.1: every one of these changes the answer and none is in the URL.
19
+ WIRE_VARY = %w[Authorization Kiosk-PoW Kiosk-Timezone].freeze
20
+
21
+ # Adds the wire `Vary`; forces `no-store` on a 402; defaults to
22
+ # `private, no-store` and refuses a shared-cache directive (§3.7.3).
23
+ def self.add_cache_policy(headers, status:)
24
+ add_wire_vary(headers)
25
+ policy = wire_cache_control(headers['Cache-Control'].to_s, status)
26
+ headers['Cache-Control'] = policy if policy
27
+ headers
28
+ end
29
+
30
+ def self.add_wire_vary(headers)
31
+ present = headers['Vary'].to_s.split(',').map(&:strip).reject(&:empty?)
32
+ missing = WIRE_VARY.reject { |t| present.any? { |p| p.casecmp?(t) } }
33
+ headers['Vary'] = (present + missing).join(', ')
34
+ end
35
+
36
+ # nil keeps the operator's own policy.
37
+ def self.wire_cache_control(operator, status)
38
+ return 'no-store' if status.to_i == 402
39
+ return 'private, no-store' if operator.empty?
40
+ return unless shared_cacheable?(operator)
41
+
42
+ refuse_shared_cache(operator)
43
+ 'private, no-store'
44
+ end
45
+
46
+ # RFC 9111 §3.5: the directives that let a shared cache reuse an answer
47
+ # to an authenticated request.
48
+ SHARED_CACHE_DIRECTIVES = /\b(?:public|s-maxage|must-revalidate)\b/i
49
+
50
+ def self.shared_cacheable?(cache_control)
51
+ SHARED_CACHE_DIRECTIVES.match?(cache_control.to_s)
52
+ end
53
+
54
+ SHARED_CACHE_RULE =
55
+ 'A verb response is for one caller, so a shared cache must not keep it. ' \
56
+ 'Sent `private, no-store` instead; send `private, max-age=N` to let the caller cache it.'
57
+
58
+ def self.refuse_shared_cache(value)
59
+ message = '[kiosk-server] refused a shared-cache policy on a wire response: ' \
60
+ "Cache-Control: #{value.inspect}. #{SHARED_CACHE_RULE}"
61
+ logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
62
+ logger ? logger.warn(message) : warn(message)
63
+ end
64
+
65
+ # The public documents (`/schema`, `/openapi.json`): a short TTL on the
66
+ # fixed URL, a year on the digest-versioned `?v=` one.
67
+ SHORT_MAX_AGE = 60
68
+ IMMUTABLE_MAX_AGE = 31_536_000
69
+
70
+ # Spelt in the order ActionDispatch re-emits the header.
71
+ PUBLIC_SHORT = "max-age=#{SHORT_MAX_AGE}, public".freeze
72
+ PUBLIC_IMMUTABLE = "max-age=#{IMMUTABLE_MAX_AGE}, public, immutable".freeze
73
+
74
+ # Sets no `Vary`: a public document has one answer for every caller.
75
+ def self.add_public_cache_policy(headers, etag:, immutable:)
76
+ headers['Cache-Control'] = immutable ? PUBLIC_IMMUTABLE : PUBLIC_SHORT
77
+ headers['ETag'] = etag
78
+ headers
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Adds the Kiosk response headers to every response under the mount path.
6
+ # Install it before `ActionDispatch::ShowExceptions` so error responses
7
+ # are stamped too; the {Engine} does.
8
+ class HeadersMiddleware
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ # Read before the call: ShowExceptions rewrites PATH_INFO on an error.
14
+ def call(env)
15
+ kiosk = kiosk_path?(env['PATH_INFO'])
16
+ status, headers, body = @app.call(env)
17
+ Headers.add_to(headers) if kiosk
18
+ [status, headers, body]
19
+ end
20
+
21
+ private
22
+
23
+ def kiosk_path?(path)
24
+ return false if path.blank?
25
+
26
+ mount = Kiosk.configuration.mount_path
27
+ path == mount || path.start_with?("#{mount}/")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The identity of a wire or event-stream caller: the bearer token, read by
6
+ # the configured agent IdP or the bundled kiosk-pop one. A cookie session
7
+ # reaches only the human-side pages and the binding endpoints.
8
+ module IdentityResolution
9
+ module_function
10
+
11
+ def agent_idp
12
+ Kiosk.configuration.agent_idp || AgentIdentityProviders::DefaultAgentIdp.new
13
+ end
14
+
15
+ def resolve(request)
16
+ agent_idp.verify(request)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/request'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # Sets `Kiosk.current_issuer` for the request: its origin when the operator
8
+ # serves it (`c.issuer` or one of `c.additional_origins`), else `c.issuer`.
9
+ # The {Engine} installs it beside {HeadersMiddleware}; a plain Rack app
10
+ # `use`s it.
11
+ class IssuerMiddleware
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ issuer = Kiosk.configuration.issuer_for(::Rack::Request.new(env).base_url)
18
+ Kiosk.with_issuer(issuer) { @app.call(env) }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The JWKS document (RFC 7517 §5) of this deployment's signing keys.
6
+ module Jwks
7
+ module_function
8
+
9
+ def build(keys:)
10
+ { keys: Array(keys).map(&:to_jwk) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'securerandom'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # Issues and verifies the RS256 access tokens of the bundled kiosk-pop IdP.
9
+ module JwtIssuer
10
+ ALGORITHM = 'RS256'
11
+ DEFAULT_EXPIRES_IN = 3600
12
+ DEFAULT_LEEWAY = 60
13
+
14
+ # A `kid` not in the JWKS: the signature cannot be checked.
15
+ UNKNOWN_KID = ->(e) { e.message.include?('public key for kid') || e.message.include?('Could not find public key') }
16
+ private_constant :UNKNOWN_KID
17
+
18
+ class Error < StandardError; end
19
+ class SignatureError < Error; end
20
+ class ExpiredError < Error; end
21
+ class AudienceError < Error; end
22
+ class InvalidError < Error; end
23
+ # The agent's revocation watermark covers the token's `iat`.
24
+ class RevokedError < Error; end
25
+
26
+ module_function
27
+
28
+ # rubocop:disable-next Metrics/ParameterLists -- a keyword-only public API; callers pass each option by name
29
+ def issue(claims:, audience:, signing_key: nil, issuer: nil, expires_in: DEFAULT_EXPIRES_IN, now: Time.current)
30
+ signing_key ||= Kiosk.configuration.signing_key
31
+ issuer ||= Kiosk.current_issuer
32
+ raise ArgumentError, 'issuer is required (set Kiosk.configuration.issuer or pass :issuer)' if issuer.blank?
33
+ raise ArgumentError, 'signing_key must carry a private key for issuance' unless signing_key.private?
34
+
35
+ payload = registered_claims(claims, issuer: issuer, audience: audience, issued_at: now.to_i, expires_in: expires_in)
36
+ ::JWT.encode(payload, signing_key.rsa, ALGORITHM, { kid: signing_key.kid, typ: 'JWT' })
37
+ end
38
+
39
+ def verify(token:, jwks:, audience: nil, issuer: nil, leeway: DEFAULT_LEEWAY)
40
+ claims = decode(token, normalize_jwks(jwks), audience: audience, issuer: issuer, leeway: leeway)
41
+ revoked = Kiosk.configuration.revocation_store.revoked?(agent_id: claims[:agent_id], iat: claims[:iat])
42
+ raise RevokedError, 'access token revoked' if revoked
43
+
44
+ claims
45
+ end
46
+
47
+ def registered_claims(claims, issuer:, audience:, issued_at:, expires_in:)
48
+ payload = claims.dup
49
+ payload[:iat] = issued_at
50
+ payload[:nbf] = issued_at
51
+ payload[:exp] = issued_at + expires_in
52
+ payload[:iss] = issuer
53
+ payload[:aud] = audience
54
+ payload[:jti] ||= SecureRandom.uuid
55
+ payload
56
+ end
57
+
58
+ def decode(token, jwks, audience:, issuer:, leeway:)
59
+ decoded, = ::JWT.decode(
60
+ token, nil, true,
61
+ jwks: jwks, algorithms: [ALGORITHM], aud: audience, iss: issuer,
62
+ verify_aud: !audience.nil?, verify_iss: !issuer.nil?, leeway: leeway
63
+ )
64
+ symbolize(decoded)
65
+ rescue ::JWT::DecodeError => e
66
+ raise error_class_for(e), e.message
67
+ end
68
+
69
+ def error_class_for(error)
70
+ case error
71
+ when ::JWT::ExpiredSignature then ExpiredError
72
+ when ::JWT::InvalidAudError then AudienceError
73
+ when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm, UNKNOWN_KID then SignatureError
74
+ else InvalidError
75
+ end
76
+ end
77
+
78
+ def normalize_jwks(input)
79
+ case input
80
+ when Hash
81
+ input
82
+ when Array
83
+ { keys: input.map { |sk| sk.is_a?(SigningKey) ? sk.to_jwk : sk } }
84
+ when SigningKey
85
+ { keys: [input.to_jwk] }
86
+ else
87
+ raise ArgumentError, "jwks must be a Hash, Array, or SigningKey, got #{input.class}"
88
+ end
89
+ end
90
+
91
+ def symbolize(hash)
92
+ hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
93
+ end
94
+
95
+ private_class_method :registered_claims, :decode, :error_class_for
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # The KYC module: `request_kyc`, the provider's callback, the
9
+ # `kyc_verification` topic and the per-person grants. The operator declares
10
+ # `kyc_claims` and gates with {.require!}.
11
+ module Kyc
12
+ NAME = 'request_kyc'
13
+ TOPIC = 'kyc_verification'
14
+ CALLBACK_PATH = 'kyc/callback'
15
+ MAX_OPEN = 3
16
+ OPEN_WINDOW = 15 * 60
17
+
18
+ REQUEST_HINT = "call `#{NAME}` to verify %<claims>s: subscribe to the #{TOPIC} topic first, relay the " \
19
+ 'verification_url it returns to your human, and retry once the event arrives'.freeze
20
+ ATTEST_HINT = 'submit a KYC attestation carrying %<claims>s to POST <endpoint>/agents/kyc, then retry'
21
+ CLOSED_HINT = 'verification of %<claims>s is not available at this origin and retrying will not help'
22
+
23
+ INPUT_SCHEMA = { type: 'object', additionalProperties: false, properties: {}, required: [] }.freeze
24
+
25
+ OUTPUT_SCHEMA = {
26
+ type: 'object', additionalProperties: false,
27
+ properties: {
28
+ request_id: { type: 'string', description: 'The id the kyc_verification event for this check carries.' },
29
+ verification_url: { type: 'string', description: 'The page to hand to your human.' },
30
+ status: { const: 'pending' }
31
+ },
32
+ required: %w[request_id verification_url status]
33
+ }.freeze
34
+
35
+ EVENT_SCHEMA = {
36
+ type: 'object', additionalProperties: false,
37
+ properties: {
38
+ request_id: { type: 'string' },
39
+ status: { const: 'approved' },
40
+ kyc_jws: { type: 'string', description: 'The signed attestation. It is already recorded here; ' \
41
+ 'you may also submit it to POST <endpoint>/agents/kyc.' }
42
+ },
43
+ required: %w[request_id status kyc_jws]
44
+ }.freeze
45
+
46
+ class << self
47
+ def provider = Kiosk.configuration.kyc_provider
48
+
49
+ def claims = Array(Kiosk.configuration.kyc_claims).map(&:to_s)
50
+
51
+ def served!
52
+ return if provider
53
+
54
+ raise Errors::ModuleNotServed.new(
55
+ 'this operator does not serve the KYC module',
56
+ hint: 'no verification can be opened at this origin and retrying will not help — ' \
57
+ 'proceed as you would at an operator that offers none.'
58
+ )
59
+ end
60
+
61
+ def register!
62
+ claims_declared!
63
+ Actions.declare(NAME, method(:call), description: description,
64
+ input_schema: INPUT_SCHEMA, output_schema: OUTPUT_SCHEMA)
65
+ Events.register(
66
+ name: TOPIC, reach: :principal,
67
+ description: 'An identity check you opened with request_kyc was approved and recorded for ' \
68
+ 'your human. Retry what you were doing.',
69
+ payload_schema: EVENT_SCHEMA,
70
+ subject_reachable: ->(request_id, identity) { owner_of(request_id) == identity.user_id.to_s }
71
+ )
72
+ end
73
+
74
+ def claims_declared!
75
+ return unless claims.empty?
76
+
77
+ raise Errors::ConfigurationError,
78
+ 'Kiosk::Server: kyc_provider is set but kyc_claims is empty, so no verification can grant ' \
79
+ 'a gated action. Set: Kiosk.configure { |c| c.kyc_claims = %w[age_over_18] }'
80
+ end
81
+
82
+ def description
83
+ "Start the identity check this origin's gated actions need (#{claims.join(', ')}) for your " \
84
+ "human. Subscribe to the `#{TOPIC}` topic BEFORE calling this, then relay verification_url " \
85
+ 'to your human: a KYC provider confirms those facts without passing on any document. Once ' \
86
+ 'they approve, the event arrives and the facts are recorded; retry the gated action. At ' \
87
+ "most #{MAX_OPEN} checks may be open at once; one stops counting when approved, or " \
88
+ "#{OPEN_WINDOW / 60} minutes after it was opened. A check your human refuses sends no event."
89
+ end
90
+
91
+ def call(_args) = Verification.open(CurrentRequest.identity.user_id.to_s)
92
+
93
+ def callback_url
94
+ "#{Kiosk.current_issuer.to_s.chomp('/')}#{Kiosk.configuration.mount_path}/#{CALLBACK_PATH}"
95
+ end
96
+
97
+ # The provider's `POST <endpoint>/kyc/callback`.
98
+ def callback(body) = Verification.complete(body.transform_keys(&:to_s))
99
+
100
+ # Replaces the person's grants with the names `attributes` holds as
101
+ # JSON `true`.
102
+ delegate :grant!, to: :'Kiosk::Server::Kyc::Store'
103
+
104
+ # The gate: raises `kyc_required` unless the calling principal holds
105
+ # every one of `names`. `detail` replaces the default refusal text.
106
+ def require!(names = claims, detail: nil)
107
+ return if Store.granted?(CurrentRequest.identity.user_id, names.uniq)
108
+
109
+ listed = names.join(', ')
110
+ raise Errors::KycRequired.new(detail || "this action requires the verified attributes #{listed}",
111
+ hint: format(gate_hint, claims: listed))
112
+ end
113
+
114
+ private
115
+
116
+ # Names only a path this origin serves.
117
+ def gate_hint
118
+ return REQUEST_HINT if provider
119
+ return ATTEST_HINT if Kiosk.configuration.kyc_public_key
120
+
121
+ CLOSED_HINT
122
+ end
123
+
124
+ def owner_of(request_id) = Store.owner_of(request_id)
125
+ end
126
+
127
+ # Opens a verification at the provider and completes it from the provider's callback.
128
+ module Verification
129
+ class << self
130
+ def open(user_id)
131
+ refuse_over_cap!(user_id)
132
+ opened = open_verification(user_id)
133
+ Store.insert_request(opened.fetch(:request_id), user_id, opened.fetch(:nonce))
134
+ { 'request_id' => opened.fetch(:request_id), 'verification_url' => opened.fetch(:verification_url),
135
+ 'status' => 'pending' }
136
+ end
137
+
138
+ def complete(body)
139
+ request_id = required_field(body, 'request_id')
140
+ kyc_jws = required_field(body, 'kyc_jws')
141
+ row = matching_request(request_id, body['nonce'].to_s)
142
+ claims = KycVerifier.verify(raw_jws: kyc_jws, subject: row.fetch('user_id'))
143
+ Store.approve!(row, claims[:attributes])
144
+ Events.emit(topic: TOPIC, subject: request_id, identity_scope: [row.fetch('user_id')],
145
+ data: { 'request_id' => request_id, 'status' => 'approved', 'kyc_jws' => kyc_jws })
146
+ end
147
+
148
+ private
149
+
150
+ def open_verification(user_id)
151
+ Kyc.provider.open_verification(subject: user_id, claims: Kyc.claims,
152
+ audience: Kiosk.configuration.kyc_audience.to_s,
153
+ callback_url: Kyc.callback_url)
154
+ rescue Kiosk::KycProviders::Unavailable => e
155
+ FailureLog.report("#{NAME} could not open a verification", e)
156
+ raise Errors::WireError.new(
157
+ 'the verification service this operator uses did not open a request',
158
+ code: 'action_failed',
159
+ hint: 'nothing about your call is wrong. Try `request_kyc` again shortly.'
160
+ )
161
+ end
162
+
163
+ def refuse_over_cap!(user_id)
164
+ return if Store.open_count(user_id, OPEN_WINDOW) < MAX_OPEN
165
+
166
+ raise Errors::WireError.new(
167
+ 'too many verifications are already open for this account',
168
+ code: 'quota_exceeded',
169
+ hint: "at most #{MAX_OPEN} may be open at once; one stops counting when your human " \
170
+ "approves it, and in any case #{OPEN_WINDOW / 60} minutes after it was opened. " \
171
+ "Wait for the #{TOPIC} event on a page you were already given."
172
+ )
173
+ end
174
+
175
+ def required_field(body, name)
176
+ value = body[name].to_s
177
+ raise Errors.missing_field(name) if value.empty?
178
+
179
+ value
180
+ end
181
+
182
+ # The open verification `request_id` names, if `nonce` is the one it was opened with.
183
+ def matching_request(request_id, nonce)
184
+ row = Store.open_request(request_id)
185
+ raise Errors::NotFound, 'no open verification has that request_id' unless row
186
+ raise Errors::Forbidden, "the callback's nonce does not match the verification" unless secure_equal?(row.fetch('nonce'), nonce)
187
+
188
+ row
189
+ end
190
+
191
+ def secure_equal?(expected, given)
192
+ !expected.to_s.empty? && expected.bytesize == given.bytesize &&
193
+ OpenSSL.fixed_length_secure_compare(expected, given)
194
+ end
195
+ end
196
+ end
197
+
198
+ # The `kyc_requests` and `kyc_attributes` tables.
199
+ module Store
200
+ class << self
201
+ def insert_request(request_id, user_id, nonce)
202
+ execute("INSERT INTO #{table('kyc_requests')} (id, user_id, nonce) VALUES ($1, $2, $3)",
203
+ request_id, user_id, nonce)
204
+ end
205
+
206
+ def open_count(user_id, window)
207
+ execute("SELECT count(*) AS n FROM #{table('kyc_requests')} WHERE user_id = $1 " \
208
+ 'AND approved_at IS NULL AND created_at > now() - make_interval(secs => $2)',
209
+ user_id, window).first.fetch('n').to_i
210
+ end
211
+
212
+ def open_request(request_id)
213
+ execute("SELECT id, user_id::text AS user_id, nonce FROM #{table('kyc_requests')} " \
214
+ 'WHERE id = $1 AND approved_at IS NULL', request_id).first
215
+ end
216
+
217
+ def owner_of(request_id)
218
+ execute("SELECT user_id::text AS user_id FROM #{table('kyc_requests')} WHERE id = $1",
219
+ request_id.to_s).first&.fetch('user_id')
220
+ end
221
+
222
+ def approve!(row, attributes)
223
+ connection.transaction do
224
+ execute("UPDATE #{table('kyc_requests')} SET approved_at = now() WHERE id = $1", row.fetch('id'))
225
+ write_grants(row.fetch('user_id'), attributes)
226
+ end
227
+ end
228
+
229
+ def grant!(user_id, attributes)
230
+ connection.transaction { write_grants(user_id, attributes) }
231
+ end
232
+
233
+ # True when the person holds every one of `names`; false for none.
234
+ def granted?(user_id, names)
235
+ return false if names.empty?
236
+
237
+ quoted = names.map { |name| connection.quote(name) }.join(', ')
238
+ rows = execute("SELECT count(*) AS n FROM #{table('kyc_attributes')} " \
239
+ "WHERE user_id = $1 AND name IN (#{quoted})", user_id.to_s)
240
+ rows.first.fetch('n').to_i == names.size
241
+ end
242
+
243
+ private
244
+
245
+ def write_grants(user_id, attributes)
246
+ execute("DELETE FROM #{table('kyc_attributes')} WHERE user_id = $1", user_id.to_s)
247
+ execute("INSERT INTO #{table('kyc_attributes')} (user_id, name) " \
248
+ "SELECT $2, key FROM jsonb_each($1::jsonb) WHERE value = 'true'::jsonb",
249
+ JSON.generate(attributes), user_id.to_s)
250
+ end
251
+
252
+ def table(name) = connection.quote_table_name("#{Kiosk.configuration.schema}.#{name}")
253
+
254
+ def execute(sql, *binds) = connection.exec_query(sql, 'Kiosk KYC', binds).to_a
255
+
256
+ def connection = ::ActiveRecord::Base.lease_connection
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # Verifies a KYC attestation JWS against `kyc_public_key`: `iss`, `aud`,
8
+ # `sub` and `level: "verified"` must match, and the configured
9
+ # `kyc_provider` must accept it; only attributes
10
+ # that are literally `true` are granted.
11
+ module KycVerifier
12
+ # Named once for the decode and the published hint.
13
+ REQUIRED_CLAIMS = %w[exp iss aud sub].freeze
14
+ SIGNATURE_HINT = 'an attestation is a compact RS256 JWS signed by the issuer this origin ' \
15
+ 'is configured to trust'
16
+ SUBJECT_HINT = "sub must name the principal: the caller's at POST <endpoint>/agents/kyc, " \
17
+ "the open verification's at the callback"
18
+
19
+ module_function
20
+
21
+ def verify(raw_jws:, subject:)
22
+ config = Kiosk.configuration
23
+ payload = decode(raw_jws, config)
24
+ check_issuer!(payload, config)
25
+ # Binds the attestation to this operator.
26
+ if payload[:aud].to_s != config.kyc_audience.to_s
27
+ raise Errors::Forbidden.new('KYC attestation audience mismatch',
28
+ hint: audience_hint(payload, config))
29
+ end
30
+
31
+ check_subject!(payload, subject)
32
+ check_provider!(payload, config)
33
+
34
+ payload.merge(attributes: verified_attributes(payload[:attributes]))
35
+ end
36
+
37
+ # The names granted as literal `true`; `{}` when absent; a non-object
38
+ # is refused.
39
+ def verified_attributes(raw)
40
+ return {} if raw.nil?
41
+
42
+ raise Errors::Forbidden, 'KYC attestation attributes must be an object of {name: true} booleans' unless raw.is_a?(Hash)
43
+
44
+ raw.each_with_object({}) do |(name, value), acc|
45
+ acc[name.to_s] = true if value == true
46
+ end
47
+ end
48
+
49
+ # The verified payload with symbol keys.
50
+ def decode(raw_jws, config)
51
+ payload, = ::JWT.decode(raw_jws, public_key!(config), true,
52
+ algorithms: ['RS256'], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
53
+ payload.transform_keys(&:to_sym)
54
+ rescue ::JWT::ExpiredSignature
55
+ raise Errors::Forbidden, 'KYC attestation expired'
56
+ rescue ::JWT::MissingRequiredClaim
57
+ raise Errors::Forbidden.new('KYC attestation missing a required claim', hint: "an attestation carries #{REQUIRED_CLAIMS.join(', ')}")
58
+ rescue ::JWT::DecodeError
59
+ raise Errors::Forbidden.new('KYC attestation signature invalid', hint: SIGNATURE_HINT)
60
+ end
61
+
62
+ # Refused when this origin trusts no KYC issuer.
63
+ def public_key!(config)
64
+ key = config.kyc_public_key
65
+ return key unless key.nil?
66
+
67
+ raise Errors::ModuleNotServed.new(
68
+ 'this operator does not serve the KYC module',
69
+ hint: 'no KYC attestation is accepted at this origin; retrying will not help. ' \
70
+ 'An operator that means to serve KYC sets Kiosk.configuration.kyc_public_key.'
71
+ )
72
+ end
73
+
74
+ def check_issuer!(payload, config)
75
+ return if payload[:iss] == config.kyc_issuer
76
+
77
+ raise Errors::Forbidden.new(
78
+ 'KYC attestation issuer mismatch',
79
+ hint: "expected #{config.kyc_issuer.inspect}, got #{payload[:iss].inspect}"
80
+ )
81
+ end
82
+
83
+ def audience_hint(payload, config)
84
+ "aud must equal this operator's kyc_audience " \
85
+ "(expected #{config.kyc_audience.inspect}, got #{payload[:aud].inspect})"
86
+ end
87
+
88
+ # Subject and level. A bigint-PK host's subject is an Integer.
89
+ def check_subject!(payload, subject)
90
+ raise Errors::Forbidden.new('KYC attestation subject mismatch', hint: SUBJECT_HINT) unless payload[:sub].to_s == subject.to_s
91
+
92
+ raise Errors::Forbidden, 'kyc level not verified' unless payload[:level] == 'verified'
93
+ end
94
+
95
+ # The provider's own checks ({Kiosk::KycProviders::Base#accepts?}).
96
+ def check_provider!(payload, config)
97
+ provider = config.kyc_provider
98
+ return if provider.nil? || provider.accepts?(payload.transform_keys(&:to_s))
99
+
100
+ raise Errors::Forbidden, 'the KYC provider does not accept this attestation at this operator'
101
+ end
102
+
103
+ private_class_method :decode, :public_key!, :check_issuer!, :audience_hint, :check_subject!, :check_provider!
104
+ end
105
+ end
106
+ end