pwn 0.5.783 → 0.5.785

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 (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  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/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -0,0 +1,810 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'openssl'
5
+ require 'pwn/plugins/yubikey'
6
+ require 'pwn/plugins/yubikey/openpgp'
7
+
8
+ # Synthetic OpenPGP Card 3.4 APDUs. No encoder constants are borrowed from production.
9
+ # Every exchange is consumed in order; unexpected auth/write/retry fails immediately.
10
+ class OpenPGPWireConnection
11
+ attr_reader :commands, :descriptor
12
+
13
+ def initialize(script, descriptor = {})
14
+ @script = script.dup
15
+ @descriptor = { protocol: :apdu }.merge(descriptor)
16
+ @commands = []
17
+ end
18
+
19
+ def exchange(opts = {})
20
+ raise 'wrong protocol' unless opts[:protocol] == :apdu
21
+
22
+ command = opts.fetch(:data).unpack1('H*')
23
+ @commands << command
24
+ expected, reply = @script.shift
25
+ raise "Unexpected C-APDU header #{command[0, 8]}" unless expected
26
+ raise "Wrong C-APDU header #{command[0, 8]} (expected #{expected[0, 8]})" unless command == expected
27
+ raise reply if reply.is_a?(Exception)
28
+
29
+ [reply].pack('H*')
30
+ end
31
+
32
+ def complete?
33
+ @script.empty?
34
+ end
35
+ end
36
+
37
+ RSpec.shared_context 'OpenPGP native wire' do
38
+ let(:api) { PWN::Plugins::YubiKey::OpenPGP }
39
+
40
+ def hx(bytes)
41
+ bytes.unpack1('H*')
42
+ end
43
+
44
+ def tagged(tag, bytes)
45
+ length = bytes.bytesize
46
+ encoded_length = if length < 128
47
+ [length].pack('C')
48
+ elsif length < 256
49
+ [0x81, length].pack('CC')
50
+ else
51
+ [0x82, length].pack('Cn')
52
+ end
53
+ [tag].pack('H*') + encoded_length + bytes
54
+ end
55
+
56
+ def app_hex(overrides = {}, card_version = '0304')
57
+ fields = {
58
+ 'c0' => '74000020080000fe0000', 'c1' => '010800001100',
59
+ 'c2' => '010800001100', 'c3' => '010800001100',
60
+ 'c4' => '007f7f7f030003', 'c5' => '00' * 60,
61
+ 'c6' => '00' * 60, 'cd' => '00' * 12,
62
+ 'de' => '010102020301', 'd6' => '0020', 'd7' => '0120', 'd8' => '0320'
63
+ }.merge(overrides).compact
64
+ discretion = fields.map { |tag, value| tagged(tag, [value].pack('H*')) }.join
65
+ outer = ["4f10d27600012401#{card_version}00061234567800005f5203000000"].pack('H*') + tagged('73', discretion)
66
+ hx(tagged('6e', outer))
67
+ end
68
+
69
+ def command_chunks(header, bytes)
70
+ chunks = bytes.bytes.each_slice(255).map { |chunk| chunk.pack('C*') }
71
+ return [header] if chunks.empty?
72
+
73
+ chunks.map.with_index do |chunk, index|
74
+ cla = header[0, 2].to_i(16) | (index == chunks.length - 1 ? 0 : 0x10)
75
+ format('%<cla>02x%<header>s%<length>02x%<payload>s', cla: cla, header: header[2..], length: chunk.bytesize, payload: hx(chunk))
76
+ end
77
+ end
78
+
79
+ def fixture_certificate
80
+ key = OpenSSL::PKey::EC.generate('prime256v1')
81
+ cert = OpenSSL::X509::Certificate.new
82
+ cert.version = 2
83
+ cert.serial = 1
84
+ cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=OpenPGP offline fixture')
85
+ cert.public_key = key
86
+ cert.not_before = Time.at(0)
87
+ cert.not_after = Time.at(1_000_000)
88
+ cert.sign(key, OpenSSL::Digest.new('SHA256'))
89
+ cert
90
+ end
91
+
92
+ def prelude(version = '050701', overrides = {})
93
+ [
94
+ %w[00a4040006d27600012401 9000],
95
+ ['00f1000000', "#{version}9000"],
96
+ ['00ca006e00', "#{app_hex(overrides)}9000"]
97
+ ]
98
+ end
99
+ end
100
+
101
+ RSpec.describe PWN::Plugins::YubiKey do
102
+ include_context 'OpenPGP native wire'
103
+
104
+ it 'reads version, capability, PIN and key metadata from actual SELECT and GET DATA APDUs' do
105
+ connection = OpenPGPWireConnection.new(prelude + [%w[00ca007a00 7a05930300000a9000]])
106
+ result = api.execute(args: ['info'], connection: connection)
107
+ expect(result).to include(application: :openpgp, firmware: [5, 7, 1], card_version: [3, 4], signature_counter: 10)
108
+ expect(result[:pin_status]).to include(user: 3, reset: 0, admin: 3, signature_policy: :always)
109
+ expect(result[:keys][:sig]).to include(origin: :generated, algorithm: :rsa, bits: 2048, touch: :off)
110
+ expect(result[:keys][:dec]).to include(origin: :imported, touch: :on)
111
+ expect(result[:keys][:aut]).to include(origin: :generated, touch: :cached)
112
+ expect(connection).to be_complete
113
+ end
114
+
115
+ it 'changes a user PIN with CHANGE REFERENCE DATA and reads fresh retry status' do
116
+ connection = OpenPGPWireConnection.new(prelude + [
117
+ %w[002400810c313131313131323232323232 9000], %w[00ca00c400 007f7f7f0300039000]
118
+ ])
119
+ result = api.execute(args: %w[access change-pin], connection: connection, confirm: true, pin: '111111', new_pin: '222222')
120
+ expect(result).to include(changed: :pin, pin_status: include(user: 3))
121
+ expect(connection).to be_complete
122
+ end
123
+
124
+ it 'does not authenticate or query a provider without literal consent' do
125
+ connection = double('unused connection')
126
+ provider = double('secret provider')
127
+ expect(connection).not_to receive(:exchange)
128
+ expect(provider).not_to receive(:call)
129
+ expect { api.execute(args: %w[access change-pin], connection: connection, confirm: 'true', secret_provider: provider) }.to raise_error(described_class::ConfirmationRequired)
130
+ end
131
+
132
+ it 'does not verify an old credential when the replacement has invalid length' do
133
+ connection = OpenPGPWireConnection.new(prelude)
134
+ expect { api.execute(args: %w[access change-pin], connection: connection, confirm: true, pin: '111111', new_pin: 'x') }.to raise_error(ArgumentError, /length/)
135
+ expect(connection.commands.none? { |command| %w[0020 0024].include?(command[0, 4]) }).to be true
136
+ end
137
+
138
+ it 'signs RSA DigestInfo through PSO and returns a host-verifiable signature' do
139
+ key = OpenSSL::PKey::RSA.generate(2048)
140
+ digest_info = '3031300d060960864801650304020105000420ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
141
+ signature = key.sign('SHA256', 'abc')
142
+ connection = OpenPGPWireConnection.new(prelude + [
143
+ %w[0020008106313131313131 9000], ["002a9e9a33#{digest_info}", "#{hx(signature)}9000"]
144
+ ])
145
+ result = api.sign(connection: connection, confirm: true, pin: '111111', message: 'abc', hash_algorithm: :sha256)
146
+ expect(result).to eq(signature)
147
+ expect(key.public_key.verify('SHA256', result, 'abc')).to be true
148
+ expect(connection).to be_complete
149
+ end
150
+
151
+ it 'changes an admin PIN without first verifying it or replaying CHANGE' do
152
+ connection = OpenPGPWireConnection.new(prelude + [
153
+ %w[002400831038383838383838383939393939393939 9000], %w[00ca00c400 007f7f7f0300039000]
154
+ ])
155
+ result = api.execute(args: %w[access change-admin-pin], connection: connection, confirm: true, admin_pin: '88888888', new_admin_pin: '99999999')
156
+ expect(result[:changed]).to eq(:admin_pin)
157
+ expect(connection).to be_complete
158
+ end
159
+
160
+ it 'sets retries after explicit admin authentication and verifies counters by reading C4' do
161
+ connection = OpenPGPWireConnection.new(prelude + [
162
+ %w[00200083083838383838383838 9000], %w[00f2000003050607 9000], %w[00ca00c400 007f7f7f0506079000]
163
+ ])
164
+ result = api.execute(args: %w[access set-retries 5 6 7], connection: connection, confirm: true, admin_pin: '88888888')
165
+ expect(result).to include(verified: true, pins_reset: false)
166
+ expect(connection).to be_complete
167
+ end
168
+
169
+ it 'rejects invalid retry counts before admin authentication or connection I/O' do
170
+ connection = OpenPGPWireConnection.new([])
171
+ expect { api.execute(args: %w[access set-retries 0 2 3], connection: connection, confirm: true) }.to raise_error(ArgumentError, /retries/)
172
+ expect(connection.commands).to be_empty
173
+ end
174
+
175
+ it 'sets a reset code using PUT DATA only after validating both secrets' do
176
+ connection = OpenPGPWireConnection.new(prelude + [
177
+ %w[00200083083838383838383838 9000], %w[00da00d3083737373737373737 9000], %w[00ca00c400 007f7f7f0303039000]
178
+ ])
179
+ result = api.execute(args: %w[access change-reset-code], connection: connection, confirm: true, admin_pin: '88888888', reset_code: '77777777')
180
+ expect(result[:pin_status][:reset]).to eq(3)
181
+ expect(connection).to be_complete
182
+ end
183
+
184
+ it 'unblocks using the reset code with RESET RETRY COUNTER P1 zero and no VERIFY' do
185
+ connection = OpenPGPWireConnection.new(prelude + [
186
+ %w[002c00810e3737373737373737323232323232 9000], %w[00ca00c400 007f7f7f0303039000]
187
+ ])
188
+ result = api.execute(args: %w[access unblock-pin], connection: connection, confirm: true, reset_code: '77777777', new_pin: '222222')
189
+ expect(result).to include(unblocked: true)
190
+ expect(connection).to be_complete
191
+ end
192
+
193
+ it 'unblocks using admin authorization with RESET RETRY COUNTER P1 two' do
194
+ connection = OpenPGPWireConnection.new(prelude + [
195
+ %w[00200083083838383838383838 9000], %w[002c028106323232323232 9000], %w[00ca00c400 007f7f7f0300039000]
196
+ ])
197
+ result = api.execute(args: %w[access unblock-pin], connection: connection, confirm: true, admin_pin: '88888888', new_pin: '222222')
198
+ expect(result).to include(unblocked: true)
199
+ expect(connection).to be_complete
200
+ end
201
+
202
+ it 'sets signature PIN policy and rejects a mismatching readback without replay' do
203
+ connection = OpenPGPWireConnection.new(prelude + [
204
+ %w[00200083083838383838383838 9000], %w[00da00c40101 9000], %w[00ca00c400 007f7f7f0300039000]
205
+ ])
206
+ expect { api.execute(args: %w[access set-signature-policy once], connection: connection, confirm: true, admin_pin: '88888888') }.to raise_error(described_class::ResponseError, /readback/)
207
+ expect(connection).to be_complete
208
+ end
209
+
210
+ it 'applies card KDF S2K bytes for PIN changes using the card salt and byte count' do
211
+ salt = 'saltsalt'
212
+ old_hash = OpenSSL::Digest::SHA256.digest(("#{salt}111111" * 2) + "#{salt}111111"[0, 4])
213
+ new_hash = OpenSSL::Digest::SHA256.digest(("#{salt}222222" * 2) + "#{salt}222222"[0, 4])
214
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c0' => '75000020080000fe0000') + [
215
+ %w[00ca00f900 810103820108830400000020840873616c7473616c749000],
216
+ ["0024008140#{hx(old_hash + new_hash)}", '9000'], %w[00ca00c400 007f7f7f0300039000]
217
+ ])
218
+ expect(api.execute(args: %w[access change-pin], connection: connection, confirm: true, pin: '111111', new_pin: '222222')[:changed]).to eq(:pin)
219
+ expect(connection).to be_complete
220
+ end
221
+
222
+ it 'sets touch UIF including its button byte and verifies it by fresh GET DATA' do
223
+ connection = OpenPGPWireConnection.new(prelude + [
224
+ %w[00ca00d600 00209000], %w[00200083083838383838383838 9000],
225
+ %w[00da00d6020420 9000], %w[00ca00d600 04209000]
226
+ ])
227
+ result = api.execute(args: %w[keys set-touch sig cached-fixed], connection: connection, confirm: true, admin_pin: '88888888')
228
+ expect(result).to include(touch: :'cached-fixed', irreversible: true, verified: true)
229
+ expect(connection).to be_complete
230
+ end
231
+
232
+ it 'refuses changing an already fixed touch policy before admin verification' do
233
+ connection = OpenPGPWireConnection.new(prelude + [%w[00ca00d600 02209000]])
234
+ expect { api.execute(args: %w[keys set-touch sig off], connection: connection, confirm: true, admin_pin: '88888888') }.to raise_error(api::UnsupportedError, /fixed/i)
235
+ expect(connection).to be_complete
236
+ end
237
+
238
+ it 'reads key metadata from application data without authentication' do
239
+ connection = OpenPGPWireConnection.new(prelude)
240
+ expect(api.execute(args: %w[keys info dec], connection: connection)).to include(slot: :dec, origin: :imported)
241
+ expect(connection).to be_complete
242
+ end
243
+
244
+ it 'exports a real DER certificate after selecting the signature slot' do
245
+ certificate = fixture_certificate
246
+ connection = OpenPGPWireConnection.new(prelude + [
247
+ %w[00a502040660045c027f21 9000], ['00ca7f2100', "#{hx(certificate.to_der)}9000"]
248
+ ])
249
+ result = api.execute(args: %w[certificates export sig - --format DER], connection: connection)
250
+ expect(result).to eq(certificate.to_der)
251
+ expect(OpenSSL::X509::Certificate.new(result).verify(certificate.public_key)).to be true
252
+ expect(connection).to be_complete
253
+ end
254
+
255
+ it 'imports a certificate through command chaining and verifies the exact stored DER' do
256
+ certificate = fixture_certificate
257
+ script = prelude + [%w[00200083083838383838383838 9000], %w[00a501040660045c027f21 9000]]
258
+ script += command_chunks('00da7f21', certificate.to_der).map { |command| [command, '9000'] }
259
+ script += [%w[00a501040660045c027f21 9000], ['00ca7f2100', "#{hx(certificate.to_der)}9000"]]
260
+ connection = OpenPGPWireConnection.new(script)
261
+ result = api.execute(args: %w[certificates import dec -], connection: connection, certificate: certificate.to_pem, confirm: true, admin_pin: '88888888')
262
+ expect(result).to include(imported: :certificate, verified: true)
263
+ expect(connection.commands.any? { |command| command.start_with?('10da7f21ff') }).to be true
264
+ expect(connection).to be_complete
265
+ end
266
+
267
+ it 'deletes certificates using an empty PUT DATA and reads the empty slot back' do
268
+ connection = OpenPGPWireConnection.new(prelude('050403') + [
269
+ %w[00200083083838383838383838 9000], %w[00a50004070660045c027f21 9000], %w[00da7f2100 9000],
270
+ %w[00a50004070660045c027f21 9000], %w[00ca7f2100 9000]
271
+ ])
272
+ result = api.execute(args: %w[certificates delete aut], connection: connection, confirm: true, admin_pin: '88888888')
273
+ expect(result).to include(deleted: :certificate, verified: true)
274
+ expect(connection).to be_complete
275
+ end
276
+
277
+ it 'attests a generated key with proprietary CLA 80 and returns the created certificate' do
278
+ certificate = fixture_certificate
279
+ connection = OpenPGPWireConnection.new(prelude + [
280
+ %w[0020008106313131313131 9000], %w[80fb010000 9000],
281
+ %w[00a502040660045c027f21 9000], ['00ca7f2100', "#{hx(certificate.to_der)}9000"]
282
+ ])
283
+ result = api.execute(args: %w[keys attest sig -], connection: connection, confirm: true, pin: '111111')
284
+ expect(result).to eq(certificate.to_pem)
285
+ expect(connection).to be_complete
286
+ end
287
+
288
+ it 'resets by deliberately exhausting only observed retry counters before TERMINATE and ACTIVATE' do
289
+ script = prelude('050701', 'c4' => '007f7f7f010001') + [
290
+ %w[00200081080000000000000000 6982], %w[00ca00c400 007f7f7f0000019000],
291
+ %w[00200083080000000000000000 6983], %w[00ca00c400 007f7f7f0000009000],
292
+ %w[00e6000000 9000], %w[0044000000 9000], %w[00a4040006d27600012401 9000],
293
+ ['00ca006e00', "#{app_hex('de' => '010002000300')}9000"]
294
+ ]
295
+ connection = OpenPGPWireConnection.new(script)
296
+ result = api.execute(args: ['reset', '--force'], connection: connection, confirm: true)
297
+ expect(result).to include(reset: true, key_slots_verified_empty: true, attestation_preserved: true)
298
+ expect(connection).to be_complete
299
+ end
300
+
301
+ it 'aborts a reset if the invalid sentinel unexpectedly verifies rather than trying guessed PINs' do
302
+ connection = OpenPGPWireConnection.new(prelude + [%w[00200081080000000000000000 9000]])
303
+ expect { api.execute(args: ['reset'], connection: connection, confirm: true) }.to raise_error(described_class::ResponseError, /unexpectedly verified/)
304
+ expect(connection).to be_complete
305
+ end
306
+
307
+ it 'never treats --force as consent for reset or irreversible touch policies' do
308
+ connection = OpenPGPWireConnection.new([])
309
+ expect { api.execute(args: %w[reset --force], connection: connection) }.to raise_error(described_class::ConfirmationRequired)
310
+ expect { api.execute(args: %w[keys set-touch att fixed --force], connection: connection) }.to raise_error(described_class::ConfirmationRequired)
311
+ expect(connection.commands).to be_empty
312
+ end
313
+
314
+ it 'returns local metadata for every OpenPGP group without a connection' do
315
+ [[], ['access'], ['keys'], ['certificates']].each do |args|
316
+ expect(api.execute(args: args)).to include(application: :openpgp, group: args.join(' '))
317
+ end
318
+ expect(api.operations).to include('info', 'access change-pin', 'keys attest', 'certificates import')
319
+ expect(api.operations).not_to include('access', 'keys', 'certificates')
320
+ end
321
+
322
+ it 'rejects unknown commands and credential argv without touching a connection' do
323
+ connection = double('unopened connection')
324
+ expect(connection).not_to receive(:exchange)
325
+ expect { api.execute(args: %w[keys explode], connection: connection) }.to raise_error(ArgumentError)
326
+ expect { api.execute(args: ['access', 'change-pin', '--pin', 'synthetic-pin'], connection: connection) }.to raise_error(ArgumentError)
327
+ end
328
+ end
329
+
330
+ RSpec.describe PWN::Plugins::YubiKey, 'native cryptographic operations' do
331
+ include_context 'OpenPGP native wire'
332
+
333
+ it 'converts ECDSA r||s card signatures to canonical DER verified by OpenSSL' do
334
+ key = OpenSSL::PKey::EC.generate('prime256v1')
335
+ digest = OpenSSL::Digest.digest('SHA256', 'abc')
336
+ der = key.dsa_sign_asn1(digest)
337
+ parts = OpenSSL::ASN1.decode(der).value.map { |number| number.value.to_s(2).rjust(32, "\0") }
338
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c1' => '132a8648ce3d030107') + [
339
+ %w[0020008106313131313131 9000], ["002a9e9a20#{hx(digest)}", "#{hx(parts.join)}9000"]
340
+ ])
341
+ result = api.sign(connection: connection, confirm: true, pin: '111111', message: 'abc')
342
+ expect(result).to eq(der)
343
+ expect(key.dsa_verify_asn1(digest, result)).to be true
344
+ expect(connection).to be_complete
345
+ end
346
+
347
+ it 'performs Ed25519 INTERNAL AUTHENTICATE on the raw message with extended PW1' do
348
+ seed = ['9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'].pack('H*')
349
+ key = OpenSSL::PKey.read(['302e020100300506032b657004220420'].pack('H*') + seed)
350
+ signature = key.sign(nil, 'abc')
351
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c3' => '162b06010401da470f01') + [
352
+ %w[0020008206313131313131 9000], ['0088000003616263', "#{hx(signature)}9000"]
353
+ ])
354
+ result = api.authenticate(connection: connection, confirm: true, pin: '111111', message: 'abc')
355
+ expect(key.verify(nil, result, 'abc')).to be true
356
+ expect(connection).to be_complete
357
+ end
358
+
359
+ it 'deciphers an RSA PKCS1 encrypted block with padding indicator zero and command chaining' do
360
+ key = OpenSSL::PKey::RSA.generate(2048)
361
+ ciphertext = key.public_encrypt('offline message')
362
+ script = prelude + [%w[0020008206313131313131 9000]]
363
+ blocks = command_chunks('002a8086', "\0#{ciphertext}")
364
+ script += blocks.map.with_index { |command, index| [command, index == blocks.size - 1 ? "#{hx(key.private_decrypt(ciphertext))}9000" : '9000'] }
365
+ connection = OpenPGPWireConnection.new(script)
366
+ expect(api.decipher(connection: connection, confirm: true, pin: '111111', value: ciphertext)).to eq('offline message')
367
+ expect(connection.commands).to include(blocks[0])
368
+ expect(connection).to be_complete
369
+ end
370
+
371
+ it 'derives an X25519 secret using the OpenPGP A6/7F49/86 peer template' do
372
+ local = OpenSSL::PKey.generate_key('X25519')
373
+ peer = OpenSSL::PKey.generate_key('X25519')
374
+ point = OpenSSL::ASN1.decode(peer.public_to_der).value[1].value
375
+ secret = local.derive(peer)
376
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c2' => '122b060104019755010501') + [
377
+ %w[0020008206313131313131 9000], ["002a808627a6257f49228620#{hx(point)}", "#{hx(secret)}9000"]
378
+ ])
379
+ expect(api.decipher(connection: connection, confirm: true, pin: '111111', value: peer)).to eq(peer.derive(local))
380
+ expect(connection).to be_complete
381
+ end
382
+
383
+ it 'rejects truncated ECDSA card signatures rather than returning invalid DER' do
384
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c1' => '132a8648ce3d030107') + [
385
+ %w[0020008106313131313131 9000], ["002a9e9a20#{hx(OpenSSL::Digest.digest('SHA256', 'abc'))}", "#{'01' * 63}9000"]
386
+ ])
387
+ expect { api.sign(connection: connection, confirm: true, pin: '111111', message: 'abc') }.to raise_error(described_class::ResponseError, /signature/)
388
+ expect(connection).to be_complete
389
+ end
390
+
391
+ it 'rejects incompatible ECDH points before a PIN attempt' do
392
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c2' => '122a8648ce3d030107'))
393
+ peer = OpenSSL::PKey::EC.generate('secp384r1')
394
+ expect { api.decipher(connection: connection, confirm: true, pin: '111111', value: peer) }.to raise_error(ArgumentError, /curve/)
395
+ expect(connection).to be_complete
396
+ end
397
+
398
+ it 'rejects invalid prehashed lengths before a PIN attempt' do
399
+ connection = OpenPGPWireConnection.new(prelude)
400
+ expect { api.sign(connection: connection, confirm: true, pin: '111111', message: 'abc', prehashed: true) }.to raise_error(ArgumentError, /digest length/)
401
+ expect(connection).to be_complete
402
+ end
403
+ end
404
+
405
+ RSpec.describe PWN::Plugins::YubiKey, 'key provisioning' do
406
+ include_context 'OpenPGP native wire'
407
+
408
+ def public_wire(key)
409
+ if key.is_a?(OpenSSL::PKey::RSA)
410
+ inner = tagged('81', key.n.to_s(2)) + tagged('82', key.e.to_s(2))
411
+ else
412
+ raw = key.is_a?(OpenSSL::PKey::EC) ? key.public_key.to_octet_string(:uncompressed) : OpenSSL::ASN1.decode(key.public_to_der).value[1].value
413
+ inner = tagged('86', raw)
414
+ end
415
+ hx(tagged('7f49', inner))
416
+ end
417
+
418
+ it 'generates an RSA key in-card and verifies its exported public key without private host generation' do
419
+ key = OpenSSL::PKey::RSA.generate(2048)
420
+ wire = public_wire(key)
421
+ connection = OpenPGPWireConnection.new(prelude('040305') + [
422
+ %w[00200083083838383838383838 9000], ['0047800002b600', "#{wire}9000"], ['0047810002b600', "#{wire}9000"]
423
+ ])
424
+ result = api.generate_key(connection: connection, confirm: true, slot: :sig, algorithm: :rsa2048, admin_pin: '88888888')
425
+ expect(result).to be_a(OpenSSL::PKey::RSA)
426
+ expect(result.public_to_der).to eq(key.public_to_der)
427
+ expect(result).not_to be_private
428
+ expect(connection).to be_complete
429
+ end
430
+
431
+ it 'uses advertised repeated FA algorithm entries before changing attributes for EC generation' do
432
+ key = OpenSSL::PKey::EC.generate('prime256v1')
433
+ wire = public_wire(key)
434
+ connection = OpenPGPWireConnection.new(prelude + [
435
+ %w[00ca00fa00 fa13c106010800001100c109132a8648ce3d0301079000],
436
+ %w[00200083083838383838383838 9000], %w[00da00c109132a8648ce3d030107 9000],
437
+ %w[00ca00c100 132a8648ce3d0301079000], ['0047800002b600', "#{wire}9000"], ['0047810002b600', "#{wire}9000"]
438
+ ])
439
+ result = api.generate_key(connection: connection, confirm: true, slot: :sig, algorithm: :secp256r1, admin_pin: '88888888')
440
+ expect(result.public_to_der).to eq(key.public_to_der)
441
+ expect(connection).to be_complete
442
+ end
443
+
444
+ it 'rejects token-unsupported curves and vulnerable RSA generation before admin verification' do
445
+ connection = OpenPGPWireConnection.new(prelude + [%w[00ca00fa00 fa08c1060108000011009000]])
446
+ expect { api.generate_key(connection: connection, confirm: true, slot: :sig, algorithm: :secp384r1, admin_pin: '88888888') }.to raise_error(api::UnsupportedError)
447
+ expect(connection).to be_complete
448
+ vulnerable = OpenPGPWireConnection.new(prelude('040304'))
449
+ expect { api.generate_key(connection: vulnerable, confirm: true, slot: :sig, algorithm: :rsa2048, admin_pin: '88888888') }.to raise_error(api::UnsupportedError, /RSA/)
450
+ expect(vulnerable).to be_complete
451
+ end
452
+
453
+ it 'imports a real PKCS8 Ed25519 fixture as its seed rather than the DER wrapper' do
454
+ seed = ['9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'].pack('H*')
455
+ key_der = ['302e020100300506032b657004220420'].pack('H*') + seed
456
+ key = OpenSSL::PKey.read(key_der)
457
+ template = tagged('4d', ['b6007f480292205f4820'].pack('H*') + seed)
458
+ script = prelude('050701', 'c1' => '162b06010401da470f01') + [
459
+ %w[00ca00fa00 fa0cc10a162b06010401da470f019000], %w[00200083083838383838383838 9000]
460
+ ]
461
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
462
+ script << ['0047810002b600', "#{public_wire(key)}9000"]
463
+ connection = OpenPGPWireConnection.new(script)
464
+ result = api.import_key(connection: connection, confirm: true, slot: :sig, key: key_der, admin_pin: '88888888')
465
+ expect(result).to include(imported: :key, slot: :sig, verified: true)
466
+ expect(result[:public_key].public_to_der).to eq(key.public_to_der)
467
+ expect(connection).to be_complete
468
+ end
469
+
470
+ it 'reverses only the X25519 raw scalar bytes for OpenPGP private key import' do
471
+ seed = ['77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'].pack('H*')
472
+ key = OpenSSL::PKey.read(['302e020100300506032b656e04220420'].pack('H*') + seed)
473
+ template = tagged('4d', ['b8007f480292205f4820'].pack('H*') + seed.reverse)
474
+ script = prelude('050701', 'c2' => '122b060104019755010501') + [
475
+ %w[00ca00fa00 fa0dc20b122b0601040197550105019000], %w[00200083083838383838383838 9000]
476
+ ]
477
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
478
+ script << ['0047810002b800', "#{public_wire(key)}9000"]
479
+ connection = OpenPGPWireConnection.new(script)
480
+ expect(api.import_key(connection: connection, confirm: true, slot: :dec, key: key, admin_pin: '88888888')[:verified]).to be true
481
+ expect(connection).to be_complete
482
+ end
483
+
484
+ {
485
+ secp256r1: ['prime256v1', '132a8648ce3d030107', :sig],
486
+ secp256k1: ['secp256k1', '132b8104000a', :sig],
487
+ secp384r1: ['secp384r1', '132b81040022', :sig],
488
+ secp521r1: ['secp521r1', '132b81040023', :sig],
489
+ brainpoolp256r1: ['brainpoolP256r1', '132b2403030208010107', :sig],
490
+ brainpoolp384r1: ['brainpoolP384r1', '132b240303020801010b', :sig],
491
+ brainpoolp512r1: ['brainpoolP512r1', '122b240303020801010d', :dec],
492
+ ed25519: ['ED25519', '162b06010401da470f01', :sig],
493
+ x25519: ['X25519', '122b060104019755010501', :dec]
494
+ }.each do |algorithm, (curve, encoded, slot)|
495
+ it "generates advertised #{algorithm} keys and reconstructs the real OpenSSL public key" do
496
+ key = %w[ED25519 X25519].include?(curve) ? OpenSSL::PKey.generate_key(curve) : OpenSSL::PKey::EC.generate(curve)
497
+ tag = slot == :sig ? 'c1' : 'c2'
498
+ crt = slot == :sig ? 'b600' : 'b800'
499
+ connection = OpenPGPWireConnection.new(prelude('050701', tag => encoded) + [
500
+ ['00ca00fa00', "#{hx(tagged('fa', tagged(tag, [encoded].pack('H*'))))}9000"],
501
+ %w[00200083083838383838383838 9000], ["0047800002#{crt}", "#{public_wire(key)}9000"], ["0047810002#{crt}", "#{public_wire(key)}9000"]
502
+ ])
503
+ result = api.generate_key(connection: connection, confirm: true, slot: slot, algorithm: algorithm, admin_pin: '88888888')
504
+ expect(result.public_to_der).to eq(key.public_to_der)
505
+ expect(connection).to be_complete
506
+ end
507
+ end
508
+
509
+ [3072, 4096].each do |bits|
510
+ it "generates RSA#{bits} with the exact advertised modulus attributes" do
511
+ key = OpenSSL::PKey::RSA.generate(bits)
512
+ encoded = %w[010c00001100 011000001100][bits == 3072 ? 0 : 1]
513
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c3' => encoded) + [
514
+ ['00ca00fa00', "#{hx(tagged('fa', tagged('c3', [encoded].pack('H*'))))}9000"],
515
+ %w[00200083083838383838383838 9000], ['0047800002a400', "#{public_wire(key)}9000"], ['0047810002a400', "#{public_wire(key)}9000"]
516
+ ])
517
+ expect(api.generate_key(connection: connection, confirm: true, slot: :aut, algorithm: "rsa#{bits}", admin_pin: '88888888').n).to eq(key.n)
518
+ expect(connection).to be_complete
519
+ end
520
+ end
521
+
522
+ it 'imports a P521 scalar with 66-byte width rather than truncating the leading bit' do
523
+ key = OpenSSL::PKey::EC.generate('secp521r1')
524
+ scalar = key.private_key.to_s(2).rjust(66, "\0")
525
+ template = tagged('4d', ['b6007f480292425f4842'].pack('H*') + scalar)
526
+ script = prelude('050701', 'c1' => '132b81040023') + [
527
+ %w[00ca00fa00 fa08c106132b810400239000], %w[00200083083838383838383838 9000]
528
+ ]
529
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
530
+ script << ['0047810002b600', "#{public_wire(key)}9000"]
531
+ connection = OpenPGPWireConnection.new(script)
532
+ expect(api.import_key(connection: connection, slot: :sig, key: key.private_to_der, confirm: true, admin_pin: '88888888')[:verified]).to be true
533
+ expect(connection).to be_complete
534
+ end
535
+
536
+ it 'imports legacy NEO RSA CRT factors including iqmp, dp, dq and modulus' do
537
+ key = OpenSSL::PKey::RSA.generate(2048)
538
+ values = [key.e.to_s(2), *[key.p, key.q, key.iqmp, key.dmp1, key.dmq1].map { |num| num.to_s(2).rjust(128, "\0") }, key.n.to_s(2)].join
539
+ template = tagged('4d', ['b600'].pack('H*') + tagged('7f48', ['910392818093818094818095818096818097820100'].pack('H*')) + tagged('5f48', values))
540
+ script = prelude('010007', 'c1' => '010800001103') + [%w[00200083083838383838383838 9000]]
541
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
542
+ script << ['0047810002b600', "#{public_wire(key)}9000"]
543
+ connection = OpenPGPWireConnection.new(script)
544
+ expect(api.import_key(connection: connection, confirm: true, slot: :sig, key: key, admin_pin: '88888888')[:verified]).to be true
545
+ expect(connection).to be_complete
546
+ end
547
+
548
+ it 'imports NEO RSA CRT when algorithm attributes are fixed rather than writable' do
549
+ key = OpenSSL::PKey::RSA.generate(2048)
550
+ values = [key.e.to_s(2), *[key.p, key.q, key.iqmp, key.dmp1, key.dmq1].map { |num| num.to_s(2).rjust(128, "\0") }, key.n.to_s(2)].join
551
+ template = tagged('4d', ['b600'].pack('H*') + tagged('7f48', ['910392818093818094818095818096818097820100'].pack('H*')) + tagged('5f48', values))
552
+ script = prelude('010007', 'c0' => '70000020080000fe0000') + [%w[00200083083838383838383838 9000]]
553
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
554
+ script << ['0047810002b600', "#{public_wire(key)}9000"]
555
+ connection = OpenPGPWireConnection.new(script)
556
+ expect(api.import_key(connection: connection, confirm: true, slot: :sig, key: key, admin_pin: '88888888')[:verified]).to be true
557
+ expect(connection).to be_complete
558
+ end
559
+
560
+ it 'exports the public key as a typed read-only API without any authentication' do
561
+ key = OpenSSL::PKey::RSA.generate(2048)
562
+ connection = OpenPGPWireConnection.new(prelude + [['0047810002b600', "#{public_wire(key)}9000"]])
563
+ expect(api.public_key(connection: connection, slot: :sig).public_to_der).to eq(key.public_to_der)
564
+ expect(connection).to be_complete
565
+ end
566
+
567
+ it 'decrypts an actual encrypted PKCS8 key using only the required in-memory provider fields' do
568
+ key = OpenSSL::PKey.generate_key('ED25519')
569
+ pem = key.private_to_pem(OpenSSL::Cipher.new('aes-256-cbc'), 'offline fixture passphrase')
570
+ seed = OpenSSL::ASN1.decode(OpenSSL::ASN1.decode(key.private_to_der).value[2].value).value
571
+ template = tagged('4d', ['b6007f480292205f4820'].pack('H*') + seed)
572
+ script = prelude('050701', 'c1' => '162b06010401da470f01') + [
573
+ %w[00ca00fa00 fa0cc10a162b06010401da470f019000], %w[00200083083838383838383838 9000]
574
+ ]
575
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
576
+ script << ['0047810002b600', "#{public_wire(key)}9000"]
577
+ connection = OpenPGPWireConnection.new(script)
578
+ names = []
579
+ provider = lambda do |name|
580
+ names << name
581
+ { key_password: 'offline fixture passphrase', admin_pin: '88888888' }.fetch(name)
582
+ end
583
+ expect(api.import_key(connection: connection, confirm: true, slot: :sig, key: pem, secret_provider: provider)[:verified]).to be true
584
+ expect(names).to eq(%i[key_password admin_pin])
585
+ expect(connection).to be_complete
586
+ end
587
+
588
+ it 'imports RSA private key factors with independent 7F48 length headers and chained PUT DATA ODD' do
589
+ key = OpenSSL::PKey::RSA.generate(2048)
590
+ components = key.e.to_s(2) + key.p.to_s(2).rjust(128, "\0") + key.q.to_s(2).rjust(128, "\0")
591
+ template = tagged('4d', ['b6038401817f48089103928180938180'].pack('H*') + tagged('5f48', components))
592
+ script = prelude('050701', 'da' => '010800001100') + [
593
+ %w[00ca00fa00 fa08da060108000011009000], %w[00200083083838383838383838 9000]
594
+ ]
595
+ script += command_chunks('00db3fff', template).map { |command| [command, '9000'] }
596
+ script << ['0047810005b603840181', "#{public_wire(key)}9000"]
597
+ connection = OpenPGPWireConnection.new(script)
598
+ result = api.execute(args: %w[keys import att -], connection: connection, key: key.private_to_pem, confirm: true, admin_pin: '88888888')
599
+ expect(result).to include(imported: :key, verified: true)
600
+ expect(connection.commands.any? { |command| command.start_with?('10db3fffff') }).to be true
601
+ expect(connection).to be_complete
602
+ end
603
+ end
604
+
605
+ RSpec.describe PWN::Plugins::YubiKey, 'safety and firmware compatibility' do
606
+ include_context 'OpenPGP native wire'
607
+
608
+ it 'never replays an incorrect PIN or an uncertain signing command' do
609
+ connection = OpenPGPWireConnection.new(prelude + [%w[0020008106313131313131 63c2]])
610
+ expect { api.sign(connection: connection, confirm: true, pin: '111111', message: 'abc') }.to raise_error(described_class::Protocol::APDUError) { |error| expect(error.status).to eq(0x63C2) }
611
+ expect(connection).to be_complete
612
+ timeout = OpenPGPWireConnection.new(prelude + [
613
+ %w[0020008106313131313131 9000],
614
+ ["002a9e9a333031300d060960864801650304020105000420#{hx(OpenSSL::Digest.digest('SHA256', 'abc'))}", described_class::TimeoutError.new('uncertain')]
615
+ ])
616
+ expect { api.sign(connection: timeout, confirm: true, pin: '111111', message: 'abc') }.to raise_error(described_class::TimeoutError)
617
+ expect(timeout).to be_complete
618
+ end
619
+
620
+ it 'obtains only required credentials from the in-memory provider without logging them' do
621
+ names = []
622
+ provider = lambda { |name|
623
+ names << name
624
+ '88888888'
625
+ }
626
+ connection = OpenPGPWireConnection.new(prelude + [
627
+ %w[00200083083838383838383838 9000], %w[00da00c40101 9000], %w[00ca00c400 017f7f7f0300039000]
628
+ ])
629
+ result = api.execute(args: %w[access set-signature-policy once], connection: connection, confirm: true, secret_provider: provider)
630
+ expect(result[:verified]).to be true
631
+ expect(result.inspect).not_to include('88888888')
632
+ expect(names).to eq([:admin_pin])
633
+ expect(connection).to be_complete
634
+ end
635
+
636
+ it 'rejects absent credentials without guessing factory PINs' do
637
+ connection = OpenPGPWireConnection.new(prelude)
638
+ expect { api.sign(connection: connection, confirm: true, message: 'abc') }.to raise_error(ArgumentError, /pin/)
639
+ expect(connection).to be_complete
640
+ end
641
+
642
+ it 'rejects oversized Ed25519 messages before authenticating' do
643
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c1' => '162b06010401da470f01'))
644
+ expect { api.sign(connection: connection, confirm: true, pin: '111111', message: 'x' * 65_536) }.to raise_error(ArgumentError, /length/)
645
+ expect(connection).to be_complete
646
+ end
647
+
648
+ it 'does not automatically activate a terminated application during an info call' do
649
+ connection = OpenPGPWireConnection.new([%w[00a4040006d27600012401 6285]])
650
+ expect { api.execute(args: ['info'], connection: connection) }.to raise_error(described_class::Protocol::APDUError)
651
+ expect(connection).to be_complete
652
+ end
653
+
654
+ it 'activates a terminated application only as an explicitly confirmed reset and verifies it' do
655
+ connection = OpenPGPWireConnection.new([
656
+ %w[00a4040006d27600012401 6285], %w[0044000000 9000],
657
+ %w[00a4040006d27600012401 9000], ['00ca006e00', "#{app_hex('de' => '010002000300')}9000"]
658
+ ])
659
+ expect(api.execute(args: ['reset'], connection: connection, confirm: true)[:key_slots_verified_empty]).to be true
660
+ expect(connection).to be_complete
661
+ end
662
+
663
+ it 'recovers a corrupted application only on reset with bounded deliberate blocking' do
664
+ connection = OpenPGPWireConnection.new([
665
+ %w[00a4040006d27600012401 6581], %w[00200081080000000000000000 6982], %w[00200081080000000000000000 6983],
666
+ %w[00200083080000000000000000 6983], %w[00e6000000 9000], %w[0044000000 9000],
667
+ %w[00a4040006d27600012401 9000], ['00ca006e00', "#{app_hex('de' => '010002000300')}9000"]
668
+ ])
669
+ expect(api.execute(args: ['reset'], connection: connection, confirm: true)[:reset]).to be true
670
+ expect(connection).to be_complete
671
+ end
672
+
673
+ it 'exposes PIN verification and reset-verification with their explicit reference and version gate' do
674
+ connection = OpenPGPWireConnection.new(prelude + [%w[0020008206313131313131 9000]])
675
+ expect(api.verify_pin(connection: connection, confirm: true, pin: '111111', mode: :extended)).to include(verified: true, mode: :extended)
676
+ expect(connection).to be_complete
677
+ unverify = OpenPGPWireConnection.new(prelude + [%w[0020ff8200 9000]])
678
+ expect(api.unverify_pin(connection: unverify, confirm: true, mode: :extended)).to include(cleared: :extended)
679
+ expect(unverify).to be_complete
680
+ end
681
+
682
+ it 'exposes algorithm information without assuming absent capabilities' do
683
+ connection = OpenPGPWireConnection.new(prelude('040400'))
684
+ result = api.algorithms(connection: connection)
685
+ expect(result.keys).to eq(%i[sig dec aut])
686
+ expect(result[:sig].map { |entry| entry[:bits] }).to eq([2048])
687
+ expect(connection).to be_complete
688
+ end
689
+
690
+ it 'handles the documented pre-5.6.1 FA length and X25519 advertising errata narrowly' do
691
+ # FA declares two bytes too many; DEC contains the historical invalid EdDSA/X25519 entry.
692
+ connection = OpenPGPWireConnection.new(prelude('050403') + [%w[00ca00fa00 fa0fc20b162b0601040197550105019000]])
693
+ result = api.algorithms(connection: connection)
694
+ expect(result[:dec]).to include(include(algorithm: :ecdh, curve: :x25519))
695
+ expect(result[:dec]).not_to include(include(algorithm: :eddsa))
696
+ expect(connection).to be_complete
697
+ end
698
+
699
+ it 'rejects unrelated malformed FA lengths rather than inventing capabilities' do
700
+ connection = OpenPGPWireConnection.new(prelude + [%w[00ca00fa00 fa20c1060108000011009000]])
701
+ expect { api.algorithms(connection: connection) }.to raise_error(described_class::ResponseError)
702
+ expect(connection).to be_complete
703
+ end
704
+
705
+ it 'reports whether the actual card KDF object enables PIN hashing' do
706
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c0' => '75000020080000fe0000') + [
707
+ %w[00ca007a00 7a0593030000009000], %w[00ca00f900 810103820108830400000020840873616c7473616c749000]
708
+ ])
709
+ expect(api.execute(args: ['info'], connection: connection)).to include(kdf_enabled: true)
710
+ expect(connection).to be_complete
711
+ end
712
+
713
+ it 'reads legacy UIF separately when it is absent from application data' do
714
+ raw = app_hex({ 'd6' => nil }, '0201')
715
+ connection = OpenPGPWireConnection.new([%w[00a4040006d27600012401 9000], %w[00f1000000 0403059000], ['00ca006e00', "#{raw}9000"], %w[00ca00d600 01209000]])
716
+ expect(api.execute(args: %w[keys info sig], connection: connection)[:touch]).to eq(:on)
717
+ expect(connection).to be_complete
718
+ end
719
+
720
+ it 'rejects misleading EC signing attributes before authentication' do
721
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c1' => '132b060104019755010501'))
722
+ expect { api.sign(connection: connection, confirm: true, message: 'abc', pin: '111111') }.to raise_error(api::UnsupportedError, /curve/)
723
+ expect(connection).to be_complete
724
+ end
725
+
726
+ it 'rejects empty slots before consuming a PIN attempt for crypto' do
727
+ connection = OpenPGPWireConnection.new(prelude('050701', 'de' => '010002000300'))
728
+ expect { api.sign(connection: connection, confirm: true, message: 'abc', pin: '111111') }.to raise_error(api::UnsupportedError, /empty/)
729
+ expect(connection).to be_complete
730
+ end
731
+
732
+ it 'rejects repeated format options and invalid certificates before authentication' do
733
+ connection = OpenPGPWireConnection.new([])
734
+ expect { api.execute(args: %w[keys attest sig - --format DER --format PEM], connection: connection, confirm: true) }.to raise_error(ArgumentError)
735
+ expect { api.execute(args: %w[certificates import sig -], certificate: 'invalid certificate', connection: connection, confirm: true) }.to raise_error(ArgumentError)
736
+ expect(connection.commands).to be_empty
737
+ end
738
+
739
+ it 'rejects oversized certificates and unsupported old certificate slots before admin verification' do
740
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c0' => '74000020001000fe0000'))
741
+ expect { api.execute(args: %w[certificates import sig -], certificate: fixture_certificate.to_der, connection: connection, confirm: true, admin_pin: '88888888') }.to raise_error(api::UnsupportedError, /certificate limit/)
742
+ expect(connection).to be_complete
743
+ old = OpenPGPWireConnection.new(prelude('040305'))
744
+ expect { api.execute(args: %w[certificates delete sig], connection: old, confirm: true, admin_pin: '88888888') }.to raise_error(api::UnsupportedError, /firmware/)
745
+ expect(old).to be_complete
746
+ end
747
+
748
+ it 'exports a certificate to an explicit exclusive output file through GET RESPONSE chaining' do
749
+ cert = fixture_certificate
750
+ der = cert.to_der
751
+ connection = OpenPGPWireConnection.new(prelude + [
752
+ %w[00a500040660045c027f21 9000], ['00ca7f2100', "#{hx(der[0, 128])}61ff"], ['00c00000ff', "#{hx(der[128..])}9000"]
753
+ ])
754
+ Dir.mktmpdir('openpgp-cert-') do |dir|
755
+ output = File.join(dir, 'certificate.der')
756
+ result = api.execute(args: ['certificates', 'export', 'aut', output, '--format', 'DER'], connection: connection)
757
+ expect(File.binread(output)).to eq(result)
758
+ expect(result).to eq(der)
759
+ expect(File.stat(output).mode & 0o777).to eq(0o600)
760
+ end
761
+ expect(connection).to be_complete
762
+ end
763
+
764
+ it 'deciphers through the decrypt alias without changing its binary result' do
765
+ connection = OpenPGPWireConnection.new(prelude + [%w[0020008206313131313131 9000]] + command_chunks('002a8086', "\0".b + ('x' * 256)).map.with_index { |command, index| [command, index.zero? ? '9000' : '0001ff9000'] })
766
+ expect(api.decrypt(connection: connection, confirm: true, pin: '111111', value: 'x' * 256)).to eq("\0\x01\xFF".b)
767
+ expect(connection).to be_complete
768
+ end
769
+
770
+ it 'uses the admin-specific S2K salt and SHA512 when advertised by the card' do
771
+ digest = OpenSSL::Digest.digest('SHA512', 'adminslt88888888' * 4)
772
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c0' => '75000020080000fe0000') + [
773
+ %w[00ca00f900 81010382010a83040000004084087573657273616c74860861646d696e736c749000],
774
+ ["0020008340#{hx(digest)}", '9000']
775
+ ])
776
+ expect(api.verify_pin(connection: connection, confirm: true, admin_pin: '88888888', mode: :admin)[:verified]).to be true
777
+ expect(connection).to be_complete
778
+ end
779
+
780
+ it 'rejects an unsupported KDF instead of sending plaintext PINs' do
781
+ connection = OpenPGPWireConnection.new(prelude('050701', 'c0' => '75000020080000fe0000') + [%w[00ca00f900 81017f9000]])
782
+ expect { api.verify_pin(connection: connection, confirm: true, pin: '111111') }.to raise_error(api::UnsupportedError, /KDF/)
783
+ expect(connection).to be_complete
784
+ end
785
+
786
+ it 'disables unsupported touch or retry mutations before admin authentication' do
787
+ touch = OpenPGPWireConnection.new(prelude('010007'))
788
+ expect { api.execute(args: %w[keys set-touch sig on], connection: touch, confirm: true, admin_pin: '88888888') }.to raise_error(api::UnsupportedError)
789
+ expect(touch).to be_complete
790
+ retries = OpenPGPWireConnection.new(prelude('040300'))
791
+ expect { api.execute(args: %w[access set-retries 3 3 3], connection: retries, confirm: true, admin_pin: '88888888') }.to raise_error(api::UnsupportedError)
792
+ expect(retries).to be_complete
793
+ end
794
+
795
+ it 'documents every public method and preserves exact leaf coverage separately from groups' do
796
+ output = StringIO.new
797
+ original = $stdout
798
+ begin
799
+ $stdout = output
800
+ api.help
801
+ ensure
802
+ $stdout = original
803
+ end
804
+ %i[execute operations generate_key import_key sign authenticate decipher decrypt algorithms public_key verify_pin unverify_pin authors].each do |method|
805
+ expect(output.string).to include("#{api}.#{method}")
806
+ end
807
+ expect(api.operations.sort).to eq(['info', 'reset', 'access set-retries', 'access change-pin', 'access change-reset-code', 'access change-admin-pin', 'access unblock-pin', 'access set-signature-policy', 'keys info', 'keys set-touch', 'keys import', 'keys attest', 'certificates export', 'certificates delete', 'certificates import'].sort)
808
+ expect(output.string).to include('no factory credentials', 'ECDH returns a raw shared secret')
809
+ end
810
+ end