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 +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +16 -13
- data/lib/kiosk/redteam/profile.rb +4 -4
- data/lib/kiosk/redteam/scenario.rb +5 -0
- data/lib/kiosk/redteam/scenarios/device_grant_role_self_selection.rb +36 -30
- data/lib/kiosk/redteam/scenarios/mandate_principal_swap.rb +3 -4
- data/lib/kiosk/redteam/scenarios/mandate_replay.rb +3 -12
- data/lib/kiosk/redteam/scenarios/pay_for_other_use_self.rb +2 -2
- data/lib/kiosk/redteam/scenarios/privilege_self_selection.rb +20 -12
- data/lib/kiosk/redteam/scenarios/registration_without_pow.rb +7 -7
- data/lib/kiosk/redteam/scenarios/stolen_token.rb +51 -0
- data/lib/kiosk/redteam/scenarios/token_tampering.rb +1 -1
- data/lib/kiosk/redteam/scenarios/{wrong_currency_cart.rb → wrong_currency_payment.rb} +9 -9
- data/lib/kiosk/redteam/staging.rb +15 -2
- data/lib/kiosk/redteam/version.rb +1 -1
- data/lib/kiosk/redteam.rb +2 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c56649c77866230a9cf50c19996d61c4dc830e8482119f6a8bc572e1089ecf1
|
|
4
|
+
data.tar.gz: ace8f750f4f8f5d28b995b2e52f0249f5a57b18eed50a71a61e46e2059746afb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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 /
|
|
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
|
|
141
|
-
| `DeviceGrantRoleSelfSelection` | the claim ceremony's
|
|
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
|
|
147
|
-
| `MandateReplay` | A's signed
|
|
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
|
-
| `
|
|
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
|
|
158
|
-
|
|
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.
|
|
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 (
|
|
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) ->
|
|
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
|
|
9
|
-
# client-chosen role or scope. Probed
|
|
10
|
-
# declares, since a vulnerable origin
|
|
11
|
-
# role-less request must still open
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return [
|
|
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
|
-
[
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
7
|
-
#
|
|
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
|
|
7
|
-
# A refusal counts only if the same
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
40
|
-
"
|
|
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
|
|
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 &&
|
|
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
|
-
"
|
|
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
|
|
58
|
+
return nil unless client_id_of(resp)
|
|
59
59
|
|
|
60
|
-
"#{label} returned
|
|
60
|
+
"#{label} returned a client_id on HTTP #{resp.status}"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def client_id_of(resp)
|
|
64
64
|
body = resp.body
|
|
65
|
-
body.is_a?(Hash) ? body['
|
|
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
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Redteam
|
|
5
5
|
module Scenarios
|
|
6
|
-
# A
|
|
7
|
-
# must be refused
|
|
8
|
-
class
|
|
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: '
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# A 401 would mean the
|
|
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}
|
|
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
|
-
|
|
24
|
-
|
|
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.
|
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/
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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
|