pwn 0.5.784 → 0.5.787

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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Native YubiKey protocol framing' do
6
+ let(:protocol) { PWN::Plugins::YubiKey::Protocol }
7
+
8
+ it 'encodes and decodes ordered BER-TLV without losing duplicate tags or binary bytes' do
9
+ first = [0x71, 3, 0, 255, 1].pack('C*')
10
+ second = [0x7f, 0x49, 0x81, 128].pack('C*') + ('x'.b * 128)
11
+ expect(protocol.tlv(tag: 0x71, value: "\x00\xff\x01".b)).to eq(first)
12
+ expect(protocol.tlv(tag: 0x7f49, value: 'x'.b * 128)).to eq(second)
13
+ expected = [{ tag: 0x71, value: "\x00\xff\x01".b }, { tag: 0x7f49, value: 'x'.b * 128 }, { tag: 0x71, value: "\x00\xff\x01".b }]
14
+ expect(protocol.untlv(data: first + second + first)).to eq(expected)
15
+ end
16
+
17
+ it 'selects an application and assembles a bounded GET RESPONSE exchange' do
18
+ frames = []
19
+ replies = ["first\x61\x03".b, "end\x90\x00".b]
20
+ connection = double('offline smartcard')
21
+ allow(connection).to receive(:exchange) do |opts|
22
+ frames << opts
23
+ replies.shift
24
+ end
25
+ expect(protocol.select(connection: connection, aid: "\xa0\x00\x01".b, timeout: 2)).to eq('firstend'.b)
26
+ expect(frames.map { |frame| frame[:data].unpack1('H*') }).to eq(%w[00a4040003a00001 00c0000003])
27
+ expect(frames).to all(include(protocol: :apdu, timeout: be_between(0, 2)))
28
+ end
29
+
30
+ it 'chains short commands and supports explicit extended-length APDUs' do
31
+ frames = []
32
+ connection = double('offline smartcard')
33
+ allow(connection).to receive(:exchange) do |opts|
34
+ frames << opts[:data]
35
+ "\x90\x00".b
36
+ end
37
+ data = 'x'.b * 256
38
+ expect(protocol.apdu(connection: connection, ins: 0xda, data: data)).to eq(''.b)
39
+ expect(frames).to eq([[0x10, 0xda, 0, 0, 255].pack('C*') + ('x' * 255), [0, 0xda, 0, 0, 1].pack('C*') << 'x'])
40
+ frames.clear
41
+ protocol.apdu(connection: connection, ins: 0xda, data: data, extended: true, le: 65_536)
42
+ expect(frames).to eq([[0, 0xda, 0, 0, 0, 1, 0].pack('C*') + data + "\x00\x00".b])
43
+ frames.clear
44
+ protocol.apdu(connection: connection, ins: 0xf8)
45
+ expect(frames).to eq([[0, 0xf8, 0, 0, 0].pack('C*')])
46
+ end
47
+
48
+ it 'corrects one wrong Le and uses an application-specific continuation instruction' do
49
+ frames = []
50
+ replies = ["\x6c\x03".b, "a\x61\x00".b, "bc\x90\x00".b]
51
+ connection = double('offline smartcard')
52
+ allow(connection).to receive(:exchange) do |opts|
53
+ frames << opts[:data].unpack1('H*')
54
+ replies.shift
55
+ end
56
+ expect(protocol.apdu(connection: connection, ins: 0xca, le: 1, get_response_ins: 0xa5, get_response_le: 0)).to eq('abc'.b)
57
+ expect(frames).to eq(%w[00ca000001 00ca000003 00a5000000])
58
+ end
59
+
60
+ it 'interprets wrong-Le zero as 256 bytes even for an extended command' do
61
+ frames = []
62
+ replies = ["\x6c\x00".b, "\x90\x00".b]
63
+ connection = double('offline smartcard')
64
+ allow(connection).to receive(:exchange) do |opts|
65
+ frames << opts[:data].unpack1('H*')
66
+ replies.shift
67
+ end
68
+ protocol.apdu(connection: connection, ins: 0xca, le: 512, extended: true)
69
+ expect(frames).to eq(%w[00ca0000000200 00ca0000000100])
70
+ end
71
+
72
+ it 'parses only declared native options and preserves identifiers and repeated values' do
73
+ args = ['--rp-id', 'first.example', '--rp-id', 'second.example', '--no-enter', '--', '-例:account ']
74
+ expect(protocol.parse_args(args: args, flags: ['--no-enter'], values: ['--rp-id'])).to eq(
75
+ positionals: ['-例:account '], options: { rp_id: %w[first.example second.example], no_enter: true }
76
+ )
77
+ expect { protocol.parse_args(args: ['--pin', 'fixture'], flags: [], values: []) }.to raise_error(ArgumentError, /option/)
78
+ expect { protocol.parse_args(args: ['--rp-id'], values: ['--rp-id']) }.to raise_error(ArgumentError, /value/)
79
+ end
80
+
81
+ it 'provides a unique-tag mapping while rejecting truncated and duplicate fields' do
82
+ expect(protocol.tlv_map(data: "\x71\x01a\x72\x01b".b)).to eq(0x71 => 'a'.b, 0x72 => 'b'.b)
83
+ expect { protocol.tlv_map(data: "\x71\x01a\x71\x01b".b) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /Duplicate/)
84
+ ["\x71", "\x71\x80", "\x71\x82\x00", "\x71\x02a", "\x7f\x81"].each do |truncated|
85
+ expect { protocol.untlv(data: truncated.b) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
86
+ end
87
+ end
88
+
89
+ it 'applies the same complete-frame bound to TLV encoding and decoding' do
90
+ expect { protocol.tlv(tag: 0x71, value: 'x' * protocol::MAX_BYTES) }.to raise_error(ArgumentError, /frame/)
91
+ end
92
+
93
+ it 'requires literal caller consent instead of truthy substitutes' do
94
+ expect(protocol.require_confirm(confirm: true)).to eq(:confirmed)
95
+ [nil, false, 'true', 1].each do |value|
96
+ expect { protocol.require_confirm(confirm: value) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
97
+ end
98
+ end
99
+
100
+ it 'obtains secrets only from explicit memory values or a supplied callback' do
101
+ provider = double('private caller input')
102
+ expect(provider).to receive(:call).with(:pin).once.and_return('000123')
103
+ expect(protocol.secret(name: :pin, secret_provider: provider)).to eq('000123')
104
+ expect(protocol.secret(name: :key, value: "\x00\xff".b, secret_provider: provider)).to eq("\x00\xff".b)
105
+ expect { protocol.secret(name: :pin, value: false, secret_provider: provider) }.to raise_error(ArgumentError, /pin/)
106
+ expect { protocol.secret(name: :pin) }.to raise_error(ArgumentError, /pin/)
107
+ end
108
+
109
+ it 'compares explicit firmware tuples without normalizing malformed versions' do
110
+ expect(protocol.version_at_least?(version: [5, 7, 0], minimum: [5, 6, 9])).to be(true)
111
+ expect(protocol.version_at_least?(version: [5, 7, 0], minimum: [5, 7, 1])).to be(false)
112
+ expect { protocol.version_at_least?(version: ['5', 7, 0], minimum: [5, 7, 0]) }.to raise_error(ArgumentError)
113
+ end
114
+
115
+ it 'rejects invalid request bounds before sending any command' do
116
+ connection = double('offline smartcard', exchange: nil)
117
+ expect(connection).not_to receive(:exchange)
118
+ [{ timeout: false }, { timeout: 0 }, { timeout: Float::INFINITY }, { ins: 256 }, { cla: 1.0 },
119
+ { extended: 'true' }, { le: 257 }, { data: 'x' * 65_536 }, { get_response_ins: -1 }, { max_response: 0 }].each do |invalid|
120
+ expect { protocol.apdu({ connection: connection, ins: 1 }.merge(invalid)) }.to raise_error(ArgumentError)
121
+ end
122
+ end
123
+
124
+ it 'keeps authentication errors secret-free and never replays them' do
125
+ connection = double('offline smartcard')
126
+ expect(connection).to receive(:exchange).once.and_return("\x63\xc2".b)
127
+ expect { protocol.apdu(connection: connection, ins: 0x20, data: 'fixture-private-pin') }.to raise_error(PWN::Plugins::YubiKey::Protocol::APDUError) do |error|
128
+ expect(error.status).to eq(0x63c2)
129
+ expect(error.message).not_to include('fixture-private-pin')
130
+ end
131
+ end
132
+
133
+ it 'bounds truncated, oversized and endless continuation responses' do
134
+ connection = double('offline smartcard')
135
+ ['', "\x90", "long\x90\x00"].each do |response|
136
+ allow(connection).to receive(:exchange).and_return(response.b)
137
+ expect { protocol.apdu(connection: connection, ins: 1, max_response: 2) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
138
+ end
139
+ allow(connection).to receive(:exchange).and_return("\x61\x00".b)
140
+ expect { protocol.apdu(connection: connection, ins: 1) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /fragments/)
141
+ end
142
+
143
+ it 'uses one total deadline across response fragments' do
144
+ connection = double('offline smartcard')
145
+ expect(connection).to receive(:exchange).once.and_return("\x61\x00".b)
146
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(1, 1, 2)
147
+ expect { protocol.apdu(connection: connection, ins: 1, timeout: 1) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
148
+ end
149
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+
6
+ RSpec.describe PWN::Plugins::YubiKey::Protocol, 'independent APDU frame vectors' do
7
+ vectors = JSON.parse(File.read(File.expand_path('../../../../fixtures/yubikey/apdu_frames.json', __dir__))).fetch('vectors')
8
+
9
+ vectors.each do |vector|
10
+ it "matches the reference formatter for #{vector.fetch('name')}" do
11
+ options = vector.fetch('options').transform_keys(&:to_sym)
12
+ options[:data] = [options.delete(:data_hex)].pack('H*')
13
+ connection = double('offline smartcard')
14
+ expect(connection).to receive(:exchange).with(hash_including(protocol: :apdu, data: [vector.fetch('expected_hex')].pack('H*'))).once.and_return("\x90\x00".b)
15
+ expect(described_class.apdu(options.merge(connection: connection))).to eq(''.b)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/plugins/yubikey/security_domain/scp'
5
+
6
+ RSpec.describe PWN::Plugins::YubiKey::SecurityDomain::SCP do
7
+ # NIST SP 800-38B / RFC 4493 examples. Public synthetic test material only.
8
+ def hex(value)
9
+ [value].pack('H*')
10
+ end
11
+
12
+ require_relative 'support'
13
+
14
+ def scp03(card, **options)
15
+ key = (0...16).to_a.pack('C*')
16
+ described_class.open({ connection: card, scp: :scp03, kvn: 1, key_enc: key, key_mac: key, key_dek: key, confirm: true }.merge(options))
17
+ end
18
+
19
+ it 'verifies both SCP03 cryptograms and encrypted APDUs while retaining the authenticated status' do
20
+ card = Scp03Card.new
21
+ channel = scp03(card)
22
+ expect(channel.exchange(protocol: :apdu, data: hex('00ca000003616263'), timeout: 1)).to eq("reply\x62\x82".b)
23
+ expect(card.received).to eq('abc')
24
+ expect(channel.descriptor).to include(secure_channel: :scp03, extended_apdu: true, authenticated: true)
25
+ expect(channel.inspect).not_to include('key_', '@enc', '@chain')
26
+ channel.close
27
+ expect(card.closed).to be(true)
28
+ end
29
+
30
+ %i[mac status missing_mac truncated_ciphertext padding padding_long counter replay].each do |fault|
31
+ it "fails closed and never passes data after #{fault}" do
32
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('HOSTRAND'.b)
33
+ card = Scp03Card.new
34
+ channel = scp03(card)
35
+ channel.exchange(protocol: :apdu, data: hex('00ca0000')) if fault == :replay
36
+ card.fault = fault
37
+ expect { channel.exchange(protocol: :apdu, data: hex('00ca0000')) }.to raise_error(described_class::ChannelError)
38
+ expect(card.closed).to be(true)
39
+ count = card.commands.length
40
+ expect { channel.exchange(protocol: :apdu, data: hex('00ca0000')) }.to raise_error(described_class::ChannelError, /closed/)
41
+ expect(card.commands.length).to eq(count)
42
+ end
43
+ end
44
+
45
+ it 'never sends host authentication after a wrong card cryptogram' do
46
+ card = Scp03Card.new
47
+ card.fault = :card_cryptogram
48
+ expect { scp03(card) }.to raise_error(described_class::ChannelError, /cryptogram/)
49
+ expect(card.commands.map { |data| data.getbyte(1) }).to eq([0xa4, 0x50])
50
+ expect(card.closed).to be(true)
51
+ end
52
+
53
+ it 'validates consent, key lengths, protocol, key version and timeout before I/O' do
54
+ [{ confirm: 'true' }, { key_enc: nil }, { key_mac: 'short' }, { kvn: 256 }, { kvn: -1 }, { timeout: Float::INFINITY }, { scp: :unknown }].each do |options|
55
+ card = Scp03Card.new
56
+ expect { scp03(card, **options) }.to raise_error(StandardError)
57
+ expect(card.commands).to be_empty
58
+ end
59
+ end
60
+
61
+ it 'supports extended command data and rejects malformed, wrapped and oversized APDUs' do
62
+ card = Scp03Card.new
63
+ channel = scp03(card)
64
+ data = 'x'.b * 300
65
+ command = hex('00da000000') + [data.bytesize].pack('n') + data + hex('0100')
66
+ expect(channel.exchange(protocol: :apdu, data: command)).to eq("reply\x62\x82".b)
67
+ expect(card.received).to eq(data)
68
+ %w[00 00da00000001 00da00000200 04da0000].each do |malformed|
69
+ expect { channel.exchange(protocol: :apdu, data: hex(malformed)) }.to raise_error(ArgumentError)
70
+ end
71
+ channel.instance_variable_set(:@enc_counter, 0x100000000)
72
+ count = card.commands.length
73
+ expect { channel.exchange(protocol: :apdu, data: hex('00ca0000')) }.to raise_error(described_class::ChannelError, /counter/)
74
+ expect(card.commands.length).to eq(count)
75
+ end
76
+
77
+ it 'closes on uncertain transport failure and cancellation without retrying' do
78
+ [IOError.new('synthetic'), Interrupt.new].each do |error|
79
+ card = Scp03Card.new
80
+ channel = scp03(card)
81
+ allow(card).to receive(:exchange).and_raise(error)
82
+ expect { channel.exchange(protocol: :apdu, data: hex('00ca0000')) }.to raise_error(error.class)
83
+ expect(card.closed).to be(true)
84
+ end
85
+ end
86
+
87
+ def certificate(key, issuer_key: key, issuer: nil, is_ca: false)
88
+ cert = OpenSSL::X509::Certificate.new
89
+ cert.version = 2
90
+ cert.serial = 7
91
+ cert.subject = OpenSSL::X509::Name.parse(is_ca ? '/CN=Synthetic CA' : '/CN=Synthetic leaf')
92
+ cert.issuer = issuer ? issuer.subject : cert.subject
93
+ cert.public_key = key
94
+ cert.not_before = Time.utc(2020)
95
+ cert.not_after = Time.utc(2090)
96
+ factory = OpenSSL::X509::ExtensionFactory.new
97
+ factory.subject_certificate = cert
98
+ factory.issuer_certificate = issuer || cert
99
+ cert.extensions = [factory.create_extension('basicConstraints', is_ca ? 'CA:TRUE' : 'CA:FALSE', true), factory.create_extension('keyUsage', is_ca ? 'keyCertSign,cRLSign' : 'keyAgreement', true)]
100
+ cert.sign(issuer_key, OpenSSL::Digest.new('SHA256'))
101
+ cert
102
+ end
103
+
104
+ %i[scp11a scp11b scp11c].each do |variant|
105
+ it "performs #{variant} receipt verification and encrypted exchange with an independent ECDH peer" do
106
+ card = Scp11Card.new
107
+ options = { connection: card, scp: variant, kvn: 1, card_public_key: card.public_key, confirm: true }
108
+ unless variant == :scp11b
109
+ ca_key = OpenSSL::PKey::EC.generate('prime256v1')
110
+ ca = certificate(ca_key, is_ca: true)
111
+ host_key = OpenSSL::PKey::EC.generate('prime256v1')
112
+ leaf = certificate(host_key, issuer_key: ca_key, issuer: ca)
113
+ options.merge!(oce_private_key: host_key, oce_certificates: [ca, leaf], oce_kid: 0x10, oce_kvn: 2)
114
+ end
115
+ channel = described_class.open(options)
116
+ expect(channel.exchange(protocol: :apdu, data: hex('00ca0000'))).to eq("reply\x62\x82".b)
117
+ expect(channel.authenticated?).to eq(variant == :scp11a)
118
+ expect(card.commands.select { |c| c.getbyte(1) == 0x2a }.map { |c| c.byteslice(2, 2) }).to eq(["\x02\x90".b, "\x02\x10".b]) unless variant == :scp11b
119
+ end
120
+ end
121
+
122
+ it 'authenticates a fetched leaf-last SCP11 bundle against explicit CA anchors' do
123
+ card = Scp11Card.new
124
+ ca_key = OpenSSL::PKey::EC.generate('prime256v1')
125
+ ca = certificate(ca_key, is_ca: true)
126
+ leaf = certificate(card.public_key, issuer_key: ca_key, issuer: ca)
127
+ card.card_bundle = leaf.to_der
128
+ channel = described_class.open(connection: card, scp: :scp11b, kvn: 1, ca_certificates: [ca], confirm: true)
129
+ expect(channel.exchange(protocol: :apdu, data: hex('00ca0000'))).to eq("reply\x62\x82".b)
130
+ expect(card.commands[1].unpack1('H*')).to eq('00cabf2106a60483021301')
131
+ end
132
+
133
+ it 'rejects untrusted, expired and pinned-mismatched card certificates before authentication' do
134
+ card = Scp11Card.new
135
+ ca_key = OpenSSL::PKey::EC.generate('prime256v1')
136
+ ca = certificate(ca_key, is_ca: true)
137
+ alien = certificate(OpenSSL::PKey::EC.generate('prime256v1'), is_ca: true)
138
+ leaf = certificate(card.public_key, issuer_key: ca_key, issuer: ca)
139
+ base = { connection: card, scp: :scp11b, kvn: 1, card_certificates: [leaf], confirm: true }
140
+ expect { described_class.open(base.merge(ca_certificates: [alien])) }.to raise_error(described_class::ChannelError, /Untrusted/)
141
+ expect { described_class.open(base.merge(card_public_key: alien.public_key)) }.to raise_error(described_class::ChannelError, /pin/)
142
+ leaf.not_after = Time.utc(2021)
143
+ leaf.sign(ca_key, OpenSSL::Digest.new('SHA256'))
144
+ expect { described_class.open(base.merge(ca_certificates: [ca])) }.to raise_error(described_class::ChannelError, /validity/)
145
+ expect(card.commands).to be_empty
146
+ end
147
+
148
+ it 'rejects a wrong SCP11 receipt and wrong card key without plaintext fallback' do
149
+ %i[receipt pin].each do |fault|
150
+ card = Scp11Card.new
151
+ card.fault = fault
152
+ key = fault == :pin ? OpenSSL::PKey::EC.generate('prime256v1') : card.public_key
153
+ expect { described_class.open(connection: card, scp: :scp11b, kvn: 1, card_public_key: key, confirm: true) }.to raise_error(described_class::ChannelError, /receipt/)
154
+ expect(card.closed).to be(true)
155
+ expect(card.commands.map { |data| data.getbyte(1) }).to eq([0xa4, 0x88])
156
+ end
157
+ end
158
+
159
+ it 'requires explicit card trust and valid OCE credentials before any authentication attempt' do
160
+ card = Scp11Card.new
161
+ expect { described_class.open(connection: card, scp: :scp11b, kvn: 1, confirm: true) }.to raise_error(ArgumentError, /pinned/)
162
+ options = { connection: card, scp: :scp11a, kvn: 1, card_public_key: card.public_key, confirm: true }
163
+ expect { described_class.open(options) }.to raise_error(KeyError)
164
+ key = OpenSSL::PKey::EC.generate('prime256v1')
165
+ cert = certificate(OpenSSL::PKey::EC.generate('prime256v1'))
166
+ expect { described_class.open(options.merge(oce_private_key: key, oce_certificates: [cert])) }.to raise_error(ArgumentError, /match/)
167
+ expect(card.commands).to be_empty
168
+ end
169
+
170
+ it 'reassembles transport response fragments before one R-MAC verification without consuming another counter' do
171
+ card = Scp03Card.new
172
+ channel = scp03(card)
173
+ calls = 0
174
+ remainder = nil
175
+ allow(card).to receive(:exchange).and_wrap_original do |original, opts|
176
+ calls += 1
177
+ if opts[:data] == hex('00c00000')
178
+ remainder
179
+ else
180
+ reply = original.call(opts)
181
+ remainder = reply.byteslice(7..)
182
+ reply.byteslice(0, 7) + hex('6100')
183
+ end
184
+ end
185
+ expect(channel.exchange(protocol: :apdu, data: hex('00ca0000'))).to eq("reply\x62\x82".b)
186
+ expect(calls).to eq(2)
187
+ expect(channel.instance_variable_get(:@enc_counter)).to eq(2)
188
+ end
189
+
190
+ it 'rejects conflicting KIDs, unsupported protocol metadata and truncated card cryptograms' do
191
+ card = Scp03Card.new
192
+ expect { scp03(card, kid: 0x13) }.to raise_error(ArgumentError, /KID/)
193
+ expect(card.commands).to be_empty
194
+ [->(reply) { reply.byteslice(0, 20) + hex('9000') }, lambda { |reply|
195
+ reply.setbyte(11, 2)
196
+ reply
197
+ }, lambda { |reply|
198
+ reply.setbyte(10, 7)
199
+ reply
200
+ }].each do |mutate|
201
+ peer = Scp03Card.new
202
+ allow(peer).to receive(:exchange).and_wrap_original do |original, options|
203
+ reply = original.call(options)
204
+ options[:data].getbyte(1) == 0x50 ? mutate.call(reply) : reply
205
+ end
206
+ expect { scp03(peer) }.to raise_error(described_class::ChannelError)
207
+ expect(peer.commands.map { |data| data.getbyte(1) }).to eq([0xa4, 0x50])
208
+ end
209
+ end
210
+
211
+ it 'rejects malformed peer EC points and repeated or truncated SCP11 receipt TLVs' do
212
+ [->(response) { response.byteslice(0...-3) + hex('9000') }, lambda { |response|
213
+ response.setbyte(3, 0)
214
+ response
215
+ }, ->(response) { response.byteslice(0...-2) + hex('8600') + hex('9000') }, ->(response) { response.byteslice(0, 2) + hex('8141') + response.byteslice(3..) }].each do |mutate|
216
+ card = Scp11Card.new
217
+ allow(card).to receive(:exchange).and_wrap_original do |original, options|
218
+ response = original.call(options)
219
+ options[:data].getbyte(1) == 0x88 ? mutate.call(response) : response
220
+ end
221
+ expect { described_class.open(connection: card, scp: :scp11b, kvn: 1, card_public_key: card.public_key, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
222
+ expect(card.closed).to be(true)
223
+ end
224
+ end
225
+
226
+ it 'carries Protocol SELECT and extended application APDUs through the real secure wrapper' do
227
+ card = Scp03Card.new
228
+ channel = scp03(card)
229
+ card.handler = ->(_header, plaintext) { [plaintext, hex('9000')] }
230
+ protocol = PWN::Plugins::YubiKey::Protocol
231
+ aid = hex('a000000308')
232
+ expect(protocol.select(connection: channel, aid: aid)).to eq(aid)
233
+ payload = 'x'.b * 300
234
+ expect(protocol.apdu(connection: channel, ins: 0xda, data: payload, extended: true)).to eq(payload)
235
+ expect(card.commands.last.getbyte(4)).to eq(0)
236
+ expect(card.commands.count { |command| command.getbyte(1) == 0xda }).to eq(1)
237
+ end
238
+
239
+ it 'bounds fragmented replies and closes without exposing an unauthenticated prefix' do
240
+ card = Scp03Card.new
241
+ channel = scp03(card)
242
+ allow(card).to receive(:exchange).and_return(hex('006100'))
243
+ expect { channel.exchange(protocol: :apdu, data: hex('00ca0000')) }.to raise_error(described_class::ChannelError, /fragmentation/)
244
+ expect(card).to have_received(:exchange).exactly(64).times
245
+ expect(card.closed).to be(true)
246
+ end
247
+
248
+ it 'implements all RFC 4493 AES-CMAC vectors, including empty and partial blocks' do
249
+ crypto = described_class::Crypto.new
250
+ key = hex('2b7e151628aed2a6abf7158809cf4f3c')
251
+ message = hex('6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710')
252
+ { 0 => 'bb1d6929e95937287fa37d129b756746', 16 => '070a16b46b4d4144f79bdd9dd04a287c', 40 => 'dfa66747de9ae63030ca32611497c827', 64 => '51f0bebf7e3b9d92fc49741779363cfe' }.each do |length, expected|
253
+ expect(crypto.cmac(key, message.byteslice(0, length))).to eq(hex(expected))
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ # Independent reference uses CBC reduction, rather than the implementation's
6
+ # block-by-block ECB chain. Never uses implementation helpers or actual devices.
7
+ class ReferenceCrypto
8
+ def aes(key, input, vector = nil, decrypt: false)
9
+ cipher = OpenSSL::Cipher.new(vector ? 'aes-128-cbc' : 'aes-128-ecb')
10
+ decrypt ? cipher.decrypt : cipher.encrypt
11
+ cipher.padding = 0
12
+ cipher.key = key
13
+ cipher.iv = vector if vector
14
+ cipher.update(input) + cipher.final
15
+ end
16
+
17
+ def cmac(key, message)
18
+ n = aes(key, "\0".b * 16).unpack1('H*').to_i(16)
19
+ subkeys = 2.times.map do
20
+ n = ((n << 1) ^ (n >= (1 << 127) ? 0x87 : 0)) & ((1 << 128) - 1)
21
+ n
22
+ end
23
+ complete = !message.empty? && message.bytesize.modulo(16).zero?
24
+ padded = complete ? message.dup : (message + "\x80".b).ljust(((message.bytesize / 16) + 1) * 16, "\0".b)
25
+ last = padded.byteslice(-16, 16).unpack1('H*').to_i(16) ^ subkeys[complete ? 0 : 1]
26
+ padded[-16, 16] = [last.to_s(16).rjust(32, '0')].pack('H*')
27
+ aes(key, padded, "\0".b * 16).byteslice(-16, 16)
28
+ end
29
+
30
+ def derive(key, type, context, bits = 128)
31
+ cmac(key, [0, 0, 0, 0, type, 0, bits, 1].pack('NNnCCCnC') + context).byteslice(0, bits / 8)
32
+ end
33
+
34
+ def crypt(key, counter, message, response: false, decrypt: false)
35
+ iv = aes(key, [(response ? 128 : 0)].pack('C') + [counter.to_s(16).rjust(30, '0')].pack('H*'))
36
+ aes(key, message, iv, decrypt: decrypt)
37
+ end
38
+ end
39
+
40
+ class Scp03Card
41
+ attr_reader :commands, :closed, :received
42
+ attr_accessor :fault, :handler
43
+
44
+ def initialize
45
+ @crypto = ReferenceCrypto.new
46
+ @commands = []
47
+ @static = (0...16).to_a.pack('C*')
48
+ @card = 'CARDRAND'.b
49
+ @counter = 0
50
+ end
51
+
52
+ def descriptor
53
+ { protocol: :apdu, reader: 'synthetic' }
54
+ end
55
+
56
+ def close
57
+ @closed = true
58
+ end
59
+
60
+ def exchange(opts)
61
+ command = opts.fetch(:data)
62
+ @commands << command
63
+ ins = command.getbyte(1)
64
+ return "\x90\0".b if ins == 0xa4 && command.getbyte(0).zero?
65
+
66
+ if ins == 0x50
67
+ context = command.byteslice(5, 8) + @card
68
+ @enc = @crypto.derive(@static, 4, context)
69
+ @mac = @crypto.derive(@static, 6, context)
70
+ @rmac = @crypto.derive(@static, 7, context)
71
+ @host = @crypto.derive(@mac, 1, context, 64)
72
+ @chain = "\0".b * 16
73
+ cryptogram = @crypto.derive(@mac, 0, context, 64)
74
+ cryptogram.setbyte(0, cryptogram.getbyte(0) ^ 1) if fault == :card_cryptogram
75
+ return ("\0".b * 10) + "\x01\x03\x70".b + @card + cryptogram + "\x90\0".b
76
+ end
77
+ start = command.getbyte(4).zero? ? 7 : 5
78
+ length = start == 7 ? command.byteslice(5, 2).unpack1('n') : command.getbyte(4)
79
+ ciphertext = command.byteslice(start, length - 8)
80
+ full_mac = @crypto.cmac(@mac, @chain + command.byteslice(0, start) + ciphertext)
81
+ raise 'synthetic card rejected command MAC' unless full_mac.byteslice(0, 8) == command.byteslice(start + length - 8, 8)
82
+
83
+ @chain = full_mac
84
+ if ins == 0x82
85
+ raise 'synthetic card rejected host cryptogram' unless ciphertext == @host && command.byteslice(0, 4) == "\x84\x82\x33\0".b
86
+
87
+ return "\x90\0".b
88
+ end
89
+ @counter += 1
90
+ @received = @crypto.crypt(@enc, @counter, ciphertext, decrypt: true).sub(/\x80\x00*\z/n, ''.b)
91
+ return @last_reply if fault == :replay
92
+ return "\x90\0".b if fault == :missing_mac
93
+
94
+ payload, sw = handler ? handler.call(command.byteslice(0, 4), @received) : ['reply'.b, "\x62\x82".b]
95
+ padding = fault == :padding ? 'x' * 16 : (payload + "\x80".b).ljust(((payload.bytesize / 16) + 1) * 16, "\0".b)
96
+ padding = "data\x80".b.ljust(32, "\0".b) if fault == :padding_long
97
+ encrypted = @crypto.crypt(@enc, fault == :counter ? @counter + 1 : @counter, padding, response: true)
98
+ encrypted = encrypted.byteslice(0, 15) if fault == :truncated_ciphertext
99
+ mac = @crypto.cmac(@rmac, @chain + encrypted + sw).byteslice(0, 8)
100
+ mac.setbyte(0, mac.getbyte(0) ^ 1) if fault == :mac
101
+ sw = "\x90\0".b if fault == :status
102
+ @last_reply = encrypted + mac + sw
103
+ end
104
+ end
105
+
106
+ class Scp11Card < Scp03Card
107
+ attr_reader :public_key, :certificates
108
+ attr_accessor :card_bundle
109
+
110
+ def initialize
111
+ super
112
+ @private_key = OpenSSL::PKey::EC.generate('prime256v1')
113
+ @public_key = OpenSSL::PKey.read(@private_key.public_to_der)
114
+ @certificates = []
115
+ end
116
+
117
+ def exchange(opts)
118
+ command = opts.fetch(:data)
119
+ ins = command.getbyte(1)
120
+ if ins == 0xca && command.getbyte(0) == 0 && card_bundle
121
+ @commands << command
122
+ return card_bundle + "\x90\0".b
123
+ end
124
+ return super unless [0x2a, 0x88, 0x82].include?(ins)
125
+
126
+ @commands << command
127
+ start = command.getbyte(4).zero? ? 7 : 5
128
+ length = start == 7 ? command.byteslice(5, 2).unpack1('n') : command.getbyte(4)
129
+ data = command.byteslice(start, length)
130
+ if ins == 0x2a
131
+ cert = OpenSSL::X509::Certificate.new(data)
132
+ raise 'synthetic OCE signature rejected' if !@certificates.empty? && !cert.verify(@certificates.last.public_key)
133
+
134
+ @certificates << cert
135
+ return "\x90\0".b
136
+ end
137
+ variant = { 0x11 => 1, 0x13 => 0, 0x15 => 3 }.fetch(command.getbyte(3))
138
+ raise 'invalid SCP11 control template' unless data.byteslice(0, 15) == ["a60d900211#{variant.to_s(16).rjust(2, '0')}95013c800188810110"].pack('H*')
139
+ raise 'invalid SCP11 point tag' unless data.byteslice(15, 3) == "\x5f\x49\x41".b
140
+
141
+ point = OpenSSL::PKey::EC::Point.new(@private_key.group, OpenSSL::BN.new(data.byteslice(18, 65), 2))
142
+ host_point = variant.zero? ? point : @certificates.last.public_key.public_key
143
+ ephemeral = OpenSSL::PKey::EC.generate('prime256v1')
144
+ z = ephemeral.dh_compute_key(point) + @private_key.dh_compute_key(host_point)
145
+ key_bytes = (1..3).map { |counter| OpenSSL::Digest::SHA256.digest(z + [counter].pack('N') + "\x3c\x88\x10".b) }.join.byteslice(0, 80)
146
+ receipt_key, @enc, @mac, @rmac, @dek = key_bytes.scan(/.{16}/mn)
147
+ peer = "\x5f\x49\x41".b + ephemeral.public_key.to_octet_string(:uncompressed)
148
+ @chain = @crypto.cmac(receipt_key, data + peer)
149
+ receipt = @chain.dup
150
+ receipt.setbyte(0, receipt.getbyte(0) ^ 1) if fault == :receipt
151
+ peer + "\x86\x10".b + receipt + "\x90\0".b
152
+ end
153
+ end