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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- 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
|