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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'openssl'
5
+
6
+ # YubiHSM Auth v5.9.1 protocol fixtures. Keys are synthetic, never hardware captures.
7
+ describe 'PWN::Plugins::YubiKey::HSMAuth native protocol' do
8
+ let(:hsmauth) { PWN::Plugins::YubiKey::HSMAuth }
9
+ let(:wire_class) do
10
+ Class.new do
11
+ attr_reader :seen
12
+
13
+ def initialize(steps)
14
+ @steps = steps.dup
15
+ @seen = []
16
+ end
17
+
18
+ def descriptor
19
+ { protocol: :apdu }
20
+ end
21
+
22
+ def exchange(opts = {})
23
+ raise 'wrong transport' unless opts[:protocol] == :apdu
24
+
25
+ actual = opts[:data].unpack1('H*')
26
+ @seen << actual
27
+ expected, response = @steps.shift
28
+ raise "unexpected APDU #{actual}" unless actual == expected
29
+
30
+ [response].pack('H*')
31
+ end
32
+
33
+ def complete?
34
+ @steps.empty?
35
+ end
36
+ end
37
+ end
38
+ let(:select) { %w[00a4040008a000000527210701 79030508009000] }
39
+ let(:management) { 'm' * 16 }
40
+ let(:password) { 'synthetic' }
41
+ let(:entry) { '7207260061636374089000' }
42
+
43
+ def field(tag, value)
44
+ (value.bytesize < 128 ? [tag, value.bytesize] : [tag, 0x81, value.bytesize]).pack('C*') + value.b
45
+ end
46
+
47
+ def apdu(ins, payload = ''.b, param1: 0, param2: 0)
48
+ ([0, ins, param1, param2] + (payload.empty? ? [] : [payload.bytesize])).pack('C*').unpack1('H*') + payload.unpack1('H*')
49
+ end
50
+
51
+ before do
52
+ require 'pwn/plugins/yubikey/protocol'
53
+ require 'pwn/plugins/yubikey/hsmauth'
54
+ end
55
+
56
+ it 'lists exact labels, algorithms, touch policies and retry counters' do
57
+ wire = wire_class.new([select, ['0005000000', entry]])
58
+ expect(hsmauth.execute(connection: wire, args: %w[credentials list])).to eq([{ label: 'acct', algorithm: :symmetric, touch: false, retries: 8 }])
59
+ expect(wire.complete?).to be(true)
60
+ end
61
+
62
+ it 'imports symmetric keys through the actual raw APDU boundary and verifies metadata' do
63
+ payload = field(0x7b, management) + field(0x71, 'acct') + field(0x74, "\x26") + field(0x75, 'e' * 16) + field(0x76, 'a' * 16) + field(0x73, password.ljust(16, "\x00")) + field(0x7a, "\x00")
64
+ wire = wire_class.new([select, %w[0005000000 9000], [apdu(1, payload), '9000'], ['0005000000', entry]])
65
+ result = hsmauth.execute(connection: wire, args: %w[credentials symmetric acct], key_enc: 'e' * 16, key_mac: 'a' * 16, credential_password: password, management_key: management, confirm: true)
66
+ expect(result).to include(label: 'acct', verified: true)
67
+ expect(wire.complete?).to be(true)
68
+ end
69
+
70
+ it 'derives AES keys using the independently calculated PBKDF2 vector' do
71
+ keys = ['6b267091bd381008dacf8eeddd0e3cd7a9cbea9664f5fc538491ce2945fb4991'].pack('H*')
72
+ payload = field(0x7b, management) + field(0x71, 'acct') + field(0x74, "\x26") + field(0x75, keys[0, 16]) + field(0x76, keys[16, 16]) + field(0x73, password.ljust(16, "\x00")) + field(0x7a, "\x00")
73
+ wire = wire_class.new([select, %w[0005000000 9000], [apdu(1, payload), '9000'], ['0005000000', entry]])
74
+ expect(hsmauth.execute(connection: wire, args: %w[credentials derive acct], derivation_password: 'synthetic derive', credential_password: password, management_key: management, confirm: true)).to include(verified: true)
75
+ expect(wire.complete?).to be(true)
76
+ end
77
+
78
+ it 'calculates and strictly splits three session keys without exposing them to a process' do
79
+ context = ('H' * 8) + ('C' * 8)
80
+ payload = field(0x71, 'acct') + field(0x77, context) + field(0x78, 'R' * 8) + field(0x73, password.ljust(16, "\x00"))
81
+ wire = wire_class.new([select, [apdu(3, payload), "#{(('E' * 16) + ('M' * 16) + ('R' * 16)).unpack1('H*')}9000"]])
82
+ expect(hsmauth.calculate(connection: wire, label: 'acct', algorithm: :symmetric, context: context, card_crypto: 'R' * 8, credential_password: password, confirm: true)).to eq(key_senc: 'E' * 16, key_smac: 'M' * 16, key_srmac: 'R' * 16)
83
+ expect(wire.complete?).to be(true)
84
+ end
85
+
86
+ it 'never assumes the factory management key or invokes a provider without consent' do
87
+ wire = wire_class.new([])
88
+ expect { hsmauth.execute(connection: wire, args: %w[credentials delete acct], secret_provider: ->(_name) { raise 'prompted' }) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
89
+ expect(wire.seen).to be_empty
90
+ end
91
+
92
+ let(:p256_point) { ['046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2964fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5'].pack('H*') }
93
+
94
+ it 'generates an asymmetric key on device using the empty private-key TLV' do
95
+ payload = field(0x7b, management) + field(0x71, 'acct') + field(0x74, "\x27") + field(0x7d, '') + field(0x73, password.ljust(16, "\x00")) + field(0x7a, "\x00")
96
+ wire = wire_class.new([select, %w[0005000000 9000], [apdu(1, payload), '9000'], ['0005000000', entry.sub('2600', '2700')]])
97
+ expect(hsmauth.execute(connection: wire, args: %w[credentials generate acct], management_key: management, credential_password: password, confirm: true)).to include(algorithm: :asymmetric, verified: true)
98
+ expect(wire.complete?).to be(true)
99
+ end
100
+
101
+ it 'imports a P-256 private scalar without treating a filename as secret input' do
102
+ scalar = "#{"\x00" * 31}\x01"
103
+ payload = field(0x7b, management) + field(0x71, 'acct') + field(0x74, "\x27") + field(0x7d, scalar) + field(0x73, password.ljust(16, "\x00")) + field(0x7a, "\x00")
104
+ wire = wire_class.new([select, %w[0005000000 9000], [apdu(1, payload), '9000'], ['0005000000', entry.sub('2600', '2700')]])
105
+ expect(hsmauth.execute(connection: wire, args: %w[credentials import acct], private_key: scalar, management_key: management, credential_password: password, confirm: true)).to include(verified: true)
106
+ expect(wire.complete?).to be(true)
107
+ end
108
+
109
+ it 'exports validated P-256 public keys as raw points, PEM and DER' do
110
+ %w[RAW PEM DER].each do |format|
111
+ wire = wire_class.new([select, [apdu(10, field(0x71, 'acct')), "#{p256_point.unpack1('H*')}9000"]])
112
+ result = hsmauth.execute(connection: wire, args: ['credentials', 'export', 'acct', '--format', format])
113
+ expect(format == 'RAW' ? result : OpenSSL::PKey.read(result).public_key.to_octet_string(:uncompressed)).to eq(p256_point)
114
+ expect(wire.complete?).to be(true)
115
+ end
116
+ end
117
+
118
+ it 'changes the credential password using the old credential password without retry' do
119
+ payload = field(0x71, 'acct') + field(0x73, password.ljust(16, "\x00")) + field(0x73, 'new'.ljust(16, "\x00"))
120
+ wire = wire_class.new([select, [apdu(11, payload), '9000']])
121
+ expect(hsmauth.execute(connection: wire, args: %w[credentials change-password acct], credential_password: password, new_credential_password: 'new', confirm: true)).to include(status: :acknowledged, verification: :not_readable)
122
+ expect(wire.complete?).to be(true)
123
+ end
124
+
125
+ it 'changes the credential password with the explicit management authorization mode' do
126
+ payload = field(0x71, 'acct') + field(0x7b, management) + field(0x73, 'new'.ljust(16, "\x00"))
127
+ wire = wire_class.new([select, [apdu(11, payload, param1: 1), '9000']])
128
+ expect(hsmauth.execute(connection: wire, args: %w[credentials change-password acct], management_key: management, new_credential_password: 'new', confirm: true)).to include(status: :acknowledged)
129
+ expect(wire.complete?).to be(true)
130
+ end
131
+
132
+ it 'changes management keys and padded passwords without assuming a default' do
133
+ payload = field(0x7b, management) + field(0x7b, 'n' * 16)
134
+ wire = wire_class.new([select, [apdu(8, payload), '9000']])
135
+ expect(hsmauth.execute(connection: wire, args: %w[access change-management-key], management_key: management, new_management_key: 'n' * 16, confirm: true)).to include(status: :acknowledged)
136
+ expect(wire.complete?).to be(true)
137
+ payload = field(0x7b, 'old'.ljust(16, "\x00")) + field(0x7b, 'new'.ljust(16, "\x00"))
138
+ wire = wire_class.new([select, [apdu(8, payload), '9000']])
139
+ expect(hsmauth.execute(connection: wire, args: %w[access change-management-password], management_password: 'old', new_management_password: 'new', confirm: true)).to include(status: :acknowledged)
140
+ expect(wire.complete?).to be(true)
141
+ end
142
+
143
+ it 'resets with DEAD confirmation bytes and verifies empty inventory' do
144
+ wire = wire_class.new([select, %w[0006dead00 9000], select, %w[0005000000 9000], %w[0009000000 089000]])
145
+ expect(hsmauth.execute(connection: wire, args: ['reset'], confirm: true)).to include(verified: true)
146
+ expect(wire.complete?).to be(true)
147
+ end
148
+
149
+ it 'reads application version and management retry status without credentials' do
150
+ wire = wire_class.new([select, %w[0009000000 059000]])
151
+ expect(hsmauth.execute(connection: wire, args: ['info'])).to include(version: [5, 8, 0], management_key_retries: 5)
152
+ expect(wire.complete?).to be(true)
153
+ end
154
+
155
+ it 'retains a selected session between asymmetric challenge and session-key calculation' do
156
+ context = p256_point + p256_point
157
+ challenge_payload = field(0x71, 'acct') + field(0x73, password.ljust(16, "\x00"))
158
+ payload = field(0x71, 'acct') + field(0x77, context) + field(0x7c, p256_point) + field(0x78, 'R' * 16) + field(0x73, password.ljust(16, "\x00"))
159
+ wire = wire_class.new([select, [apdu(4, challenge_payload), "#{p256_point.unpack1('H*')}9000"], [apdu(3, payload), "#{'01' * 48}9000"]])
160
+ session = hsmauth.open_session(connection: wire)
161
+ expect(hsmauth.challenge(session: session, label: 'acct', algorithm: :asymmetric, credential_password: password, confirm: true)).to eq(p256_point)
162
+ expect(hsmauth.calculate(session: session, label: 'acct', algorithm: :asymmetric, context: context, public_key: p256_point, card_crypto: 'R' * 16, credential_password: password, confirm: true)).to include(key_senc: "\x01" * 16)
163
+ expect(wire.complete?).to be(true)
164
+ end
165
+
166
+ it 'deletes an exact label with management authorization and verifies absence' do
167
+ payload = field(0x7b, management) + field(0x71, 'acct')
168
+ wire = wire_class.new([select, ['0005000000', entry], [apdu(2, payload), '9000'], %w[0005000000 9000]])
169
+ expect(hsmauth.execute(connection: wire, args: %w[credentials delete acct], management_key: management, confirm: true)).to include(deleted: 'acct', verified: true)
170
+ expect(wire.complete?).to be(true)
171
+ wire = wire_class.new([select, ['0005000000', entry]])
172
+ expect { hsmauth.execute(connection: wire, args: %w[credentials delete acc], management_key: management, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /exact/)
173
+ expect(wire.complete?).to be(true)
174
+ end
175
+
176
+ it 'reports wrong credentials once without trying a default or another password' do
177
+ payload = field(0x71, 'acct') + field(0x77, 'c' * 16) + field(0x73, password.ljust(16, "\x00"))
178
+ wire = wire_class.new([select, [apdu(3, payload), '63c7']])
179
+ expect { hsmauth.calculate(connection: wire, label: 'acct', context: 'c' * 16, credential_password: password, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::Protocol::APDUError) { |error| expect(error.status).to eq(0x63c7) }
180
+ expect(wire.complete?).to be(true)
181
+ end
182
+
183
+ it 'rejects malformed session-key output instead of returning partial keys' do
184
+ payload = field(0x71, 'acct') + field(0x77, 'c' * 16) + field(0x73, password.ljust(16, "\x00"))
185
+ wire = wire_class.new([select, [apdu(3, payload), "#{'00' * 47}9000"]])
186
+ expect { hsmauth.calculate(connection: wire, label: 'acct', context: 'c' * 16, credential_password: password, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /exactly/)
187
+ expect(wire.complete?).to be(true)
188
+ end
189
+
190
+ it 'gates asymmetric and password-changing commands on the selected version' do
191
+ old = [select[0], '79030504039000']
192
+ wire = wire_class.new([old])
193
+ expect { hsmauth.execute(connection: wire, args: %w[credentials generate acct], management_key: management, credential_password: password, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::Error, /unsupported/)
194
+ wire = wire_class.new([old])
195
+ expect { hsmauth.execute(connection: wire, args: %w[credentials change-password acct], credential_password: password, new_credential_password: 'new', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::Error, /unsupported/)
196
+ expect(wire.complete?).to be(true)
197
+ end
198
+
199
+ it 'refuses missing management credentials and malformed private keys without I/O' do
200
+ wire = wire_class.new([])
201
+ expect { hsmauth.execute(connection: wire, args: %w[credentials delete acct], confirm: true) }.to raise_error(ArgumentError, /management_key/)
202
+ expect { hsmauth.execute(connection: wire, args: %w[credentials import acct], private_key: "\x00" * 32, management_key: management, credential_password: password, confirm: true) }.to raise_error(ArgumentError, /scalar/)
203
+ expect(wire.seen).to be_empty
204
+ end
205
+
206
+ it 'gets a symmetric challenge without guessing a credential password' do
207
+ wire = wire_class.new([select, [apdu(4, field(0x71, 'acct')), '01020304050607089000']])
208
+ expect(hsmauth.challenge(connection: wire, label: 'acct', confirm: true)).to eq(['0102030405060708'].pack('H*'))
209
+ expect(wire.complete?).to be(true)
210
+ end
211
+
212
+ it 'documents every public API and secret option in actual help output' do
213
+ expect { hsmauth.help }.to output(/\.execute\(.*new_credential_password:.*management_key:.*\.operations.*\.open_session\(.*\.challenge\(.*\.calculate\(.*public_key:.*\.authors/m).to_stdout
214
+ end
215
+
216
+ it 'keeps groups local and rejects secret argv even with confirmation' do
217
+ wire = wire_class.new([])
218
+ expect(hsmauth.execute(connection: wire, args: ['credentials'])).to include(application: :hsmauth)
219
+ expect { hsmauth.execute(connection: wire, args: %w[credentials generate acct --management-key secret], confirm: true) }.to raise_error(ArgumentError)
220
+ expect(wire.seen).to be_empty
221
+ end
222
+
223
+ it 'does not silently change an explicit credential authentication mode' do
224
+ wire = wire_class.new([])
225
+ expect { hsmauth.execute(connection: wire, args: %w[credentials change-password acct], auth_method: :credential, management_key: management, new_credential_password: 'new', confirm: true) }.to raise_error(ArgumentError, /conflict/)
226
+ expect { hsmauth.execute(connection: wire, args: %w[credentials symmetric acct], generate: 'false', confirm: true) }.to raise_error(ArgumentError, /boolean/)
227
+ expect(wire.seen).to be_empty
228
+ end
229
+
230
+ it 'validates all public calculation arguments before credentials or I/O' do
231
+ wire = wire_class.new([])
232
+ expect { hsmauth.calculate(connection: wire, label: 'acct', algorithm: :symmetric, context: 'short', credential_password: password, confirm: true) }.to raise_error(ArgumentError)
233
+ expect(wire.seen).to be_empty
234
+ end
235
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/plugins/yubikey/protocol'
5
+ require 'pwn/plugins/yubikey/management'
6
+
7
+ RSpec.describe 'PWN::Plugins::YubiKey::Management native protocol' do
8
+ subject(:management) { PWN::Plugins::YubiKey::Management }
9
+
10
+ let(:wire) do
11
+ Class.new do
12
+ attr_reader :calls, :descriptor
13
+
14
+ def initialize(responses, descriptor = { protocol: :otp })
15
+ @responses = responses
16
+ @descriptor = descriptor
17
+ @calls = []
18
+ end
19
+
20
+ def exchange(opts = {})
21
+ @calls << opts
22
+ raise 'Unexpected management request' if @responses.empty?
23
+
24
+ response = @responses.shift
25
+ raise response if response.is_a?(Exception)
26
+
27
+ response
28
+ end
29
+ end
30
+ end
31
+
32
+ let(:status) { [5, 7, 1, 9, 3, 0].pack('C*') }
33
+ # Literal protocol fields, independent of the application encoder.
34
+ let(:tlvs) { ['0102023f020400bc614e0302023f04018105030507010602000007010f0801400a01000d02023f0e02023f'].pack('H*') }
35
+ let(:encoded) { [tlvs.bytesize].pack('C') + tlvs }
36
+
37
+ it 'reads exact native device capabilities without inventing application support' do
38
+ peer = wire.new([status, encoded])
39
+ info = management.info(connection: peer)
40
+ expect(info).to include(version: [5, 7, 1], serial: '12345678', is_locked: false, is_fips: true, form_factor: 1)
41
+ expect(info[:supported_capabilities]).to eq(usb: 0x23f, nfc: 0x23f)
42
+ expect(info[:enabled_capabilities]).to eq(usb: 0x23f, nfc: 0x23f)
43
+ expect(peer.calls.last).to include(protocol: :otp, command: 0x13, data: "\0".b)
44
+ end
45
+
46
+ it 'reads length-prefixed information over management smartcard and CTAP vendor commands' do
47
+ peer = wire.new(["YubiKey 5.7.1\x90\0".b, encoded + "\x90\0".b], protocol: :apdu)
48
+ expect(management.info(connection: peer)[:serial]).to eq('12345678')
49
+ expect(peer.calls.first[:data]).to eq(['00a4040008a000000527471117'].pack('H*'))
50
+ expect(peer.calls.last[:data]).to eq(['001d000000'].pack('H*'))
51
+ peer = wire.new([encoded], protocol: :ctap, version: [5, 7, 1])
52
+ expect(management.info(connection: peer)[:version]).to eq([5, 7, 1])
53
+ expect(peer.calls.first).to include(command: 0x42, data: "\0".b)
54
+ end
55
+
56
+ it 'changes only requested USB applications and verifies new device fields' do
57
+ changed = encoded.sub(['0302023f'].pack('H*'), ['0302022f'].pack('H*'))
58
+ peer = wire.new([status, encoded, ''.b, changed])
59
+ result = management.configure_usb(connection: peer, disable: ['PIV'], confirm: true)
60
+ expect(peer.calls[2]).to include(command: 0x15, data: ['040302022f'].pack('H*'))
61
+ expect(result).to include(verified: true, verification: :device_information)
62
+ expect(result[:info][:enabled_capabilities][:usb]).to eq(0x22f)
63
+ expect(peer.calls.last[:command]).to eq(0x13)
64
+ end
65
+
66
+ it 'requires explicit consent and refuses unsupported capabilities without a write' do
67
+ peer = wire.new([])
68
+ expect { management.configure_usb(connection: peer, disable: ['PIV'], confirm: 'true') }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
69
+ expect(peer.calls).to be_empty
70
+ peer = wire.new([status, encoded])
71
+ expect { management.configure_usb(connection: peer, enable: ['HSMAUTH'], confirm: true) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /supported/i)
72
+ expect(peer.calls.none? { |call| call[:command] == 0x15 }).to be(true)
73
+ end
74
+
75
+ it 'treats lost ACK and mismatching readback as uncertain without replay' do
76
+ peer = wire.new([status, encoded, ''.b, encoded])
77
+ expect { management.configure_usb(connection: peer, disable: ['PIV'], confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /uncertain/i)
78
+ expect(peer.calls.count { |call| call[:command] == 0x15 }).to eq(1)
79
+ peer = wire.new([status, encoded, PWN::Plugins::YubiKey::TimeoutError.new('synthetic timeout')])
80
+ expect { management.configure_usb(connection: peer, disable: ['PIV'], confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /uncertain/i)
81
+ expect(peer.calls.count { |call| call[:command] == 0x15 }).to eq(1)
82
+ end
83
+
84
+ it 'routes all config leaves and treats group/list paths as read-only' do
85
+ expect(management.operations).to contain_exactly('usb', 'nfc', 'mode', 'set-lock-code', 'reset')
86
+ expect(management.execute(args: [])).to include(application: :management)
87
+ peer = wire.new([status, encoded])
88
+ expect(management.execute(args: ['usb', '--list'], connection: peer)[:enabled]).to include('OTP', 'PIV', 'FIDO2')
89
+ expect(peer.calls.length).to eq(2)
90
+ end
91
+
92
+ it 'disables NFC applications while retaining USB settings and validates readback' do
93
+ changed = encoded.sub(['0e02023f'].pack('H*'), ['0e020000'].pack('H*'))
94
+ peer = wire.new([status, encoded, ''.b, changed])
95
+ result = management.execute(args: ['nfc', '--disable-all'], connection: peer, confirm: true)
96
+ expect(peer.calls[2]).to include(command: 0x15, data: ['040e020000'].pack('H*'))
97
+ expect(result[:info][:enabled_capabilities]).to eq(usb: 0x23f, nfc: 0)
98
+ end
99
+
100
+ it 'sets a configuration lock with secret bytes only in the native request and reads the flag back' do
101
+ locked = encoded.sub(['0a0100'].pack('H*'), ['0a0101'].pack('H*'))
102
+ peer = wire.new([status, encoded, ''.b, locked])
103
+ result = management.execute(args: ['set-lock-code'], connection: peer, confirm: true, new_lock_code: 'L' * 16)
104
+ expect(peer.calls[2][:data]).to eq([18, 10, 16].pack('C*') + ('L' * 16))
105
+ expect(result[:info][:is_locked]).to be(true)
106
+ expect(result[:lock_secret_readback]).to be(false)
107
+ expect(result.to_s).not_to include('L' * 16)
108
+ end
109
+
110
+ it 'supplies existing lock through an explicit secret provider and clears protection once' do
111
+ locked = encoded.sub(['0a0100'].pack('H*'), ['0a0101'].pack('H*'))
112
+ provider = instance_double(Proc)
113
+ expect(provider).to receive(:call).with(:lock_code).once.and_return('L' * 16)
114
+ peer = wire.new([status, locked, ''.b, encoded])
115
+ result = management.execute(args: ['set-lock-code', '--clear'], connection: peer, confirm: true, secret_provider: provider)
116
+ expect(result[:info][:is_locked]).to be(false)
117
+ expect(peer.calls[2][:data]).to eq([36, 11, 16].pack('C*') + ('L' * 16) + [10, 16].pack('C*') + ("\0" * 16))
118
+ end
119
+
120
+ it 'sets legacy mode using little-endian fields and verifies a fresh USB product descriptor' do
121
+ old_status = [4, 3, 1, 9, 3, 0].pack('C*')
122
+ old_info = encoded.sub(['0503050701'].pack('H*'), ['0503040301'].pack('H*'))
123
+ peer = wire.new([old_status, old_info, ''.b], protocol: :otp, product_id: 0x0407)
124
+ fresh = wire.new([old_status, old_info], protocol: :otp, product_id: 0x0403)
125
+ result = management.execute(args: ['mode', 'OTP+FIDO', '--chalresp-timeout', '15'], connection: peer, confirm: true, reconnect: ->(_identity) { fresh })
126
+ expect(peer.calls.last).to include(command: 0x11, data: [4, 15, 0, 0].pack('C*'))
127
+ expect(result).to include(verified: true, verification: :usb_descriptor, usb_interfaces: 3)
128
+ end
129
+
130
+ it 'translates modern mode to capabilities and verifies reboot on a fresh handle' do
131
+ changed = encoded.sub(['0302023f'].pack('H*'), ['03020203'].pack('H*'))
132
+ peer = wire.new([status, encoded, ''.b])
133
+ fresh = wire.new([status, changed])
134
+ result = management.execute(args: ['mode', 'OTP+FIDO'], connection: peer, confirm: true, reconnect: ->(_identity) { fresh })
135
+ expect(peer.calls.last).to include(command: 0x15, data: ['060c0003020203'].pack('H*'))
136
+ expect(result[:reconnected]).to be(true)
137
+ expect(result[:info][:enabled_capabilities][:usb]).to eq(0x203)
138
+ end
139
+
140
+ it 'factory-resets only a reported Bio multi-protocol device and verifies reset-state metadata' do
141
+ bio = tlvs.sub(['040181'].pack('H*'), ['040106'].pack('H*')) + ['18021010'].pack('H*')
142
+ cleared = bio.sub(['18021010'].pack('H*'), ['18020000'].pack('H*'))
143
+ before = [bio.bytesize].pack('C') + bio
144
+ after = [cleared.bytesize].pack('C') + cleared
145
+ peer = wire.new(["5.7.1\x90\0".b, before + "\x90\0".b, "\x90\0".b, after + "\x90\0".b], protocol: :apdu)
146
+ result = management.execute(args: ['reset'], connection: peer, confirm: true)
147
+ expect(peer.calls[2][:data]).to eq(['001f000000'].pack('H*'))
148
+ expect(result).to include(verified: true, verification: :reset_state_metadata, data_erasure_verified: false)
149
+ peer = wire.new(["5.7.1\x90\0".b, encoded + "\x90\0".b], protocol: :apdu)
150
+ expect { management.reset(connection: peer, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /supported/)
151
+ expect(peer.calls.length).to eq(2)
152
+ end
153
+
154
+ it 'restricts NFC only on 5.7 or later and verifies the actual restriction field' do
155
+ restricted = tlvs + ['170101'].pack('H*')
156
+ peer = wire.new([status, encoded, ''.b, [restricted.bytesize].pack('C') + restricted])
157
+ expect(management.execute(args: ['nfc', '--restrict'], connection: peer, confirm: true)[:info][:nfc_restricted]).to be(true)
158
+ expect(peer.calls[2][:data]).to eq(['03170101'].pack('H*'))
159
+ end
160
+
161
+ it 'supports legacy NEO mode switching without fabricating capability metadata' do
162
+ old_status = [3, 4, 1, 9, 3, 0].pack('C*')
163
+ peer = wire.new([old_status, ''.b], protocol: :otp, product_id: 0x0116, serial: '12345678')
164
+ fresh = wire.new([old_status], protocol: :otp, product_id: 0x0114, serial: '12345678')
165
+ result = management.set_mode(connection: peer, mode: 'OTP+FIDO', confirm: true, reconnect: ->(_identity) { fresh })
166
+ expect(result[:info][:supported_capabilities]).to be_empty
167
+ expect(result[:usb_interfaces]).to eq(3)
168
+ expect(peer.calls.last).to include(command: 0x11, data: [4, 0, 0, 0].pack('C*'))
169
+ end
170
+
171
+ it 'applies the YubiKey 4.2.4 capability-reporting correction but never trusts its enabled tag' do
172
+ old_status = [4, 2, 4, 9, 3, 0].pack('C*')
173
+ old_info = encoded.sub(['0503050701'].pack('H*'), ['0503040204'].pack('H*')).sub(['0102023f'].pack('H*'), ['01020000'].pack('H*'))
174
+ peer = wire.new([old_status, old_info], protocol: :otp, product_id: 0x0407)
175
+ result = management.info(connection: peer)
176
+ expect(result[:supported_capabilities][:usb]).to eq(0x3f)
177
+ expect(result[:enabled_capabilities]).not_to have_key(:usb)
178
+ expect(result[:usb_interfaces]).to eq(7)
179
+ end
180
+
181
+ it 'collects paginated metadata and retains exact additional fields' do
182
+ first = tlvs + ['100101'].pack('H*')
183
+ second = ['1305414243313216010114011115010120030102032103040506'].pack('H*')
184
+ peer = wire.new([status, [first.bytesize].pack('C') + first, [second.bytesize].pack('C') + second])
185
+ result = management.info(connection: peer)
186
+ expect(peer.calls.last[:data]).to eq("\1".b)
187
+ expect(result).to include(part_number: 'ABC12', pin_complexity: true, fps_version: [1, 2, 3], stm_version: [4, 5, 6], fips_capable: 0x300, fips_approved: 0x200)
188
+ end
189
+
190
+ it 'requires an explicit reconnection verifier before any reboot write' do
191
+ peer = wire.new([status, encoded])
192
+ expect { management.reboot(connection: peer, confirm: true) }.to raise_error(ArgumentError, /reconnect/i)
193
+ expect(peer.calls.count { |call| call[:command] == 0x15 }).to eq(0)
194
+ peer = wire.new([status, encoded, ''.b])
195
+ fresh = wire.new([status, encoded])
196
+ result = management.reboot(connection: peer, confirm: true, reconnect: ->(_identity) { fresh })
197
+ expect(peer.calls.last[:data]).to eq(['020c00'].pack('H*'))
198
+ expect(result[:reconnected]).to be(true)
199
+ end
200
+
201
+ it 'does not silently ignore configuration options or guess absent lock metadata' do
202
+ peer = wire.new([])
203
+ expect { management.configure_usb(connection: peer, confirm: true) }.to raise_error(ArgumentError)
204
+ expect { management.execute(args: ['usb', '--list', '--disable', 'PIV'], connection: peer, confirm: true) }.to raise_error(ArgumentError)
205
+ expect(peer.calls).to be_empty
206
+ missing = tlvs.sub(['0a0100'].pack('H*'), '')
207
+ peer = wire.new([status, [missing.bytesize].pack('C') + missing])
208
+ expect { management.configure_usb(connection: peer, disable: ['PIV'], confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /lock/)
209
+ expect(peer.calls.length).to eq(2)
210
+ end
211
+
212
+ it 'uses the NEO OTP applet for smartcard mode commands after management selection' do
213
+ old_status = [3, 4, 1, 9, 3, 0].pack('C*')
214
+ peer = wire.new(["3.4.1\x90\0".b, "\x90\0".b, old_status + "\x90\0".b, "\x90\0".b], protocol: :apdu, serial: '12345678', product_id: 0x0116)
215
+ fresh = wire.new([old_status], protocol: :otp, serial: '12345678', product_id: 0x0114)
216
+ result = management.set_mode(connection: peer, mode: 4, confirm: true, reconnect: ->(_identity) { fresh })
217
+ expect(peer.calls[1][:data]).to eq(['a4040008'].pack('H*'))
218
+ expect(peer.calls[2][:data]).to eq(['00a4040007a0000005272001'].pack('H*'))
219
+ expect(peer.calls[3][:data]).to eq(['000111000404000000'].pack('H*'))
220
+ expect(result[:verified]).to be(true)
221
+ end
222
+
223
+ it 'parses qualified development firmware metadata instead of guessing a production version' do
224
+ qualifier = ['190b0103050800020101030107'].pack('H*')
225
+ reported = tlvs.sub(['0503050701'].pack('H*'), ['0503000001'].pack('H*')) + qualifier
226
+ peer = wire.new([[0, 0, 1, 0, 0, 0].pack('C*'), [reported.bytesize].pack('C') + reported])
227
+ result = management.info(connection: peer)
228
+ expect(result[:version]).to eq([5, 8, 0])
229
+ expect(result[:reported_version]).to eq([0, 0, 1])
230
+ expect(result[:version_qualifier]).to eq(version: [5, 8, 0], type: :beta, iteration: 7)
231
+ end
232
+
233
+ it 'falls back only for an absent NFC management applet on a legacy NEO' do
234
+ old_status = [3, 4, 1, 9, 3, 0].pack('C*')
235
+ peer = wire.new(["\x6a\x82".b, old_status + "\x90\0".b], protocol: :apdu, transport: :nfc)
236
+ expect(management.info(connection: peer)[:version]).to eq([3, 4, 1])
237
+ expect(peer.calls.last[:data]).to eq(['00a4040007a0000005272001'].pack('H*'))
238
+ end
239
+
240
+ it 'rejects ambiguous boolean controls instead of interpreting strings as consented flags' do
241
+ peer = wire.new([])
242
+ expect { management.configure_usb(connection: peer, enable_all: 'false', confirm: true) }.to raise_error(ArgumentError)
243
+ expect { management.set_lock_code(connection: peer, clear: 'false', confirm: true) }.to raise_error(ArgumentError)
244
+ expect(peer.calls).to be_empty
245
+ end
246
+
247
+ it 'writes capability settings through actual APDU and CTAP vendor request codecs' do
248
+ changed = encoded.sub(['0302023f'].pack('H*'), ['0302022f'].pack('H*'))
249
+ peer = wire.new(["5.7.1\x90\0".b, encoded + "\x90\0".b, "\x90\0".b, changed + "\x90\0".b], protocol: :apdu)
250
+ expect(management.configure_usb(connection: peer, disable: ['PIV'], confirm: true)[:verified]).to be(true)
251
+ expect(peer.calls[2][:data]).to eq(['001c000005040302022f'].pack('H*'))
252
+ peer = wire.new([encoded, ''.b, changed], protocol: :ctap, version: [5, 7, 1])
253
+ expect(management.configure_usb(connection: peer, disable: ['PIV'], confirm: true)[:verified]).to be(true)
254
+ expect(peer.calls.map { |call| call[:command] }).to eq([0x42, 0x43, 0x42])
255
+ expect(peer.calls[1][:data]).to eq(['040302022f'].pack('H*'))
256
+ end
257
+
258
+ it 'does not verify a different serial-less selector after reboot' do
259
+ no_serial = tlvs.sub(['020400bc614e'].pack('H*'), '')
260
+ data = [no_serial.bytesize].pack('C') + no_serial
261
+ peer = wire.new([status, data, ''.b], protocol: :otp, device_path: '/synthetic/key-a')
262
+ fresh = wire.new([status, data], protocol: :otp, device_path: '/synthetic/key-b')
263
+ expect { management.reboot(connection: peer, confirm: true, reconnect: ->(_identity) { fresh }) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /uncertain/)
264
+ expect(peer.calls.count { |call| call[:command] == 0x15 }).to eq(1)
265
+ end
266
+
267
+ it 'rejects bad length and duplicate metadata rather than silently overwriting it' do
268
+ peer = wire.new([status, "#{encoded}\0"])
269
+ expect { management.info(connection: peer) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
270
+ duplicate = tlvs + ['0503050701'].pack('H*')
271
+ peer = wire.new([status, [duplicate.bytesize].pack('C') + duplicate])
272
+ expect { management.info(connection: peer) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
273
+ duplicate = tlvs + ['100101100100'].pack('H*')
274
+ peer = wire.new([status, [duplicate.bytesize].pack('C') + duplicate])
275
+ expect { management.info(connection: peer) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
276
+ end
277
+ end