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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +158 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +247 -0
  5. data/lib/kiosk/redteam/battery.rb +148 -0
  6. data/lib/kiosk/redteam/leak_scan.rb +104 -0
  7. data/lib/kiosk/redteam/profile.rb +58 -0
  8. data/lib/kiosk/redteam/runner.rb +37 -0
  9. data/lib/kiosk/redteam/scenario.rb +34 -0
  10. data/lib/kiosk/redteam/scenarios/cross_tenant_read.rb +57 -0
  11. data/lib/kiosk/redteam/scenarios/device_grant_role_self_selection.rb +117 -0
  12. data/lib/kiosk/redteam/scenarios/expired_kyc.rb +27 -0
  13. data/lib/kiosk/redteam/scenarios/forged_kyc.rb +27 -0
  14. data/lib/kiosk/redteam/scenarios/forged_user_id.rb +108 -0
  15. data/lib/kiosk/redteam/scenarios/kyc_attack.rb +43 -0
  16. data/lib/kiosk/redteam/scenarios/mandate_principal_swap.rb +32 -0
  17. data/lib/kiosk/redteam/scenarios/mandate_replay.rb +42 -0
  18. data/lib/kiosk/redteam/scenarios/missing_kyc.rb +38 -0
  19. data/lib/kiosk/redteam/scenarios/pay_for_other_use_self.rb +59 -0
  20. data/lib/kiosk/redteam/scenarios/privilege_self_selection.rb +51 -0
  21. data/lib/kiosk/redteam/scenarios/registration_without_pow.rb +70 -0
  22. data/lib/kiosk/redteam/scenarios/spent_resource_reuse.rb +47 -0
  23. data/lib/kiosk/redteam/scenarios/token_tampering.rb +40 -0
  24. data/lib/kiosk/redteam/scenarios/unpaid_gated_action.rb +37 -0
  25. data/lib/kiosk/redteam/scenarios/wrong_currency_cart.rb +39 -0
  26. data/lib/kiosk/redteam/staging.rb +45 -0
  27. data/lib/kiosk/redteam/token_claims.rb +51 -0
  28. data/lib/kiosk/redteam/verdict.rb +13 -0
  29. data/lib/kiosk/redteam/verdicts.rb +130 -0
  30. data/lib/kiosk/redteam/version.rb +7 -0
  31. data/lib/kiosk/redteam.rb +94 -0
  32. 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