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
@@ -108,7 +108,7 @@ module Kiosk
108
108
  when 'pow_required'
109
109
  %(Kiosk-PoW realm="#{issuer}")
110
110
  when 'payment_setup_required'
111
- %(Payment realm="#{issuer}", method="ap2")
111
+ %(Kiosk-Payment-Setup realm="#{issuer}")
112
112
  end
113
113
  end
114
114
  end
data/lib/kiosk/server.rb CHANGED
@@ -10,6 +10,8 @@ require 'active_support/core_ext' # the core extensions a Rails host loads, whic
10
10
  require 'zeitwerk'
11
11
 
12
12
  Zeitwerk::Loader.for_gem_extension(Kiosk).setup
13
+ # Registers the JOSE algorithm EdDSA with ruby-jwt.
14
+ require_relative 'server/eddsa'
13
15
 
14
16
  module Kiosk
15
17
  module Server
@@ -5,12 +5,16 @@ require 'active_support/concern'
5
5
  module Kiosk
6
6
  # `include Kiosk::SkillLink` in a page controller: every answer carries
7
7
  # `Link: <skill_url>; rel="kiosk"`, the discovery signal for a client that
8
- # does not read HTML.
8
+ # does not read HTML, and the RFC 9727 `api-catalog` link.
9
9
  module SkillLink
10
10
  extend ActiveSupport::Concern
11
11
 
12
+ API_CATALOG = '</.well-known/api-catalog>; rel="api-catalog"'
13
+
12
14
  included do
13
- before_action { response.set_header('Link', %(<#{Kiosk.configuration.skill_url}>; rel="kiosk")) }
15
+ before_action do
16
+ response.set_header('Link', %(<#{Kiosk.configuration.skill_url}>; rel="kiosk", #{API_CATALOG}))
17
+ end
14
18
  end
15
19
  end
16
20
  end
data/listen.py CHANGED
@@ -8,10 +8,15 @@ cadence to invent.
8
8
 
9
9
  python3 listen.py --url wss://shop.example/kiosk/events \
10
10
  --token-file ~/.kiosk/https_shop.example_443/token \
11
+ --proof-command "python3 ~/.kiosk/dpop.py ~/.kiosk/https_shop.example_443/key.pem" \
11
12
  --topic order_delivery:<order-id>
12
13
 
13
- The bearer token is read from a file, never from the command line, where every
14
- process listing and shell history would show it.
14
+ The access token is read from a file, never from the command line, where every
15
+ process listing and shell history would show it. The token works only with a
16
+ DPoP proof (RFC 9449) that your private key signs. This file never reads that
17
+ key: before each connection it runs your --proof-command, writes the URL to
18
+ prove and the token to its stdin, one per line, and sends the proof that the
19
+ command prints.
15
20
 
16
21
  WAKE ON THE EVENT: run it as a background process your runtime TRACKS and wakes
17
22
  you on when it exits. With `--until-event` it prints the first event and exits 0
@@ -21,7 +26,7 @@ on the event, tell your human, and start it again with `--since <its id>`. Keep
21
26
  process the runtime does not track -- nohup, `&`, setsid -- wakes nobody, and
22
27
  neither does a line landing in a file.
23
28
 
24
- python3 listen.py --url … --token-file … --topic kyc_verification \
29
+ python3 listen.py --url … --token-file … --proof-command … --topic kyc_verification \
25
30
  --until-event --max-seconds 300
26
31
 
27
32
  Each line is a JSON object with a `type`:
@@ -36,7 +41,8 @@ Each line is a JSON object with a `type`:
36
41
  {"type":"error","message":…}
37
42
 
38
43
  EXIT CODES: 0 you asked it to stop (--max-seconds), or --until-event got its
39
- event; 2 the arguments or the URL are wrong; 3 the operator will not have this
44
+ event; 2 the arguments or the URL are wrong, or --proof-command printed no
45
+ proof; 3 the operator will not have this
40
46
  token back and no --token-command was given — mint a fresh one and start it
41
47
  again; 4 nothing you asked for can arrive: every topic you named was refused,
42
48
  or the origin serves no event stream (`501 module_not_served`); 5
@@ -88,6 +94,22 @@ def problem_code(response):
88
94
  return None
89
95
 
90
96
 
97
+ def proof_url(url):
98
+ """The `htu` of the upgrade: the events URL with `wss` written as `https`,
99
+ no query."""
100
+ parts = urlsplit(url)
101
+ scheme = {"wss": "https", "ws": "http"}[parts.scheme]
102
+ return urlunsplit((scheme, parts.netloc, parts.path, "", ""))
103
+
104
+
105
+ def dpop_proof(command, url, token):
106
+ """The proof your --proof-command prints for this URL and token, or None."""
107
+ made = subprocess.run(command, shell=True, input=f"{proof_url(url)}\n{token}\n",
108
+ capture_output=True, text=True, check=False)
109
+ proof = made.stdout.strip()
110
+ return proof if made.returncode == 0 and proof else None
111
+
112
+
91
113
  def stream_url(url, topics, since):
92
114
  """`?topic=` is how you subscribe: the operator reads it on connect and
93
115
  confirms each one, so a round trip per topic is not needed."""
@@ -145,7 +167,7 @@ def read_frames(socket, state, deadline):
145
167
  reconnect=frame.get("reconnect", True))
146
168
  # `reconnect: false` is the operator saying this credential is done:
147
169
  # it expired, or the human revoked the assistant. A retry with the
148
- # same bearer loses the same argument, so mint a new one or stop.
170
+ # same token loses the same argument, so mint a new one or stop.
149
171
  if frame.get("reconnect", True):
150
172
  return "retry"
151
173
  return "retry" if state["refresh"] else "credential"
@@ -191,12 +213,15 @@ def main():
191
213
  parser = argparse.ArgumentParser(description="Kiosk event-stream listener")
192
214
  parser.add_argument("--url", required=True, help="the operator's events_url")
193
215
  parser.add_argument("--token-file", required=True, metavar="PATH",
194
- help="a file holding your bearer token")
216
+ help="a file holding your access token")
217
+ parser.add_argument("--proof-command", required=True,
218
+ help="shell command that reads a URL and a token on stdin and prints "
219
+ "the DPoP proof your private key signs for them")
195
220
  parser.add_argument("--topic", action="append", required=True, metavar="NAME[:SUBJECT]",
196
221
  help="repeatable; NAME:SUBJECT narrows to one row")
197
222
  parser.add_argument("--since", type=int, help="resume after this event id")
198
223
  parser.add_argument("--token-command",
199
- help="shell command printing a fresh bearer token; run when the "
224
+ help="shell command printing a fresh access token; run when the "
200
225
  "operator says this one will not be accepted back")
201
226
  parser.add_argument("--max-seconds", type=float, help="stop after this long")
202
227
  parser.add_argument("--until-event", action="store_true",
@@ -224,10 +249,15 @@ def main():
224
249
  if deadline and time.monotonic() >= deadline:
225
250
  return 5 if args.until_event else 0
226
251
  renew = True
252
+ proof = dpop_proof(args.proof_command, args.url, state["token"])
253
+ if proof is None:
254
+ emit(type="error", message="--proof-command produced no proof")
255
+ return 2
227
256
  try:
228
257
  with connect(stream_url(args.url, args.topic, state["since"]),
229
258
  subprotocols=[SUBPROTOCOL],
230
- additional_headers={"Authorization": f"Bearer {state['token']}"},
259
+ additional_headers={"Authorization": f"DPoP {state['token']}",
260
+ "DPoP": proof},
231
261
  open_timeout=15, close_timeout=5) as socket:
232
262
  emit(type="open")
233
263
  attempt = 0
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kiosk-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.20
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Phil Pirozhkov
@@ -15,21 +15,21 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.5.0
18
+ version: 0.6.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.5.0
25
+ version: 0.6.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: jwt
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '2.8'
32
+ version: '2.10'
33
33
  - - "<"
34
34
  - !ruby/object:Gem::Version
35
35
  version: '4.0'
@@ -39,7 +39,7 @@ dependencies:
39
39
  requirements:
40
40
  - - ">="
41
41
  - !ruby/object:Gem::Version
42
- version: '2.8'
42
+ version: '2.10'
43
43
  - - "<"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '4.0'
@@ -167,14 +167,15 @@ description: |
167
167
  - The controllers — the per-verb wire (GET <endpoint>/<query-name>,
168
168
  POST <endpoint>/<action-name>) and the reserved endpoints
169
169
  (/kiosk/schema, /kiosk/pay), a derived OpenAPI description of both,
170
- the register/login proof-of-possession auth plane, JWKS, the KYC
170
+ OAuth client registration (RFC 7591) and the token endpoint
171
+ (private_key_jwt client assertions, DPoP), JWKS, the KYC
171
172
  attestation endpoint, agents.txt / agents.json / kiosk.json
172
- discovery, and the account-binding ceremony (RFC 8628-shaped device
173
- authorization, the possession-proof-gated token poll, the verify and
174
- «Link an assistant» pages).
173
+ discovery, and the account-binding ceremony (RFC 8628 device
174
+ authorization and token poll, the verify and «Link an assistant»
175
+ pages).
175
176
  - Kiosk::Server::Executor — dispatches a resolved command to the
176
177
  queries and actions the host registered.
177
- - Agent registration and login, with a pluggable agent-IdP.
178
+ - Client registration and authentication, with a pluggable agent-IdP.
178
179
  - The PoW gate that enforces a kiosk-reputation policy's challenge
179
180
  (soft dependency; zero overhead when no policy is set).
180
181
  - A Rack middleware injecting the Kiosk-Server-Version /
@@ -208,12 +209,13 @@ files:
208
209
  - app/controllers/kiosk/server/kyc_attestation_controller.rb
209
210
  - app/controllers/kiosk/server/kyc_callback_controller.rb
210
211
  - app/controllers/kiosk/server/oauth_device_authorization_controller.rb
212
+ - app/controllers/kiosk/server/oauth_registration_controller.rb
211
213
  - app/controllers/kiosk/server/oauth_token_controller.rb
212
214
  - app/controllers/kiosk/server/open_api_controller.rb
213
215
  - app/controllers/kiosk/server/payment_setup_controller.rb
214
216
  - app/controllers/kiosk/server/verb_controller.rb
215
217
  - app/controllers/kiosk/server/wire_controller.rb
216
- - app/models/kiosk/cart_mandate.rb
218
+ - app/models/kiosk/checkout_mandate.rb
217
219
  - app/models/kiosk/server/application_record.rb
218
220
  - app/models/kiosk/settlement.rb
219
221
  - app/models/kiosk/visit.rb
@@ -221,7 +223,6 @@ files:
221
223
  - app/views/kiosk/server/device_verify/decided.html.erb
222
224
  - app/views/kiosk/server/device_verify/show.html.erb
223
225
  - lib/generators/kiosk/install/install_generator.rb
224
- - lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt
225
226
  - lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt
226
227
  - lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt
227
228
  - lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt
@@ -242,15 +243,17 @@ files:
242
243
  - lib/kiosk/server/action_event.rb
243
244
  - lib/kiosk/server/actions.rb
244
245
  - lib/kiosk/server/agent_identity_providers/default_agent_idp.rb
245
- - lib/kiosk/server/agent_login.rb
246
- - lib/kiosk/server/agent_registration.rb
246
+ - lib/kiosk/server/agent_rotation.rb
247
247
  - lib/kiosk/server/argument_decoder.rb
248
+ - lib/kiosk/server/assistant_key.rb
248
249
  - lib/kiosk/server/audit_sink.rb
249
- - lib/kiosk/server/auth_challenge.rb
250
- - lib/kiosk/server/auth_challenge_stores.rb
251
250
  - lib/kiosk/server/auth_toll.rb
252
251
  - lib/kiosk/server/binding_module_gate.rb
253
252
  - lib/kiosk/server/caller_timezone.rb
253
+ - lib/kiosk/server/checkout.rb
254
+ - lib/kiosk/server/client.rb
255
+ - lib/kiosk/server/client_assertion.rb
256
+ - lib/kiosk/server/client_registration.rb
254
257
  - lib/kiosk/server/column_spending_cap.rb
255
258
  - lib/kiosk/server/configuration_extension.rb
256
259
  - lib/kiosk/server/controller_verb.rb
@@ -260,6 +263,8 @@ files:
260
263
  - lib/kiosk/server/device_authorization_stores.rb
261
264
  - lib/kiosk/server/device_code_grant.rb
262
265
  - lib/kiosk/server/device_verification.rb
266
+ - lib/kiosk/server/dpop.rb
267
+ - lib/kiosk/server/eddsa.rb
263
268
  - lib/kiosk/server/engine.rb
264
269
  - lib/kiosk/server/errors.rb
265
270
  - lib/kiosk/server/event_stores.rb
@@ -277,6 +282,7 @@ files:
277
282
  - lib/kiosk/server/headers_middleware.rb
278
283
  - lib/kiosk/server/identity_resolution.rb
279
284
  - lib/kiosk/server/issuer_middleware.rb
285
+ - lib/kiosk/server/jwk.rb
280
286
  - lib/kiosk/server/jwks.rb
281
287
  - lib/kiosk/server/jwt_issuer.rb
282
288
  - lib/kiosk/server/kyc.rb
@@ -284,15 +290,17 @@ files:
284
290
  - lib/kiosk/server/link_code.rb
285
291
  - lib/kiosk/server/linked_assistants.rb
286
292
  - lib/kiosk/server/mandate_verifier.rb
293
+ - lib/kiosk/server/oauth_answers.rb
294
+ - lib/kiosk/server/oauth_error.rb
287
295
  - lib/kiosk/server/open_api.rb
288
296
  - lib/kiosk/server/page.rb
289
297
  - lib/kiosk/server/payment_claim.rb
290
298
  - lib/kiosk/server/payment_setup.rb
291
- - lib/kiosk/server/pop_verifier.rb
292
299
  - lib/kiosk/server/pow_gate.rb
293
300
  - lib/kiosk/server/pow_spent_stores.rb
294
301
  - lib/kiosk/server/pow_toll.rb
295
302
  - lib/kiosk/server/queries.rb
303
+ - lib/kiosk/server/receipts.rb
296
304
  - lib/kiosk/server/reputation_factors.rb
297
305
  - lib/kiosk/server/request_validation.rb
298
306
  - lib/kiosk/server/response_validation.rb
@@ -307,6 +315,7 @@ files:
307
315
  - lib/kiosk/server/schemas/kyc.schema.json
308
316
  - lib/kiosk/server/schemas/mandates.schema.json
309
317
  - lib/kiosk/server/schemas/pow.schema.json
318
+ - lib/kiosk/server/sd_jwt.rb
310
319
  - lib/kiosk/server/session_context.rb
311
320
  - lib/kiosk/server/settled_spend.rb
312
321
  - lib/kiosk/server/signing_key.rb
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kiosk
4
- # The signed cart the engine records when it settles a `pay`: the line items
5
- # the assistant agreed to pay for. Read-only for the operator.
6
- class CartMandate < Server::ApplicationRecord
7
- def self.table_name = "#{Kiosk.configuration.schema}.cart_mandates"
8
-
9
- has_many :settlements, class_name: 'Kiosk::Settlement', dependent: nil, inverse_of: :cart_mandate
10
-
11
- # Carts with a line item carrying these attributes, by jsonb containment:
12
- # `referencing(order_id: id)` matches `[{"order_id": id, "qty": 2}]`. The
13
- # values are quoted by the adapter, never interpolated.
14
- scope :referencing, lambda { |**attributes|
15
- where(Arel::Nodes::InfixOperation.new(
16
- '@>', arel_table[:line_items],
17
- Arel::Nodes.build_quoted([attributes.transform_values(&:to_s)].to_json)
18
- ))
19
- }
20
- end
21
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Migration 010 — create kiosk.auth_challenges.
4
- # Each public key's outstanding auth challenge, so a challenge issued by one
5
- # process is redeemed by any other (AuthChallengeStores::ActiveRecord, the default).
6
- class CreateKioskAuthChallenges < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:auth_challenge) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".auth_challenges)
15
- end
16
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kiosk
4
- module Server
5
- # Login with an already-registered public key: proves possession and mints a
6
- # fresh token. An unknown key is a 404, never a new account.
7
- module AgentLogin
8
- module_function
9
-
10
- def call(public_key_pem:, signed:)
11
- pem = public_key_pem.to_s.strip
12
-
13
- # Prove possession BEFORE any lookup or state change.
14
- payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
15
- AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
16
-
17
- row = registered_agent(pem)
18
- agent_id = row.fetch('id')
19
- role = primary_role(row.fetch('allowed_roles'))
20
- token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
21
- { access_token: token }
22
- end
23
-
24
- def registered_agent(pem)
25
- row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
26
- SELECT id, user_id, allowed_roles FROM #{Kiosk.configuration.schema}.agents
27
- WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
28
- LIMIT 1
29
- SQL
30
- return row if row
31
-
32
- raise Errors::NotFound.new(
33
- 'no agent registered for this public key',
34
- hint: 'POST /auth/register to create an identity for a new key'
35
- )
36
- end
37
- private_class_method :registered_agent
38
-
39
- # `allowed_roles` is an Array or a text[] literal ("{customer}"), by adapter.
40
- def primary_role(allowed_roles)
41
- case allowed_roles
42
- when Array then allowed_roles.first
43
- else allowed_roles.to_s.delete('{}').split(',').first
44
- end
45
- end
46
- private_class_method :primary_role
47
- end
48
- end
49
- end
@@ -1,88 +0,0 @@
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
- # Self-registration with no human: creates the assistant account, binds an
9
- # agent to the key, and mints its first token.
10
- module AgentRegistration
11
- module_function
12
-
13
- def call(public_key_pem:, signed:, pow: nil)
14
- config = Kiosk.configuration
15
- role = registration_role(config)
16
- public_key_pem = public_key_pem.to_s.strip
17
-
18
- AuthToll.gate(verb: 'auth/register', body: { public_key: public_key_pem }, pow: pow, config: config)
19
-
20
- # Prove possession of the private key before any lookup or write.
21
- payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
22
- AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
23
-
24
- conn = ::ActiveRecord::Base.lease_connection
25
- refuse_known_key!(conn, config, public_key_pem)
26
- conn.transaction { register(conn, config, public_key_pem, role) }
27
- end
28
-
29
- # The operator's `assistant_creation` factory, else a bare `user_model.create!`.
30
- def create_assistant_account(config, public_key_pem)
31
- return config.user_model.constantize.create!.id unless config.assistant_creation
32
-
33
- id = config.assistant_creation.call(public_key_pem)
34
- return id unless id.nil?
35
-
36
- raise Errors::ConfigurationError,
37
- 'config.assistant_creation returned nil. The block must create the ' \
38
- 'assistant account and RETURN its id (used as agents.user_id), e.g. ' \
39
- 'c.assistant_creation = ->(pubkey) { AssistantAccount.create!(...).id }'
40
- end
41
-
42
- # The role is the server's choice, never the agent's.
43
- def registration_role(config)
44
- role = config.registration_role&.to_s
45
- return if role.blank?
46
- return role if config.roles.map(&:to_s).include?(role)
47
-
48
- raise Errors::ConfigurationError,
49
- "registration_role #{role.inspect} is not among configured roles " \
50
- "#{config.roles.inspect}"
51
- end
52
-
53
- # A known key logs in instead; it is never registered twice.
54
- def refuse_known_key!(conn, config, public_key_pem)
55
- existing = conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [public_key_pem, Kiosk.current_issuer]).to_a.first
56
- SELECT id FROM #{config.schema}.agents
57
- WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
58
- LIMIT 1
59
- SQL
60
- return unless existing
61
-
62
- raise Errors::Conflict.new(
63
- 'public key already registered',
64
- hint: 'use POST /auth/login to refresh a token for an existing key'
65
- )
66
- end
67
-
68
- def register(conn, config, public_key_pem, role)
69
- assistant_account_id = create_assistant_account(config, public_key_pem)
70
- agent_id = insert_agent(conn, config, [assistant_account_id, public_key_pem, Kiosk.current_issuer], role)
71
- token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
72
- { agent_id: agent_id, user_id: assistant_account_id.to_s, access_token: token }
73
- end
74
-
75
- # No role is the empty array: `allowed_roles` is NOT NULL.
76
- def insert_agent(conn, config, binds, role)
77
- allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
78
- rows = conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, *role_binds])
79
- INSERT INTO #{config.schema}.agents (user_id, allowed_roles, public_key, issuer)
80
- VALUES ($1, #{allowed_roles_sql}, $2, $3)
81
- RETURNING id
82
- SQL
83
- rows.to_a.first.fetch('id')
84
- end
85
- private_class_method :registration_role, :refuse_known_key!, :register, :insert_agent
86
- end
87
- end
88
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'securerandom'
4
-
5
- module Kiosk
6
- module Server
7
- # Server side of the proof-of-possession challenge: a single-use, short-lived
8
- # nonce per public key.
9
- module AuthChallenge
10
- module_function
11
-
12
- # The agent signs `challenge` into its JWS; `exp` is the Unix expiry.
13
- def issue(public_key_pem:, now: Time.current)
14
- config = Kiosk.configuration
15
- nonce = SecureRandom.urlsafe_base64(32)
16
- exp = now.to_i + config.auth_challenge_ttl
17
- config.auth_challenge_store.put(public_key_pem.to_s.strip, nonce, exp)
18
- { challenge: nonce, exp: exp }
19
- end
20
-
21
- def consume!(public_key_pem:, nonce:)
22
- ok = Kiosk.configuration.auth_challenge_store.take(public_key_pem.to_s.strip, nonce.to_s)
23
- return true if ok
24
-
25
- raise Errors::Unauthenticated.new(
26
- 'no matching auth challenge',
27
- hint: 'GET /auth/challenge?public_key=… first, then sign the returned nonce; ' \
28
- 'challenges are single-use and short-lived'
29
- )
30
- end
31
- end
32
- end
33
- end
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kiosk
4
- module Server
5
- # The challenge store, shared by every process on the database (§15.2).
6
- module AuthChallengeStores
7
- # The default: the `<schema>.auth_challenges` table ({SchemaDefinitions.auth_challenge_sql}).
8
- class ActiveRecord
9
- # Seconds between expired-row sweeps; a sweep bounds growth, not correctness.
10
- DEFAULT_PRUNE_INTERVAL = 60
11
-
12
- def initialize(prune_interval: DEFAULT_PRUNE_INTERVAL)
13
- @prune_interval = prune_interval
14
- @last_prune_at = 0
15
- @mutex = Mutex.new
16
- end
17
-
18
- # Replaces any earlier challenge for the key.
19
- def put(public_key_pem, nonce, exp)
20
- return if public_key_pem.nil?
21
-
22
- prune_if_due!
23
- sql = <<~SQL.squish
24
- INSERT INTO #{table} AS c (public_key, nonce, expires_at)
25
- VALUES ($1, $2, to_timestamp($3))
26
- ON CONFLICT (public_key) DO UPDATE
27
- SET nonce = EXCLUDED.nonce, expires_at = EXCLUDED.expires_at
28
- SQL
29
- connection.exec_query(sql, 'Kiosk auth_challenge put', [public_key_pem, nonce, exp.to_i])
30
- nil
31
- end
32
-
33
- # One atomic DELETE, so two processes presenting one challenge get one `true`.
34
- def take(public_key_pem, nonce) # rubocop:disable Naming/PredicateMethod -- `take` is the store contract `config.auth_challenge_store` names
35
- return false if public_key_pem.nil? || nonce.nil?
36
-
37
- sql = <<~SQL.squish
38
- DELETE FROM #{table}
39
- WHERE public_key = $1 AND nonce = $2 AND expires_at > now()
40
- RETURNING public_key
41
- SQL
42
- rows = connection.exec_query(
43
- sql, 'Kiosk auth_challenge take', [public_key_pem, nonce]
44
- ).to_a
45
- !rows.empty?
46
- end
47
-
48
- def prune!
49
- connection.exec_query(%(DELETE FROM #{table} WHERE expires_at <= now()),
50
- 'Kiosk auth_challenge prune')
51
- nil
52
- end
53
-
54
- private
55
-
56
- def prune_if_due!
57
- now = Time.now.to_i
58
- due = @mutex.synchronize do
59
- if now - @last_prune_at >= @prune_interval
60
- @last_prune_at = now
61
- true
62
- else
63
- false
64
- end
65
- end
66
- prune! if due
67
- end
68
-
69
- def connection = ::ActiveRecord::Base.lease_connection
70
- def table = %("#{Kiosk.configuration.schema}".auth_challenges)
71
- end
72
- end
73
- end
74
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'jwt'
4
- require 'openssl'
5
- require 'rails'
6
-
7
- module Kiosk
8
- module Server
9
- # Verifies an agent's proof-of-possession JWS: RS256 by the presented key,
10
- # `aud` equal to this origin (the relay defence), and the challenge `nonce`.
11
- # The caller burns the nonce only after a clean verify.
12
- module PopVerifier
13
- # Names no origin: an echoed one is what a relaying server would exploit.
14
- AUDIENCE_HINT =
15
- 'sign `aud` = the origin you connected to, taken from your own request ' \
16
- 'URL — never from a value echoed back in a response'
17
-
18
- PROOF_REQUIRED_CLAIMS = %w[aud nonce jti].freeze
19
-
20
- MAX_KEY_BITS = 4096
21
-
22
- PROOF_CLAIMS_HINT =
23
- "a proof's payload carries #{PROOF_REQUIRED_CLAIMS.join(', ')} — `aud` is the origin " \
24
- 'you connected to, `nonce` the value GET /auth/challenge just issued for this key, ' \
25
- '`jti` a unique id for this proof'.freeze
26
-
27
- PROOF_SIGNATURE_HINT =
28
- 'a proof is a compact RS256 JWS — header.payload.signature — signed with the ' \
29
- 'private key matching the public_key you sent'
30
-
31
- module_function
32
-
33
- def verify!(public_key_pem:, signed:)
34
- pem = public_key_pem.to_s.strip
35
- payload = decode(signed, load_public_key(pem))
36
- check_audience!(payload[:aud])
37
- raise Errors::Unauthenticated, 'proof key thumbprint mismatch' if payload.key?(:pub) && payload[:pub] != SigningKey.from_pem(pem).kid
38
-
39
- payload
40
- end
41
-
42
- def decode(signed, key)
43
- payload, = ::JWT.decode(signed.to_s, key, true, algorithms: ['RS256'], required_claims: PROOF_REQUIRED_CLAIMS)
44
- payload.transform_keys(&:to_sym)
45
- rescue ::JWT::MissingRequiredClaim
46
- raise Errors::Unauthenticated.new('proof is missing a required claim', hint: PROOF_CLAIMS_HINT)
47
- rescue ::JWT::DecodeError
48
- raise Errors::Unauthenticated.new('proof signature invalid', hint: PROOF_SIGNATURE_HINT)
49
- end
50
-
51
- def check_audience!(signed_aud)
52
- issuer = Kiosk.current_issuer
53
- return if signed_aud == issuer
54
-
55
- log_audience_mismatch(signed_aud: signed_aud, issuer: issuer)
56
- raise Errors::Unauthenticated.new('proof audience mismatch', hint: AUDIENCE_HINT)
57
- end
58
-
59
- # Operator log only, since the response must not name an origin: the
60
- # caller signed a wrong value, or the origin it reached is not served.
61
- def log_audience_mismatch(signed_aud:, issuer:)
62
- message = "[kiosk] PoP audience mismatch: caller signed aud=#{signed_aud.inspect}, " \
63
- "the issuer for this request is #{issuer.inspect}. If the signed value is " \
64
- 'an origin your assistants actually reach, list it in `c.issuer` or ' \
65
- '`c.additional_origins`.'
66
- logger = ::Rails.logger
67
- logger ? logger.warn(message) : warn(message)
68
- end
69
-
70
- # Also the key bounds of account binding.
71
- def load_public_key(pem)
72
- OpenSSL::PKey::RSA.new(pem).tap { require_key_size!(it.n.num_bits) }
73
- rescue OpenSSL::PKey::PKeyError
74
- raise Errors::BadRequest.new(
75
- 'invalid public key',
76
- hint: "send a PEM-encoded RSA public key of #{SigningKey::MIN_KEY_BITS} to #{MAX_KEY_BITS} bits " \
77
- '(-----BEGIN PUBLIC KEY----- …)'
78
- )
79
- end
80
-
81
- def require_key_size!(bits)
82
- raise Errors::BadRequest, "public key too small (#{bits} bits; minimum #{SigningKey::MIN_KEY_BITS})" if bits < SigningKey::MIN_KEY_BITS
83
- raise Errors::BadRequest, "public key too large (#{bits} bits; maximum #{MAX_KEY_BITS})" if bits > MAX_KEY_BITS
84
- end
85
- end
86
- end
87
- end