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,854 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/plugins/yubikey/fido'
5
+
6
+ # The only fake boundary is Connection#exchange. Crypto inside the fake is
7
+ # independently implemented with OpenSSL KDF/AES, not FIDO::PinProtocol.
8
+ class FidoFixtureConnection
9
+ attr_reader :calls, :descriptor, :token
10
+ attr_accessor :info, :fail_code, :handler, :app_handler
11
+
12
+ def initialize(version: 2, options: {}, descriptor: { protocol: :ctap })
13
+ @descriptor = descriptor
14
+ @calls = []
15
+ @version = version
16
+ @token = 't'.b * 32
17
+ @private_key = OpenSSL::PKey::EC.generate('prime256v1')
18
+ @info = { 1 => ['FIDO_2_1'], 3 => "\x00".b * 16, 4 => { 'clientPin' => true, 'pinUvAuthToken' => true }.merge(options), 5 => 4096, 6 => [version], 13 => 4 }
19
+ end
20
+
21
+ def exchange(opts = {})
22
+ @calls << opts
23
+ return @handler.call(opts) if @handler
24
+
25
+ raise 'Unexpected HID command' unless opts[:protocol] == :ctap && opts[:command] == 0x10
26
+
27
+ command = opts[:data].getbyte(0)
28
+ payload = opts[:data].bytesize > 1 ? PWN::Plugins::YubiKey::FIDO::CBOR.decode(data: opts[:data].byteslice(1..)) : {}
29
+ response = case command
30
+ when 4 then @info
31
+ when 7 then {}
32
+ when 6 then client_pin(payload)
33
+ else
34
+ raise "Unexpected CTAP command #{command}" unless @app_handler
35
+
36
+ @app_handler.call(command, payload)
37
+ end
38
+ return [response].pack('C') if response.is_a?(Integer)
39
+
40
+ "\x00".b + (response.empty? ? ''.b : PWN::Plugins::YubiKey::FIDO::CBOR.encode(value: response))
41
+ end
42
+
43
+ def client_pin(payload)
44
+ case payload[2]
45
+ when 1 then { 3 => 8, 4 => false }
46
+ when 7 then { 5 => 5 }
47
+ when 2
48
+ pub = @private_key.public_key.to_octet_string(:uncompressed)
49
+ { 1 => { 1 => 2, 3 => -25, -1 => 1, -2 => pub.byteslice(1, 32), -3 => pub.byteslice(33, 32) } }
50
+ when 3, 4, 5, 6, 9
51
+ return @fail_code if @fail_code
52
+
53
+ peer = payload.fetch(3)
54
+ point = OpenSSL::PKey::EC::Point.new(@private_key.group, OpenSSL::BN.new("\x04".b + peer[-2] + peer[-3], 2))
55
+ z = @private_key.dh_compute_key(point)
56
+ secret = if @version == 1
57
+ OpenSSL::Digest::SHA256.digest(z)
58
+ else
59
+ ['CTAP2 HMAC key', 'CTAP2 AES key'].map { |info| OpenSSL::KDF.hkdf(z, salt: "\x00".b * 32, info: info, length: 32, hash: 'SHA256') }.join.b
60
+ end
61
+ raise 'Incorrect PIN encryption' if payload[6] && decrypt(secret, payload[6]) != OpenSSL::Digest.digest('SHA256', '123456').byteslice(0, 16)
62
+
63
+ if [3, 4].include?(payload[2])
64
+ raise 'Incorrect new PIN encryption' unless decrypt(secret, payload[5]) == '654321'.b.ljust(64, "\x00".b)
65
+
66
+ message = payload[5] + (payload[6] || ''.b)
67
+ raise 'Incorrect PIN MAC' unless payload[4] == mac(secret, message)
68
+
69
+ {}
70
+ else
71
+ { 2 => encrypt(secret, @token) }
72
+ end
73
+ else raise 'Unexpected ClientPIN subcommand'
74
+ end
75
+ end
76
+
77
+ def mac(key, message)
78
+ result = OpenSSL::HMAC.digest('SHA256', @version == 1 ? key : key.byteslice(0, 32), message)
79
+ @version == 1 ? result.byteslice(0, 16) : result
80
+ end
81
+
82
+ def encrypt(key, plain)
83
+ iv = @version == 1 ? "\x00".b * 16 : 'i'.b * 16
84
+ result = crypt(key, iv, plain, true)
85
+ @version == 1 ? result : iv + result
86
+ end
87
+
88
+ def decrypt(key, data)
89
+ iv = @version == 1 ? "\x00".b * 16 : data.byteslice(0, 16)
90
+ crypt(key, iv, @version == 1 ? data : data.byteslice(16..), false)
91
+ end
92
+
93
+ def crypt(key, iv_bytes, data, encrypt)
94
+ cipher = OpenSSL::Cipher.new('aes-256-cbc')
95
+ encrypt ? cipher.encrypt : cipher.decrypt
96
+ cipher.padding = 0
97
+ cipher.key = @version == 1 ? key : key.byteslice(32, 32)
98
+ cipher.iv = iv_bytes
99
+ cipher.update(data) + cipher.final
100
+ end
101
+ end
102
+
103
+ RSpec.describe 'Native YubiKey FIDO capability and PIN paths' do
104
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
105
+
106
+ it 'returns local help for each old group without touching a connection' do
107
+ [[], ['access'], ['credentials'], ['fingerprints'], ['config']].each do |args|
108
+ expect(fido.execute(args: args)).to include(:operations)
109
+ end
110
+ end
111
+
112
+ it 'reads getInfo on a serial-less Security Key and resets once with literal consent' do
113
+ connection = FidoFixtureConnection.new
114
+ result = fido.execute(connection: connection, args: ['info'])
115
+ expect(result[:versions]).to eq(['FIDO_2_1'])
116
+ expect(result[:options]['clientPin']).to be(true)
117
+ expect(connection.calls.first[:data]).to eq("\x04".b)
118
+ expect { fido.execute(connection: connection, args: ['reset'], confirm: 'true') }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
119
+ fido.execute(connection: connection, args: ['reset'], confirm: true)
120
+ expect(connection.calls.count { |call| call[:data] == "\x07".b }).to eq(1)
121
+ end
122
+
123
+ [1, 2].each do |version|
124
+ it "sets, changes and verifies PIN with actual protocol #{version} ECDH/AES/MAC" do
125
+ connection = FidoFixtureConnection.new(version: version)
126
+ fido.execute(connection: connection, args: %w[access change-pin], pin: '123456', new_pin: '654321', confirm: true)
127
+ expect(fido.execute(connection: connection, args: %w[access verify-pin], pin: '123456', confirm: true)).to eq({ verified: true })
128
+ connection.info[4]['clientPin'] = false
129
+ expect(fido.execute(connection: connection, args: %w[access change-pin], new_pin: '654321', confirm: true)).to eq({ changed: true, initialized: true })
130
+ end
131
+ end
132
+
133
+ it 'does not retry a wrong PIN, reveal secrets, invoke a provider before consent or accept secret argv' do
134
+ connection = FidoFixtureConnection.new
135
+ connection.fail_code = 0x31
136
+ expect { fido.execute(connection: connection, args: %w[access verify-pin], pin: '123456', confirm: true) }.to raise_error(fido::CTAPError) { |e|
137
+ expect(e.code).to eq(0x31)
138
+ expect(e.message).not_to include('123456')
139
+ }
140
+ attempts = connection.calls.count { |call| call[:data].start_with?("\x06".b) }
141
+ expect(attempts).to eq(2)
142
+ provider = ->(_name) { raise 'Provider must not be invoked' }
143
+ expect { fido.execute(connection: connection, args: %w[access change-pin], secret_provider: provider) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
144
+ expect { fido.execute(connection: connection, args: %w[access verify-pin --pin 123456], confirm: true) }.to raise_error(ArgumentError)
145
+ end
146
+
147
+ it 'reports U2F-only capabilities honestly and rejects unsupported CTAP2 management' do
148
+ connection = FidoFixtureConnection.new(descriptor: { protocol: :ctap, capabilities: 1 })
149
+ connection.handler = lambda do |request|
150
+ expect(request[:command]).to eq(3)
151
+ expect(request[:data]).to eq(['000300000000000000'].pack('H*'))
152
+ 'U2F_V2'.b + "\x90\x00".b
153
+ end
154
+ expect(fido.execute(connection: connection, args: ['info'])).to include(versions: ['U2F_V2'], ctap2: false, options: {})
155
+ expect { fido.execute(connection: connection, args: %w[credentials list], confirm: true, pin: '123456') }.to raise_error(fido::UnsupportedError)
156
+ end
157
+
158
+ it 'propagates keepalive cancellation and malformed responses without retries' do
159
+ connection = FidoFixtureConnection.new
160
+ count = 0
161
+ connection.handler = lambda { |request|
162
+ count += 1
163
+ request[:on_keepalive]&.call(2)
164
+ "\x00".b
165
+ }
166
+ expect { fido.execute(connection: connection, args: ['reset'], confirm: true, on_keepalive: ->(_status) { raise Interrupt }) }.to raise_error(Interrupt)
167
+ expect(count).to eq(1)
168
+ connection.handler = ->(_request) { ''.b }
169
+ expect { fido.execute(connection: connection, args: ['info']) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
170
+ end
171
+ end
172
+
173
+ RSpec.describe 'Native YubiKey FIDO credential and config paths' do
174
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
175
+ let(:connection) { FidoFixtureConnection.new(options: { 'credMgmt' => true, 'authnrCfg' => true, 'setMinPINLength' => true, 'alwaysUv' => false, 'ep' => false }) }
176
+ let(:options) { { connection: connection, pin: '123456', confirm: true } }
177
+
178
+ def payloads
179
+ connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }
180
+ end
181
+
182
+ it 'enumerates every RP and credential with authenticated begins and unauthenticated nexts' do
183
+ expected_subcommands = [1, 2, 3, 4, 5, 4]
184
+ connection.app_handler = lambda do |command, params|
185
+ expect(command).to eq(10)
186
+ subcommand = expected_subcommands.shift
187
+ expect(params[1]).to eq(subcommand)
188
+ if [3, 5].include?(subcommand)
189
+ expect(params).to eq({ 1 => subcommand })
190
+ else
191
+ msg = [subcommand].pack('C') + (params[2] ? fido::CBOR.encode(value: params[2]) : ''.b)
192
+ expect(params[3]).to eq(2)
193
+ expect(params[4]).to eq(connection.mac(connection.token, msg))
194
+ end
195
+ case subcommand
196
+ when 1 then { 1 => 3, 2 => 22 }
197
+ when 2 then { 3 => { 'id' => 'first.example' }, 4 => 'a'.b * 32, 5 => 2 }
198
+ when 3 then { 3 => { 'id' => 'second.example' }, 4 => 'b'.b * 32 }
199
+ when 4
200
+ { 6 => { 'id' => 'user'.b, 'name' => 'Example' }, 7 => { 'id' => params[2][1].byteslice(0, 1), 'type' => 'public-key' }, 8 => { 1 => 2 }, 9 => params[2][1].start_with?('a') ? 2 : 1 }
201
+ when 5 then { 6 => { 'id' => 'user2'.b }, 7 => { 'id' => 'c'.b, 'type' => 'public-key' }, 8 => { 1 => 2 } }
202
+ end
203
+ end
204
+ result = fido.execute(options.merge(args: %w[credentials list]))
205
+ expect(result[:credentials].map { |cred| cred[:credential]['id'] }).to eq(%w[a c b])
206
+ expect(result[:relying_parties].size).to eq(2)
207
+ expect(expected_subcommands).to be_empty
208
+ expect(payloads.find { |payload| payload[2] == 9 }[9]).to eq(4)
209
+ end
210
+
211
+ it 'deletes an exact credential and updates the original user ID with authenticated canonical parameters' do
212
+ id = 'credential'.b
213
+ expected = [6, 7]
214
+ connection.app_handler = lambda do |command, params|
215
+ expect(command).to eq(10)
216
+ expect(params[1]).to eq(expected.shift)
217
+ expect(params[2][2]).to eq({ 'id' => id, 'type' => 'public-key' })
218
+ expect(params[4]).to eq(connection.mac(connection.token, [params[1]].pack('C') + fido::CBOR.encode(value: params[2])))
219
+ expect(params[2][3]).to eq({ 'id' => 'original'.b, 'name' => 'new name', 'displayName' => 'Display' }) if params[1] == 7
220
+ {}
221
+ end
222
+ expect(fido.execute(options.merge(args: ['credentials', 'delete', id.unpack1('H*')]))).to eq({ deleted: true })
223
+ expect(fido.perform(options.merge(operation: :update_credential, credential_id: id, user: { 'id' => 'original'.b, 'name' => 'new name', 'displayName' => 'Display' }))).to eq({ updated: true })
224
+ expect(expected).to be_empty
225
+ end
226
+
227
+ it 'supports preview credential management but rejects update and malformed IDs before authentication' do
228
+ connection.info[1] = ['FIDO_2_1_PRE']
229
+ connection.info[4].delete('credMgmt')
230
+ connection.info[4]['credentialMgmtPreview'] = true
231
+ connection.info[4]['pinUvAuthToken'] = false
232
+ connection.app_handler = lambda do |command, params|
233
+ expect(command).to eq(0x41)
234
+ params[1] == 1 ? { 1 => 0, 2 => 25 } : 0x2e
235
+ end
236
+ expect(fido.execute(options.merge(args: %w[credentials list]))[:credentials]).to be_empty
237
+ expect(payloads.any? { |payload| payload[2] == 5 && !payload.key?(9) }).to be(true)
238
+ expect { fido.perform(options.merge(operation: :update_credential, credential_id: 'a'.b, user: { 'id' => 'u'.b })) }.to raise_error(fido::UnsupportedError)
239
+ count = connection.calls.size
240
+ expect { fido.execute(options.merge(args: %w[credentials delete not-hex])) }.to raise_error(ArgumentError)
241
+ expect(connection.calls.size).to eq(count)
242
+ end
243
+
244
+ it 'rejects inconsistent enumeration totals rather than claiming a partial list is complete' do
245
+ connection.app_handler = ->(_command, params) { params[1] == 1 ? { 1 => 2, 2 => 2 } : 0x2e }
246
+ expect { fido.execute(options.merge(args: %w[credentials list])) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /count/)
247
+ end
248
+
249
+ it 'signs all configuration commands with the ff32 and 0d domain separator' do
250
+ connection.info[16] = 2
251
+ subcommands = [3, 3, 2, 1]
252
+ expected_parameters = [{ 3 => true }, { 1 => 6, 2 => %w[first.example second.example], 3 => false }, nil, nil]
253
+ connection.app_handler = lambda do |command, params|
254
+ expect(command).to eq(13)
255
+ expect(params[1]).to eq(subcommands.shift)
256
+ expected_params = expected_parameters.shift
257
+ expect(params[2]).to eq(expected_params)
258
+ message = ("\xff".b * 32) + "\x0d".b + [params[1]].pack('C') + (expected_params ? fido::CBOR.encode(value: expected_params) : ''.b)
259
+ expect(params[4]).to eq(connection.mac(connection.token, message))
260
+ {}
261
+ end
262
+ [%w[access force-change], %w[access set-min-length 6 --rp-id first.example --rp-id second.example], %w[config toggle-always-uv], %w[config enable-ep-attestation]].each do |args|
263
+ expect(fido.execute(options.merge(args: args))).to include(configured: true)
264
+ end
265
+ expect(subcommands).to be_empty
266
+ expect(payloads.select { |payload| payload[2] == 9 }.map { |payload| payload[9] }.uniq).to eq([32])
267
+ end
268
+
269
+ it 'uses no fake PIN when configuration is allowed before a PIN is set' do
270
+ connection.info[4]['clientPin'] = false
271
+ connection.app_handler = lambda do |command, params|
272
+ expect(command).to eq(13)
273
+ expect(params).to eq({ 1 => 2 })
274
+ {}
275
+ end
276
+ expect(fido.execute(options.merge(args: %w[config toggle-always-uv], pin: nil))).to include(configured: true)
277
+ expect(payloads).to be_empty
278
+ end
279
+ end
280
+
281
+ RSpec.describe 'Native YubiKey FIDO fingerprint paths' do
282
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
283
+ let(:connection) { FidoFixtureConnection.new(options: { 'bioEnroll' => false }) }
284
+ let(:options) { { connection: connection, pin: '123456', confirm: true } }
285
+
286
+ def bio_reply(command, params)
287
+ expect(command).to eq(9)
288
+ return { 1 => 1 } if params[6]
289
+ return { 2 => 1, 3 => 4, 8 => 20 } if params[2] == 7
290
+
291
+ expect(params[1]).to eq(1)
292
+ unless params[2] == 3
293
+ expect(params[4]).to eq(2)
294
+ msg = [1, params[2]].pack('CC') + (params[3] ? fido::CBOR.encode(value: params[3]) : ''.b)
295
+ expect(params[5]).to eq(connection.mac(connection.token, msg))
296
+ end
297
+ yield params[2], params[3]
298
+ end
299
+
300
+ it 'lists, renames and deletes templates with permission 08 and exact identifiers' do
301
+ commands = [4, 5, 6]
302
+ connection.app_handler = lambda do |command, params|
303
+ bio_reply(command, params) do |subcommand, values|
304
+ expect(subcommand).to eq(commands.shift)
305
+ expect(values).to eq({ 1 => 't'.b, 2 => 'index finger' }) if subcommand == 5
306
+ expect(values).to eq({ 1 => 't'.b }) if subcommand == 6
307
+ subcommand == 4 ? { 7 => [{ 1 => 't'.b, 2 => 'thumb' }] } : {}
308
+ end
309
+ end
310
+ expect(fido.execute(options.merge(args: %w[fingerprints list]))).to eq([{ template_id: 't'.b, name: 'thumb' }])
311
+ expect(fido.execute(options.merge(args: ['fingerprints', 'rename', '74', 'index finger']))).to eq({ renamed: true })
312
+ expect(fido.execute(options.merge(args: %w[fingerprints delete 74]))).to eq({ deleted: true })
313
+ requests = connection.calls.select { |call| call[:data].getbyte(0) == 6 }.map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) }
314
+ expect(requests.select { |req| req[2] == 9 }.map { |req| req[9] }.uniq).to eq([8])
315
+ expect(commands).to be_empty
316
+ end
317
+
318
+ it 'enrolls bounded samples with authenticated capture-next and assigns the final friendly name' do
319
+ commands = [1, 2, 2, 5]
320
+ samples = []
321
+ connection.app_handler = lambda do |command, params|
322
+ bio_reply(command, params) do |subcommand, values|
323
+ expect(subcommand).to eq(commands.shift)
324
+ case subcommand
325
+ when 1
326
+ expect(values).to eq({ 3 => 1000 })
327
+ { 4 => 't'.b, 5 => 0, 6 => 1 }
328
+ when 2
329
+ expect(values).to eq({ 1 => 't'.b, 3 => 1000 })
330
+ commands == [2, 5] ? { 5 => 7, 6 => 1 } : { 5 => 0, 6 => 0 }
331
+ when 5 then {}
332
+ end
333
+ end
334
+ end
335
+ result = fido.execute(options.merge(args: %w[fingerprints add index], capture_timeout_ms: 1000, on_sample: ->(sample) { samples << sample }))
336
+ expect(result).to include(enrolled: true, template_id: 't'.b, name: 'index')
337
+ expect(samples.map { |sample| sample[:status] }).to eq([0, 7, 0])
338
+ expect(commands).to be_empty
339
+ end
340
+
341
+ it 'cancels a partial enrollment exactly once on callback interruption without masking the original failure' do
342
+ cancels = 0
343
+ connection.app_handler = lambda do |command, params|
344
+ bio_reply(command, params) do |subcommand, _values|
345
+ case subcommand
346
+ when 1 then { 4 => 't'.b, 5 => 0, 6 => 1 }
347
+ when 3
348
+ cancels += 1
349
+ expect(params).to eq({ 1 => 1, 2 => 3 })
350
+ {}
351
+ else raise 'Unexpected enrollment retry'
352
+ end
353
+ end
354
+ end
355
+ expect { fido.execute(options.merge(args: %w[fingerprints add], on_sample: ->(_sample) { raise Interrupt })) }.to raise_error(Interrupt)
356
+ expect(cancels).to eq(1)
357
+ end
358
+
359
+ it 'cancels enrollment explicitly with consent and no PIN token' do
360
+ connection.app_handler = lambda do |command, params|
361
+ expect(command).to eq(9)
362
+ expect(params).to eq({ 1 => 1, 2 => 3 })
363
+ {}
364
+ end
365
+ expect(fido.perform(connection: connection, operation: :cancel_enrollment, confirm: true)).to eq({ cancelled: true })
366
+ expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
367
+ end
368
+
369
+ it 'supports preview enrollment and sensor information without attempting a PIN' do
370
+ connection.info[1] = ['FIDO_2_1_PRE']
371
+ connection.info[4].delete('bioEnroll')
372
+ connection.info[4]['userVerificationMgmtPreview'] = false
373
+ connection.app_handler = lambda do |command, params|
374
+ expect(command).to eq(0x40)
375
+ params[6] ? { 1 => 1 } : { 2 => 1, 3 => 4, 8 => 20 }
376
+ end
377
+ expect(fido.perform(connection: connection, operation: :bio_sensor_info)).to include(max_samples: 4, max_name_bytes: 20)
378
+ expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
379
+ end
380
+ end
381
+
382
+ RSpec.describe 'Native YubiKey FIDO operational APIs' do
383
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
384
+ let(:connection) { FidoFixtureConnection.new }
385
+ let(:hash) { 'h'.b * 32 }
386
+ let(:options) { { connection: connection, confirm: true, pin: '123456', client_data_hash: hash } }
387
+ let(:auth_data) { ('r'.b * 32) + [1, 12].pack('CN') }
388
+
389
+ it 'makes credentials with caller-supplied RP/hash and permission 01 scoped to that RP' do
390
+ connection.app_handler = lambda do |command, params|
391
+ expect(command).to eq(1)
392
+ expect(params[1]).to eq(hash)
393
+ expect(params[2]).to eq({ 'id' => 'login.example', 'name' => 'Example' })
394
+ expect(params[3]).to eq({ 'id' => 'user'.b, 'name' => 'alice' })
395
+ expect(params[4]).to eq([{ 'type' => 'public-key', 'alg' => -7 }])
396
+ expect(params[8]).to eq(connection.mac(connection.token, hash))
397
+ expect(params[9]).to eq(2)
398
+ { 1 => 'packed', 2 => auth_data, 3 => { 'alg' => -7, 'sig' => 'signature'.b } }
399
+ end
400
+ result = fido.make_credential(options.merge(rp: { 'id' => 'login.example', 'name' => 'Example' }, user: { 'id' => 'user'.b, 'name' => 'alice' }))
401
+ expect(result).to include(format: 'packed', authenticator_data: auth_data)
402
+ request = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |entry| entry[2] == 9 }
403
+ expect(request.values_at(9, 10)).to eq([1, 'login.example'])
404
+ end
405
+
406
+ it 'gets all advertised assertions with permission 02 and handles built-in UV token negotiation' do
407
+ connection.info[4]['uv'] = true
408
+ expected = [2, 8]
409
+ connection.app_handler = lambda do |command, params|
410
+ expect(command).to eq(expected.shift)
411
+ if command == 2
412
+ expect(params[1]).to eq('login.example')
413
+ expect(params[2]).to eq(hash)
414
+ expect(params[6]).to eq(connection.mac(connection.token, hash))
415
+ expect(params[7]).to eq(2)
416
+ else
417
+ expect(params).to be_empty
418
+ end
419
+ { 1 => { 'type' => 'public-key', 'id' => 'cred'.b }, 2 => auth_data, 3 => 'sig'.b, 5 => 2 }
420
+ end
421
+ result = fido.get_assertion(options.merge(pin: nil, uv: true, rp_id: 'login.example'))
422
+ expect(result.size).to eq(2)
423
+ expect(result.first).to include(authenticator_data: auth_data, signature: 'sig'.b)
424
+ request = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |entry| entry[2] == 6 }
425
+ expect(request.values_at(9, 10)).to eq([2, 'login.example'])
426
+ expect(request).not_to have_key(6)
427
+ end
428
+
429
+ it 'rejects missing RP/hash, textual IDs and oversized inputs before device authentication' do
430
+ expect { fido.make_credential(options.merge(rp: {}, user: { 'id' => 'user'.b })) }.to raise_error(ArgumentError)
431
+ expect { fido.get_assertion(options.merge(client_data_hash: nil, rp_id: 'login.example')) }.to raise_error(ArgumentError)
432
+ expect { fido.get_assertion(options.merge(rp_id: 'login.example', allow_list: [{ 'id' => 'not-binary', 'type' => 'public-key' }])) }.to raise_error(ArgumentError)
433
+ expect(connection.calls).to be_empty
434
+ end
435
+
436
+ it 'registers U2F and parses the raw credential and certificate without fabricating clientData' do
437
+ key = OpenSSL::PKey::EC.generate('prime256v1')
438
+ cert = OpenSSL::X509::Certificate.new
439
+ cert.version = 2
440
+ cert.serial = 1
441
+ cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=Synthetic fixture')
442
+ cert.public_key = key
443
+ cert.not_before = Time.at(0)
444
+ cert.not_after = Time.at(1)
445
+ cert.sign(key, OpenSSL::Digest.new('SHA256'))
446
+ pub = key.public_key.to_octet_string(:uncompressed)
447
+ connection.handler = lambda do |request|
448
+ expect(request[:command]).to eq(3)
449
+ expect(request[:data]).to eq(['00010000000040'].pack('H*') + hash + ('a'.b * 32) + "\x00\x00".b)
450
+ "\x05".b + pub + "\x03".b + 'key'.b + cert.to_der + 'signature'.b + "\x90\x00".b
451
+ end
452
+ result = fido.u2f_register(options.merge(app_id_hash: 'a'.b * 32))
453
+ expect(result).to include(public_key: pub, key_handle: 'key'.b, certificate: cert.to_der, signature: 'signature'.b)
454
+ end
455
+
456
+ it 'authenticates and checks U2F key handles using exact extended APDU vectors without retrying touch errors' do
457
+ connection.handler = lambda do |request|
458
+ expect(request[:data].byteslice(0, 7)).to eq(['00020300000044'].pack('H*'))
459
+ expect(request[:data].byteslice(7, 68)).to eq(hash + ('a'.b * 32) + "\x03key".b)
460
+ "\x01\x00\x00\x00\x09signature\x90\x00".b
461
+ end
462
+ expect(fido.u2f_authenticate(options.merge(app_id_hash: 'a'.b * 32, key_handle: 'key'.b))).to include(user_presence: 1, counter: 9, signature: 'signature'.b)
463
+ connection.handler = lambda do |request|
464
+ expect(request[:data].getbyte(2)).to eq(7)
465
+ "\x69\x85".b
466
+ end
467
+ expect(fido.u2f_authenticate(options.merge(app_id_hash: 'a'.b * 32, key_handle: 'key'.b, check_only: true))).to eq({ recognized: true })
468
+ connection.handler = ->(_request) { "\x69\x85".b }
469
+ before = connection.calls.size
470
+ expect { fido.u2f_authenticate(options.merge(app_id_hash: 'a'.b * 32, key_handle: 'key'.b)) }.to raise_error(fido::APDUError)
471
+ expect(connection.calls.size).to eq(before + 1)
472
+ end
473
+ end
474
+
475
+ RSpec.describe 'Native YubiKey FIDO smartcard and FIPS paths' do
476
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
477
+
478
+ it 'selects the FIDO AID and reads CTAP2 over NFC/PCSC with GET RESPONSE chaining' do
479
+ connection = FidoFixtureConnection.new(descriptor: { protocol: :apdu })
480
+ encoded_info = "\x00".b + fido::CBOR.encode(value: connection.info)
481
+ calls = 0
482
+ connection.handler = lambda do |request|
483
+ expect(request[:protocol]).to eq(:apdu)
484
+ calls += 1
485
+ case calls
486
+ when 1
487
+ expect(request[:data]).to eq(['00a4040008a0000006472f000100'].pack('H*'))
488
+ 'FIDO_2_0'.b + "\x90\x00".b
489
+ when 2
490
+ expect(request[:data]).to eq(['80108000010400'].pack('H*'))
491
+ encoded_info.byteslice(0, 12) + "\x61\x00".b
492
+ when 3
493
+ expect(request[:data]).to eq(['00c0000000'].pack('H*'))
494
+ encoded_info.byteslice(12..) + "\x90\x00".b
495
+ end
496
+ end
497
+ expect(fido.execute(connection: connection, args: ['info'])[:ctap2]).to be(true)
498
+ expect(calls).to eq(3)
499
+ end
500
+
501
+ it 'handles NFC keepalive and sends NFC cancellation when the callback interrupts' do
502
+ connection = FidoFixtureConnection.new(descriptor: { protocol: :apdu })
503
+ calls = 0
504
+ connection.handler = lambda do |request|
505
+ calls += 1
506
+ case request[:data].byteslice(0, 2)
507
+ when "\x00\xa4".b then 'FIDO_2_0'.b + "\x90\x00".b
508
+ when "\x80\x10".b
509
+ if request[:data].getbyte(5) == 4
510
+ "\x00".b + fido::CBOR.encode(value: connection.info) + "\x90\x00".b
511
+ else
512
+ "\x02\x91\x00".b
513
+ end
514
+ when "\x80\x11".b
515
+ expect(request[:data]).to eq(['8011110000'].pack('H*'))
516
+ "\x2d\x90\x00".b
517
+ else raise 'Unexpected APDU'
518
+ end
519
+ end
520
+ expect { fido.execute(connection: connection, args: ['reset'], confirm: true, on_keepalive: ->(_status) { raise Interrupt }) }.to raise_error(Interrupt)
521
+ expect(calls).to eq(4)
522
+ end
523
+
524
+ it 'performs YubiKey 4 FIPS U2F PIN change, verification and one-shot reset without a CTAP2 stub' do
525
+ connection = FidoFixtureConnection.new(descriptor: { protocol: :ctap, capabilities: 1 })
526
+ mutation_ins = []
527
+ connection.handler = lambda do |request|
528
+ expect(request[:command]).to eq(3)
529
+ ins = request[:data].getbyte(1)
530
+ length = request[:data].byteslice(5, 2).unpack1('n')
531
+ data = request[:data].byteslice(7, length)
532
+ case ins
533
+ when 3 then 'U2F_V2'.b + "\x90\x00".b
534
+ when 0x46 then "\x90\x00".b
535
+ when 0x43
536
+ expect(data).to eq('123456')
537
+ mutation_ins << ins
538
+ "\x90\x00".b
539
+ when 0x44
540
+ expect(data).to eq("#{"\x06".b}123456654321")
541
+ mutation_ins << ins
542
+ "\x90\x00".b
543
+ when 0x45
544
+ mutation_ins << ins
545
+ "\x69\x85".b
546
+ else raise 'Unexpected FIPS command'
547
+ end
548
+ end
549
+ opts = { connection: connection, pin: '123456', new_pin: '654321', confirm: true }
550
+ expect(fido.execute(opts.merge(args: %w[access change-pin --u2f]))).to include(changed: true)
551
+ expect(fido.execute(opts.merge(args: %w[access verify-pin]))).to eq({ verified: true })
552
+ expect { fido.execute(opts.merge(args: ['reset'])) }.to raise_error(fido::APDUError) { |e| expect(e.code).to eq(0x6985) }
553
+ expect(mutation_ins).to eq([0x44, 0x43, 0x45])
554
+ end
555
+ end
556
+
557
+ RSpec.describe 'Native YubiKey FIDO boundary regressions' do
558
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
559
+
560
+ it 'uses capabilities learned during HID INIT to recognize legacy U2F-only keys' do
561
+ connection = FidoFixtureConnection.new
562
+ connection.handler = lambda do |request|
563
+ if request[:command] == 16
564
+ connection.descriptor[:capabilities] = 1
565
+ raise PWN::Plugins::YubiKey::CommandError, 'CTAPHID invalid command'
566
+ end
567
+ 'U2F_V2'.b + "\x90\x00".b
568
+ end
569
+ expect(fido.execute(connection: connection, args: ['info'])[:versions]).to eq(['U2F_V2'])
570
+ end
571
+
572
+ it 'passes cancellation to the transport for a blocked read with no keepalive' do
573
+ connection = FidoFixtureConnection.new
574
+ cancelled = -> { false }
575
+ connection.handler = lambda do |request|
576
+ expect(request[:cancelled]).to equal(cancelled)
577
+ raise PWN::Plugins::YubiKey::TimeoutError, 'synthetic timeout'
578
+ end
579
+ expect { fido.execute(connection: connection, args: ['info'], cancelled: cancelled) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
580
+ expect(connection.calls.size).to eq(1)
581
+ end
582
+
583
+ it 'does not turn a ClientPIN invalid-option response into an empty fingerprint list' do
584
+ connection = FidoFixtureConnection.new(options: { 'bioEnroll' => true })
585
+ connection.fail_code = 0x2c
586
+ connection.app_handler = ->(_command, params) { params[6] ? { 1 => 1 } : { 2 => 1, 3 => 4 } }
587
+ expect { fido.execute(connection: connection, args: %w[fingerprints list], pin: '123456', confirm: true) }.to raise_error(fido::CTAPError)
588
+ end
589
+
590
+ it 'preserves a completed enrollment identifier if friendly naming fails, without deleting it' do
591
+ connection = FidoFixtureConnection.new(options: { 'bioEnroll' => false })
592
+ subcommands = []
593
+ connection.app_handler = lambda do |_command, params|
594
+ next({ 1 => 1 }) if params[6]
595
+
596
+ subcommands << params[2]
597
+ case params[2]
598
+ when 7 then { 2 => 1, 3 => 4 }
599
+ when 1 then { 4 => 'new'.b, 5 => 0, 6 => 0 }
600
+ when 5 then 0x30
601
+ else raise 'Must not delete or cancel a completed enrollment'
602
+ end
603
+ end
604
+ expect { fido.execute(connection: connection, args: %w[fingerprints add name], pin: '123456', confirm: true) }.to raise_error(fido::EnrollmentError) { |error| expect(error.template_id).to eq('new'.b) }
605
+ expect(subcommands).to eq([7, 1, 5])
606
+ end
607
+
608
+ it 'enforces literal consent even on the underlying session entry points' do
609
+ connection = FidoFixtureConnection.new
610
+ session = fido::Session.new(connection: connection, pin: '123456', new_pin: '654321', secret_provider: ->(_name) { raise 'No provider call permitted' })
611
+ expect { session.verify_pin }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
612
+ expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
613
+ end
614
+ end
615
+
616
+ RSpec.describe 'Native YubiKey FIDO validation and bounded failures' do
617
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
618
+ let(:connection) { FidoFixtureConnection.new }
619
+
620
+ it 'rejects nonboolean advertised capabilities and malformed PIN limits before a PIN attempt' do
621
+ connection.info[4]['clientPin'] = 'false'
622
+ expect { fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456') }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
623
+ connection.info[4]['clientPin'] = true
624
+ connection.info[13] = '6'
625
+ expect { fido.execute(connection: connection, args: %w[access change-pin], confirm: true, pin: '123456', new_pin: '654321') }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
626
+ expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
627
+ end
628
+
629
+ it 'does not suppress a NO_CREDENTIALS error partway through an advertised RP enumeration' do
630
+ connection.info[4]['credMgmt'] = true
631
+ connection.app_handler = lambda do |_command, params|
632
+ case params[1]
633
+ when 1 then { 1 => 0, 2 => 25 }
634
+ when 2 then { 3 => { 'id' => 'first.example' }, 4 => 'a'.b * 32, 5 => 2 }
635
+ when 3 then 0x2e
636
+ else raise 'unexpected command'
637
+ end
638
+ end
639
+ expect { fido.execute(connection: connection, args: %w[credentials list], confirm: true, pin: '123456') }.to raise_error(fido::CTAPError)
640
+ end
641
+
642
+ it 'validates booleans, timeouts, and limits without opening or authenticating anything' do
643
+ expect { fido.execute(args: false) }.to raise_error(ArgumentError)
644
+ expect { fido.execute(connection: connection, args: ['info'], timeout: Complex(1, 2)) }.to raise_error(ArgumentError)
645
+ expect { fido.execute(connection: connection, args: %w[credentials list], confirm: true, pin: '123456', max_records: 0) }.to raise_error(ArgumentError)
646
+ expect(connection.calls).to be_empty
647
+ end
648
+
649
+ it 'rejects false callbacks, ambiguous boolean selectors and a current PIN on an uninitialized authenticator' do
650
+ expect { fido.execute(connection: connection, args: ['info'], cancelled: false) }.to raise_error(ArgumentError)
651
+ expect { fido.execute(connection: connection, args: %w[access change-pin], u2f: 'true', confirm: true) }.to raise_error(ArgumentError)
652
+ connection.info[4]['clientPin'] = false
653
+ expect { fido.execute(connection: connection, args: %w[access change-pin], pin: '123456', new_pin: '654321', confirm: true) }.to raise_error(ArgumentError)
654
+ expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
655
+ end
656
+
657
+ it 'does not label built-in biometric authentication as PIN verification' do
658
+ connection.info[4].merge!('credMgmt' => true, 'uv' => true)
659
+ expect { fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, uv: true) }.to raise_error(ArgumentError)
660
+ expect(connection.calls).to be_empty
661
+ end
662
+
663
+ it 'uses a supported non-RP token permission or an explicit RP for modern PIN verification' do
664
+ connection.info[4]['credMgmt'] = true
665
+ expect(fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456')).to eq({ verified: true })
666
+ first = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |params| params[2] == 9 }
667
+ expect(first&.values_at(9, 10)).to eq([4, nil])
668
+ connection.info[4].delete('credMgmt')
669
+ connection.calls.clear
670
+ fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456', rp_id: 'operator.example')
671
+ second = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |params| params[2] == 9 }
672
+ expect(second&.values_at(9, 10)).to eq([2, 'operator.example'])
673
+ end
674
+
675
+ it 'rejects an overlong padded PIN before key agreement even if a newer device advertises a larger limit' do
676
+ connection.info[29] = 255
677
+ expect { fido.execute(connection: connection, args: %w[access change-pin], confirm: true, pin: '123456', new_pin: 'x' * 250) }.to raise_error(ArgumentError)
678
+ expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
679
+ end
680
+
681
+ it 'reads PIN retries without spending an attempt and formats explicit CSV as an in-memory return' do
682
+ expect(fido.perform(connection: connection, operation: :pin_retries)).to include(pin_retries: 8, power_cycle_state: false)
683
+ connection.info[4]['credMgmt'] = true
684
+ connection.app_handler = ->(_command, params) { params[1] == 1 ? { 1 => 0, 2 => 25 } : 0x2e }
685
+ csv = fido.execute(connection: connection, args: %w[credentials list --csv], confirm: true, pin: '123456')
686
+ expect(csv).to eq("credential_id,rp_id,user_id,user_name,display_name\n")
687
+ end
688
+
689
+ it 'never retries reset rejection or blocked PIN status codes' do
690
+ [0x31, 0x32, 0x34, 0x37].each do |status|
691
+ connection.fail_code = status
692
+ before = connection.calls.size
693
+ expect { fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456') }.to raise_error(fido::CTAPError) { |error| expect(error.code).to eq(status) }
694
+ expect(connection.calls.size - before).to eq(3)
695
+ end
696
+ connection.handler = lambda do |request|
697
+ request[:data] == "\x04".b ? "\x00".b + fido::CBOR.encode(value: connection.info) : "\x30".b
698
+ end
699
+ before = connection.calls.size
700
+ expect { fido.execute(connection: connection, args: ['reset'], confirm: true) }.to raise_error(fido::CTAPError)
701
+ expect(connection.calls.size - before).to eq(2)
702
+ end
703
+
704
+ it 'leaves caller-owned PIN Strings unchanged and keeps session diagnostics redacted' do
705
+ pin = String.new('123456')
706
+ new_pin = String.new('654321')
707
+ fido.execute(connection: connection, args: %w[access change-pin], confirm: true, pin: pin, new_pin: new_pin)
708
+ expect([pin, new_pin]).to eq(%w[123456 654321])
709
+ session = fido::Session.new(connection: connection, pin: pin, confirm: true)
710
+ expect(session.inspect).not_to include(pin)
711
+ session.verify_pin
712
+ owned_buffers = session.instance_variable_get(:@secrets).dup
713
+ session.forget
714
+ expect(owned_buffers).not_to be_empty
715
+ expect(owned_buffers.all? { |buffer| buffer.bytes.all?(&:zero?) }).to be(true)
716
+ end
717
+ end
718
+
719
+ RSpec.describe 'Native YubiKey FIDO transport integration' do
720
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
721
+
722
+ it 'drives the real CTAPHID framing/Connection with only native packet IO replaced' do
723
+ require 'pwn/plugins/yubikey/transport'
724
+ transport = PWN::Plugins::YubiKey::Transport
725
+ io_type = Class.new do
726
+ attr_reader :commands
727
+
728
+ def initialize(capabilities, info_bytes)
729
+ @capabilities = capabilities
730
+ @info_bytes = info_bytes
731
+ @queue = []
732
+ @commands = []
733
+ end
734
+
735
+ def write_packet(packet)
736
+ marker = packet.getbyte(4)
737
+ @commands << marker
738
+ channel = packet.unpack1('N')
739
+ response = case marker
740
+ when 0x86 then packet.byteslice(7, 8) + [0x12345678, 2, 1, 0, 0, @capabilities].pack('NCCCCC')
741
+ when 0x90 then @info_bytes
742
+ when 0x83 then 'U2F_V2'.b + "\x90\x00".b
743
+ else raise 'Unexpected native packet command'
744
+ end
745
+ @queue << ([channel, marker, response.bytesize].pack('NCn') + response.byteslice(0, 57)).ljust(64, "\x00".b)
746
+ offset = 57
747
+ sequence = 0
748
+ while offset < response.bytesize
749
+ @queue << ([channel, sequence].pack('NC') + response.byteslice(offset, 59)).ljust(64, "\x00".b)
750
+ offset += 59
751
+ sequence += 1
752
+ end
753
+ packet.bytesize
754
+ end
755
+
756
+ def read_packet(_timeout)
757
+ @queue.shift || ''.b
758
+ end
759
+
760
+ def close
761
+ :closed
762
+ end
763
+ end
764
+ info = FidoFixtureConnection.new.info
765
+ [1, 5].each do |caps|
766
+ io = io_type.new(caps, "\x00".b + fido::CBOR.encode(value: info))
767
+ connection = transport::Connection.new(descriptor: { protocol: :ctap }, io: io, prepare: transport::Deadline.new(2))
768
+ begin
769
+ result = fido.execute(connection: connection, args: ['info'])
770
+ expect(result[:ctap2]).to eq(caps == 5)
771
+ expect(io.commands).to eq(caps == 5 ? [0x86, 0x90] : [0x86, 0x83])
772
+ ensure
773
+ connection.close
774
+ end
775
+ end
776
+ end
777
+
778
+ it 'enforces the FIPS 32-byte limit before any vendor PIN mutation' do
779
+ connection = FidoFixtureConnection.new(descriptor: { protocol: :ctap, capabilities: 1 })
780
+ expect { fido.execute(connection: connection, args: %w[access change-pin --u2f], confirm: true, new_pin: 'x' * 33) }.to raise_error(ArgumentError)
781
+ expect(connection.calls).to be_empty
782
+ end
783
+ end
784
+
785
+ RSpec.describe 'Native YubiKey FIDO PIN cryptography' do
786
+ let(:fido) { PWN::Plugins::YubiKey::FIDO }
787
+ let(:z) { ['7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978'].pack('H*') }
788
+
789
+ it 'derives protocol 1 and 2 keys against independent OpenSSL KDF golden values' do
790
+ expect(fido::PinProtocol.new(1).derive(z).unpack1('H*')).to eq('23775201799b2234a18e8071e409cec80d42632fe77534180afdc533c9b76f81')
791
+ expect(fido::PinProtocol.new(2).derive(z).unpack1('H*')).to eq('64ca541c7473e4a526a447a65f24c755d6098f8341d6731894bb241e6a6bf289ffdd11da665666f9e5a99ba18a1cf674a4f1adf68a436bf9d7f219980910e877')
792
+ end
793
+
794
+ it 'matches NIST SP800-38A AES-256 CBC including protocol 2 IV prefix and RFC4231 HMAC' do
795
+ key = ['603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4'].pack('H*')
796
+ iv = ['000102030405060708090a0b0c0d0e0f'].pack('H*')
797
+ plaintext = ['6bc1bee22e409f96e93d7e117393172a'].pack('H*')
798
+ expected = iv + ['f58c4c04d6e5f1ba779eabfb5f7bfbd6'].pack('H*')
799
+ allow(SecureRandom).to receive(:random_bytes).with(16).and_return(iv)
800
+ proto = fido::PinProtocol.new(2)
801
+ expect(proto.encrypt(("\x00".b * 32) + key, plaintext)).to eq(expected)
802
+ expect(proto.decrypt(("\x00".b * 32) + key, expected)).to eq(plaintext)
803
+ expect(fido::PinProtocol.new(1).encrypt("\x00".b * 32, "\x00".b * 16).unpack1('H*')).to eq('dc95c078a2408989ad48a21492842087')
804
+ expect(proto.authenticate("\x0b".b * 20, 'Hi There').unpack1('H*')).to eq('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7')
805
+ expect(fido::PinProtocol.new(1).authenticate("\x0b".b * 20, 'Hi There').unpack1('H*')).to eq('b0344c61d8db38535ca8afceaf0bf12b')
806
+ end
807
+
808
+ it 'performs actual P-256 ECDH with COSE keys and rejects invalid curve points' do
809
+ generator = ['046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2964fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5'].pack('H*')
810
+ private_der = OpenSSL::ASN1::Sequence([
811
+ OpenSSL::ASN1::Integer(1), OpenSSL::ASN1::OctetString(("\x00".b * 31) + "\x01".b),
812
+ OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT), OpenSSL::ASN1::BitString(generator, 1, :EXPLICIT)
813
+ ]).to_der
814
+ allow(OpenSSL::PKey::EC).to receive(:generate).with('prime256v1').and_return(OpenSSL::PKey.read(private_der))
815
+ peer = { 1 => 2, 3 => -25, -1 => 1, -2 => z, -3 => ['07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1'].pack('H*') }
816
+ cose, shared = fido::PinProtocol.new(1).encapsulate(peer)
817
+ expect(cose).to eq({ 1 => 2, 3 => -25, -1 => 1, -2 => generator.byteslice(1, 32), -3 => generator.byteslice(33, 32) })
818
+ expect(shared.unpack1('H*')).to eq('23775201799b2234a18e8071e409cec80d42632fe77534180afdc533c9b76f81')
819
+ expect { fido::PinProtocol.new(1).encapsulate(peer.merge(-2 => "\x00".b * 32)) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
820
+ expect { fido::PinProtocol.new(2).decrypt('x' * 64, 'x' * 16) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
821
+ end
822
+ end
823
+
824
+ RSpec.describe 'Native YubiKey FIDO CBOR' do
825
+ def codec
826
+ PWN::Plugins::YubiKey::FIDO::CBOR
827
+ end
828
+
829
+ it 'encodes independent RFC 8949 integer, text, byte and container vectors' do
830
+ vectors = { 0 => '00', 23 => '17', 24 => '1818', 1000 => '1903e8', -1 => '20', -1000 => '3903e7', true => 'f5', false => 'f4', nil => 'f6', [1, 2, 3] => '83010203', 'IETF' => '6449455446' }
831
+ vectors.each do |value, hex|
832
+ expect(codec.encode(value: value).unpack1('H*')).to eq(hex)
833
+ expect(codec.decode(data: [hex].pack('H*'))).to eq(value)
834
+ end
835
+ expect(codec.encode(value: "\x01\x02".b).unpack1('H*')).to eq('420102')
836
+ expect(codec.decode(data: ['420102'].pack('H*')).encoding).to eq(Encoding::BINARY)
837
+ end
838
+
839
+ it 'uses CTAP canonical major-type ordering rather than length-first ordering' do
840
+ expect(codec.encode(value: { -1 => 1, 1000 => 2, 'z' => 3 }).unpack1('H*')).to eq('a31903e8022001617a03')
841
+ end
842
+
843
+ it 'rejects malformed, ambiguous, noncanonical and unbounded inputs' do
844
+ %w[1817 5f 9f01ff a201010102 a220000100 61ff 1bffffffffffffffff00 d800 f7 fa00000000 440102].each do |hex|
845
+ expect { codec.decode(data: [hex].pack('H*')) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
846
+ end
847
+ cycle = []
848
+ cycle << cycle
849
+ expect { codec.encode(value: cycle) }.to raise_error(ArgumentError, /depth|cyclic/)
850
+ expect { codec.decode(data: ("\x81".b * 40) + "\x00".b) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /depth/)
851
+ expect { codec.encode(value: 'a' * 1_048_577) }.to raise_error(ArgumentError, /limit/)
852
+ expect { codec.decode(data: ['9bffffffffffffffff'].pack('H*')) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /limit/)
853
+ end
854
+ end