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
@@ -134,22 +134,22 @@ module Kiosk
134
134
  end
135
135
  end
136
136
 
137
- # The `pay` verb: settles an AP2 cart. The PSP capture runs outside any transaction,
138
- # between phases 1 and 3 (§11.6).
137
+ # The `pay` verb: settles an operator-signed checkout through two closed AP2
138
+ # mandates. The PSP capture runs outside any transaction, between phases 1 and 3 (§11.6).
139
139
  module Pay
140
140
  RETRYABLE_HINT = 'the charge did not go through and no money moved. The human may need to ' \
141
- 'update the payment method (payment_setup). Then sign a new chain and pay again.'
141
+ 'update the payment method (payment_setup). Then sign new mandates and pay again.'
142
142
  UNKNOWN_HINT = 'the payment provider did not confirm the charge, so its status is unknown. ' \
143
- 'Send the SAME chain again, byte for byte: this operator charges one chain at ' \
144
- 'most once. Do not sign a new chain.'
145
- CONFLICT_HINT = 'this operator will not charge this chain: an earlier call with it was declined, ' \
146
- 'or these mandate ids came before with other content. Before you sign a new ' \
147
- "chain, read the order's paid state through the operator's own query, and sign " \
148
- 'a new chain only on a clear "not paid".'
149
-
150
- # The verified mandates, the cart row they were persisted under, and
143
+ 'Send the SAME two mandates again, byte for byte: this operator charges one pair ' \
144
+ 'at most once. Do not sign new mandates.'
145
+ CONFLICT_HINT = 'this operator will not charge these mandates: an earlier call with them was ' \
146
+ 'declined, or another pair for this checkout is settled or still in progress. ' \
147
+ "Before you sign new mandates, read the order's paid state through the operator's " \
148
+ 'own query, and sign new ones only on a clear "not paid".'
149
+
150
+ # The verified mandates, the checkout row they were persisted under, and
151
151
  # whether an earlier call already persisted them.
152
- Chain = Struct.new(:intent, :cart, :payment, :cart_row, :repeat)
152
+ Chain = Struct.new(:checkout, :payment, :checkout_row, :repeat)
153
153
 
154
154
  private
155
155
 
@@ -158,7 +158,7 @@ module Kiosk
158
158
  # Whether this origin serves payments is answered before any argument is read.
159
159
  provider = payment_provider!
160
160
  raws = pay_mandates!(args)
161
- # Refused before phase 1 so the mandate ids are not burned on a charge that cannot succeed.
161
+ # Refused before phase 1, so the checkout is not held by a charge that cannot succeed.
162
162
  raise Errors::PaymentSetupRequired if provider.respond_to?(:setup_required?) && provider.setup_required?(user_id: identity.user_id)
163
163
 
164
164
  chain = persist_chain(raws)
@@ -177,20 +177,21 @@ module Kiosk
177
177
  )
178
178
  end
179
179
 
180
- # The three signed mandates from the args, by part.
180
+ # The two signed mandates from the args, by part.
181
181
  def pay_mandates!(args)
182
182
  # Mandates are agent-signed, so a principal without an agent id cannot pay.
183
183
  raise Errors::Forbidden, 'payment requires an agent identity (mandates are agent-signed)' if identity.agent_id.nil?
184
184
 
185
- %i[intent cart payment].to_h do |part|
186
- raw = args[:"#{part}_mandate_jws"]
187
- raise Errors::BadRequest, "args.#{part}_mandate_jws required" if raw.nil? || raw.to_s.empty?
185
+ %i[checkout payment].to_h do |part|
186
+ raw = args[:"#{part}_mandate"]
187
+ raise Errors::BadRequest, "args.#{part}_mandate required" if raw.nil? || raw.to_s.empty?
188
188
 
189
189
  [part, raw]
190
190
  end
191
191
  end
192
192
 
193
- # Phase 1: verify and persist the mandate chain. A unique violation means the chain was presented before.
193
+ # Phase 1: verify and persist the mandates. A unique violation means they, or another
194
+ # live pair for the same checkout, were presented before.
194
195
  def persist_chain(raws)
195
196
  chain = Chain.new
196
197
  verify_and_persist(chain, raws)
@@ -202,30 +203,24 @@ module Kiosk
202
203
 
203
204
  def verify_and_persist(chain, raws)
204
205
  SessionContext.open(connection: connection, identity: identity) do
205
- cart = verify_intent_and_cart(chain, raws)
206
- # Before any persist or capture, so a refusal burns nothing.
207
- enforce_spending_cap!(cart)
208
- chain.payment = MandateVerifier.verify_payment(raw_jws: raws[:payment], identity: identity, cart: cart)
209
- chain.cart_row = persist_mandates(chain)
206
+ chain.checkout = MandateVerifier.verify_checkout(sd_jwt: raws[:checkout], identity: identity)
207
+ # Before any persist or capture, so a refusal holds nothing.
208
+ enforce_spending_cap!(chain.checkout)
209
+ chain.payment = MandateVerifier.verify_payment(sd_jwt: raws[:payment], identity: identity, checkout: chain.checkout)
210
+ chain.checkout_row = persist_mandates(chain)
210
211
  end
211
212
  chain
212
213
  end
213
214
 
214
- def verify_intent_and_cart(chain, raws)
215
- chain.intent = MandateVerifier.verify_intent(raw_jws: raws[:intent], identity: identity)
216
- chain.cart = MandateVerifier.verify_cart(raw_jws: raws[:cart], identity: identity, intent: chain.intent)
217
- end
218
-
219
- # The cart row id.
215
+ # The checkout row id.
220
216
  def persist_mandates(chain)
221
- intent_row = persist_intent_mandate(chain.intent)
222
- cart_row = persist_cart_mandate(chain.cart, intent_row_id: intent_row)
223
- persist_payment_mandate(cart_row_id: cart_row, payment: chain.payment)
224
- cart_row
217
+ checkout_row = persist_checkout_mandate(chain.checkout)
218
+ persist_payment_mandate(checkout_row_id: checkout_row, payment: chain.payment)
219
+ checkout_row
225
220
  end
226
221
 
227
222
  # Phase 2: the irreversible capture. A refusal under which no money moved
228
- # declines the cart, so it stops counting against the spending cap.
223
+ # declines the checkout mandate, so it stops counting against the spending cap.
229
224
  def capture(provider, chain)
230
225
  charge(provider, chain)
231
226
  rescue Kiosk::PaymentProviders::SetupRequired
@@ -239,139 +234,125 @@ module Kiosk
239
234
  raise
240
235
  end
241
236
 
242
- # A repeated chain resumes its capture under the same idempotency key.
237
+ # A repeated pair resumes its capture under the same idempotency key.
243
238
  def charge(provider, chain)
244
239
  method = chain.repeat ? :resume : :capture
245
- provider.public_send(method, chain.cart, payment_method: chain.payment.payment_method)
240
+ provider.public_send(method, chain.checkout, payment_method: chain.payment.payment_instrument)
246
241
  end
247
242
 
248
243
  # Phase 3: record the settlement, then tell the provider, so the
249
244
  # operator's work after the payment reads the settlement. A concurrent
250
- # call that settled the same cart first answers with its settlement.
245
+ # call that settled the same checkout first answers with its settlement.
251
246
  def settle(provider, chain, settled)
252
247
  settlement_id = record_settlement(chain, settled)
253
- provider.settled(chain.cart) if provider.respond_to?(:settled)
254
- settlement_result(settlement_id, settled[:psp_reference], settled[:settled_amount_cents], chain.cart.currency)
248
+ provider.settled(chain.checkout) if provider.respond_to?(:settled)
249
+ settlement_result(chain, settlement_id, settled, chain.checkout.currency)
255
250
  rescue StandardError => e
256
251
  row = unique_violation?(e) && recorded_chain(chain)
257
252
  raise unless row && row['settlement_id']
258
253
 
259
- stored_settlement(row)
254
+ stored_settlement(chain, row)
260
255
  end
261
256
 
262
257
  # The settlement row's id.
263
258
  def record_settlement(chain, settled)
264
259
  SessionContext.open(connection: connection, identity: identity) do
265
- persist_settlement(cart_row_id: chain.cart_row, cart: chain.cart, settled: settled)
260
+ persist_settlement(checkout_row_id: chain.checkout_row, checkout: chain.checkout, settled: settled)
266
261
  end
267
262
  end
268
263
 
269
- def settlement_result(settlement_id, psp_reference, settled_amount_cents, currency)
270
- Result.new(kind: :value, payload: { settlement_id:, psp_reference:, settled_amount_cents:, currency: })
264
+ # The settlement, and the two receipts that name its mandates (§11.3).
265
+ # `settled`: `psp_reference`, `settled_amount_cents` and `network_confirmation_id`.
266
+ def settlement_result(chain, settlement_id, settled, currency)
267
+ psp_reference = settled[:psp_reference]
268
+ receipts = Receipts.issue(checkout: chain.checkout, payment: chain.payment, settlement_id:, psp_reference:,
269
+ network_confirmation_id: settled[:network_confirmation_id])
270
+ Result.new(kind: :value, payload: { settlement_id:, psp_reference:, settled_amount_cents: settled[:settled_amount_cents],
271
+ currency:, **receipts })
271
272
  end
272
273
  end
273
274
 
274
275
  # The mandate and settlement rows. Every value is a bind parameter; only the
275
276
  # operator-set schema name is interpolated.
276
277
  module Ledger
277
- # Byte-identical chain, scoped to the acting principal on every mandate row, with its settlement if any.
278
+ # The byte-identical pair of the acting principal, with its settlement if any.
278
279
  CHAIN_SQL = <<~SQL.squish
279
- SELECT c.id AS cart_row, c.declined_at,
280
- s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency
281
- FROM %<schema>s.cart_mandates c
282
- JOIN %<schema>s.intent_mandates i ON i.id = c.intent_mandate_id
283
- JOIN %<schema>s.payment_mandates p ON p.cart_mandate_id = c.id
284
- LEFT JOIN %<schema>s.settlements s ON s.cart_mandate_id = c.id
285
- WHERE c.user_id = $1 AND i.user_id = $1 AND p.user_id = $1
286
- AND c.mandate_id = $2
287
- AND c.raw_jws = $3
288
- AND i.raw_jws = $4
289
- AND p.raw_jws = $5
280
+ SELECT c.id AS checkout_row, c.declined_at,
281
+ s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency,
282
+ s.network_confirmation_id
283
+ FROM %<schema>s.checkout_mandates c
284
+ JOIN %<schema>s.payment_mandates p ON p.checkout_mandate_id = c.id
285
+ LEFT JOIN %<schema>s.settlements s ON s.checkout_mandate_id = c.id
286
+ WHERE c.user_id = $1 AND p.user_id = $1
287
+ AND c.reference = $2
288
+ AND c.sd_jwt = $3
289
+ AND p.sd_jwt = $4
290
290
  LIMIT 1
291
291
  SQL
292
292
 
293
- # The signed id goes to `mandate_id`; the row id is server-generated and returned.
294
- INTENT_INSERT_SQL = <<~SQL.squish
295
- INSERT INTO %<schema>s.intent_mandates
296
- (mandate_id, user_id, agent_id, issuer, scope, cap_amount_cents,
297
- currency, expires_at, created_at, raw_jws)
298
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), $9)
299
- RETURNING id
300
- SQL
301
-
302
293
  # `line_items` arrives as JSON text; the cast stores a jsonb array rather than a string.
303
- CART_INSERT_SQL = <<~SQL.squish
304
- INSERT INTO %<schema>s.cart_mandates
305
- (mandate_id, intent_mandate_id, user_id, agent_id, issuer, line_items,
306
- total_amount_cents, currency, expires_at, created_at, raw_jws)
294
+ CHECKOUT_INSERT_SQL = <<~SQL.squish
295
+ INSERT INTO %<schema>s.checkout_mandates
296
+ (reference, checkout_hash, user_id, agent_id, issuer, line_items,
297
+ total_amount_cents, currency, expires_at, created_at, sd_jwt)
307
298
  VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, now(), $10)
308
299
  RETURNING id
309
300
  SQL
310
301
 
311
302
  PAYMENT_INSERT_SQL = <<~SQL.squish
312
303
  INSERT INTO %<schema>s.payment_mandates
313
- (mandate_id, cart_mandate_id, user_id, agent_id, issuer,
314
- payment_method, amount_cents, currency, expires_at, created_at, raw_jws)
304
+ (reference, checkout_mandate_id, user_id, agent_id, issuer,
305
+ payment_instrument, amount_cents, currency, expires_at, created_at, sd_jwt)
315
306
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10)
316
307
  RETURNING id
317
308
  SQL
318
309
 
319
- # Nobody signs a settlement, so it has no `mandate_id` or `raw_jws`; `UNIQUE (cart_mandate_id)` keeps it single.
310
+ # Nobody signs a settlement; `UNIQUE (checkout_mandate_id)` keeps it single.
320
311
  SETTLEMENT_INSERT_SQL = <<~SQL.squish
321
312
  INSERT INTO %<schema>s.settlements
322
- (cart_mandate_id, user_id, agent_id, issuer, psp_reference,
323
- settled_amount_cents, currency, settled_at)
324
- VALUES ($1, $2, $3, $4, $5, $6, $7, now())
313
+ (checkout_mandate_id, user_id, agent_id, issuer, psp_reference,
314
+ settled_amount_cents, currency, network_confirmation_id, settled_at)
315
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
325
316
  RETURNING id
326
317
  SQL
327
318
 
328
319
  DECLINE_SQL = <<~SQL.squish
329
- UPDATE %<schema>s.cart_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
320
+ UPDATE %<schema>s.checkout_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
330
321
  SQL
331
322
 
332
323
  private
333
324
 
334
325
  def chain_record(chain)
335
326
  connection.exec_query(ledger_sql(CHAIN_SQL), 'Kiosk chain lookup', [
336
- identity.user_id, chain.cart.id, chain.cart.raw_jws, chain.intent.raw_jws,
337
- chain.payment.raw_jws
327
+ identity.user_id, chain.checkout.reference, chain.checkout.sd_jwt, chain.payment.sd_jwt
338
328
  ]).to_a.first
339
329
  end
340
330
 
341
- def decline_cart(cart_row)
342
- connection.exec_query(ledger_sql(DECLINE_SQL), 'Kiosk cart decline', [cart_row])
343
- end
344
-
345
- def persist_intent_mandate(intent)
346
- insert_returning_id('Kiosk intent_mandate insert', INTENT_INSERT_SQL, [
347
- intent.id, intent.user_id, intent.agent_id, intent.issuer, intent.scope,
348
- intent.cap_amount_cents.to_i, intent.currency, intent.expires_at, intent.raw_jws
349
- ])
331
+ def decline_checkout(checkout_row)
332
+ connection.exec_query(ledger_sql(DECLINE_SQL), 'Kiosk checkout decline', [checkout_row])
350
333
  end
351
334
 
352
- # References the server intent id; the signed intent-cart binding is checked by MandateVerifier.
353
- def persist_cart_mandate(cart, intent_row_id:)
354
- insert_returning_id('Kiosk cart_mandate insert', CART_INSERT_SQL, [
355
- cart.id, intent_row_id, cart.user_id, cart.agent_id, cart.issuer,
356
- cart.line_items.to_json, cart.total_amount_cents.to_i, cart.currency,
357
- cart.expires_at, cart.raw_jws
335
+ def persist_checkout_mandate(checkout)
336
+ insert_returning_id('Kiosk checkout_mandate insert', CHECKOUT_INSERT_SQL, [
337
+ checkout.reference, checkout.checkout_hash, checkout.user_id, checkout.agent_id,
338
+ checkout.issuer, checkout.line_items.to_json, checkout.total_amount_cents,
339
+ checkout.currency, checkout.expires_at, checkout.sd_jwt
358
340
  ])
359
341
  end
360
342
 
361
- def persist_payment_mandate(cart_row_id:, payment:)
362
- # No presented payment method means the on-file card funded the charge.
363
- pm_db = payment.payment_method.to_s.empty? ? 'on_file' : payment.payment_method
343
+ def persist_payment_mandate(checkout_row_id:, payment:)
364
344
  insert_returning_id('Kiosk payment_mandate insert', PAYMENT_INSERT_SQL, [
365
- payment.id, cart_row_id, payment.user_id, payment.agent_id, payment.issuer,
366
- pm_db, payment.amount_cents.to_i, payment.currency, payment.expires_at,
367
- payment.raw_jws
345
+ payment.reference, checkout_row_id, payment.user_id, payment.agent_id, payment.issuer,
346
+ payment.payment_instrument, payment.amount_cents, payment.currency, payment.expires_at,
347
+ payment.sd_jwt
368
348
  ])
369
349
  end
370
350
 
371
- def persist_settlement(cart_row_id:, cart:, settled:)
351
+ def persist_settlement(checkout_row_id:, checkout:, settled:)
372
352
  insert_returning_id('Kiosk settlement insert', SETTLEMENT_INSERT_SQL, [
373
- cart_row_id, cart.user_id, cart.agent_id, cart.issuer,
374
- settled[:psp_reference], settled[:settled_amount_cents].to_i, cart.currency
353
+ checkout_row_id, checkout.user_id, checkout.agent_id, checkout.issuer,
354
+ settled[:psp_reference], settled[:settled_amount_cents].to_i, checkout.currency,
355
+ settled[:network_confirmation_id]
375
356
  ])
376
357
  end
377
358
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Kiosk
4
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
5
+ # The identity of a wire or event-stream caller: the access token, read by
6
+ # the configured agent IdP or the bundled one. A cookie session
7
7
  # reaches only the human-side pages and the binding endpoints.
8
8
  module IdentityResolution
9
9
  module_function
@@ -12,8 +12,17 @@ module Kiosk
12
12
  Kiosk.configuration.agent_idp || AgentIdentityProviders::DefaultAgentIdp.new
13
13
  end
14
14
 
15
+ # The identity of the token, once the request's DPoP proof shows the key
16
+ # the token is bound to; nil with no valid token.
17
+ # @raise [Errors::AccessTokenRefused] a valid token without that proof
15
18
  def resolve(request)
16
- agent_idp.verify(request)
19
+ identity = agent_idp.verify(request)
20
+ return if identity.nil?
21
+
22
+ Dpop.verify!(request.headers['DPoP'],
23
+ token: Dpop.token(request.headers['Authorization']), jkt: identity.claims.dig(:cnf, :jkt),
24
+ method: request.request_method, url: "#{Kiosk.current_issuer}#{request.path}")
25
+ identity
17
26
  end
18
27
  end
19
28
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'jwt'
7
+ require 'openssl'
8
+
9
+ module Kiosk
10
+ module Server
11
+ # The public keys Kiosk signs and verifies with, as OpenSSL keys and as
12
+ # JWKs: Ed25519 (RFC 8037, `kty` OKP, algorithm EdDSA) and P-256 (RFC 7518,
13
+ # `kty` EC, algorithm ES256).
14
+ module Jwk
15
+ # The JOSE algorithm of each key type.
16
+ ALGORITHMS = { 'OKP' => 'EdDSA', 'EC' => 'ES256' }.freeze
17
+
18
+ # The curve of each key type.
19
+ CURVES = { 'OKP' => 'Ed25519', 'EC' => 'P-256' }.freeze
20
+
21
+ module_function
22
+
23
+ def ed25519?(key) = key.is_a?(OpenSSL::PKey::PKey) && key.oid == 'ED25519'
24
+
25
+ def p256?(key) = key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == 'prime256v1'
26
+
27
+ # OpenSSL answers `private?` for an EC key only.
28
+ def private?(key)
29
+ return key.private? if key.is_a?(OpenSSL::PKey::EC)
30
+
31
+ key.raw_private_key
32
+ true
33
+ rescue OpenSSL::PKey::PKeyError
34
+ false
35
+ end
36
+
37
+ # `OKP`, `EC`, or nil for a key Kiosk does not use.
38
+ def kty(key)
39
+ if ed25519?(key) then 'OKP'
40
+ elsif p256?(key) then 'EC'
41
+ end
42
+ end
43
+
44
+ # The JOSE algorithm that `key` signs with, or nil.
45
+ def algorithm(key) = ALGORITHMS[kty(key)]
46
+
47
+ # The public members of `key`: the RFC 7638 required members.
48
+ def public_members(key)
49
+ case kty(key)
50
+ when 'OKP' then { crv: CURVES['OKP'], kty: 'OKP', x: b64(key.raw_public_key) }
51
+ when 'EC'
52
+ point = key.public_key.to_bn(:uncompressed).to_s(2)
53
+ { crv: CURVES['EC'], kty: 'EC', x: b64(point[1, 32]), y: b64(point[33, 32]) }
54
+ else raise ArgumentError, "Kiosk uses Ed25519 and P-256 keys, got #{key.class}"
55
+ end
56
+ end
57
+
58
+ # The public key of a JWK with the public members of its `kty`.
59
+ # @raise [ArgumentError] when the JWK is not an Ed25519 or a P-256 public key
60
+ def import(jwk)
61
+ jwk = jwk.transform_keys(&:to_s)
62
+ kty = jwk['kty']
63
+ raise ArgumentError, "the key type #{kty.inspect} is not OKP or EC" unless CURVES.key?(kty)
64
+ raise ArgumentError, "the curve of an #{kty} key is #{CURVES[kty]}" unless jwk['crv'] == CURVES[kty]
65
+
66
+ public_key(jwk)
67
+ rescue OpenSSL::PKey::PKeyError, ::JWT::JWKError
68
+ raise ArgumentError, "the key is not a valid #{kty} public key"
69
+ end
70
+
71
+ def public_key(jwk)
72
+ return OpenSSL::PKey.new_raw_public_key('ED25519', Base64.urlsafe_decode64(jwk['x'].to_s)) if jwk['kty'] == 'OKP'
73
+
74
+ ::JWT::JWK.import(jwk.slice('kty', 'crv', 'x', 'y')).verify_key
75
+ end
76
+
77
+ # RFC 7638: the SHA-256 of the required members, in alphabetical order.
78
+ def thumbprint(key) = b64(Digest::SHA256.digest(JSON.generate(public_members(key))))
79
+
80
+ def b64(bytes) = Base64.urlsafe_encode64(bytes, padding: false)
81
+ end
82
+ end
83
+ end
@@ -9,6 +9,12 @@ module Kiosk
9
9
  def build(keys:)
10
10
  { keys: Array(keys).map(&:to_jwk) }
11
11
  end
12
+
13
+ # The public key of the JWK in `jwks` whose `kid` is `kid`, or nil.
14
+ def find(jwks, kid)
15
+ jwk = Array(jwks[:keys] || jwks['keys']).find { |k| (k[:kid] || k['kid']) == kid }
16
+ jwk && Jwk.import(jwk)
17
+ end
12
18
  end
13
19
  end
14
20
  end
@@ -5,16 +5,12 @@ require 'securerandom'
5
5
 
6
6
  module Kiosk
7
7
  module Server
8
- # Issues and verifies the RS256 access tokens of the bundled kiosk-pop IdP.
8
+ # Issues and verifies the EdDSA access tokens of the bundled agent IdP.
9
9
  module JwtIssuer
10
- ALGORITHM = 'RS256'
10
+ ALGORITHM = Eddsa::NAME
11
11
  DEFAULT_EXPIRES_IN = 3600
12
12
  DEFAULT_LEEWAY = 60
13
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
14
  class Error < StandardError; end
19
15
  class SignatureError < Error; end
20
16
  class ExpiredError < Error; end
@@ -33,7 +29,7 @@ module Kiosk
33
29
  raise ArgumentError, 'signing_key must carry a private key for issuance' unless signing_key.private?
34
30
 
35
31
  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' })
32
+ ::JWT.encode(payload, signing_key.key, ALGORITHM, { kid: signing_key.kid, typ: 'JWT' })
37
33
  end
38
34
 
39
35
  def verify(token:, jwks:, audience: nil, issuer: nil, leeway: DEFAULT_LEEWAY)
@@ -58,9 +54,9 @@ module Kiosk
58
54
  def decode(token, jwks, audience:, issuer:, leeway:)
59
55
  decoded, = ::JWT.decode(
60
56
  token, nil, true,
61
- jwks: jwks, algorithms: [ALGORITHM], aud: audience, iss: issuer,
57
+ algorithms: [ALGORITHM], aud: audience, iss: issuer,
62
58
  verify_aud: !audience.nil?, verify_iss: !issuer.nil?, leeway: leeway
63
- )
59
+ ) { |header| Jwks.find(jwks, header['kid']) || raise(SignatureError, 'no key of the JWKS has this kid') }
64
60
  symbolize(decoded)
65
61
  rescue ::JWT::DecodeError => e
66
62
  raise error_class_for(e), e.message
@@ -70,7 +66,7 @@ module Kiosk
70
66
  case error
71
67
  when ::JWT::ExpiredSignature then ExpiredError
72
68
  when ::JWT::InvalidAudError then AudienceError
73
- when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm, UNKNOWN_KID then SignatureError
69
+ when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm then SignatureError
74
70
  else InvalidError
75
71
  end
76
72
  end
@@ -88,9 +84,7 @@ module Kiosk
88
84
  end
89
85
  end
90
86
 
91
- def symbolize(hash)
92
- hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
93
- end
87
+ def symbolize(hash) = hash.deep_symbolize_keys
94
88
 
95
89
  private_class_method :registered_claims, :decode, :error_class_for
96
90
  end
@@ -11,7 +11,7 @@ module Kiosk
11
11
  module KycVerifier
12
12
  # Named once for the decode and the published hint.
13
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 ' \
14
+ SIGNATURE_HINT = 'an attestation is a compact EdDSA JWS signed by the issuer this origin ' \
15
15
  'is configured to trust'
16
16
  SUBJECT_HINT = "sub must name the principal: the caller's at POST <endpoint>/agents/kyc, " \
17
17
  "the open verification's at the callback"
@@ -49,7 +49,7 @@ module Kiosk
49
49
  # The verified payload with symbol keys.
50
50
  def decode(raw_jws, config)
51
51
  payload, = ::JWT.decode(raw_jws, public_key!(config), true,
52
- algorithms: ['RS256'], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
52
+ algorithms: [Eddsa::NAME], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
53
53
  payload.transform_keys(&:to_sym)
54
54
  rescue ::JWT::ExpiredSignature
55
55
  raise Errors::Forbidden, 'KYC attestation expired'
@@ -4,7 +4,7 @@ module Kiosk
4
4
  module Server
5
5
  # Account binding started by the human: the signed-in holder mints a
6
6
  # single-use link code ({.mint}, POST /auth/link) and their assistant
7
- # redeems it with its key, a possession proof and the auth toll ({.redeem}, POST /auth/claim).
7
+ # redeems it with a client assertion and the auth toll ({.redeem}, POST /auth/claim).
8
8
  module LinkCode
9
9
  CLIENT_ID = 'kiosk-link'
10
10
 
@@ -31,37 +31,28 @@ module Kiosk
31
31
  end
32
32
 
33
33
  # The toll comes before the lookup, so an unknown code costs the same work as a live one.
34
- def redeem(code:, public_key_pem:, signed:, pow: nil, # rubocop:disable Metrics/ParameterLists -- the claim controller and the specs pass these by name
34
+ #
35
+ # @return [Client] the client under the account of the human who minted the code
36
+ # @raise [ClientAssertion::Refused] the client assertion is refused
37
+ def redeem(code:, assertion_type:, assertion:, pow: nil, # rubocop:disable Metrics/ParameterLists -- the claim controller and the specs pass these by name
35
38
  store: Kiosk.configuration.device_authorization_store,
36
39
  now: Time.current)
37
40
  raise Errors::BadRequest, 'code required' if code.to_s.empty?
38
41
 
39
- pem = public_key_pem.to_s.strip
40
- PopVerifier.load_public_key(pem)
41
- AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s, public_key: pem }, pow: pow)
42
+ AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s }, pow: pow)
42
43
  da = live_link(store, code.to_s, now)
43
44
 
44
- # The proof comes before the consume, so a failed one leaves the code live.
45
- prove_possession!(pem, signed)
45
+ # The client authenticates before the consume, so a refused assertion leaves the code live.
46
+ client = ClientAssertion.authenticate!(assertion_type, assertion)
46
47
  # The atomic consume, not `consumed?` below, decides single use under a race.
47
48
  raise Errors::Conflict, 'link code already used' if store.claim_consume(da, now: now).nil?
48
49
 
49
- bind(pem, da)
50
+ AccountBinding.bind!(agent_id: client.id, user_id: da.user_id, requested_role: da.requested_role)
50
51
  end
51
52
 
52
53
  class << self
53
54
  private
54
55
 
55
- def prove_possession!(pem, signed)
56
- payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
57
- AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
58
- end
59
-
60
- def bind(pem, link)
61
- result = AccountBinding.bind!(public_key_pem: pem, user_id: link.user_id, requested_role: link.requested_role)
62
- { agent_id: result[:agent_id], user_id: result[:user_id], access_token: result[:access_token] }
63
- end
64
-
65
56
  def live_link(store, code, now)
66
57
  da = store.lookup_by_device_code_hash(DeviceAuthorization.hash_device_code(code))
67
58
  unless da&.link?
@@ -106,7 +106,7 @@ module Kiosk
106
106
  def fingerprint(pem)
107
107
  return '(no key)' if pem.blank?
108
108
 
109
- SigningKey.from_pem(pem).kid
109
+ AssistantKey.thumbprint(AssistantKey.from_pem(pem))
110
110
  rescue StandardError
111
111
  '(unreadable key)'
112
112
  end