kiosk-redteam 0.5.20
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 +7 -0
- data/CHANGELOG.md +158 -0
- data/LICENSE.txt +202 -0
- data/README.md +247 -0
- data/lib/kiosk/redteam/battery.rb +148 -0
- data/lib/kiosk/redteam/leak_scan.rb +104 -0
- data/lib/kiosk/redteam/profile.rb +58 -0
- data/lib/kiosk/redteam/runner.rb +37 -0
- data/lib/kiosk/redteam/scenario.rb +34 -0
- data/lib/kiosk/redteam/scenarios/cross_tenant_read.rb +57 -0
- data/lib/kiosk/redteam/scenarios/device_grant_role_self_selection.rb +117 -0
- data/lib/kiosk/redteam/scenarios/expired_kyc.rb +27 -0
- data/lib/kiosk/redteam/scenarios/forged_kyc.rb +27 -0
- data/lib/kiosk/redteam/scenarios/forged_user_id.rb +108 -0
- data/lib/kiosk/redteam/scenarios/kyc_attack.rb +43 -0
- data/lib/kiosk/redteam/scenarios/mandate_principal_swap.rb +32 -0
- data/lib/kiosk/redteam/scenarios/mandate_replay.rb +42 -0
- data/lib/kiosk/redteam/scenarios/missing_kyc.rb +38 -0
- data/lib/kiosk/redteam/scenarios/pay_for_other_use_self.rb +59 -0
- data/lib/kiosk/redteam/scenarios/privilege_self_selection.rb +51 -0
- data/lib/kiosk/redteam/scenarios/registration_without_pow.rb +70 -0
- data/lib/kiosk/redteam/scenarios/spent_resource_reuse.rb +47 -0
- data/lib/kiosk/redteam/scenarios/token_tampering.rb +40 -0
- data/lib/kiosk/redteam/scenarios/unpaid_gated_action.rb +37 -0
- data/lib/kiosk/redteam/scenarios/wrong_currency_cart.rb +39 -0
- data/lib/kiosk/redteam/staging.rb +45 -0
- data/lib/kiosk/redteam/token_claims.rb +51 -0
- data/lib/kiosk/redteam/verdict.rb +13 -0
- data/lib/kiosk/redteam/verdicts.rb +130 -0
- data/lib/kiosk/redteam/version.rb +7 -0
- data/lib/kiosk/redteam.rb +94 -0
- metadata +113 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# B calls forge_action with A's user_id injected; the server must take the
|
|
7
|
+
# principal from the token, so A must not see the new resource.
|
|
8
|
+
class ForgedUserId < Scenario
|
|
9
|
+
# The protocol's name for a principal everywhere it appears.
|
|
10
|
+
FORGED_ARG = 'user_id'
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
super(name: 'ForgedUserId')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(client, profile)
|
|
17
|
+
skip_unless(profile, forge_action: 'no forge_action', forge_args: 'no forge_args') ||
|
|
18
|
+
attack(client, profile)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def attack(client, profile)
|
|
24
|
+
a = client.register!
|
|
25
|
+
b = client.register!
|
|
26
|
+
forged_args = profile.forge_args.call(client, a, b).merge(FORGED_ARG.to_sym => a.user_id)
|
|
27
|
+
resp = client.run(b, name: profile.forge_action, **forged_args)
|
|
28
|
+
refusal_verdict(resp, profile) || ownership_verdict(client, a, resp, profile)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The verdict when the origin refused the forged call, else nil.
|
|
32
|
+
def refusal_verdict(resp, profile)
|
|
33
|
+
stall = payment_required_stall(resp, step: "the forged-user_id #{profile.forge_action} call")
|
|
34
|
+
return stall if stall
|
|
35
|
+
return contract_refusal(resp) if contract_refusal?(resp)
|
|
36
|
+
return verdict_from(resp, expect: [401, 403], detail: 'forge_action rejected') if Kiosk::Redteam.blocked?(resp)
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A 400 naming the injected property is the input schema refusing it (§8.1).
|
|
42
|
+
def contract_refusal?(resp)
|
|
43
|
+
resp.status == 400 && Kiosk::Redteam.error_code(resp) == 'bad_request' &&
|
|
44
|
+
refusal_names?(resp, FORGED_ARG)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def contract_refusal(resp)
|
|
48
|
+
blocked_verdict(resp.status,
|
|
49
|
+
'forge_action refused by the declared input contract: 400 bad_request naming ' \
|
|
50
|
+
"#{FORGED_ARG.inspect} — the principal is not an accepted argument")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ownership_verdict(client, owner, resp, profile)
|
|
54
|
+
# Without per_user_query ownership cannot be verified, so this cannot pass.
|
|
55
|
+
unless profile.per_user_query
|
|
56
|
+
return breach_verdict(resp.status,
|
|
57
|
+
"forge_action returned #{resp.status}; cannot verify ownership (no per_user_query)")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
new_id = extract_id(resp, profile.result_id_key)
|
|
61
|
+
return leak_verdict(client, owner, resp, profile, new_id) if new_id
|
|
62
|
+
|
|
63
|
+
breach_verdict(resp.status,
|
|
64
|
+
"forge_action returned #{resp.status} but result_id_key=#{profile.result_id_key.inspect} " \
|
|
65
|
+
'not found in response; cannot confirm ownership was enforced')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def leak_verdict(client, owner, resp, profile, new_id)
|
|
69
|
+
answer = client.query(owner, name: profile.per_user_query)
|
|
70
|
+
return unreadable(answer, profile) unless rows_answer?(answer)
|
|
71
|
+
return blocked_verdict(resp.status) if rows_from(answer).none? { |r| r[profile.row_id_key].to_s == new_id.to_s }
|
|
72
|
+
|
|
73
|
+
breach_verdict(resp.status,
|
|
74
|
+
"forged user_id=#{owner.user_id} was honoured; resource id=#{new_id} visible in A's rows")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# An error or another shape would read as no rows, which proves nothing.
|
|
78
|
+
def rows_answer?(answer) = answer.status == 200 && answer.body.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
def unreadable(answer, profile)
|
|
81
|
+
breach_verdict(answer.status, "A's #{profile.per_user_query} answered HTTP #{answer.status} " \
|
|
82
|
+
'without a rows array; cannot confirm ownership was enforced')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def refusal_names?(response, key)
|
|
86
|
+
return false if key.nil? || key.to_s.empty?
|
|
87
|
+
|
|
88
|
+
body = response.body
|
|
89
|
+
return false unless body.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
"#{body['detail']} #{body['hint']}".include?(key.to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An action answers its own object (§8.2); a `value` key of the operator's own is read too.
|
|
95
|
+
def extract_id(response, result_id_key)
|
|
96
|
+
body = response.body
|
|
97
|
+
return nil unless body.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
nested = body['value']
|
|
100
|
+
source = nested.is_a?(Hash) && nested.key?(result_id_key) ? nested : body
|
|
101
|
+
|
|
102
|
+
v = source[result_id_key]
|
|
103
|
+
v&.to_s&.empty? == false ? v : nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# Submits a bad attestation to /kyc, then pays and runs the gated action.
|
|
7
|
+
# The attack is refused when either step refuses it.
|
|
8
|
+
module KycAttack
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def kyc_attack(client, profile, attestation:, kind:, check:)
|
|
12
|
+
a = client.register!
|
|
13
|
+
kyc_resp = client.kyc(a, attestation_jws: attestation.call(a.user_id))
|
|
14
|
+
kyc_refusal(kyc_resp, kind) || gated_after_kyc(client, profile, a, kind: kind, check: check)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The verdict when /kyc already answered the attack, else nil.
|
|
18
|
+
def kyc_refusal(kyc_resp, kind)
|
|
19
|
+
# A metered /kyc answers 402 before the attestation is examined.
|
|
20
|
+
stall = payment_required_stall(kyc_resp, step: "the #{kind} attestation this scenario submits to /kyc")
|
|
21
|
+
return stall if stall
|
|
22
|
+
# 401 and 403 both mean the attestation did not take effect.
|
|
23
|
+
return verdict_from(kyc_resp, detail: "#{kind} KYC was accepted by /kyc endpoint") if Kiosk::Redteam.blocked?(kyc_resp)
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def gated_after_kyc(client, profile, principal, kind:, check:)
|
|
29
|
+
owned_ref = profile.create_owned.call(client, principal)
|
|
30
|
+
failure = stage_payment(
|
|
31
|
+
client, principal, profile, owned_ref,
|
|
32
|
+
because: 'The gated action would then be refused by the payment gate, and this ' \
|
|
33
|
+
"scenario would credit that refusal to the #{check}."
|
|
34
|
+
)
|
|
35
|
+
return failure if failure
|
|
36
|
+
|
|
37
|
+
resp = run_gated(client, principal, profile, owned_ref)
|
|
38
|
+
verdict_from(resp, detail: "#{kind} KYC accepted everywhere; gated action returned #{resp.status}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# B pays, under its own token and key, mandates claiming A's identity: must be refused.
|
|
7
|
+
class MandatePrincipalSwap < Scenario
|
|
8
|
+
def initialize
|
|
9
|
+
super(name: 'MandatePrincipalSwap')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(client, profile)
|
|
13
|
+
skip_unless(profile, pay_for: 'no pay_for', create_owned: 'no create_owned') ||
|
|
14
|
+
attack(client, profile)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def attack(client, profile)
|
|
20
|
+
a = client.register!
|
|
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])
|
|
24
|
+
|
|
25
|
+
# Only 403: a 402 or 401 means the swap was never examined.
|
|
26
|
+
verdict_from(resp, expect: 403, expect_code: %w[forbidden rls_denied],
|
|
27
|
+
detail: "principal-swap mandate accepted (HTTP #{resp.status})")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# B re-submits A's already-used mandate JWS under B's token: must be refused.
|
|
7
|
+
class MandateReplay < Scenario
|
|
8
|
+
def initialize
|
|
9
|
+
super(name: 'MandateReplay')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(client, profile)
|
|
13
|
+
skip_unless(profile, pay_for: 'no pay_for', create_owned: 'no create_owned') ||
|
|
14
|
+
attack(client, profile)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def attack(client, profile)
|
|
20
|
+
a = client.register!
|
|
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])
|
|
25
|
+
resp = client.pay_raw(b, **signed)
|
|
26
|
+
|
|
27
|
+
# Only 403: a 402 decline would mean the replay was never verified.
|
|
28
|
+
verdict_from(resp, expect: 403, expect_code: %w[forbidden rls_denied],
|
|
29
|
+
detail: "mandate replay accepted under B's token (HTTP #{resp.status})")
|
|
30
|
+
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
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# The gated action must be refused, after payment, when no KYC was submitted.
|
|
7
|
+
class MissingKyc < Scenario
|
|
8
|
+
def initialize
|
|
9
|
+
super(name: 'MissingKyc')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(client, profile)
|
|
13
|
+
skip_unless(profile, requires_kyc: 'requires_kyc is false', gated_action: 'no gated_action',
|
|
14
|
+
create_owned: 'no create_owned', pay_for: 'no pay_for') ||
|
|
15
|
+
attack(client, profile)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def attack(client, profile)
|
|
21
|
+
a = client.register!
|
|
22
|
+
# No KYC call here — that is the attack.
|
|
23
|
+
owned_ref = profile.create_owned.call(client, a)
|
|
24
|
+
failure = stage_payment(
|
|
25
|
+
client, a, profile, owned_ref,
|
|
26
|
+
because: 'The gated action would then be refused by the payment gate, and this ' \
|
|
27
|
+
'scenario would credit that refusal to the KYC gate it is meant to prove.'
|
|
28
|
+
)
|
|
29
|
+
return failure if failure
|
|
30
|
+
|
|
31
|
+
resp = run_gated(client, a, profile, owned_ref)
|
|
32
|
+
# 403 kyc_required, or 401 from an origin that treats the principal as unauthenticated.
|
|
33
|
+
verdict_from(resp, detail: "gated action succeeded without KYC (HTTP #{resp.status})")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
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.
|
|
8
|
+
class PayForOtherUseSelf < Scenario
|
|
9
|
+
KYC_BECAUSE = 'B would otherwise be refused for want of KYC, and this scenario would ' \
|
|
10
|
+
'credit that refusal to the ownership gate it exists to prove.'
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
super(name: 'PayForOtherUseSelf')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(client, profile)
|
|
17
|
+
skip_unless(profile, gated_action: 'no gated_action', create_owned: 'no create_owned',
|
|
18
|
+
pay_for: 'no pay_for') ||
|
|
19
|
+
attack(client, profile)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def attack(client, profile)
|
|
25
|
+
a = client.register!
|
|
26
|
+
b = client.register!
|
|
27
|
+
failure = stage_kyc(client, b, profile, step: 'the valid KYC attestation this scenario stages for B',
|
|
28
|
+
because: KYC_BECAUSE)
|
|
29
|
+
return failure if failure
|
|
30
|
+
|
|
31
|
+
owned_ref_a = profile.create_owned.call(client, a)
|
|
32
|
+
pay_verdict(pay_for_owned(client, b, profile, owned_ref_a)) || use(client, b, profile, owned_ref_a)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The verdict when B's payment already decided the attack, else nil.
|
|
36
|
+
def pay_verdict(pay_resp)
|
|
37
|
+
# Only 403 forbidden / rls_denied at pay time is the same ownership gate, moved earlier.
|
|
38
|
+
return blocked_verdict(pay_resp.status, 'blocked at pay step (early ownership check)') if pay_resp.status == 403 && %w[forbidden rls_denied].include?(error_code(pay_resp))
|
|
39
|
+
|
|
40
|
+
# Any other non-200 at pay time means the attack was never attempted.
|
|
41
|
+
setup_failure(
|
|
42
|
+
pay_resp,
|
|
43
|
+
step: "B's payment for A's resource",
|
|
44
|
+
because: 'A 403 forbidden/rls_denied here would be the ownership gate firing early ' \
|
|
45
|
+
'and is scored as a pass; any other refusal (a 402 decline, a 401 expired ' \
|
|
46
|
+
'token) simply means the attack below was never attempted.'
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Only the ownership gate counts: B did pay, so a 402 or kyc_required is another gate.
|
|
51
|
+
def use(client, principal, profile, owned_ref)
|
|
52
|
+
resp = run_gated(client, principal, profile, owned_ref)
|
|
53
|
+
verdict_from(resp, expect: 403, expect_code: %w[forbidden rls_denied],
|
|
54
|
+
detail: "B used A's resource after paying for it (C2 breach, HTTP #{resp.status})")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
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
|
+
class PrivilegeSelfSelection < Scenario
|
|
9
|
+
ESCALATED_ROLE = 'master'
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super(name: 'PrivilegeSelfSelection')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(client, _profile)
|
|
16
|
+
resp = client.register_raw(wire_role: ESCALATED_ROLE)
|
|
17
|
+
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
|
+
|
|
20
|
+
breach_verdict(201, "issued token carries the self-selected role #{ESCALATED_ROLE.inspect}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def refusal_verdict(client, resp)
|
|
26
|
+
if resp.status >= 500 || resp.status.zero?
|
|
27
|
+
return breach_verdict(resp.status,
|
|
28
|
+
"register crashed on the injected role (HTTP #{resp.status}: " \
|
|
29
|
+
"#{resp.body.inspect}) — a crash is not a gate")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
control_failure(client.register_raw, resp) || blocked_verdict(resp.status)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def control_failure(control, resp)
|
|
36
|
+
return nil if control.status == 201 && access_token(control)
|
|
37
|
+
|
|
38
|
+
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}. " \
|
|
41
|
+
"The injected registration's HTTP #{resp.status} refusal therefore " \
|
|
42
|
+
'says nothing about whether the role is pinned server-side.')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def access_token(resp)
|
|
46
|
+
resp.body.is_a?(Hash) ? resp.body['access_token'] : nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
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.
|
|
8
|
+
class RegistrationWithoutPow < Scenario
|
|
9
|
+
def initialize
|
|
10
|
+
super(name: 'RegistrationWithoutPow')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(client, profile)
|
|
14
|
+
return skip_verdict('pow_difficulty is 0 (no PoW gate)') unless profile.pow_difficulty.positive?
|
|
15
|
+
|
|
16
|
+
attack(client)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def attack(client)
|
|
22
|
+
attempts = [['pow: :skip', client.register_raw(pow: :skip)], ['pow: "0"', client.register_raw(pow: '0')]]
|
|
23
|
+
problems = attempts.filter_map { |label, resp| not_a_pow_rejection(label, resp) }
|
|
24
|
+
control = control_registration(client, problems)
|
|
25
|
+
|
|
26
|
+
Verdict.new(blocked: problems.empty?, skipped: false,
|
|
27
|
+
status: (control || attempts.last.last).status, detail: problems.join('; '))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The control costs a real solve, so it runs only when both attempts were refused.
|
|
31
|
+
# A failed control appends its problem.
|
|
32
|
+
def control_registration(client, problems)
|
|
33
|
+
return nil unless problems.empty?
|
|
34
|
+
|
|
35
|
+
control = client.register_raw
|
|
36
|
+
problems << control_problem(control) unless honest_registration?(control)
|
|
37
|
+
control
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def honest_registration?(control)
|
|
41
|
+
control.status == 201 && token_of(control)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def control_problem(control)
|
|
45
|
+
'CONTROL FAILED: a properly solved registration must return 201 with ' \
|
|
46
|
+
"an access_token — got HTTP #{control.status} #{control.body.inspect}. " \
|
|
47
|
+
'A server that refuses every registration refuses the two unproven ' \
|
|
48
|
+
'ones too, which says nothing about a PoW gate.'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Why this attempt is not a PoW refusal, or nil when it is.
|
|
52
|
+
def not_a_pow_rejection(label, resp)
|
|
53
|
+
return "#{label} returned 201 — registration succeeded with no proof" if resp.status == 201
|
|
54
|
+
if resp.status >= 500 || resp.status.zero?
|
|
55
|
+
return "#{label} crashed (HTTP #{resp.status}: #{resp.body.inspect}) — " \
|
|
56
|
+
'a crash is not a gate'
|
|
57
|
+
end
|
|
58
|
+
return nil unless token_of(resp)
|
|
59
|
+
|
|
60
|
+
"#{label} returned an access_token on HTTP #{resp.status}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def token_of(resp)
|
|
64
|
+
body = resp.body
|
|
65
|
+
body.is_a?(Hash) ? body['access_token'] : nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# A consumed resource cannot be used again: the second gated_action call
|
|
7
|
+
# must be refused. Skipped when gated_action_consumes is false.
|
|
8
|
+
class SpentResourceReuse < Scenario
|
|
9
|
+
KYC_BECAUSE = 'The first, legitimate use below would then fail for want of KYC, and ' \
|
|
10
|
+
'the re-use this scenario tests would never be reached.'
|
|
11
|
+
REUSE_BECAUSE = 'The resource is then not spent, so the re-use this scenario tests would never be reached.'
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super(name: 'SpentResourceReuse')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(client, profile)
|
|
18
|
+
skip_unless(profile, gated_action: 'no gated_action', create_owned: 'no create_owned',
|
|
19
|
+
pay_for: 'no pay_for', gated_action_consumes: 'gated_action spends nothing') ||
|
|
20
|
+
attack(client, profile)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def attack(client, profile)
|
|
26
|
+
a = client.register!
|
|
27
|
+
failure = stage_kyc(client, a, profile, step: 'the valid KYC attestation this scenario stages',
|
|
28
|
+
because: KYC_BECAUSE)
|
|
29
|
+
return failure if failure
|
|
30
|
+
|
|
31
|
+
owned_ref = profile.create_owned.call(client, a)
|
|
32
|
+
stage_payment(client, a, profile, owned_ref, because: REUSE_BECAUSE) ||
|
|
33
|
+
setup_failure(run_gated(client, a, profile, owned_ref),
|
|
34
|
+
step: 'the first, legitimate gated_action', because: REUSE_BECAUSE) ||
|
|
35
|
+
reuse(client, a, profile, owned_ref)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Only the resource-state gate's 403 counts: A paid, and the first use succeeded.
|
|
39
|
+
def reuse(client, principal, profile, owned_ref)
|
|
40
|
+
resp = run_gated(client, principal, profile, owned_ref)
|
|
41
|
+
verdict_from(resp, expect: 403, expect_code: %w[forbidden rls_denied],
|
|
42
|
+
detail: "spent resource re-activated (HTTP #{resp.status})")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# A token whose claim was changed without re-signing must be refused 401.
|
|
7
|
+
# The probe dials per_user_query: an unrouted verb answers 404 before any credential is read.
|
|
8
|
+
class TokenTampering < Scenario
|
|
9
|
+
def initialize
|
|
10
|
+
super(name: 'TokenTampering')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
NO_ROUTED_VERB = 'this profile declares no `per_user_query`, so there is no verb this ' \
|
|
14
|
+
'origin is known to route — and a name it does not route answers a ' \
|
|
15
|
+
'routing 404 before the credential is read, which would score a block ' \
|
|
16
|
+
'the token check never made. Declare `per_user_query:` to run this beat.'
|
|
17
|
+
|
|
18
|
+
def call(client, profile)
|
|
19
|
+
verb = profile.per_user_query
|
|
20
|
+
return Verdict.new(blocked: false, skipped: true, status: 0, detail: NO_ROUTED_VERB) if verb.nil?
|
|
21
|
+
|
|
22
|
+
resp = client.query(tampered(client.register!), name: verb)
|
|
23
|
+
# A broken signature is an authentication failure: 403 or 402 would mean it was accepted.
|
|
24
|
+
verdict_from(resp, expect: 401, detail: "tampered token accepted (HTTP #{resp.status})")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def tampered(principal)
|
|
30
|
+
Kiosk::TestHelpers::Assistant::Principal.new(
|
|
31
|
+
agent_id: principal.agent_id,
|
|
32
|
+
user_id: principal.user_id,
|
|
33
|
+
token: tamper_token(principal.token),
|
|
34
|
+
rsa_key: principal.rsa_key
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
module Scenarios
|
|
6
|
+
# The gated action must be refused when nothing was paid for the resource.
|
|
7
|
+
class UnpaidGatedAction < Scenario
|
|
8
|
+
def initialize
|
|
9
|
+
super(name: 'UnpaidGatedAction')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(client, profile)
|
|
13
|
+
skip_unless(profile, gated_action: 'no gated_action', create_owned: 'no create_owned') ||
|
|
14
|
+
attack(client, profile)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def attack(client, profile)
|
|
20
|
+
a = client.register!
|
|
21
|
+
# A refused attestation would let the KYC gate answer in the payment gate's name.
|
|
22
|
+
failure = stage_kyc(
|
|
23
|
+
client, a, profile,
|
|
24
|
+
step: 'the valid KYC attestation this scenario stages',
|
|
25
|
+
because: 'Without it the principal is also un-attested, so the refusal below ' \
|
|
26
|
+
'would be the KYC gate rather than the payment gate under test.'
|
|
27
|
+
)
|
|
28
|
+
return failure if failure
|
|
29
|
+
|
|
30
|
+
resp = run_gated(client, a, profile, profile.create_owned.call(client, a))
|
|
31
|
+
# 401 or 403; a 402 cannot say which of its three codes answered.
|
|
32
|
+
verdict_from(resp, detail: "gated action succeeded without payment (HTTP #{resp.status})")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
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
|
|
9
|
+
ALTERNATIVES = %w[usd eur gbp jpy].freeze
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super(name: 'WrongCurrencyCart')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(client, profile)
|
|
16
|
+
native = profile.currency.to_s.downcase
|
|
17
|
+
return skip_verdict('no currency') if native.empty?
|
|
18
|
+
|
|
19
|
+
skip_unless(profile, create_owned: 'no create_owned', pay_for: 'no pay_for') ||
|
|
20
|
+
attack(client, profile, native)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def attack(client, profile, native)
|
|
26
|
+
foreign = ALTERNATIVES.find { |c| c != native }
|
|
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.
|
|
32
|
+
verdict_from(resp, expect: 403,
|
|
33
|
+
detail: "a #{foreign} cart settled at a #{native.upcase} operator " \
|
|
34
|
+
"(HTTP #{resp.status})")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Redteam
|
|
5
|
+
# Setup steps the scenarios share. Mixed into Scenario as private methods.
|
|
6
|
+
module Staging
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def submit_valid_kyc(client, principal, profile)
|
|
10
|
+
return nil unless profile.kyc_valid
|
|
11
|
+
|
|
12
|
+
client.kyc(principal, attestation_jws: profile.kyc_valid.call(principal.user_id))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Submits a valid attestation when the profile requires KYC. Answers a setup-failure verdict or nil.
|
|
16
|
+
def stage_kyc(client, principal, profile, step:, because:)
|
|
17
|
+
kyc_resp = (submit_valid_kyc(client, principal, profile) if profile.requires_kyc)
|
|
18
|
+
setup_failure(kyc_resp, step: step, because: because)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Pays for +owned_ref+ as +payer+ and answers the pay response.
|
|
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])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pays for +owned_ref+ before a gated action. Answers a setup-failure verdict or nil.
|
|
28
|
+
def stage_payment(client, principal, profile, owned_ref, because:)
|
|
29
|
+
setup_failure(pay_for_owned(client, principal, profile, owned_ref),
|
|
30
|
+
step: 'the payment this scenario stages before the gated action', because: because)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_gated(client, principal, profile, owned_ref)
|
|
34
|
+
gated_args = profile.gated_args ? profile.gated_args.call(owned_ref) : { id: owned_ref[:id] }
|
|
35
|
+
client.run(principal, name: profile.gated_action, **gated_args)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A query answers a bare Array (§8.2); any other body reads as no rows.
|
|
39
|
+
def rows_from(response)
|
|
40
|
+
body = response.body
|
|
41
|
+
body.is_a?(Array) ? body : []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|