pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -0,0 +1,760 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ffi'
5
+ require 'pwn/plugins/yubikey/transport' if File.file?(File.join(__dir__, '../../../../../lib/pwn/plugins/yubikey/transport.rb'))
6
+
7
+ RSpec.shared_context 'native YubiKey wire fixtures' do
8
+ let(:transport) { PWN::Plugins::YubiKey::Transport }
9
+
10
+ def packet(hex)
11
+ [hex].pack('H*').ljust(64, "\0")
12
+ end
13
+
14
+ def fake_io(packets: [], features: [])
15
+ Class.new do
16
+ attr_reader :writes, :feature_writes, :closed
17
+
18
+ def initialize(packets, features)
19
+ @packets = packets
20
+ @features = features
21
+ @writes = []
22
+ @feature_writes = []
23
+ @closed = 0
24
+ end
25
+
26
+ def read_packet(_milliseconds)
27
+ @packets.shift || ''.b
28
+ end
29
+
30
+ def write_packet(bytes)
31
+ @writes << bytes
32
+ end
33
+
34
+ def read_feature
35
+ @features.shift || ("\0" * 8)
36
+ end
37
+
38
+ def write_feature(bytes)
39
+ @feature_writes << bytes
40
+ end
41
+
42
+ def close
43
+ @closed += 1
44
+ end
45
+ end.new(packets, features)
46
+ end
47
+
48
+ def fake_hid_library
49
+ lib = Module.new
50
+ allow(lib).to receive(:ffi_lib)
51
+ allow(lib).to receive(:attach_function)
52
+ lib
53
+ end
54
+ end
55
+
56
+ RSpec.describe 'PWN::Plugins::YubiKey::Transport native boundary' do
57
+ include_context 'native YubiKey wire fixtures'
58
+
59
+ it 'negotiates CTAPHID INIT with a nonce and reassembles an independent continuation fixture' do
60
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('abcdefgh')
61
+ # FIDO CTAPHID: broadcast INIT, 17-byte reply, CID 01020304, v2, fw 5.7.1, CBOR.
62
+ io = fake_io(packets: [packet('ffffffff8600116162636465666768010203040205070104'),
63
+ packet("0102030490003c#{'a5' * 57}"),
64
+ packet('0102030400a5a5a5')])
65
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io, timeout: 1)
66
+ expect(conn.exchange(protocol: :ctap, command: 0x10, data: "\x01".b)).to eq("\xa5".b * 60)
67
+ expect(io.writes).to eq([packet('ffffffff8600086162636465666768'), packet('0102030490000101')])
68
+ expect(conn.descriptor).to include(protocol: :ctap, version: [5, 7, 1], capabilities: 4)
69
+ conn.close
70
+ conn.close
71
+ expect(io.closed).to eq(1)
72
+ end
73
+
74
+ it 'frames OTP serial requests with a fixed CRC fixture and strips the checked response CRC' do
75
+ status = ['0005070109000000'].pack('H*')
76
+ io = fake_io(features: [status, status, status, ['0001020329a70040'].pack('H*'), ['0000000000000040'].pack('H*')])
77
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io, timeout: 1)
78
+ expect(conn.exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4)).to eq(['00010203'].pack('H*'))
79
+ # CRC16 of 64 zero bytes is 5b6b (uncomplemented). Only first/last chunks are sent.
80
+ expect(io.feature_writes).to eq(%w[0000000000000080 00106b5b00000089 00000000000000ff].map { |h| [h].pack('H*') })
81
+ conn.close
82
+ expect(io.closed).to eq(1)
83
+ end
84
+
85
+ it 'enumerates HID interfaces via FFI, preserves wide serial text, and frees all native resources' do
86
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library, platform: :linux)
87
+ lib = native.library
88
+ text = FFI::MemoryPointer.new(:uint32, 7)
89
+ text.write_array_of_uint32(' 0007 '.codepoints + [0])
90
+ path = FFI::MemoryPointer.from_string('/fake/hid')
91
+ # Independent C hid_device_info layout (HIDAPI 0.14 ABI prefix).
92
+ struct = Class.new(FFI::Struct) do
93
+ layout :path, :pointer, :vendor_id, :ushort, :product_id, :ushort,
94
+ :serial_number, :pointer, :release_number, :ushort, :manufacturer_string, :pointer,
95
+ :product_string, :pointer, :usage_page, :ushort, :usage, :ushort,
96
+ :interface_number, :int, :next, :pointer
97
+ end.new
98
+ struct[:path] = path
99
+ struct[:vendor_id] = 0x1050
100
+ struct[:product_id] = 0x0407
101
+ struct[:serial_number] = text
102
+ struct[:usage_page] = 0xf1d0
103
+ struct[:usage] = 1
104
+ struct[:next] = FFI::Pointer::NULL
105
+ expect(lib).to receive(:hid_init).ordered.and_return(0)
106
+ expect(lib).to receive(:hid_enumerate).with(0x1050, 0).ordered.and_return(struct.pointer)
107
+ expect(lib).to receive(:hid_free_enumeration).with(struct.pointer).ordered
108
+ expect(lib).to receive(:hid_exit).ordered.and_return(0)
109
+ expect(native.devices).to include(include(protocol: :ctap, device_path: '/fake/hid', serial: ' 0007 '))
110
+ end
111
+
112
+ it 'uses HIDAPI report-id prefixes and exact transfer lengths at the C boundary' do
113
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library, platform: :linux)
114
+ lib = native.library
115
+ ptr = FFI::MemoryPointer.new(:char)
116
+ expect(lib).to receive(:hid_init).and_return(0)
117
+ expect(lib).to receive(:hid_open_path).with('/fake/hid').and_return(ptr)
118
+ expect(lib).to receive(:hid_write) do |handle, buffer, length|
119
+ expect(handle).to eq(ptr)
120
+ expect(length).to eq(65)
121
+ expect(buffer.read_bytes(length)).to eq("\0#{packet('01020304810000')}")
122
+ 65
123
+ end
124
+ expect(lib).to receive(:hid_get_feature_report) do |handle, buffer, length|
125
+ expect(handle).to eq(ptr)
126
+ expect(length).to eq(9)
127
+ expect(buffer.get_uint8(0)).to eq(0)
128
+ buffer.put_bytes(0, ['000005070109000000'].pack('H*'))
129
+ 9
130
+ end
131
+ expect(lib).to receive(:hid_close).with(ptr).ordered
132
+ expect(lib).to receive(:hid_exit).ordered.and_return(0)
133
+ io = native.open({ device_path: '/fake/hid', protocol: :ctap })
134
+ io.write_packet(packet('01020304810000'))
135
+ expect(io.read_feature).to eq(['0005070109000000'].pack('H*'))
136
+ io.close
137
+ io.close
138
+ end
139
+
140
+ it 'performs PC/SC exclusive connect and a raw APDU exchange through the C ABI' do
141
+ lib = fake_hid_library
142
+ pci = FFI::MemoryPointer.new(:ulong, 2)
143
+ pci.write_array_of_ulong([2, FFI.type_size(:ulong) * 2])
144
+ allow(lib).to receive(:ffi_libraries).and_return([double(find_symbol: pci)])
145
+ native = transport::NativeLibrary.new(:pcsc, library: lib, platform: :linux)
146
+ expect(lib).to receive(:SCardEstablishContext).with(2, nil, nil, anything) do |_, _, _, ptr|
147
+ ptr.write_long(123)
148
+ 0
149
+ end
150
+ expect(lib).to receive(:SCardConnect) do |context, reader, share, preferred, card, protocol| # rubocop:disable Metrics/ParameterLists -- Mirrors the native C signature.
151
+ expect([context, reader.read_string, share, preferred]).to eq([123, 'Yubico YubiKey synthetic', 1, 3])
152
+ card.write_long(456)
153
+ protocol.write_ulong(2)
154
+ 0
155
+ end
156
+ expect(lib).to receive(:SCardGetAttrib) do |card, attribute, atr, length|
157
+ expect([card, attribute]).to eq([456, 0x00090303])
158
+ atr.put_bytes(0, ['3bf81300008131fe15597562696b657934d4'].pack('H*'))
159
+ length.write_ulong(17)
160
+ 0
161
+ end
162
+ expect(lib).to receive(:SCardTransmit) do |card, send_pci, data, size, receive_pci, output, length| # rubocop:disable Metrics/ParameterLists -- Mirrors the native C signature.
163
+ expect([card, send_pci, data.read_bytes(size), receive_pci]).to eq([456, pci, ['00a4040000'].pack('H*'), nil])
164
+ output.put_bytes(0, ['0102039000'].pack('H*'))
165
+ length.write_ulong(5)
166
+ 0
167
+ end
168
+ expect(lib).to receive(:SCardDisconnect).with(456, 0).ordered.and_return(0)
169
+ expect(lib).to receive(:SCardReleaseContext).with(123).ordered.and_return(0)
170
+ io = native.open({ reader: 'Yubico YubiKey synthetic', protocol: :apdu })
171
+ conn = transport::Connection.new(descriptor: { protocol: :apdu }, io: io, timeout: 1)
172
+ expect(conn.descriptor[:transport]).to eq(:usb)
173
+ expect(conn.exchange(protocol: :apdu, data: ['00a4040000'].pack('H*'))).to eq(['0102039000'].pack('H*'))
174
+ conn.close
175
+ conn.close
176
+ end
177
+
178
+ it 'uses platform-specific PC/SC DWORD and handle widths without establishing contexts' do
179
+ { linux: %i[ulong long long], macos: %i[uint32 int32 int32], windows: %i[uint32 uintptr_t int32] }.each do |platform, (dword, handle, result)|
180
+ lib = fake_hid_library
181
+ allow(lib).to receive(:ffi_convention)
182
+ allow(lib).to receive(:ffi_libraries).and_return([double(find_symbol: FFI::Pointer.new(1))])
183
+ expect(lib).to receive(:attach_function).with(:SCardEstablishContext, [dword, :pointer, :pointer, :pointer], result, blocking: true)
184
+ expect(lib).to receive(:attach_function).with(:SCardTransmit, [handle, :pointer, :pointer, dword, :pointer, :pointer, :pointer], result, blocking: true)
185
+ expect(lib).not_to receive(:SCardEstablishContext)
186
+ transport::NativeLibrary.new(:pcsc, library: lib, platform: platform)
187
+ end
188
+ end
189
+
190
+ it 'bounds a blocked C call without freeing its handle early and closes it after the call returns' do
191
+ entered = Queue.new
192
+ release = Queue.new
193
+ io = fake_io
194
+ allow(io).to receive(:transmit) do |_data|
195
+ entered << true
196
+ release.pop
197
+ "\x90\x00".b
198
+ end
199
+ conn = transport::Connection.new(descriptor: { protocol: :apdu }, io: io, timeout: 0.04)
200
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
201
+ timer = Thread.new do
202
+ entered.pop
203
+ sleep(0.2)
204
+ release << true
205
+ end
206
+ expect { conn.exchange(protocol: :apdu, data: "\x00\xa4\x04\x00".b) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
207
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 0.18
208
+ expect(io.closed).to eq(0)
209
+ expect { conn.exchange(protocol: :apdu, data: "\x00\xa4\x04\x00".b) }.to raise_error(PWN::Plugins::YubiKey::Error, /closed/)
210
+ timer.join
211
+ conn.close
212
+ expect(io.closed).to eq(1)
213
+ ensure
214
+ release << true if release
215
+ timer&.join(1)
216
+ conn&.close
217
+ end
218
+
219
+ it 'cancels CTAPHID on touch-wait interruption and closes exactly once' do
220
+ allow(SecureRandom).to receive(:random_bytes).and_return('abcdefgh')
221
+ io = fake_io(packets: [packet('ffffffff8600116162636465666768010203040205070104'), packet('01020304bb000102')])
222
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io, timeout: 1)
223
+ expect { conn.exchange(protocol: :ctap, command: 0x10, data: ''.b, on_keepalive: ->(_status) { raise Interrupt }) }.to raise_error(Interrupt)
224
+ conn.close
225
+ expect(io.writes.last).to eq(packet('01020304910000'))
226
+ expect(io.closed).to eq(1)
227
+ end
228
+
229
+ it 'selects exact serial strings and never chooses the first of multiple keys' do
230
+ records = [{ backend: :hid, protocol: :otp, serial: '0007', device_path: '/fake/one' },
231
+ { backend: :hid, protocol: :otp, serial: '7', device_path: '/fake/two' }]
232
+ native = double(devices: records)
233
+ allow(transport).to receive(:native).with(kind: :hid).and_return(native)
234
+ allow(native).to receive(:open) { raise 'must not open an ambiguous selection' }
235
+ expect { transport.open(application: :otp) }.to raise_error(PWN::Plugins::YubiKey::Error, /multiple/i)
236
+ expect { transport.open(application: :otp, serial: 7) }.to raise_error(ArgumentError, /serial/)
237
+ io = fake_io
238
+ allow(native).to receive(:open).with(records.first).and_return(io)
239
+ conn = transport.open(application: :otp, serial: '0007')
240
+ expect(conn.descriptor[:serial]).to eq('0007')
241
+ conn.close
242
+ end
243
+
244
+ it 'selects serial-less Security Keys by path and reader-selected OTP over PC/SC' do
245
+ hid = double(devices: [{ backend: :hid, protocol: :ctap, serial: nil, device_path: '/fake/security-key' }])
246
+ pcsc = double(devices: [{ backend: :pcsc, protocol: :apdu, serial: nil, reader: 'NFC test reader' }])
247
+ allow(transport).to receive(:native).with(kind: :hid).and_return(hid)
248
+ allow(transport).to receive(:native).with(kind: :pcsc).and_return(pcsc)
249
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('abcdefgh')
250
+ allow(hid).to receive(:open).and_return(fake_io(packets: [packet('ffffffff8600116162636465666768010203040205070104')]))
251
+ allow(pcsc).to receive(:open).and_return(fake_io)
252
+ conn = transport.open(application: :fido, device_path: '/fake/security-key')
253
+ expect(conn.descriptor[:protocol]).to eq(:ctap)
254
+ conn.close
255
+ conn = transport.open(application: :otp, reader: 'NFC test reader')
256
+ expect(conn.descriptor[:protocol]).to eq(:apdu)
257
+ conn.close
258
+ end
259
+
260
+ it 'enumerates PC/SC reader strings without connecting and releases its context' do
261
+ lib = fake_hid_library
262
+ allow(lib).to receive(:ffi_libraries).and_return([double(find_symbol: FFI::Pointer.new(1))])
263
+ native = transport::NativeLibrary.new(:pcsc, library: lib, platform: :linux)
264
+ names = "Yubico YubiKey test\0NFC test\0\0".b
265
+ expect(lib).to receive(:SCardEstablishContext) { |_, _, _, ptr|
266
+ ptr.write_long(123)
267
+ 0
268
+ }
269
+ expect(lib).to(receive(:SCardListReaders).with(123, nil, nil, anything).ordered do |_, _, _, len|
270
+ len.write_ulong(names.bytesize)
271
+ 0
272
+ end)
273
+ expect(lib).to(receive(:SCardListReaders).with(123, nil, kind_of(FFI::MemoryPointer), anything).ordered do |_, _, ptr, len|
274
+ ptr.put_bytes(0, names)
275
+ len.write_ulong(names.bytesize)
276
+ 0
277
+ end)
278
+ expect(lib).to receive(:SCardReleaseContext).with(123).ordered.and_return(0)
279
+ expect(lib).not_to receive(:SCardConnect)
280
+ expect(native.devices.map { |entry| entry[:reader] }).to eq(['Yubico YubiKey test', 'NFC test'])
281
+ end
282
+
283
+ it 'resolves a PC/SC serial using read-only OTP APDUs and rejects alternate spellings' do
284
+ entry = { backend: :pcsc, protocol: :apdu, reader: 'Yubico YubiKey synthetic', serial: nil }
285
+ binding = double(devices: [entry])
286
+ allow(transport).to receive(:native).with(kind: :pcsc).and_return(binding)
287
+ io = fake_io
288
+ allow(binding).to receive(:open).and_return(io)
289
+ expect(io).to receive(:transmit).with(['00a4040007a0000005272001'].pack('H*')).and_return(['0507010900009000'].pack('H*'))
290
+ expect(io).to receive(:transmit).with(['0001100000'].pack('H*')).and_return(['000000079000'].pack('H*'))
291
+ conn = transport.open(application: :piv, reader: entry[:reader], serial: '7')
292
+ expect(conn.descriptor[:serial]).to eq('7')
293
+ conn.close
294
+ expect(io.closed).to eq(1)
295
+ end
296
+
297
+ it 'does not accept an unobservable PC/SC serial or coerce leading zero selectors' do
298
+ entry = { backend: :pcsc, protocol: :apdu, reader: 'Yubico YubiKey synthetic', serial: nil }
299
+ binding = double(devices: [entry])
300
+ allow(transport).to receive(:native).with(kind: :pcsc).and_return(binding)
301
+ io = fake_io
302
+ allow(binding).to receive(:open).and_return(io)
303
+ allow(io).to receive(:transmit).and_return(['9000'].pack('H*'), ['000000079000'].pack('H*'))
304
+ expect { transport.open(application: :piv, reader: entry[:reader], serial: '0007') }.to raise_error(PWN::Plugins::YubiKey::Error, /serial|matching/i)
305
+ expect(io.closed).to eq(1)
306
+ end
307
+
308
+ it 'finds a PC/SC serial through management when the OTP applet is disabled' do
309
+ require 'pwn/plugins/yubikey/protocol'
310
+ entry = { backend: :pcsc, protocol: :apdu, reader: 'NFC test reader', serial: nil }
311
+ io = fake_io
312
+ binding = double(devices: [entry], open: io)
313
+ allow(transport).to receive(:native).with(kind: :pcsc).and_return(binding)
314
+ expect(io).to receive(:transmit).with(['00a4040007a0000005272001'].pack('H*')).ordered.and_return(['6a82'].pack('H*'))
315
+ expect(io).to receive(:transmit).with(['00a4040008a000000527471117'].pack('H*')).ordered.and_return("5.7.1\x90\0".b)
316
+ expect(io).to receive(:transmit).with(['001d000000'].pack('H*')).ordered.and_return(['060204000000079000'].pack('H*'))
317
+ conn = transport.open(application: :piv, reader: entry[:reader], serial: '7')
318
+ expect(conn.descriptor[:serial]).to eq('7')
319
+ conn.close
320
+ expect(io.closed).to eq(1)
321
+ end
322
+
323
+ it 'binds HIDAPI lazily using a fake FFI shared-library boundary' do
324
+ ffi_library = Module.new
325
+ allow(ffi_library).to receive(:extend)
326
+ expect(ffi_library).to receive(:ffi_lib).with(kind_of(Array))
327
+ expect(ffi_library).to receive(:attach_function).with(:hid_init, [], :int, blocking: true)
328
+ allow(ffi_library).to receive(:attach_function)
329
+ expect(ffi_library).not_to receive(:hid_init)
330
+ expect(ffi_library).not_to receive(:hid_enumerate)
331
+ bound = transport::NativeLibrary.new(:hid, library: ffi_library, platform: :linux)
332
+ expect(bound.library).to eq(ffi_library)
333
+ end
334
+
335
+ it 'probes native library symbols without initializing or discovering hardware' do
336
+ library = double('native shared library')
337
+ expect(library).not_to receive(:hid_init)
338
+ expect(library).not_to receive(:hid_enumerate)
339
+ expect(library).not_to receive(:SCardEstablishContext)
340
+ allow(transport).to receive(:native).with(kind: :hid).and_return(library)
341
+ expect(transport.available?(backend: :hid)).to be(true)
342
+ end
343
+ end
344
+
345
+ RSpec.describe 'PWN::Plugins::YubiKey::Transport dependency and lifetime safety' do
346
+ include_context 'native YubiKey wire fixtures'
347
+
348
+ it 'documents its public selectors, protocol contract and driver timeout limit' do
349
+ expect { transport.help }.to output(/available\?.*devices.*open.*driver/m).to_stdout
350
+ expect(transport.authors).to include('0day Inc.')
351
+ end
352
+
353
+ it 'probes only PC/SC for an APDU-only application and never initializes hardware' do
354
+ expect(transport).to receive(:native).with(kind: :pcsc).and_return(double('loaded PC/SC symbols'))
355
+ expect(transport).not_to receive(:native).with(kind: :hid)
356
+ expect(transport.available?(application: :piv)).to be(true)
357
+ end
358
+
359
+ it 'rejects explicit false backend and application selectors before any native lookup' do
360
+ expect(transport).not_to receive(:native)
361
+ expect { transport.available?(backend: false) }.to raise_error(ArgumentError)
362
+ expect { transport.available?(application: false) }.to raise_error(ArgumentError)
363
+ expect { transport.devices(backend: false) }.to raise_error(ArgumentError)
364
+ end
365
+
366
+ it 'reports absent shared libraries without touching an initialization entry point' do
367
+ lib = fake_hid_library
368
+ allow(lib).to receive(:ffi_lib).and_raise(LoadError, 'synthetic absence')
369
+ expect(lib).not_to receive(:hid_init)
370
+ expect { transport::NativeLibrary.new(:hid, library: lib) }.to raise_error(PWN::Plugins::YubiKey::DependencyError, /HIDAPI/)
371
+ allow(transport).to receive(:native).and_raise(PWN::Plugins::YubiKey::DependencyError, 'missing native library')
372
+ expect(transport.available?).to be(false)
373
+ expect { transport.devices }.to raise_error(PWN::Plugins::YubiKey::DependencyError)
374
+ end
375
+
376
+ it 'balances HIDAPI initialization when a selected path cannot be opened' do
377
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library)
378
+ expect(native.library).to receive(:hid_init).and_return(0)
379
+ expect(native.library).to receive(:hid_open_path).and_return(FFI::Pointer::NULL)
380
+ expect(native.library).not_to receive(:hid_close)
381
+ expect(native.library).to receive(:hid_exit).and_return(0)
382
+ expect { native.open({ device_path: '/fake/unavailable' }) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /permissions/)
383
+ end
384
+
385
+ it 'does not exit HIDAPI while another handle still owns a reference' do
386
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library)
387
+ lib = native.library
388
+ one = FFI::MemoryPointer.new(:char)
389
+ two = FFI::MemoryPointer.new(:char)
390
+ expect(lib).to receive(:hid_init).once.and_return(0)
391
+ expect(lib).to receive(:hid_open_path).with('/fake/1').and_return(one)
392
+ expect(lib).to receive(:hid_open_path).with('/fake/2').and_return(two)
393
+ expect(lib).to receive(:hid_close).with(one).ordered
394
+ expect(lib).to receive(:hid_close).with(two).ordered
395
+ expect(lib).to receive(:hid_exit).ordered.and_return(0)
396
+ first = native.open({ device_path: '/fake/1' })
397
+ second = native.open({ device_path: '/fake/2' })
398
+ first.close
399
+ second.close
400
+ end
401
+
402
+ it 'decodes Windows UTF-16 surrogate pairs and Unix wchar strings without stripping serials' do
403
+ { windows: :uint16, linux: :uint32, macos: :uint32 }.each do |platform, type|
404
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library, platform: platform)
405
+ value = " 00\u{1f511} "
406
+ units = type == :uint16 ? value.encode(Encoding::UTF_16LE).unpack('v*') : value.codepoints
407
+ memory = FFI::MemoryPointer.new(type, units.length + 1)
408
+ memory.public_send(:"write_array_of_#{type}", units + [0])
409
+ expect(transport::HIDInventory.new(native).send(:wide_string, memory)).to eq(value)
410
+ end
411
+ end
412
+
413
+ it 'releases a PC/SC context after an exclusive connect failure without disconnecting an invalid handle' do
414
+ lib = fake_hid_library
415
+ allow(lib).to receive(:ffi_libraries).and_return([double(find_symbol: FFI::Pointer.new(1))])
416
+ native = transport::NativeLibrary.new(:pcsc, library: lib, platform: :linux)
417
+ expect(lib).to receive(:SCardEstablishContext) { |_, _, _, ptr|
418
+ ptr.write_long(123)
419
+ 0
420
+ }
421
+ expect(lib).to receive(:SCardConnect).and_return(0x8010000b)
422
+ expect(lib).not_to receive(:SCardDisconnect)
423
+ expect(lib).to receive(:SCardReleaseContext).with(123).and_return(0)
424
+ expect { native.open({ reader: 'Yubico YubiKey fixture' }) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /exclusive/)
425
+ end
426
+
427
+ it 'rejects unknown applications, conflicting selectors and empty discovery without choosing another reader' do
428
+ binding = double(devices: [])
429
+ allow(transport).to receive(:native).and_return(binding)
430
+ expect(binding).not_to receive(:open)
431
+ expect { transport.open(application: :unknown) }.to raise_error(ArgumentError)
432
+ expect { transport.open(application: :piv, reader: 'one', device_path: 'two') }.to raise_error(ArgumentError)
433
+ expect { transport.open(application: :fido) }.to raise_error(PWN::Plugins::YubiKey::Error, /No matching/)
434
+ allow(binding).to receive(:devices).and_return([{ backend: :pcsc, protocol: :apdu, reader: 'Other card', serial: nil }])
435
+ expect { transport.open(application: :piv) }.to raise_error(PWN::Plugins::YubiKey::Error, /No matching/)
436
+ expect { transport.open(application: :piv, reader: 'missing') }.to raise_error(PWN::Plugins::YubiKey::Error, /No matching/)
437
+ end
438
+
439
+ it 'attempts every handle cleanup when ambiguous serial probes encounter a close failure' do
440
+ entries = [1, 2].map { |number| { backend: :pcsc, protocol: :apdu, reader: "Yubico YubiKey fixture #{number}", serial: nil } }
441
+ first = fake_io
442
+ second = fake_io
443
+ [first, second].each do |io|
444
+ allow(io).to receive(:transmit).and_return(['9000'].pack('H*'), ['000000079000'].pack('H*'))
445
+ end
446
+ allow(first).to receive(:close).and_wrap_original do |method|
447
+ method.call
448
+ raise IOError, 'synthetic close failure'
449
+ end
450
+ binding = double(devices: entries)
451
+ allow(binding).to receive(:open).and_return(first, second)
452
+ allow(transport).to receive(:native).with(kind: :pcsc).and_return(binding)
453
+ expect { transport.open(application: :piv, serial: '7') }.to raise_error(PWN::Plugins::YubiKey::Error, /Multiple/)
454
+ expect([first.closed, second.closed]).to eq([1, 1])
455
+ end
456
+
457
+ it 'uses bounded HIDAPI reads and zero report IDs for OTP writes at the native boundary' do
458
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library)
459
+ lib = native.library
460
+ handle = FFI::MemoryPointer.new(:char)
461
+ expect(lib).to receive(:hid_init).and_return(0)
462
+ expect(lib).to receive(:hid_open_path).and_return(handle)
463
+ expect(lib).to receive(:hid_read_timeout).with(handle, anything, 64, 17) do |_, buffer, _, _|
464
+ buffer.put_bytes(0, packet('0102030490000100'))
465
+ 64
466
+ end
467
+ expect(lib).to receive(:hid_send_feature_report).with(handle, anything, 9) do |_, buffer, length|
468
+ expect(buffer.read_bytes(length)).to eq(['0000000000000000ff'].pack('H*'))
469
+ 9
470
+ end
471
+ expect(lib).to receive(:hid_close).with(handle)
472
+ expect(lib).to receive(:hid_exit).and_return(0)
473
+ io = native.open({ device_path: '/fake/hid' })
474
+ expect(io.read_packet(17)).to eq(packet('0102030490000100'))
475
+ io.write_feature(['00000000000000ff'].pack('H*'))
476
+ io.close
477
+ end
478
+
479
+ it 'rejects partial HID writes and truncated input reports without retrying the C call' do
480
+ native = transport::NativeLibrary.new(:hid, library: fake_hid_library)
481
+ lib = native.library
482
+ handle = FFI::MemoryPointer.new(:char)
483
+ allow(lib).to receive(:hid_init).and_return(0)
484
+ allow(lib).to receive(:hid_open_path).and_return(handle)
485
+ expect(lib).to receive(:hid_write).once.and_return(64)
486
+ expect(lib).to receive(:hid_read_timeout).once.and_return(63)
487
+ expect(lib).to receive(:hid_get_feature_report).once.and_return(8)
488
+ expect(lib).to receive(:hid_close).with(handle)
489
+ expect(lib).to receive(:hid_exit).and_return(0)
490
+ io = native.open({ device_path: '/fake/hid' })
491
+ expect { io.write_packet(packet('0102030490000100')) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /incomplete/)
492
+ expect { io.read_packet(20) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /length/)
493
+ expect { io.read_feature }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /length/)
494
+ io.close
495
+ end
496
+
497
+ it 'closes an acquired native handle that arrives after its opening deadline' do
498
+ release = Queue.new
499
+ cleaned = Queue.new
500
+ io = fake_io
501
+ allow(io).to(receive(:close).and_wrap_original do |method|
502
+ method.call
503
+ cleaned << true
504
+ end)
505
+ entry = { backend: :hid, protocol: :otp, serial: '7', device_path: '/fake/late' }
506
+ binding = double(devices: [entry])
507
+ allow(binding).to receive(:open) {
508
+ release.pop
509
+ io
510
+ }
511
+ allow(transport).to receive(:native).with(kind: :hid).and_return(binding)
512
+ expect { transport.open(application: :otp, serial: '7', timeout: 0.03) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
513
+ expect(io.closed).to eq(0)
514
+ release << true
515
+ expect(cleaned.pop(timeout: 1)).to be(true)
516
+ expect(io.closed).to eq(1)
517
+ ensure
518
+ release << true if release
519
+ end
520
+
521
+ it 'keeps the public discovery waiter bounded while the native enumerator still owns its resources' do
522
+ release = Queue.new
523
+ cleaned = Queue.new
524
+ binding = double
525
+ allow(binding).to receive(:devices) do
526
+ release.pop
527
+ []
528
+ ensure
529
+ cleaned << true
530
+ end
531
+ allow(transport).to receive(:native).with(kind: :hid).and_return(binding)
532
+ expect { transport.devices(backend: :hid, timeout: 0.03) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
533
+ expect(cleaned.empty?).to be(true)
534
+ release << true
535
+ expect(cleaned.pop(timeout: 1)).to be(true)
536
+ ensure
537
+ release << true if release
538
+ end
539
+
540
+ it 'requires exact Windows wide reader names and uint32 reader-list lengths' do
541
+ lib = fake_hid_library
542
+ allow(lib).to receive(:ffi_convention)
543
+ allow(lib).to receive(:ffi_libraries).and_return([double(find_symbol: FFI::Pointer.new(1))])
544
+ expect(lib).to receive(:attach_function).with(:SCardListReaders, :SCardListReadersW, %i[uintptr_t pointer pointer pointer], :int32, blocking: true)
545
+ native = transport::NativeLibrary.new(:pcsc, library: lib, platform: :windows)
546
+ names = "NFC \u{1f511}\0\0".encode(Encoding::UTF_16LE).b
547
+ expect(lib).to receive(:SCardEstablishContext) do |_, _, _, pointer|
548
+ pointer.write(:uintptr_t, 123)
549
+ 0
550
+ end
551
+ expect(lib).to receive(:SCardListReaders).with(123, nil, nil, anything) do |_, _, _, length|
552
+ length.write_uint32(names.bytesize / 2)
553
+ 0
554
+ end
555
+ expect(lib).to receive(:SCardListReaders).with(123, nil, kind_of(FFI::MemoryPointer), anything) do |_, _, buffer, length|
556
+ buffer.put_bytes(0, names)
557
+ length.write_uint32(names.bytesize / 2)
558
+ 0
559
+ end
560
+ expect(lib).to receive(:SCardReleaseContext).with(123).and_return(0)
561
+ expect(native.devices.first[:reader]).to eq("NFC \u{1f511}")
562
+ end
563
+
564
+ it 'rejects simultaneous exchanges instead of interleaving packets and closes after the active call' do
565
+ entered = Queue.new
566
+ release = Queue.new
567
+ io = fake_io
568
+ allow(io).to receive(:transmit) { |_data|
569
+ entered << true
570
+ release.pop
571
+ "\x90\0".b
572
+ }
573
+ conn = transport::Connection.new(descriptor: { protocol: :apdu }, io: io, timeout: 1)
574
+ first = Thread.new { conn.exchange(protocol: :apdu, data: 'abcd') }
575
+ entered.pop
576
+ expect { conn.exchange(protocol: :apdu, data: 'abcd') }.to raise_error(PWN::Plugins::YubiKey::CommandError, /active/)
577
+ release << true
578
+ expect(first.value).to eq("\x90\0".b)
579
+ conn.close
580
+ expect(io.closed).to eq(1)
581
+ ensure
582
+ release << true if release
583
+ first&.join(1)
584
+ conn&.close
585
+ end
586
+ end
587
+
588
+ RSpec.describe 'PWN::Plugins::YubiKey::Transport framing failures and limits' do
589
+ include_context 'native YubiKey wire fixtures'
590
+
591
+ let(:init_reply) { packet('ffffffff8600116162636465666768010203040205070104') }
592
+ let(:status) { ['0005070109000000'].pack('H*') }
593
+
594
+ before { allow(SecureRandom).to receive(:random_bytes).with(8).and_return('abcdefgh') }
595
+
596
+ it 'rejects reserved CTAPHID control commands before any device I/O' do
597
+ [0x06, 0x11, 0x3b, 0x3f].each do |command|
598
+ io = fake_io
599
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io, timeout: 0.02)
600
+ expect { conn.exchange(protocol: :ctap, command: command, data: ''.b) }.to raise_error(ArgumentError, /reserved/)
601
+ expect(io.writes).to be_empty
602
+ conn.close
603
+ end
604
+ end
605
+
606
+ it 'rejects a status-only OTP reply when a checked data response was required' do
607
+ io = fake_io(features: [status, status, status, ['000507010a010000'].pack('H*')])
608
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io)
609
+ expect { conn.exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
610
+ expect(io.closed).to eq(1)
611
+ end
612
+
613
+ it 'exposes CTAPHID INIT capabilities before returning an opened FIDO connection' do
614
+ entry = { backend: :hid, protocol: :ctap, device_path: '/fake/security-key', serial: nil }
615
+ io = fake_io(packets: [init_reply])
616
+ binding = double(devices: [entry], open: io)
617
+ allow(transport).to receive(:native).with(kind: :hid).and_return(binding)
618
+ conn = transport.open(application: :fido, device_path: entry[:device_path], timeout: 1)
619
+ expect(conn.descriptor).to include(version: [5, 7, 1], capabilities: 4)
620
+ expect(io.writes).to eq([packet('ffffffff8600086162636465666768')])
621
+ conn.close
622
+ end
623
+
624
+ {
625
+ nonce: 'ffffffff8600116162636465666769010203040205070104',
626
+ channel: 'ffffffff8600116162636465666768ffffffff0205070104',
627
+ version: 'ffffffff8600116162636465666768010203040105070104',
628
+ length: 'ffffffff86001061626364656667680102030402050701'
629
+ }.each do |label, hex|
630
+ it "rejects a CTAPHID INIT with invalid #{label} and closes the handle" do
631
+ io = fake_io(packets: [packet(hex)])
632
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io, timeout: 0.5)
633
+ expect { conn.exchange(protocol: :ctap, command: 1, data: ''.b) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
634
+ expect(io.closed).to eq(1)
635
+ expect(io.writes.length).to eq(1)
636
+ end
637
+ end
638
+
639
+ it 'ignores unrelated channels and reports keepalive while preserving the binary CTAP status byte' do
640
+ io = fake_io(packets: [init_reply, packet('0506070890000101'), packet('01020304bb000102'), packet('0102030490000300a100')])
641
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io)
642
+ statuses = []
643
+ expect(conn.exchange(protocol: :ctap, command: 0x10, data: ''.b, on_keepalive: ->(value) { statuses << value })).to eq(['00a100'].pack('H*'))
644
+ expect(statuses).to eq([2])
645
+ conn.close
646
+ end
647
+
648
+ {
649
+ sequence: ["0102030490003c#{'00' * 57}", '0102030401000000'],
650
+ oversized: ['01020304901dba'],
651
+ keepalive: ['01020304bb000103'],
652
+ command: ['01020304810000']
653
+ }.each do |label, response|
654
+ it "rejects malformed CTAPHID #{label} without replaying the request" do
655
+ io = fake_io(packets: [init_reply] + response.map { |hex| packet(hex) })
656
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io)
657
+ expect { conn.exchange(protocol: :ctap, command: 0x10, data: ''.b) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
658
+ expect(io.writes.map { |wire| wire.getbyte(4) }).to eq([0x86, 0x90, 0x91])
659
+ expect(io.closed).to eq(1)
660
+ end
661
+ end
662
+
663
+ it 'raises CTAPHID busy errors without an implicit retry of an uncertain command' do
664
+ io = fake_io(packets: [init_reply, packet('01020304bf000106')])
665
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io)
666
+ expect { conn.exchange(protocol: :ctap, command: 0x10, data: ''.b) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /0x06/)
667
+ expect(io.writes.count { |wire| wire.getbyte(4) == 0x90 }).to eq(1)
668
+ expect(io.closed).to eq(1)
669
+ end
670
+
671
+ it 'frames the full 7609-byte CTAPHID limit without wrapping continuation numbers' do
672
+ io = fake_io(packets: [init_reply, packet('01020304810000')])
673
+ conn = transport::Connection.new(descriptor: { protocol: :ctap }, io: io)
674
+ expect(conn.exchange(protocol: :ctap, command: 1, data: 'Z' * 7609)).to eq(''.b)
675
+ expect(io.writes.length).to eq(130)
676
+ expect(io.writes[1]).to eq(packet("01020304811db9#{'5a' * 57}"))
677
+ expect(io.writes.last).to eq(packet("010203047f#{'5a' * 59}"))
678
+ conn.close
679
+ end
680
+
681
+ it 'waits for OTP touch then validates a fixed data CRC' do
682
+ io = fake_io(features: [status, status, status, ['0000000000000020'].pack('H*'), ['0001020329a70040'].pack('H*'), ['0000000000000040'].pack('H*')])
683
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io)
684
+ statuses = []
685
+ expect(conn.exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4, on_keepalive: ->(value) { statuses << value })).to eq(['00010203'].pack('H*'))
686
+ expect(statuses).to eq([2])
687
+ conn.close
688
+ end
689
+
690
+ it 'aborts the OTP touch state when the cancellation predicate becomes true' do
691
+ io = fake_io(features: [status, status, status, ['0000000000000020'].pack('H*')])
692
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io)
693
+ stopped = false
694
+ expect do
695
+ conn.exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4,
696
+ on_keepalive: ->(_value) { stopped = true }, cancelled: -> { stopped })
697
+ end.to raise_error(transport::CancelledError)
698
+ expect(io.feature_writes.last).to eq(['00000000000000ff'].pack('H*'))
699
+ expect(io.closed).to eq(1)
700
+ end
701
+
702
+ {
703
+ crc: %w[0001020328a70040 0000000000000040],
704
+ sequence: %w[0001020329a70041],
705
+ truncated: %w[0001020329a70040 0005070109000000]
706
+ }.each do |label, response|
707
+ it "rejects an OTP #{label} fault and resets the device state" do
708
+ io = fake_io(features: [status, status, status] + response.map { |hex| [hex].pack('H*') })
709
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io)
710
+ expect { conn.exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
711
+ expect(io.feature_writes.last).to eq(['00000000000000ff'].pack('H*'))
712
+ expect(io.closed).to eq(1)
713
+ end
714
+ end
715
+
716
+ it 'returns programming status only after the sequence increments or the last slot is deleted' do
717
+ %w[000507010a010000 0005070100000000].each do |updated|
718
+ io = fake_io(features: [status, status, status, [updated].pack('H*')])
719
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io)
720
+ expect(conn.exchange(protocol: :otp, command: 1, data: ''.b)).to eq([updated].pack('H*').byteslice(1, 6))
721
+ conn.close
722
+ end
723
+ end
724
+
725
+ it 'does not accept a zero programming sequence while a slot still reports programmed' do
726
+ previous = ['00050701ff010000'].pack('H*')
727
+ io = fake_io(features: [previous, previous, previous, ['0005070100010000'].pack('H*')])
728
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io)
729
+ expect { conn.exchange(protocol: :otp, command: 1, data: ''.b) }.to raise_error(PWN::Plugins::YubiKey::CommandError)
730
+ expect(io.closed).to eq(1)
731
+ end
732
+
733
+ it 'bounds the OTP busy writer without sending the application payload' do
734
+ io = fake_io(features: [status])
735
+ allow(io).to receive(:read_feature).and_return(status, ['0005070109000080'].pack('H*'))
736
+ conn = transport::Connection.new(descriptor: { protocol: :otp }, io: io, timeout: 0.03)
737
+ expect { conn.exchange(protocol: :otp, command: 1, data: ''.b) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
738
+ expect(io.feature_writes).to eq([['00000000000000ff'].pack('H*')])
739
+ expect(io.closed).to eq(1)
740
+ end
741
+
742
+ it 'rejects invalid protocol, sizes, callbacks and nonfinite deadlines before I/O' do
743
+ %i[ctap otp apdu].each do |protocol|
744
+ limit = { ctap: 7609, otp: 64, apdu: 65_544 }.fetch(protocol)
745
+ io = fake_io
746
+ conn = transport::Connection.new(descriptor: { protocol: protocol }, io: io)
747
+ expect { conn.exchange(protocol: protocol, command: 1, data: 'x' * (limit + 1)) }.to raise_error(ArgumentError)
748
+ expect { conn.exchange(protocol: :other, command: 1, data: 'data') }.to raise_error(ArgumentError)
749
+ [true, false].each do |callback|
750
+ expect { conn.exchange(protocol: protocol, command: 1, data: 'data', on_keepalive: callback, timeout: 0.02) }.to raise_error(ArgumentError)
751
+ expect { conn.exchange(protocol: protocol, command: 1, data: 'data', cancelled: callback) }.to raise_error(ArgumentError)
752
+ end
753
+ [0, -1, Float::NAN, Float::INFINITY, '1'].each do |timeout|
754
+ expect { conn.exchange(protocol: protocol, command: 1, data: 'data', timeout: timeout) }.to raise_error(ArgumentError)
755
+ end
756
+ expect(io.writes + io.feature_writes).to be_empty
757
+ conn.close
758
+ end
759
+ end
760
+ end