kiosk-server 0.5.20 → 0.6.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +63 -41
  4. data/app/controllers/kiosk/server/auth_controller.rb +18 -62
  5. data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
  6. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  7. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  8. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
  9. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  10. data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
  11. data/app/models/kiosk/checkout_mandate.rb +22 -0
  12. data/app/models/kiosk/settlement.rb +1 -1
  13. data/lib/generators/kiosk/install/install_generator.rb +0 -5
  14. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
  15. data/lib/kiosk/payable.rb +19 -25
  16. data/lib/kiosk/server/account_binding.rb +19 -58
  17. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  18. data/lib/kiosk/server/agent_rotation.rb +62 -0
  19. data/lib/kiosk/server/assistant_key.rb +86 -0
  20. data/lib/kiosk/server/auth_toll.rb +1 -1
  21. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  22. data/lib/kiosk/server/checkout.rb +57 -0
  23. data/lib/kiosk/server/client.rb +61 -0
  24. data/lib/kiosk/server/client_assertion.rb +153 -0
  25. data/lib/kiosk/server/client_registration.rb +94 -0
  26. data/lib/kiosk/server/configuration_extension.rb +56 -44
  27. data/lib/kiosk/server/device_code_grant.rb +38 -69
  28. data/lib/kiosk/server/dpop.rb +121 -0
  29. data/lib/kiosk/server/eddsa.rb +38 -0
  30. data/lib/kiosk/server/engine.rb +14 -11
  31. data/lib/kiosk/server/errors.rb +7 -0
  32. data/lib/kiosk/server/events_connection.rb +15 -5
  33. data/lib/kiosk/server/executor/repeat.rb +13 -12
  34. data/lib/kiosk/server/executor/spending_cap.rb +25 -26
  35. data/lib/kiosk/server/executor.rb +82 -101
  36. data/lib/kiosk/server/identity_resolution.rb +12 -3
  37. data/lib/kiosk/server/jwk.rb +83 -0
  38. data/lib/kiosk/server/jwks.rb +6 -0
  39. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  40. data/lib/kiosk/server/kyc_verifier.rb +2 -2
  41. data/lib/kiosk/server/link_code.rb +9 -18
  42. data/lib/kiosk/server/linked_assistants.rb +1 -1
  43. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  44. data/lib/kiosk/server/oauth_answers.rb +30 -0
  45. data/lib/kiosk/server/oauth_error.rb +30 -0
  46. data/lib/kiosk/server/open_api.rb +22 -20
  47. data/lib/kiosk/server/payment_claim.rb +44 -75
  48. data/lib/kiosk/server/receipts.rb +29 -0
  49. data/lib/kiosk/server/request_validation.rb +1 -2
  50. data/lib/kiosk/server/revocation_stores.rb +8 -0
  51. data/lib/kiosk/server/schema_definitions.rb +33 -64
  52. data/lib/kiosk/server/schemas/auth.schema.json +368 -57
  53. data/lib/kiosk/server/schemas/binding.schema.json +23 -58
  54. data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
  55. data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
  56. data/lib/kiosk/server/sd_jwt.rb +104 -0
  57. data/lib/kiosk/server/signing_key.rb +18 -52
  58. data/lib/kiosk/server/verb_wire.rb +1 -1
  59. data/lib/kiosk/server/version.rb +1 -1
  60. data/lib/kiosk/server/well_known.rb +268 -204
  61. data/lib/kiosk/server/wire_rendering.rb +1 -1
  62. data/lib/kiosk/server.rb +2 -0
  63. data/lib/kiosk/skill_link.rb +6 -2
  64. data/listen.py +38 -8
  65. metadata +26 -17
  66. data/app/models/kiosk/cart_mandate.rb +0 -21
  67. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  68. data/lib/kiosk/server/agent_login.rb +0 -49
  69. data/lib/kiosk/server/agent_registration.rb +0 -88
  70. data/lib/kiosk/server/auth_challenge.rb +0 -33
  71. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  72. data/lib/kiosk/server/pop_verifier.rb +0 -87
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'securerandom'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # The checkout an operator answers for an item to pay for: a JWT it signs
9
+ # EdDSA with its signing key. The assistant's checkout mandate names it by
10
+ # hash. EdDSA signatures are deterministic, so a random `jti` makes the hash
11
+ # of each checkout impossible to guess (AP2 v0.2, rainbow table attacks).
12
+ module Checkout
13
+ ALGORITHM = Eddsa::NAME
14
+
15
+ module_function
16
+
17
+ # @param user_id the account the checkout is for; only that account can pay it
18
+ # @return [String] the compact JWT
19
+ def sign(user_id:, currency:, total_amount_cents:, line_items:)
20
+ signed({ iss: Kiosk.current_issuer, sub: user_id.to_s, iat: Time.now.to_i, jti: SecureRandom.urlsafe_base64(16),
21
+ merchant: merchant, currency: currency, total_amount_cents: total_amount_cents, line_items: line_items })
22
+ end
23
+
24
+ # @return [String] `claims` as a compact JWT, signed EdDSA with the operator's signing key
25
+ def signed(claims)
26
+ key = Kiosk.configuration.signing_key
27
+ ::JWT.encode(claims, key.key, ALGORITHM, { kid: key.kid, typ: 'JWT' })
28
+ end
29
+
30
+ # The AP2 Merchant object: the payee the payment mandate names.
31
+ def merchant
32
+ issuer = Kiosk.current_issuer
33
+ { id: issuer, name: Kiosk.configuration.owner[:name] || issuer, website: issuer }
34
+ end
35
+
36
+ # @return [Hash] the claims of a checkout this origin signed, with its current or previous key, for this account
37
+ # @raise [Errors::Forbidden]
38
+ def verify(checkout_jwt, identity:)
39
+ claims = decode(checkout_jwt)
40
+ raise Errors::Forbidden.new('checkout is not from this origin', hint: ISSUER_HINT) unless claims['iss'] == Kiosk.current_issuer
41
+ return claims if claims['sub'] == identity.user_id.to_s
42
+
43
+ raise Errors::Forbidden.new('checkout is for another account', hint: 'pay only a checkout this operator answered to you')
44
+ rescue ::JWT::DecodeError
45
+ raise Errors::Forbidden.new('checkout_jwt signature invalid', hint: ISSUER_HINT)
46
+ end
47
+
48
+ # The claims of a checkout that the current or the previous signing key signed.
49
+ def decode(checkout_jwt)
50
+ jwks = Jwks.build(keys: Kiosk.configuration.signing_keys)
51
+ ::JWT.decode(checkout_jwt, nil, true, algorithms: [ALGORITHM]) { |header| Jwks.find(jwks, header['kid']) }.first
52
+ end
53
+
54
+ ISSUER_HINT = 'send the checkout_jwt this operator answered, unchanged'
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # A registered OAuth client: one live row of the agents table at the origin
8
+ # of this request. Its `id` is both the `client_id` and the `agent_id`.
9
+ class Client < Data.define(:id, :user_id, :public_key_pem, :allowed_roles, :issued_at)
10
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
11
+
12
+ CLIENT_CREDENTIALS = 'client_credentials'
13
+ DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code'
14
+
15
+ AUTH_METHOD = 'private_key_jwt'
16
+
17
+ # The grant types this origin serves, so the grant types of every client.
18
+ def self.grant_types(config = Kiosk.configuration)
19
+ config.serve_account_binding ? [CLIENT_CREDENTIALS, DEVICE_CODE] : [CLIENT_CREDENTIALS]
20
+ end
21
+
22
+ # nil for an id that is not a UUID, so the database never sees it.
23
+ def self.find(id)
24
+ lookup('id = $1', id.to_s) if UUID.match?(id.to_s)
25
+ end
26
+
27
+ def self.with_key(key)
28
+ lookup('public_key = $1', AssistantKey.to_pem(key))
29
+ end
30
+
31
+ def self.lookup(condition, value)
32
+ row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk client lookup', [value, Kiosk.current_issuer]).to_a.first
33
+ SELECT id, user_id, public_key, allowed_roles, extract(epoch FROM created_at)::bigint AS issued_at
34
+ FROM #{Kiosk.configuration.schema}.agents
35
+ WHERE #{condition} AND issuer = $2 AND revoked_at IS NULL
36
+ LIMIT 1
37
+ SQL
38
+ row && new(id: row['id'], user_id: row['user_id'], public_key_pem: row['public_key'],
39
+ allowed_roles: row['allowed_roles'], issued_at: row['issued_at'].to_i)
40
+ end
41
+ private_class_method :lookup
42
+
43
+ def key = AssistantKey.from_pem(public_key_pem)
44
+
45
+ def thumbprint = AssistantKey.thumbprint(key)
46
+
47
+ # The first role. `allowed_roles` is an Array or a text[] literal ("{customer}"), by adapter.
48
+ def role
49
+ roles = allowed_roles.is_a?(Array) ? allowed_roles : allowed_roles.to_s.delete('{}').split(',')
50
+ roles.first
51
+ end
52
+
53
+ # The RFC 7591 Section 3.2.1 client information response.
54
+ def information
55
+ { client_id: id, client_id_issued_at: issued_at, jwks: { keys: [AssistantKey.to_jwk(key)] },
56
+ token_endpoint_auth_method: AUTH_METHOD, grant_types: self.class.grant_types,
57
+ dpop_bound_access_tokens: true }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'rails'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # Client authentication by `private_key_jwt`: an RFC 7523 client assertion
9
+ # that the registered key of the client signs. Each refusal says what to fix.
10
+ module ClientAssertion
11
+ TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
12
+
13
+ # Seconds: the longest life an assertion can have, and the clock difference accepted.
14
+ MAX_LIFETIME = 300
15
+ LEEWAY = 60
16
+
17
+ REQUIRED_CLAIMS = %w[iss sub aud exp jti].freeze
18
+
19
+ # A refused client assertion. The `/oauth/*` endpoints answer it as
20
+ # `invalid_client`, the other endpoints as `401 unauthenticated`.
21
+ class Refused < StandardError
22
+ attr_reader :hint
23
+
24
+ def initialize(message, hint:)
25
+ @hint = hint
26
+ super(message)
27
+ end
28
+
29
+ def to_oauth = OauthError.new(:invalid_client, "#{message}: #{hint}")
30
+ def to_problem = Errors::Unauthenticated.new(message, hint: hint)
31
+ end
32
+
33
+ # A verified assertion that is not yet spent.
34
+ Verified = Data.define(:client, :claims)
35
+
36
+ # Names no origin: a value that a response sends back is what a relaying server would exploit.
37
+ AUDIENCE_HINT = 'sign `aud` = the origin you connected to, taken from your own request URL, ' \
38
+ 'never from a value that a response sends back'
39
+
40
+ CLAIMS_HINT = "the payload carries #{REQUIRED_CLAIMS.join(', ')}: `iss` and `sub` are your client_id, " \
41
+ '`aud` is the origin you connected to, `exp` is at most 300 seconds from now, ' \
42
+ '`jti` is a new unique id'.freeze
43
+
44
+ UNKNOWN_CLIENT_HINT = 'register your key at registration_url; the answer carries your client_id. ' \
45
+ 'A client that the account holder unlinked is unknown'
46
+
47
+ SIGNATURE_HINT = 'a client assertion is a compact JWS that the private key of your registered key signs: ' \
48
+ 'EdDSA for an Ed25519 key, ES256 for a P-256 key'
49
+
50
+ module_function
51
+
52
+ # Verifies and spends the assertion; answers its client.
53
+ # @raise [Refused]
54
+ def authenticate!(type, assertion, client_id: nil)
55
+ verified = verify(type, assertion, client_id: client_id)
56
+ spend!(verified)
57
+ verified.client
58
+ end
59
+
60
+ # Every check but the single use. `key` replaces the registered key: a
61
+ # rotation verifies the assertion of the new key with it.
62
+ # @raise [Refused]
63
+ def verify(type, assertion, client_id: nil, key: nil)
64
+ check_type!(type, assertion)
65
+ client = client_of(assertion)
66
+ claims = decode(assertion, key || registered_key(client))
67
+ check_claims!(claims, client_id)
68
+ Verified.new(client: client, claims: claims)
69
+ end
70
+
71
+ # Claims the `jti` in the spent-id set until the assertion can no longer be accepted.
72
+ # @raise [Refused] when the `jti` is spent already
73
+ def spend!(verified)
74
+ id = "client_assertion:#{verified.client.id}:#{verified.claims['jti']}"
75
+ return if Kiosk.configuration.pow_spent_store.claim(id, verified.claims['exp'].to_i + LEEWAY)
76
+
77
+ refuse!('client assertion already used', 'sign a new client assertion with a new `jti` for each request')
78
+ end
79
+
80
+ def check_type!(type, assertion)
81
+ if assertion.to_s.empty?
82
+ refuse!('client authentication required',
83
+ "send client_assertion_type = #{TYPE} and client_assertion, a JWT that your registered key signs")
84
+ end
85
+ refuse!('unsupported client_assertion_type', "send client_assertion_type = #{TYPE}") unless type == TYPE
86
+ end
87
+
88
+ # The client the `sub` names. The signature is checked after this, with its key.
89
+ def client_of(assertion)
90
+ payload = ::JWT.decode(assertion.to_s, nil, false).first
91
+ Client.find(payload.is_a?(Hash) ? payload['sub'] : nil) || refuse!('unknown client', UNKNOWN_CLIENT_HINT)
92
+ rescue ::JWT::DecodeError
93
+ refuse!('client assertion is not a JWT', SIGNATURE_HINT)
94
+ end
95
+
96
+ # A key that this operator accepts no longer, such as a P-256 key once ES256 is off.
97
+ def registered_key(client)
98
+ client.key
99
+ rescue Errors::BadRequest
100
+ refuse!('the registered key is not accepted here',
101
+ "this operator accepts #{AssistantKey.algorithms.join(' and ')} keys: register a new key at registration_url")
102
+ end
103
+
104
+ def decode(assertion, key)
105
+ ::JWT.decode(assertion.to_s, key, true, algorithms: [AssistantKey.algorithm(key)],
106
+ required_claims: REQUIRED_CLAIMS, leeway: LEEWAY).first
107
+ rescue ::JWT::IncorrectAlgorithm
108
+ refuse!("client assertion alg is not #{AssistantKey.algorithm(key)}, the algorithm of your registered key", SIGNATURE_HINT)
109
+ rescue ::JWT::MissingRequiredClaim
110
+ refuse!('client assertion is missing a required claim', CLAIMS_HINT)
111
+ rescue ::JWT::ExpiredSignature
112
+ refuse!('client assertion expired', 'sign a new client assertion')
113
+ rescue ::JWT::DecodeError
114
+ refuse!('client assertion signature invalid', SIGNATURE_HINT)
115
+ end
116
+
117
+ def check_claims!(claims, client_id)
118
+ refuse!('client assertion iss is not its sub', CLAIMS_HINT) unless claims['iss'] == claims['sub']
119
+ refuse!('client_id is not the client assertion sub', CLAIMS_HINT) if client_id.present? && client_id != claims['sub']
120
+ check_audience!(claims['aud'])
121
+ return if claims['exp'].to_i <= Time.now.to_i + MAX_LIFETIME
122
+
123
+ refuse!('client assertion lives too long', "set `exp` at most #{MAX_LIFETIME} seconds after now")
124
+ end
125
+
126
+ # `aud` names this origin as its only value (RFC 7523bis Section 4).
127
+ def check_audience!(aud)
128
+ issuer = Kiosk.current_issuer
129
+ return if Array(aud) == [issuer]
130
+
131
+ log_audience_mismatch(aud, issuer)
132
+ refuse!('client assertion audience mismatch', AUDIENCE_HINT)
133
+ end
134
+
135
+ # Operator log only, because the answer must not name an origin: the
136
+ # caller signed a wrong value, or this deployment does not serve the origin it reached.
137
+ def log_audience_mismatch(aud, issuer)
138
+ message = "[kiosk] client assertion audience mismatch: the caller signed aud=#{aud.inspect}, " \
139
+ "the issuer for this request is #{issuer.inspect}. If the signed value is an origin " \
140
+ 'your assistants actually reach, list it in `c.issuer` or `c.additional_origins`.'
141
+ logger = ::Rails.logger
142
+ logger ? logger.warn(message) : warn(message)
143
+ end
144
+
145
+ def refuse!(message, hint)
146
+ raise Refused.new(message, hint: hint)
147
+ end
148
+
149
+ private_class_method :check_type!, :client_of, :registered_key, :decode, :check_claims!, :check_audience!,
150
+ :log_audience_mismatch, :refuse!
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support/core_ext/string/inflections' # String#constantize
5
+
6
+ module Kiosk
7
+ module Server
8
+ # RFC 7591 open registration of an AI assistant's public key, with no human:
9
+ # creates the assistant account and its client. A key with a live client
10
+ # gets that client again, so an assistant that lost its client_id recovers it.
11
+ module ClientRegistration
12
+ module_function
13
+
14
+ # @param metadata [Hash] the parsed request body
15
+ # @return [Client]
16
+ # @raise [OauthError] `invalid_client_metadata`
17
+ def call(metadata, pow: nil)
18
+ config = Kiosk.configuration
19
+ role = registration_role(config)
20
+ key = checked_key(metadata)
21
+
22
+ AuthToll.gate(verb: 'oauth/register', body: { key: AssistantKey.thumbprint(key) }, pow: pow, config: config)
23
+ Client.with_key(key) || register(config, key, role)
24
+ end
25
+
26
+ def checked_key(metadata)
27
+ invalid!('the request body must be a JSON object') unless metadata.is_a?(Hash)
28
+ metadata = metadata.transform_keys(&:to_s)
29
+ method = metadata['token_endpoint_auth_method']
30
+ invalid!("token_endpoint_auth_method must be #{Client::AUTH_METHOD}") unless [nil, Client::AUTH_METHOD].include?(method)
31
+ check_grant_types!(metadata['grant_types'])
32
+ AssistantKey.from_jwks(metadata['jwks'])
33
+ rescue Errors::BadRequest => e
34
+ invalid!("jwks: #{e.message}; #{e.hint}")
35
+ end
36
+
37
+ def check_grant_types!(grant_types)
38
+ return if grant_types.nil?
39
+ return if grant_types.is_a?(Array) && (grant_types - Client.grant_types).empty?
40
+
41
+ invalid!("grant_types must be a list of the grant types this origin serves: #{Client.grant_types.join(', ')}")
42
+ end
43
+
44
+ # The operator's `assistant_creation` factory, else a bare `user_model.create!`.
45
+ def create_assistant_account(config, pem)
46
+ return config.user_model.constantize.create!.id unless config.assistant_creation
47
+
48
+ id = config.assistant_creation.call(pem)
49
+ return id unless id.nil?
50
+
51
+ raise Errors::ConfigurationError,
52
+ 'config.assistant_creation returned nil. The block must create the ' \
53
+ 'assistant account and RETURN its id (used as agents.user_id), e.g. ' \
54
+ 'c.assistant_creation = ->(pubkey) { AssistantAccount.create!(...).id }'
55
+ end
56
+
57
+ # The role is the server's choice, never the assistant's.
58
+ def registration_role(config)
59
+ role = config.registration_role&.to_s
60
+ return if role.blank?
61
+ return role if config.roles.map(&:to_s).include?(role)
62
+
63
+ raise Errors::ConfigurationError,
64
+ "registration_role #{role.inspect} is not among configured roles " \
65
+ "#{config.roles.inspect}"
66
+ end
67
+
68
+ # The live unique index on (issuer, public_key) picks the winner of a race.
69
+ def register(config, key, role)
70
+ pem = AssistantKey.to_pem(key)
71
+ conn = ::ActiveRecord::Base.lease_connection
72
+ conn.transaction(requires_new: true) { insert_agent(conn, config, [create_assistant_account(config, pem), pem], role) }
73
+ Client.with_key(key)
74
+ rescue ::ActiveRecord::RecordNotUnique
75
+ Client.with_key(key)
76
+ end
77
+
78
+ # No role is the empty array: `allowed_roles` is NOT NULL.
79
+ def insert_agent(conn, config, binds, role)
80
+ allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
81
+ conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, Kiosk.current_issuer, *role_binds])
82
+ INSERT INTO #{config.schema}.agents (user_id, public_key, issuer, allowed_roles)
83
+ VALUES ($1, $2, $3, #{allowed_roles_sql})
84
+ SQL
85
+ end
86
+
87
+ def invalid!(description)
88
+ raise OauthError.new(:invalid_client_metadata, description)
89
+ end
90
+
91
+ private_class_method :checked_key, :check_grant_types!, :registration_role, :register, :insert_agent, :invalid!
92
+ end
93
+ end
94
+ end
@@ -35,7 +35,7 @@ module Kiosk
35
35
  attr_writer :skill_url
36
36
 
37
37
  def skill_url
38
- @skill_url ||= 'https://kiosk.tech/skill-v0.5.20.md'
38
+ @skill_url ||= 'https://kiosk.tech/skill-v0.6.1.md'
39
39
  end
40
40
  attr_accessor :skill_sha256
41
41
 
@@ -57,7 +57,7 @@ module Kiosk
57
57
 
58
58
  # The proof-of-work settings: registration proofs and the reputation challenge.
59
59
  module ProofOfWork
60
- # Equihash proofs asked at `POST /auth/register`, at the link-code claim and
60
+ # Equihash proofs asked at `POST /oauth/register`, at the link-code claim and
61
61
  # at the first token poll of each device code, at the gem's default params.
62
62
  # Default 0 (open). Above 0 needs `pow_secret` and `kiosk-pow-equihash`.
63
63
  attr_writer :registration_pow_count
@@ -122,20 +122,16 @@ module Kiosk
122
122
  @kyc_audience || Kiosk.current_issuer
123
123
  end
124
124
 
125
- # The KYC provider's RSA key: an OpenSSL::PKey or a PEM string.
125
+ # The KYC provider's Ed25519 public key: an OpenSSL::PKey or a PEM string.
126
126
  def kyc_public_key
127
127
  @kyc_public_key
128
128
  end
129
129
 
130
130
  def kyc_public_key=(value)
131
- @kyc_public_key = case value
132
- when OpenSSL::PKey::PKey then value
133
- when String then OpenSSL::PKey::RSA.new(value)
134
- when nil then nil
135
- else
136
- raise ArgumentError,
137
- "kyc_public_key must be an OpenSSL::PKey or PEM string, got #{value.class}"
138
- end
131
+ @kyc_public_key = value.is_a?(String) ? OpenSSL::PKey.read(value) : value
132
+ return if @kyc_public_key.nil? || Jwk.ed25519?(@kyc_public_key)
133
+
134
+ raise ArgumentError, "kyc_public_key must be an Ed25519 key or its PEM string, got #{@kyc_public_key.class}"
139
135
  end
140
136
  end
141
137
 
@@ -169,27 +165,50 @@ module Kiosk
169
165
  include Kyc
170
166
  include Database
171
167
 
172
- # Required. A SigningKey or a PEM string.
168
+ # Required. A SigningKey or the PEM string of an Ed25519 key.
173
169
  def signing_key
174
170
  @signing_key || raise(Errors::ConfigurationError,
175
- 'c.signing_key is not set. Generate one with `openssl genrsa 2048`.')
171
+ "c.signing_key is not set. #{SigningKey::HINT.capitalize}.")
176
172
  end
177
173
 
178
174
  def signing_key=(value)
179
- @signing_key = case value
180
- when Kiosk::Server::SigningKey, nil then value
181
- when String then Kiosk::Server::SigningKey.from_pem(value)
182
- else raise ArgumentError, "signing_key must be a SigningKey or PEM string, got #{value.class}"
183
- end
175
+ @signing_key = signing_key_from(value, 'signing_key')
176
+ end
177
+
178
+ # Optional. The key that signed before {#signing_key}: the JWKS still
179
+ # publishes it, and a token or checkout it signed still verifies. A public
180
+ # PEM is enough. Keep it for at least one access-token lifetime, and while
181
+ # a checkout it signed can still be paid.
182
+ attr_reader :previous_signing_key
183
+
184
+ def previous_signing_key=(value)
185
+ @previous_signing_key = signing_key_from(value, 'previous_signing_key')
184
186
  end
185
187
 
186
- # The role every self-registered agent is pinned to, and the default a
188
+ # The keys the JWKS publishes and verification accepts, the current one first.
189
+ def signing_keys = [signing_key, previous_signing_key].compact
190
+
191
+ # The JOSE algorithms an AI assistant can sign with. Default `["EdDSA"]`:
192
+ # Ed25519 keys only. `%w[EdDSA ES256]` also accepts P-256 keys.
193
+ def assistant_algorithms
194
+ @assistant_algorithms || [Eddsa::NAME]
195
+ end
196
+
197
+ def assistant_algorithms=(value)
198
+ algorithms = Array(value).map(&:to_s)
199
+ valid = [[Eddsa::NAME], [Eddsa::NAME, 'ES256']].include?(algorithms)
200
+ raise Errors::ConfigurationError, "c.assistant_algorithms is %w[EdDSA] or %w[EdDSA ES256], got #{algorithms.inspect}" unless valid
201
+
202
+ @assistant_algorithms = algorithms.freeze
203
+ end
204
+
205
+ # The role every registered client is pinned to, and the default a
187
206
  # binding falls back to. Required, and one of {#roles}, when {#roles} is set.
188
207
  # Kiosk.configure { |c| c.roles = %i[customer]; c.registration_role = :customer }
189
208
  attr_accessor :registration_role
190
209
 
191
- # Optional `->(public_key) { record_id }` creating the account behind a
192
- # self-registered agent. Unset: `user_model.constantize.create!`.
210
+ # Optional `->(public_key_pem) { record_id }` creating the account behind a
211
+ # registered client. Unset: `user_model.constantize.create!`.
193
212
  attr_accessor :assistant_creation
194
213
 
195
214
  # Account-binding ceremony state. Default: the shared database store.
@@ -224,7 +243,7 @@ module Kiosk
224
243
  true
225
244
  end
226
245
 
227
- # Called as `(agent:, from:, to:)` when an assistant's key moves from one
246
+ # Called as `(agent:, from:, to:)` when an assistant's client moves from one
228
247
  # account to another, inside the rebind transaction: the place to move
229
248
  # the operator's own rows. `from` and `to` are `user_model` records.
230
249
  attr_accessor :assistant_claimed
@@ -234,7 +253,7 @@ module Kiosk
234
253
  attr_accessor :assistant_unlinked
235
254
 
236
255
  # Optional `(agent_id:) → { amount_cents:, currency: } | nil`, checked
237
- # before capture; 0 blocks payments, and so does a cart in another currency.
256
+ # before capture; 0 blocks payments, and so does a checkout in another currency.
238
257
  # {Kiosk::Server::ColumnSpendingCap} reads
239
258
  # `agents.spending_cap_cents`, the column edited by the manage-assistants
240
259
  # page, and `agents.spending_cap_currency`.
@@ -243,8 +262,8 @@ module Kiosk
243
262
  # Days of settled spend summed against the cap. Default nil: all time.
244
263
  attr_accessor :spending_cap_window_days
245
264
 
246
- # `call(id, lines) → cents | String`: the payable row's price, or why the cart is refused.
247
- attr_accessor :cart_price_checker
265
+ # `call(id, lines) → cents | String`: the payable row's price, or why the checkout is refused.
266
+ attr_accessor :checkout_price_checker
248
267
 
249
268
  # Optional `call(id)`, run once the settlement that paid row `id` is recorded.
250
269
  attr_accessor :after_payment
@@ -282,7 +301,8 @@ module Kiosk
282
301
  @audit_sink = value
283
302
  end
284
303
 
285
- # Spent challenge ids, so a proof is accepted once. Default: the shared database table.
304
+ # Spent ids, so a proof-of-work proof or a client assertion is accepted once.
305
+ # Default: the shared database table.
286
306
  attr_writer :pow_spent_store
287
307
 
288
308
  def pow_spent_store
@@ -290,16 +310,6 @@ module Kiosk
290
310
  LAZY_STORE_MUTEX.synchronize { @pow_spent_store ||= Kiosk::Server::PowSpentStores::ActiveRecord.new }
291
311
  end
292
312
 
293
- # Outstanding auth nonces. Default: the shared database table.
294
- attr_writer :auth_challenge_store
295
-
296
- def auth_challenge_store
297
- @auth_challenge_store ||
298
- LAZY_STORE_MUTEX.synchronize do
299
- @auth_challenge_store ||= Kiosk::Server::AuthChallengeStores::ActiveRecord.new
300
- end
301
- end
302
-
303
313
  # Seconds an access token lives. One paid proof-of-work toll lasts as long. Default 3600.
304
314
  attr_writer :access_token_ttl
305
315
 
@@ -307,14 +317,6 @@ module Kiosk
307
317
  @access_token_ttl ||= JwtIssuer::DEFAULT_EXPIRES_IN
308
318
  end
309
319
 
310
- # Seconds between `GET /auth/challenge` and the signed POST. Default covers
311
- # the auth toll's proof-of-work window plus 60s.
312
- attr_writer :auth_challenge_ttl
313
-
314
- def auth_challenge_ttl
315
- @auth_challenge_ttl ||= (pow_ttl * [registration_pow_count.to_i, 1].max) + 60
316
- end
317
-
318
320
  # Revocation watermarks, checked on every access token. Default: the
319
321
  # shared database table, so a revoke or an unlink holds in every process.
320
322
  attr_writer :revocation_store
@@ -323,6 +325,16 @@ module Kiosk
323
325
  @revocation_store ||
324
326
  LAZY_STORE_MUTEX.synchronize { @revocation_store ||= Kiosk::Server::RevocationStores::ActiveRecord.new }
325
327
  end
328
+
329
+ private
330
+
331
+ def signing_key_from(value, name)
332
+ case value
333
+ when Kiosk::Server::SigningKey, nil then value
334
+ when String then Kiosk::Server::SigningKey.from_pem(value)
335
+ else raise ArgumentError, "#{name} must be a SigningKey or PEM string, got #{value.class}"
336
+ end
337
+ end
326
338
  end
327
339
  end
328
340
  end