kiosk-redteam 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f685eaaad2b4f5c00e9a30b853c7b955944b5ebfe3cc2232da9595a5deb5325b
4
- data.tar.gz: 5e7361227cfe0ac60f95085a875af20451992d0ed0a0514ca1ef3d7f50101efa
3
+ metadata.gz: 2c56649c77866230a9cf50c19996d61c4dc830e8482119f6a8bc572e1089ecf1
4
+ data.tar.gz: ace8f750f4f8f5d28b995b2e52f0249f5a57b18eed50a71a61e46e2059746afb
5
5
  SHA512:
6
- metadata.gz: c59a347eb3eb6261fd991cb85047c3698bac43d6d29d3dbbfcb1bbcc6cec4a86d1957dbc47a5853cd9be412b55219cfc45ac5821b8ac4d2b4ef659000adf7a54
7
- data.tar.gz: 043e887db0d72927446207bf3e0c3dff8aee07d44f976890c74c40f72660ee0e4d2f74d6ff12d93f47809884a6c067db5e8b89b044b931b0a4e05fbe0d030625
6
+ metadata.gz: 7c01e1cf090a73e15c30dfa2a47646d3681839c65d1e232ee2e473aee737c4876a91a8e5c6b8428bbc076588f30997aaee38a21c5cf2c7a349bbe08f7584fc90
7
+ data.tar.gz: e4b0bdba33d9b7f322bdf0aae993bafbfa0a51b2bab7e025e237dab437d4aef133a8690d446ff236981e393d2e187448d8162f903407c9a8f8915da4de073c2a
data/CHANGELOG.md CHANGED
@@ -12,6 +12,47 @@ already written is edited.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.6.1] — 2026-10-22
16
+
17
+ - 2026-10-22: Version 0.6.1, the tree cut that matches skill 0.6.1.
18
+ - 2026-10-21: The bundle that runs a battery against an Equihash origin holds `kiosk-pow-equihash`; kiosk-test-support does not bring it.
19
+
20
+ ## [0.6.0] — 2026-10-21
21
+
22
+ - 2026-10-21: Scenarios and batteries sign with Ed25519 keys, and the KYC test issuer signs EdDSA.
23
+ - 2026-10-21: Version 0.6.0, the tree cut that matches skill 0.6.0.
24
+
25
+ ## [0.5.27] — 2026-10-21
26
+
27
+ - 2026-10-21: Scenarios and batteries register at `oauth/register` and get tokens by client assertion and DPoP; the device-grant probes sign with a registered client.
28
+ - 2026-10-21: Version 0.5.27, the tree cut that matches skill 0.5.27.
29
+
30
+ ## [0.5.26] — 2026-10-21
31
+
32
+ - 2026-10-21: Version 0.5.26, the tree cut that matches skill 0.5.26.
33
+
34
+ ## [0.5.25] — 2026-10-21
35
+
36
+ - 2026-10-21: Version 0.5.25, the tree cut that matches skill 0.5.25.
37
+
38
+ ## [0.5.24] — 2026-10-21
39
+
40
+ - 2026-10-21: Version 0.5.24, the tree cut that matches skill 0.5.24.
41
+
42
+ ## [0.5.23] — 2026-10-21
43
+
44
+ - 2026-10-21: `Scenarios::StolenToken` asserts that a real access token without its key is refused 401. Every suite and scenario sends tokens with a DPoP proof.
45
+ - 2026-10-21: Version 0.5.23, the tree cut that matches skill 0.5.23.
46
+
47
+ ## [0.5.22] — 2026-10-21
48
+
49
+ - 2026-10-21: The payment scenarios attack the signed checkout and the two AP2 v0.2 mandates; `WrongCurrencyCart` is `WrongCurrencyPayment`.
50
+ - 2026-10-21: Version 0.5.22, the tree cut that matches skill 0.5.22.
51
+
52
+ ## [0.5.21] — 2026-10-21
53
+
54
+ - 2026-10-21: Version 0.5.21, the tree cut that matches skill 0.5.21.
55
+
15
56
  ## [0.5.20] — 2026-10-20
16
57
 
17
58
  - 2026-10-20: Version 0.5.20, the tree cut that matches skill 0.5.20.
data/README.md CHANGED
@@ -15,7 +15,7 @@ regression.
15
15
  **No Rails.** The harness speaks the wire and nothing else, so
16
16
  it can be pointed at any Kiosk origin — your own app in CI, or a third party's
17
17
  deployment. Everything provider-specific (which verbs exist, how to create an
18
- owned row, how to build a mandate) lives in a `Profile` you supply; no provider
18
+ owned row, which checkout pays for it) lives in a `Profile` you supply; no provider
19
19
  name is hard-coded in the gem.
20
20
 
21
21
  **`http` or `https` is decided by the base URL you pass and by nothing else.**
@@ -66,8 +66,9 @@ gem "kiosk-redteam", github: "kiosk-hq/kiosk", group: :development
66
66
  ```
67
67
 
68
68
  The registration toll is real Equihash: the assistant client solves the server's
69
- 402 challenges with the reference solver that ships inside `kiosk-pow-equihash`,
70
- so the machine running the battery needs **`python3` with `numpy`** on `PATH`. Point a battery at an
69
+ 402 challenges with the reference solver that ships inside `kiosk-pow-equihash`.
70
+ The bundle that runs the battery holds that gem, as the origin's bundle does,
71
+ and the machine needs **`python3` with `numpy`** on `PATH`. Point a battery at an
71
72
  origin whose PoW is off and nothing is solved and nothing is needed.
72
73
 
73
74
  ## Usage
@@ -81,7 +82,7 @@ require "kiosk/redteam"
81
82
  SERVER = ENV.fetch("SERVER_URL", "http://127.0.0.1:3000")
82
83
 
83
84
  profile = Kiosk::Redteam::Profile.new(
84
- # >0 means this origin is expected to gate /auth/register with a toll.
85
+ # >0 means this origin is expected to gate oauth/register with a toll.
85
86
  pow_difficulty: 1,
86
87
  # The roles this origin declares (Kiosk.configuration.roles), as strings.
87
88
  declared_roles: %w[customer],
@@ -108,6 +109,7 @@ battery.absorb(runner.run([
108
109
  Kiosk::Redteam::Scenarios::CrossTenantRead.new,
109
110
  Kiosk::Redteam::Scenarios::ForgedUserId.new,
110
111
  Kiosk::Redteam::Scenarios::TokenTampering.new,
112
+ Kiosk::Redteam::Scenarios::StolenToken.new,
111
113
  Kiosk::Redteam::Scenarios::RegistrationWithoutPow.new,
112
114
  Kiosk::Redteam::Scenarios::DeviceGrantRoleSelfSelection.new,
113
115
  ]))
@@ -137,16 +139,17 @@ scenarios share:
137
139
  |---|---|
138
140
  | `CrossTenantRead` | B's per-user query must not return rows owned by A |
139
141
  | `ForgedUserId` | a caller-supplied `user_id` never decides ownership |
140
- | `PrivilegeSelfSelection` | a client-chosen registration role is ignored — the role is server-pinned |
141
- | `DeviceGrantRoleSelfSelection` | the claim ceremony's unauthenticated opening request refuses `role`/`scope`, at a DECLARED value as well as an invented one |
142
+ | `PrivilegeSelfSelection` | a role in the registration body does not reach the client's token — the role is server-pinned |
143
+ | `DeviceGrantRoleSelfSelection` | the claim ceremony's opening request, signed by a registered client, refuses `role`/`scope`, at a DECLARED value as well as an invented one |
142
144
  | `SpentResourceReuse` | a consumed resource cannot be re-activated |
143
145
  | `PayForOtherUseSelf` | B paying for A's resource does not let B use it |
144
146
  | `TokenTampering` | a JWT with a flipped claim and the original signature is 401 |
147
+ | `StolenToken` | a real access token without its key is 401: as a bearer token, and with a DPoP proof that another key signs |
145
148
  | `RegistrationWithoutPow` | a missing or bad proof is rejected when the origin charges a toll |
146
- | `MandatePrincipalSwap` | B signing a mandate that carries A's identity is rejected |
147
- | `MandateReplay` | A's signed mandate JWS re-submitted under B's token is rejected |
149
+ | `MandatePrincipalSwap` | B paying, with its own key, the checkout the operator signed for A is rejected |
150
+ | `MandateReplay` | A's signed mandates re-submitted under B's token are rejected |
148
151
  | `UnpaidGatedAction` | the gated action without a prior settled payment is denied |
149
- | `WrongCurrencyCart` | a chain-consistent cart denominated in a currency the operator does not price in is refused at capture |
152
+ | `WrongCurrencyPayment` | a payment mandate in a currency other than its checkout's is refused before any capture |
150
153
  | `MissingKyc` | the gated action after payment but without an attestation is denied |
151
154
  | `ExpiredKyc` | an attestation whose `exp` has passed is rejected |
152
155
  | `ForgedKyc` | an attestation with a wrong issuer or a bad signature is rejected |
@@ -154,8 +157,8 @@ scenarios share:
154
157
  Beside them the gem ships `Profile`, `Scenario`, `Verdict`, `Runner`,
155
158
  `Battery` and `LeakScan`, which decides whether a refusal leaked the runtime's
156
159
  own vocabulary, discounting needles the attack itself supplied. A scenario
157
- receives a `Kiosk::TestHelpers::Assistant` — register, query, run, pay with
158
- signed mandates, kyc, and the device-authorization request — and its
160
+ receives a `Kiosk::TestHelpers::Assistant` — register, token, query, run, pay
161
+ with closed AP2 mandates, kyc, and the device-authorization request — and its
159
162
  `events(principal)` is the assistant's side of `<endpoint>/events`.
160
163
 
161
164
  ## Your own beats, in the same battery
@@ -168,7 +171,7 @@ library scenario can sit in one run.
168
171
 
169
172
  `Kiosk::TestHelpers::Wire` is the raw wire: an arbitrary method at an arbitrary path with arbitrary
170
173
  headers, which is what an attack on the REQUEST rather than on the principal
171
- needs — a forged or absent `Authorization`, a verb nobody registered, the wrong
174
+ needs — a forged or absent `Authorization`, a token without its key, a verb nobody registered, the wrong
172
175
  method at a real verb's path, an assertion on `Allow:` rather than on a body. A
173
176
  body that is not JSON reads as `{}` and a connection error reads as status `0`,
174
177
  so a hostile answer is asserted on instead of crashing the run; the bytes stay
@@ -183,7 +186,7 @@ client = Kiosk::TestHelpers::Assistant.new(base_url: SERVER)
183
186
  battery = Kiosk::Redteam::Battery.new
184
187
 
185
188
  # your own beat, against your own verb
186
- status, rows = wire.get_json("/kiosk/my_listings", {}, wire.bearer(bob.token))
189
+ status, rows = wire.get_json("/kiosk/my_listings", {}, wire.dpop(bob.token, bob.key))
187
190
  battery.record("CrossTenantRead", status == 200 && !ids(rows).include?(alice_row),
188
191
  "Bob's rows #{ids(rows).inspect} exclude Alice's #{alice_row}")
189
192
 
@@ -5,20 +5,20 @@ module Kiosk
5
5
  # What an origin tells the generic scenarios about itself. A nil field skips
6
6
  # the scenarios that need that surface.
7
7
  #
8
- # pow_difficulty >0 when /register is gated by a PoW toll; 0 skips RegistrationWithoutPow
8
+ # pow_difficulty >0 when oauth/register is gated by a PoW toll; 0 skips RegistrationWithoutPow
9
9
  # declared_roles the roles the origin declares; DeviceGrantRoleSelfSelection needs a real one
10
10
  # requires_kyc whether the gated action needs a KYC attestation
11
- # currency the ISO 4217 code the origin prices in, lower case (WrongCurrencyCart)
11
+ # currency the ISO 4217 code the origin prices in, lower case (WrongCurrencyPayment)
12
12
  # per_user_query query returning the caller's own rows (CrossTenantRead)
13
13
  # row_id_key key of a row's id in query results
14
14
  # result_id_key key of the new resource's id in forge_action's result
15
- # create_owned (client, principal) -> Hash with at least :id
15
+ # create_owned (client, principal) -> Hash with at least :id, and :checkout_jwt where it is paid for
16
16
  # forge_action action whose caller-supplied user_id the server must ignore
17
17
  # forge_args (client, principal_a, principal_b) -> Hash, without user_id
18
18
  # gated_action action gated behind payment (and KYC when requires_kyc)
19
19
  # gated_action_consumes false when calling gated_action twice is correct (skips SpentResourceReuse)
20
20
  # gated_args (owned_ref) -> Hash
21
- # pay_for (client, principal, owned_ref) -> { intent:, cart: }
21
+ # pay_for (client, principal, owned_ref) -> the checkout_jwt that pays for owned_ref
22
22
  # kyc_valid (user_id) -> a valid attestation JWS
23
23
  # kyc_expired (user_id) -> an expired attestation JWS
24
24
  # kyc_forged (user_id) -> an attestation with a wrong issuer or signature
@@ -25,6 +25,11 @@ module Kiosk
25
25
 
26
26
  private
27
27
 
28
+ # The client a 201 registration answer names, with the key it registered. No token yet.
29
+ def registered_principal(resp, key)
30
+ Kiosk::TestHelpers::Assistant::Principal.new(agent_id: resp.body['client_id'], user_id: nil, token: nil, key: key)
31
+ end
32
+
28
33
  # Changes a payload claim and keeps the original signature.
29
34
  def tamper_token(token)
30
35
  TokenClaims.tamper(token)
@@ -5,10 +5,11 @@ require 'openssl'
5
5
  module Kiosk
6
6
  module Redteam
7
7
  module Scenarios
8
- # The unauthenticated device_authorization request must refuse a
9
- # client-chosen role or scope. Probed with roles the origin actually
10
- # declares, since a vulnerable origin refuses an undeclared one too; the
11
- # role-less request must still open the ceremony.
8
+ # The device_authorization request, which a registered client signs with
9
+ # its client assertion, must refuse a client-chosen role or scope. Probed
10
+ # with roles the origin actually declares, since a vulnerable origin
11
+ # refuses an undeclared one too; the role-less request must still open
12
+ # the ceremony.
12
13
  class DeviceGrantRoleSelfSelection < Scenario
13
14
  # Refused by a vulnerable origin too; probed only to show both filters in the detail.
14
15
  UNDECLARED_ROLE = 'master'
@@ -21,28 +22,30 @@ module Kiosk
21
22
  end
22
23
 
23
24
  def call(client, profile)
24
- wire_role, setup = wire_declared_role(client)
25
+ principal, setup = registered_client(client)
26
+ setup || attack(client, principal, profile)
27
+ end
28
+
29
+ private
30
+
31
+ def attack(client, principal, profile)
32
+ wire_role, setup = wire_declared_role(client, principal)
25
33
  return setup if setup
26
34
 
27
35
  declared = (profile.declared_roles + [wire_role]).compact.uniq
28
36
  return skip_verdict(no_declared_role_reason) if declared.empty?
29
37
 
30
- probes = probe(client, declared)
31
- control_ok, control_detail = control(client)
38
+ probes = probe(client, principal, declared)
39
+ control_ok, control_detail = control(client, principal)
32
40
  refused = probes.all?(&:first)
33
41
  Verdict.new(blocked: refused && control_ok, skipped: false, status: refused ? 400 : 200,
34
42
  detail: summary(probes, control_detail, declared, wire_role))
35
43
  end
36
44
 
37
- private
38
-
39
45
  # [refused?, detail] for each role or scope the request names.
40
- def probe(client, declared)
41
- pem = OpenSSL::PKey::RSA.generate(2048).public_key.to_pem
46
+ def probe(client, principal, declared)
42
47
  probe_labels(declared).map do |label, param, value|
43
- resp = client.device_authorization(
44
- client_id: 'redteam-device-grant-role', public_key: pem, param => value
45
- )
48
+ resp = client.device_authorization(principal, param => value)
46
49
  [resp.status == 400 && body_field(resp, 'error') == 'invalid_request',
47
50
  "#{label} -> HTTP #{resp.status} error=#{body_field(resp, 'error').inspect}"]
48
51
  end
@@ -51,7 +54,7 @@ module Kiosk
51
54
  def summary(probes, control_detail, declared, wire_role)
52
55
  "#{probes.map(&:last).join('; ')}; #{control_detail} " \
53
56
  "[declared roles probed: #{declared.inspect} " \
54
- "(#{wire_role.inspect} read off this origin's own registration token); " \
57
+ "(#{wire_role.inspect} read off this origin's own client_credentials token); " \
55
58
  'want every role/scope refused 400 invalid_request AND the role-less ' \
56
59
  'ceremony still opening]'
57
60
  end
@@ -71,38 +74,41 @@ module Kiosk
71
74
  end
72
75
  end
73
76
 
74
- def control(client)
75
- key = OpenSSL::PKey::RSA.generate(2048)
76
- resp = client.device_authorization(
77
- client_id: 'redteam-device-grant-role', public_key: key.public_key.to_pem
78
- )
77
+ def control(client, principal)
78
+ resp = client.device_authorization(principal)
79
79
  code = resp.body.is_a?(Hash) ? resp.body['user_code'].to_s : ''
80
80
  [resp.status == 200 && code.match?(USER_CODE),
81
81
  "CONTROL role-less request -> HTTP #{resp.status} user_code=#{code.inspect}"]
82
82
  end
83
83
 
84
- # The `role` claim of a token this origin mints at registration.
85
- def wire_declared_role(client)
86
- resp = client.register_raw
87
- failure = (registration_failure(resp) unless resp.status == 201)
88
- return [nil, failure] if failure
84
+ # The client that signs every request of this scenario, or the verdict of a failed registration.
85
+ def registered_client(client)
86
+ key = OpenSSL::PKey.generate_key('ED25519')
87
+ resp = client.register_raw(key:)
88
+ return [registered_principal(resp, key), nil] if resp.status == 201
89
89
 
90
- [token_role(body_field(resp, 'access_token')), nil]
90
+ [nil, untested_verdict(resp.status, 'SETUP FAILED: the registration of the client that signs the probes ' \
91
+ "returned HTTP #{resp.status} #{resp.body.inspect}.")]
91
92
  end
92
93
 
93
- def registration_failure(resp)
94
- setup_failure(
94
+ # The `role` claim of the first token this origin mints for the client.
95
+ def wire_declared_role(client, principal)
96
+ resp = client.token(principal)
97
+ failure = setup_failure(
95
98
  resp,
96
- step: 'the CONTROL registration this scenario reads a declared role from',
99
+ step: 'the CONTROL token request this scenario reads a declared role from',
97
100
  because: 'without a role the origin actually declares, the only probe left is an ' \
98
101
  'invented one — which even a vulnerable origin refuses, so the battery ' \
99
102
  'would print BLOCKED without testing anything.'
100
103
  )
104
+ return [nil, failure] if failure
105
+
106
+ [token_role(body_field(resp, 'access_token')), nil]
101
107
  end
102
108
 
103
109
  def no_declared_role_reason
104
110
  'this origin declares no role — `declared_roles` is empty in the profile AND the ' \
105
- 'token it minted at registration carries no `role` claim, so a client-chosen ' \
111
+ 'first token it minted carries no `role` claim, so a client-chosen ' \
106
112
  'DECLARED role has nothing to name here'
107
113
  end
108
114
 
@@ -3,7 +3,7 @@
3
3
  module Kiosk
4
4
  module Redteam
5
5
  module Scenarios
6
- # B pays, under its own token and key, mandates claiming A's identity: must be refused.
6
+ # B pays, under its own token and key, the checkout the operator signed for A: must be refused.
7
7
  class MandatePrincipalSwap < Scenario
8
8
  def initialize
9
9
  super(name: 'MandatePrincipalSwap')
@@ -19,12 +19,11 @@ module Kiosk
19
19
  def attack(client, profile)
20
20
  a = client.register!
21
21
  b = client.register!
22
- mandates = profile.pay_for.call(client, a, profile.create_owned.call(client, a))
23
- resp = client.pay(b, intent: mandates[:intent], cart: mandates[:cart])
22
+ resp = client.pay(b, profile.pay_for.call(client, a, profile.create_owned.call(client, a)))
24
23
 
25
24
  # Only 403: a 402 or 401 means the swap was never examined.
26
25
  verdict_from(resp, expect: 403, expect_code: %w[forbidden rls_denied],
27
- detail: "principal-swap mandate accepted (HTTP #{resp.status})")
26
+ detail: "B paid the checkout signed for A (HTTP #{resp.status})")
28
27
  end
29
28
  end
30
29
  end
@@ -3,7 +3,7 @@
3
3
  module Kiosk
4
4
  module Redteam
5
5
  module Scenarios
6
- # B re-submits A's already-used mandate JWS under B's token: must be refused.
6
+ # B re-submits A's already-used mandates, signed by A's key, under B's token: must be refused.
7
7
  class MandateReplay < Scenario
8
8
  def initialize
9
9
  super(name: 'MandateReplay')
@@ -19,23 +19,14 @@ module Kiosk
19
19
  def attack(client, profile)
20
20
  a = client.register!
21
21
  b = client.register!
22
- mandates = profile.pay_for.call(client, a, profile.create_owned.call(client, a))
23
- signed = signed_mandates(client, a, mandates)
24
- client.pay(a, intent: mandates[:intent], cart: mandates[:cart])
22
+ signed = Kiosk::TestHelpers::Mandate.pay(a.key, profile.pay_for.call(client, a, profile.create_owned.call(client, a)))
23
+ client.pay_raw(a, **signed)
25
24
  resp = client.pay_raw(b, **signed)
26
25
 
27
26
  # Only 403: a 402 decline would mean the replay was never verified.
28
27
  verdict_from(resp, expect: 403, expect_code: %w[forbidden rls_denied],
29
28
  detail: "mandate replay accepted under B's token (HTTP #{resp.status})")
30
29
  end
31
-
32
- def signed_mandates(client, principal, mandates)
33
- {
34
- intent_jws: client.sign_mandate(principal, mandates[:intent]),
35
- cart_jws: client.sign_mandate(principal, mandates[:cart]),
36
- payment_jws: client.sign_mandate(principal, client.payment_mandate(principal, cart: mandates[:cart]))
37
- }
38
- end
39
30
  end
40
31
  end
41
32
  end
@@ -3,8 +3,8 @@
3
3
  module Kiosk
4
4
  module Redteam
5
5
  module Scenarios
6
- # B pays a mandate that references A's resource, then invokes the gated
7
- # action on it: the ownership gate must refuse at use time, not only at pay time.
6
+ # B pays the checkout of A's resource, then invokes the gated action on
7
+ # it: the ownership gate must refuse at use time, not only at pay time.
8
8
  class PayForOtherUseSelf < Scenario
9
9
  KYC_BECAUSE = 'B would otherwise be refused for want of KYC, and this scenario would ' \
10
10
  'credit that refusal to the ownership gate it exists to prove.'
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  module Kiosk
4
6
  module Redteam
5
7
  module Scenarios
6
- # A role injected into /auth/register must not reach the issued token.
7
- # A refusal counts only if the same registration without the role succeeds.
8
+ # A role injected into the registration body must not reach the token
9
+ # that the client then gets. A refusal counts only if the same
10
+ # registration without the role succeeds.
8
11
  class PrivilegeSelfSelection < Scenario
9
12
  ESCALATED_ROLE = 'master'
10
13
 
@@ -13,15 +16,24 @@ module Kiosk
13
16
  end
14
17
 
15
18
  def call(client, _profile)
16
- resp = client.register_raw(wire_role: ESCALATED_ROLE)
19
+ key = OpenSSL::PKey.generate_key('ED25519')
20
+ resp = client.register_raw(wire_role: ESCALATED_ROLE, key:)
17
21
  return refusal_verdict(client, resp) unless resp.status == 201
18
- return blocked_verdict(201) unless TokenClaims.claims(access_token(resp))&.fetch('role', nil) == ESCALATED_ROLE
19
22
 
20
- breach_verdict(201, "issued token carries the self-selected role #{ESCALATED_ROLE.inspect}")
23
+ issued = client.token(registered_principal(resp, key))
24
+ setup_failure(issued, step: 'the token request after the registration with the injected role',
25
+ because: 'without a token there is no role to read.') || role_verdict(issued)
21
26
  end
22
27
 
23
28
  private
24
29
 
30
+ def role_verdict(issued)
31
+ role = TokenClaims.claims(issued.body['access_token'])&.fetch('role', nil)
32
+ return blocked_verdict(201) unless [role, issued.body['scope']].include?(ESCALATED_ROLE)
33
+
34
+ breach_verdict(issued.status, "issued token carries the self-selected role #{ESCALATED_ROLE.inspect}")
35
+ end
36
+
25
37
  def refusal_verdict(client, resp)
26
38
  if resp.status >= 500 || resp.status.zero?
27
39
  return breach_verdict(resp.status,
@@ -33,18 +45,14 @@ module Kiosk
33
45
  end
34
46
 
35
47
  def control_failure(control, resp)
36
- return nil if control.status == 201 && access_token(control)
48
+ return nil if control.status == 201 && control.body.is_a?(Hash) && control.body['client_id']
37
49
 
38
50
  breach_verdict(control.status,
39
- 'CONTROL FAILED: an honest registration must return 201 with an ' \
40
- "access_token — got HTTP #{control.status} #{control.body.inspect}. " \
51
+ 'CONTROL FAILED: an honest registration must return 201 with a ' \
52
+ "client_id — got HTTP #{control.status} #{control.body.inspect}. " \
41
53
  "The injected registration's HTTP #{resp.status} refusal therefore " \
42
54
  'says nothing about whether the role is pinned server-side.')
43
55
  end
44
-
45
- def access_token(resp)
46
- resp.body.is_a?(Hash) ? resp.body['access_token'] : nil
47
- end
48
56
  end
49
57
  end
50
58
  end
@@ -4,7 +4,7 @@ module Kiosk
4
4
  module Redteam
5
5
  module Scenarios
6
6
  # With a PoW gate, registration with no proof or a wrong one must be refused
7
- # (not a 201, no token, not a crash), while a solved control registration succeeds.
7
+ # (not a 201, no client_id, not a crash), while a solved control registration succeeds.
8
8
  class RegistrationWithoutPow < Scenario
9
9
  def initialize
10
10
  super(name: 'RegistrationWithoutPow')
@@ -38,12 +38,12 @@ module Kiosk
38
38
  end
39
39
 
40
40
  def honest_registration?(control)
41
- control.status == 201 && token_of(control)
41
+ control.status == 201 && client_id_of(control)
42
42
  end
43
43
 
44
44
  def control_problem(control)
45
45
  'CONTROL FAILED: a properly solved registration must return 201 with ' \
46
- "an access_token — got HTTP #{control.status} #{control.body.inspect}. " \
46
+ "a client_id — got HTTP #{control.status} #{control.body.inspect}. " \
47
47
  'A server that refuses every registration refuses the two unproven ' \
48
48
  'ones too, which says nothing about a PoW gate.'
49
49
  end
@@ -55,14 +55,14 @@ module Kiosk
55
55
  return "#{label} crashed (HTTP #{resp.status}: #{resp.body.inspect}) — " \
56
56
  'a crash is not a gate'
57
57
  end
58
- return nil unless token_of(resp)
58
+ return nil unless client_id_of(resp)
59
59
 
60
- "#{label} returned an access_token on HTTP #{resp.status}"
60
+ "#{label} returned a client_id on HTTP #{resp.status}"
61
61
  end
62
62
 
63
- def token_of(resp)
63
+ def client_id_of(resp)
64
64
  body = resp.body
65
- body.is_a?(Hash) ? body['access_token'] : nil
65
+ body.is_a?(Hash) ? body['client_id'] : nil
66
66
  end
67
67
  end
68
68
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Kiosk
6
+ module Redteam
7
+ module Scenarios
8
+ # A copied access token without its private key buys no request (RFC 9449):
9
+ # sent as a bearer token, or with a DPoP proof that another key signs, it
10
+ # must be refused 401. The token with its own key is the control.
11
+ class StolenToken < Scenario
12
+ def initialize
13
+ super(name: 'StolenToken')
14
+ end
15
+
16
+ def call(client, profile)
17
+ verb = profile.per_user_query
18
+ return skipped if verb.nil?
19
+
20
+ victim = client.register!
21
+ control = client.query(victim, name: verb)
22
+ return vacuous(control) unless control.status == 200
23
+
24
+ accepted(attacks(client, victim, verb))
25
+ end
26
+
27
+ private
28
+
29
+ def skipped = Verdict.new(blocked: false, skipped: true, status: 0, detail: TokenTampering::NO_ROUTED_VERB)
30
+
31
+ # Blocked when every attack was refused 401; else the first accepted one.
32
+ def accepted(attacks)
33
+ label, resp = attacks.find { |_, answer| !verdict_from(answer, expect: 401).blocked }
34
+ return blocked_verdict(401) unless resp
35
+
36
+ breach_verdict(resp.status, "a stolen token was accepted #{label} (HTTP #{resp.status})")
37
+ end
38
+
39
+ def vacuous(control)
40
+ breach_verdict(control.status, "the token with its own key was refused (HTTP #{control.status}): the refusals prove nothing")
41
+ end
42
+
43
+ def attacks(client, victim, verb)
44
+ path = "/kiosk/#{verb}"
45
+ { 'as a bearer token' => client.wire.get(path, {}, 'Authorization' => "Bearer #{victim.token}"),
46
+ "with the thief's own key" => client.query(victim.with(key: OpenSSL::PKey.generate_key('ED25519')), name: verb) }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -31,7 +31,7 @@ module Kiosk
31
31
  agent_id: principal.agent_id,
32
32
  user_id: principal.user_id,
33
33
  token: tamper_token(principal.token),
34
- rsa_key: principal.rsa_key
34
+ key: principal.key
35
35
  )
36
36
  end
37
37
  end
@@ -3,13 +3,13 @@
3
3
  module Kiosk
4
4
  module Redteam
5
5
  module Scenarios
6
- # A consistent mandate pair in a currency the operator does not price in
7
- # must be refused by the operator at capture.
8
- class WrongCurrencyCart < Scenario
6
+ # A payment mandate in a currency other than the checkout's, with the
7
+ # same amount, must be refused before any capture.
8
+ class WrongCurrencyPayment < Scenario
9
9
  ALTERNATIVES = %w[usd eur gbp jpy].freeze
10
10
 
11
11
  def initialize
12
- super(name: 'WrongCurrencyCart')
12
+ super(name: 'WrongCurrencyPayment')
13
13
  end
14
14
 
15
15
  def call(client, profile)
@@ -25,12 +25,12 @@ module Kiosk
25
25
  def attack(client, profile, native)
26
26
  foreign = ALTERNATIVES.find { |c| c != native }
27
27
  a = client.register!
28
- mandates = profile.pay_for.call(client, a, profile.create_owned.call(client, a))
29
- resp = client.pay(a, intent: mandates[:intent].merge(currency: foreign),
30
- cart: mandates[:cart].merge(currency: foreign))
31
- # A 401 would mean the cart never reached the operator's capture check.
28
+ checkout_jwt = profile.pay_for.call(client, a, profile.create_owned.call(client, a))
29
+ amount = JWT.decode(checkout_jwt, nil, false).first['total_amount_cents']
30
+ resp = client.pay_raw(a, **mandates_with_payment(a, checkout_jwt, payment_amount: { amount:, currency: foreign }))
31
+ # A 401 would mean the payment never reached the mandate check.
32
32
  verdict_from(resp, expect: 403,
33
- detail: "a #{foreign} cart settled at a #{native.upcase} operator " \
33
+ detail: "a #{foreign} payment of a #{native.upcase} checkout settled " \
34
34
  "(HTTP #{resp.status})")
35
35
  end
36
36
  end
@@ -20,8 +20,21 @@ module Kiosk
20
20
 
21
21
  # Pays for +owned_ref+ as +payer+ and answers the pay response.
22
22
  def pay_for_owned(client, payer, profile, owned_ref)
23
- mandates = profile.pay_for.call(client, payer, owned_ref)
24
- client.pay(payer, intent: mandates[:intent], cart: mandates[:cart])
23
+ client.pay(payer, profile.pay_for.call(client, payer, owned_ref))
24
+ end
25
+
26
+ # The claims of +checkout_jwt+ with +changes+, signed again by a key the operator never held.
27
+ def tampered_checkout(checkout_jwt, **changes)
28
+ claims, header = JWT.decode(checkout_jwt, nil, false)
29
+ JWT.encode(claims.merge(changes.transform_keys(&:to_s)), OpenSSL::PKey.generate_key('ED25519'), 'EdDSA',
30
+ header.slice('kid', 'typ'))
31
+ end
32
+
33
+ # The two mandates for +checkout_jwt+, with the payment mandate's fields changed and signed again.
34
+ def mandates_with_payment(principal, checkout_jwt, **changes)
35
+ mandates = Kiosk::TestHelpers::Mandate.pay(principal.key, checkout_jwt)
36
+ payment = Kiosk::TestHelpers::Mandate.content(mandates[:payment]).merge(changes.transform_keys(&:to_s))
37
+ mandates.merge(payment: Kiosk::TestHelpers::Mandate.sign(principal.key, payment))
25
38
  end
26
39
 
27
40
  # Pays for +owned_ref+ before a gated action. Answers a setup-failure verdict or nil.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kiosk
4
4
  module Redteam
5
- VERSION = '0.5.20'
5
+ VERSION = '0.6.1'
6
6
  end
7
7
  end
data/lib/kiosk/redteam.rb CHANGED
@@ -14,6 +14,7 @@ require 'kiosk/redteam/scenarios/forged_user_id'
14
14
  require 'kiosk/redteam/scenarios/mandate_principal_swap'
15
15
  require 'kiosk/redteam/scenarios/mandate_replay'
16
16
  require 'kiosk/redteam/scenarios/token_tampering'
17
+ require 'kiosk/redteam/scenarios/stolen_token'
17
18
  require 'kiosk/redteam/scenarios/registration_without_pow'
18
19
  require 'kiosk/redteam/scenarios/privilege_self_selection'
19
20
  require 'kiosk/redteam/scenarios/device_grant_role_self_selection'
@@ -23,7 +24,7 @@ require 'kiosk/redteam/scenarios/expired_kyc'
23
24
  require 'kiosk/redteam/scenarios/forged_kyc'
24
25
  require 'kiosk/redteam/scenarios/spent_resource_reuse'
25
26
  require 'kiosk/redteam/scenarios/pay_for_other_use_self'
26
- require 'kiosk/redteam/scenarios/wrong_currency_cart'
27
+ require 'kiosk/redteam/scenarios/wrong_currency_payment'
27
28
 
28
29
  module Kiosk
29
30
  # Adversarial regression harness: drives hostile HTTP scenarios against a
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kiosk-redteam
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
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.5.0
32
+ version: 0.6.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.5.0
39
+ version: 0.6.0
40
40
  description: |
41
41
  kiosk-redteam drives hostile HTTP scenarios against any Kiosk provider
42
42
  and asserts each attack is correctly blocked — a deliberate refusal, never
@@ -76,9 +76,10 @@ files:
76
76
  - lib/kiosk/redteam/scenarios/privilege_self_selection.rb
77
77
  - lib/kiosk/redteam/scenarios/registration_without_pow.rb
78
78
  - lib/kiosk/redteam/scenarios/spent_resource_reuse.rb
79
+ - lib/kiosk/redteam/scenarios/stolen_token.rb
79
80
  - lib/kiosk/redteam/scenarios/token_tampering.rb
80
81
  - lib/kiosk/redteam/scenarios/unpaid_gated_action.rb
81
- - lib/kiosk/redteam/scenarios/wrong_currency_cart.rb
82
+ - lib/kiosk/redteam/scenarios/wrong_currency_payment.rb
82
83
  - lib/kiosk/redteam/staging.rb
83
84
  - lib/kiosk/redteam/token_claims.rb
84
85
  - lib/kiosk/redteam/verdict.rb