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,1017 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Lint/RescueException -- Native resource ownership must survive Interrupt/SystemExit; errors are re-raised or forwarded, never logged.
4
+
5
+ require 'securerandom'
6
+
7
+ module PWN
8
+ module Plugins
9
+ module YubiKey
10
+ # Native HIDAPI and PC/SC only. Requiring this file does not load a shared
11
+ # library, initialize a library, enumerate devices or open a reader.
12
+ module Transport
13
+ # Each binding object owns its platform ABI. No PWN::FFI name resolution.
14
+ class NativeLibrary
15
+ attr_reader :library, :platform, :kind, :dword, :handle_type, :pci
16
+
17
+ def initialize(kind, library: nil, platform: nil)
18
+ require 'ffi'
19
+ @kind = kind
20
+ @users = 0
21
+ @mutex = Mutex.new
22
+ @platform = platform || (if ::FFI::Platform.windows?
23
+ :windows
24
+ else
25
+ (::FFI::Platform.mac? ? :macos : :linux)
26
+ end)
27
+ @library = library || Module.new
28
+ @library.extend(::FFI::Library)
29
+ case kind
30
+ when :hid then bind_hid
31
+ when :pcsc then bind_pcsc
32
+ else raise ArgumentError, 'backend must be :hid or :pcsc'
33
+ end
34
+ rescue LoadError, ::FFI::NotFoundError => e
35
+ raise DependencyError, "Native #{kind} library unavailable; install HIDAPI for USB HID or PC/SC for smartcard/NFC (#{e.class})"
36
+ end
37
+
38
+ def acquire
39
+ @mutex.synchronize do
40
+ raise CommandError, 'HIDAPI initialization failed' if @users.zero? && library.hid_init.negative?
41
+
42
+ @users += 1
43
+ end
44
+ end
45
+
46
+ def release
47
+ @mutex.synchronize do
48
+ @users -= 1
49
+ library.hid_exit if @users.zero?
50
+ end
51
+ :released
52
+ end
53
+
54
+ def devices
55
+ kind == :hid ? HIDInventory.new(self).devices : PCSCInventory.new(self).devices
56
+ end
57
+
58
+ def open(descriptor)
59
+ kind == :hid ? HIDIO.new(self, descriptor[:device_path]) : PCSCIO.new(self, descriptor[:reader])
60
+ end
61
+
62
+ private
63
+
64
+ def bind_pcsc
65
+ @dword = platform == :linux ? :ulong : :uint32
66
+ result = platform == :linux ? :long : :int32
67
+ @handle_type = platform == :windows ? :uintptr_t : result
68
+ library.ffi_convention(:stdcall) if platform == :windows
69
+ names = case platform
70
+ when :windows then ['winscard.dll']
71
+ when :macos then ['/System/Library/Frameworks/PCSC.framework/PCSC']
72
+ else ['libpcsclite.so.1', 'libpcsclite.so']
73
+ end
74
+ library.ffi_lib(names)
75
+ {
76
+ SCardEstablishContext: [[dword, :pointer, :pointer, :pointer], result],
77
+ SCardReleaseContext: [[handle_type], result], SCardCancel: [[handle_type], result],
78
+ SCardDisconnect: [[handle_type, dword], result],
79
+ SCardGetAttrib: [[handle_type, dword, :pointer, :pointer], result],
80
+ SCardTransmit: [[handle_type, :pointer, :pointer, dword, :pointer, :pointer, :pointer], result]
81
+ }.each { |name, (args, output)| library.attach_function(name, args, output, blocking: true) }
82
+ { SCardListReaders: [handle_type, :pointer, :pointer, :pointer],
83
+ SCardConnect: [handle_type, :pointer, dword, dword, :pointer, :pointer] }.each do |name, args|
84
+ symbol = platform == :windows ? :"#{name}W" : name
85
+ library.attach_function(name, symbol, args, result, blocking: true)
86
+ end
87
+ # Use exported PCI structs, not a guessed macOS struct packing.
88
+ @pci = { 1 => 'g_rgSCardT0Pci', 2 => 'g_rgSCardT1Pci' }.transform_values do |symbol|
89
+ pointer = library.ffi_libraries.filter_map { |lib| lib.find_symbol(symbol) }.first
90
+ raise DependencyError, 'PC/SC library has no protocol control information symbols' unless pointer
91
+
92
+ pointer
93
+ end
94
+ end
95
+
96
+ def bind_hid
97
+ names = case platform
98
+ when :windows then ['hidapi.dll']
99
+ when :macos then ['libhidapi.dylib', '/opt/homebrew/lib/libhidapi.dylib', '/usr/local/lib/libhidapi.dylib']
100
+ else ['libhidapi-hidraw.so.0', 'libhidapi-hidraw.so', 'libhidapi-libusb.so.0', 'libhidapi-libusb.so', 'libhidapi.so']
101
+ end
102
+ library.ffi_lib(names)
103
+ {
104
+ hid_init: [[], :int], hid_exit: [[], :int],
105
+ hid_enumerate: [%i[ushort ushort], :pointer], hid_free_enumeration: [[:pointer], :void],
106
+ hid_open_path: [[:string], :pointer], hid_close: [[:pointer], :void],
107
+ hid_write: [%i[pointer pointer size_t], :int],
108
+ hid_read_timeout: [%i[pointer pointer size_t int], :int],
109
+ hid_send_feature_report: [%i[pointer pointer size_t], :int],
110
+ hid_get_feature_report: [%i[pointer pointer size_t], :int]
111
+ }.each { |name, (args, result)| library.attach_function(name, args, result, blocking: true) }
112
+ end
113
+ end
114
+
115
+ # C structures are created only after the lazy FFI dependency is loaded.
116
+ class HIDInventory
117
+ def initialize(native)
118
+ @native = native
119
+ @info = Class.new(::FFI::Struct) do
120
+ layout :path, :pointer, :vendor_id, :ushort, :product_id, :ushort,
121
+ :serial_number, :pointer, :release_number, :ushort,
122
+ :manufacturer_string, :pointer, :product_string, :pointer,
123
+ :usage_page, :ushort, :usage, :ushort, :interface_number, :int,
124
+ :next, :pointer
125
+ end
126
+ end
127
+
128
+ def devices
129
+ @native.acquire
130
+ owned = true
131
+ pointer = @native.library.hid_enumerate(0x1050, 0)
132
+ current = pointer
133
+ found = []
134
+ seen = {}
135
+ until current.null?
136
+ raise ResponseError, 'Invalid or oversized HID enumeration' if seen[current.address] || seen.length >= 1024
137
+
138
+ seen[current.address] = true
139
+ entry = @info.new(current)
140
+ protocol = case [entry[:usage_page], entry[:usage]]
141
+ when [1, 6] then :otp
142
+ when [0xf1d0, 1] then :ctap
143
+ end
144
+ if entry[:vendor_id] == 0x1050 && protocol
145
+ found << {
146
+ backend: :hid, transport: :usb, protocol: protocol, device_path: string(entry[:path]),
147
+ serial: wide_string(entry[:serial_number]), vendor_id: entry[:vendor_id],
148
+ product_id: entry[:product_id], interface: entry[:interface_number],
149
+ product: wide_string(entry[:product_string])
150
+ }
151
+ end
152
+ current = entry[:next]
153
+ end
154
+ found
155
+ ensure
156
+ begin
157
+ @native.library.hid_free_enumeration(pointer) if pointer && !pointer.null?
158
+ ensure
159
+ @native.release if owned
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def string(pointer)
166
+ raise ResponseError, 'HID device has no path' if pointer.null?
167
+
168
+ bytes = ''.b
169
+ 4096.times do |index|
170
+ byte = pointer.get_uint8(index)
171
+ return bytes if byte.zero?
172
+
173
+ bytes << byte
174
+ end
175
+ raise ResponseError, 'Unterminated native device path'
176
+ end
177
+
178
+ def wide_string(pointer)
179
+ return nil if pointer.null?
180
+
181
+ windows = @native.platform == :windows
182
+ units = []
183
+ 4096.times do |index|
184
+ unit = windows ? pointer.get_uint16(index * 2) : pointer.get_uint32(index * 4)
185
+ if unit.zero?
186
+ return windows ? units.pack('v*').force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8) : units.pack('U*')
187
+ end
188
+
189
+ units << unit
190
+ end
191
+ raise ResponseError, 'Unterminated native wide string'
192
+ end
193
+ end
194
+
195
+ # One native handle; FFI buffers remain alive for the entire C call.
196
+ class HIDIO
197
+ def initialize(native, path)
198
+ @native = native
199
+ @native.acquire
200
+ @owned = true
201
+ @handle = @native.library.hid_open_path(path)
202
+ raise CommandError, 'Cannot open selected HID device; check permissions and competing clients' if @handle.null?
203
+ rescue Exception
204
+ close
205
+ raise
206
+ end
207
+
208
+ def write_packet(bytes)
209
+ write(:hid_write, bytes, 64)
210
+ end
211
+
212
+ def write_feature(bytes)
213
+ write(:hid_send_feature_report, bytes, 8)
214
+ end
215
+
216
+ def read_packet(milliseconds)
217
+ buffer = ::FFI::MemoryPointer.new(:uchar, 64)
218
+ count = @native.library.hid_read_timeout(@handle, buffer, 64, milliseconds)
219
+ raise CommandError, 'HIDAPI read failed; device may be disconnected' if count.negative?
220
+ raise ResponseError, 'Invalid HIDAPI input length' unless [0, 64].include?(count)
221
+
222
+ buffer.read_bytes(count)
223
+ end
224
+
225
+ def read_feature
226
+ buffer = ::FFI::MemoryPointer.new(:uchar, 9)
227
+ count = @native.library.hid_get_feature_report(@handle, buffer, 9)
228
+ raise CommandError, 'HIDAPI feature read failed' if count.negative?
229
+ raise ResponseError, 'Invalid HIDAPI feature length or report ID' unless count == 9 && buffer.get_uint8(0).zero?
230
+
231
+ buffer.get_bytes(1, 8)
232
+ end
233
+
234
+ def close
235
+ return :closed unless @owned
236
+
237
+ @owned = false
238
+ begin
239
+ @native.library.hid_close(@handle) if @handle && !@handle.null?
240
+ ensure
241
+ @native.release
242
+ end
243
+ :closed
244
+ end
245
+
246
+ private
247
+
248
+ def write(function, bytes, size)
249
+ raise ArgumentError, 'Invalid outgoing HID report size' unless bytes.bytesize == size
250
+
251
+ wire = "\0".b + bytes
252
+ buffer = ::FFI::MemoryPointer.new(:uchar, wire.bytesize)
253
+ buffer.put_bytes(0, wire)
254
+ count = @native.library.public_send(function, @handle, buffer, wire.bytesize)
255
+ raise CommandError, 'HIDAPI write failed or was incomplete; command not retried' unless count == wire.bytesize
256
+
257
+ :sent
258
+ end
259
+ end
260
+
261
+ # Reader enumeration does not connect to cards or execute application APDUs.
262
+ class PCSCInventory
263
+ def initialize(native)
264
+ @native = native
265
+ end
266
+
267
+ def devices
268
+ context_ptr = ::FFI::MemoryPointer.new(@native.handle_type)
269
+ check(@native.library.SCardEstablishContext(2, nil, nil, context_ptr))
270
+ context = context_ptr.read(@native.handle_type)
271
+ length = ::FFI::MemoryPointer.new(@native.dword)
272
+ code = @native.library.SCardListReaders(context, nil, nil, length)
273
+ return [] if (code & 0xffffffff) == 0x8010002e
274
+
275
+ check(code)
276
+ count = length.read(@native.dword)
277
+ raise ResponseError, 'Invalid PC/SC reader list size' unless count.between?(2, 65_536)
278
+
279
+ unit = @native.platform == :windows ? 2 : 1
280
+ buffer = ::FFI::MemoryPointer.new(:uchar, count * unit)
281
+ check(@native.library.SCardListReaders(context, nil, buffer, length))
282
+ actual = length.read(@native.dword)
283
+ raise ResponseError, 'PC/SC reader list grew; rerun discovery explicitly' unless actual.between?(2, count)
284
+
285
+ bytes = buffer.read_bytes(actual * unit)
286
+ text = unit == 2 ? bytes.force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8) : bytes.force_encoding(Encoding::UTF_8)
287
+ raise ResponseError, 'Invalid PC/SC reader list encoding or terminator' unless text.valid_encoding? && text.end_with?("\0\0")
288
+
289
+ text.split("\0").map { |reader| { backend: :pcsc, protocol: :apdu, reader: reader, serial: nil } }
290
+ ensure
291
+ check(@native.library.SCardReleaseContext(context)) if context
292
+ end
293
+
294
+ private
295
+
296
+ def check(code)
297
+ raise CommandError, format('PC/SC reader discovery failed (0x%08x); check the PC/SC service', code & 0xffffffff) unless code.zero?
298
+ end
299
+ end
300
+
301
+ # An exclusive PC/SC session never falls back to sharing with another client.
302
+ class PCSCIO
303
+ def initialize(native, reader)
304
+ @native = native
305
+ @context = establish
306
+ handle = ::FFI::MemoryPointer.new(native.handle_type)
307
+ active = ::FFI::MemoryPointer.new(native.dword)
308
+ bytes = native.platform == :windows ? "#{reader.encode(Encoding::UTF_16LE).b}\0\0" : "#{reader.b}\0"
309
+ name = ::FFI::MemoryPointer.new(:uchar, bytes.bytesize)
310
+ name.put_bytes(0, bytes)
311
+ check(native.library.SCardConnect(@context, name, 1, 3, handle, active), 'connect (exclusive; close competing clients)')
312
+ @handle = handle.read(native.handle_type)
313
+ @protocol = active.read(native.dword)
314
+ raise ResponseError, 'PC/SC negotiated neither T=0 nor T=1' unless native.pci.key?(@protocol)
315
+ rescue Exception
316
+ close
317
+ raise
318
+ end
319
+
320
+ def metadata
321
+ atr = ::FFI::MemoryPointer.new(:uchar, 33)
322
+ length = ::FFI::MemoryPointer.new(@native.dword)
323
+ length.write(@native.dword, 33)
324
+ check(@native.library.SCardGetAttrib(@handle, 0x00090303, atr, length), 'read ATR')
325
+ count = length.read(@native.dword)
326
+ raise ResponseError, 'Invalid smartcard ATR size' unless count.between?(2, 33)
327
+
328
+ # Yubico's documented USB/NFC ATR distinction; not a vendor attestation.
329
+ { transport: atr.get_uint8(1).allbits?(0xf0) ? :usb : :nfc }
330
+ end
331
+
332
+ def transmit(data)
333
+ input = ::FFI::MemoryPointer.new(:uchar, data.bytesize)
334
+ input.put_bytes(0, data)
335
+ output = ::FFI::MemoryPointer.new(:uchar, 65_538)
336
+ length = ::FFI::MemoryPointer.new(@native.dword)
337
+ length.write(@native.dword, 65_538)
338
+ check(@native.library.SCardTransmit(@handle, @native.pci.fetch(@protocol), input, data.bytesize, nil, output, length), 'transmit; command not retried')
339
+ count = length.read(@native.dword)
340
+ raise ResponseError, 'PC/SC returned a truncated or oversized R-APDU' unless count.between?(2, 65_538)
341
+
342
+ output.read_bytes(count)
343
+ end
344
+
345
+ def close
346
+ handle = @handle
347
+ context = @context
348
+ @handle = @context = nil
349
+ begin
350
+ check(@native.library.SCardDisconnect(handle, 0), 'disconnect') if handle
351
+ ensure
352
+ check(@native.library.SCardReleaseContext(context), 'release context') if context
353
+ end
354
+ :closed
355
+ end
356
+
357
+ private
358
+
359
+ def establish
360
+ context = ::FFI::MemoryPointer.new(@native.handle_type)
361
+ check(@native.library.SCardEstablishContext(2, nil, nil, context), 'establish context; ensure the PC/SC service is running')
362
+ context.read(@native.handle_type)
363
+ end
364
+
365
+ def check(code, operation)
366
+ return :ok if code.zero?
367
+
368
+ raise CommandError, format('PC/SC %<operation>s failed (0x%<code>08x)', operation: operation, code: code & 0xffffffff)
369
+ end
370
+ end
371
+
372
+ # One monotonic budget covers INIT, packet writes, reads and touch waits.
373
+ class Deadline
374
+ def initialize(seconds, cancelled = nil)
375
+ raise ArgumentError, 'timeout must be finite positive seconds' unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && seconds.positive?
376
+
377
+ @expires = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
378
+ @cancelled = cancelled
379
+ @stopped = false
380
+ end
381
+
382
+ def stop
383
+ @stopped = true
384
+ end
385
+
386
+ def check!
387
+ raise CancelledError, 'Device operation cancelled; do not retry uncertain writes' if @stopped || @cancelled&.call
388
+ raise TimeoutError, 'Device operation timed out; do not retry uncertain writes' unless remaining.positive?
389
+ end
390
+
391
+ def remaining
392
+ @expires - Process.clock_gettime(Process::CLOCK_MONOTONIC)
393
+ end
394
+
395
+ def milliseconds
396
+ check!
397
+ (remaining * 1000).ceil.clamp(1, 50)
398
+ end
399
+
400
+ def pause(seconds)
401
+ check!
402
+ sleep(remaining.clamp(0, seconds))
403
+ check!
404
+ end
405
+ end
406
+
407
+ class CancelledError < Error
408
+ end
409
+
410
+ # Fixed-size YubiKey CTAPHID reports. Application status stays opaque.
411
+ class CTAP
412
+ MAX_PAYLOAD = 57 + (128 * 59)
413
+
414
+ def initialize(io, descriptor)
415
+ @io = io
416
+ @descriptor = descriptor
417
+ @channel = 0xffffffff
418
+ end
419
+
420
+ def prepare(deadline)
421
+ initialize_channel(deadline) if @channel == 0xffffffff
422
+ end
423
+
424
+ def exchange(command, data, deadline, keepalive)
425
+ prepare(deadline)
426
+ send_message(command, data, deadline)
427
+ receive_message(command, deadline, keepalive)
428
+ rescue Exception
429
+ # Cancellation is serialized behind the current native call; never
430
+ # race hid_write/close against an in-flight C read on the handle.
431
+ begin
432
+ @io.write_packet([@channel, 0x91, 0].pack('NCn').ljust(64, "\0")) unless @channel == 0xffffffff
433
+ rescue StandardError
434
+ nil
435
+ end
436
+ raise
437
+ end
438
+
439
+ private
440
+
441
+ def initialize_channel(deadline)
442
+ nonce = SecureRandom.random_bytes(8)
443
+ send_message(0x06, nonce, deadline)
444
+ reply = receive_message(0x06, deadline, nil)
445
+ raise ResponseError, 'Invalid CTAPHID INIT length or nonce' unless reply.bytesize == 17 && reply.byteslice(0, 8) == nonce
446
+
447
+ channel, protocol, major, minor, patch, caps = reply.byteslice(8, 9).unpack('NCCCCC')
448
+ raise ResponseError, 'Invalid CTAPHID channel or protocol' if [0, 0xffffffff].include?(channel) || protocol != 2
449
+
450
+ @channel = channel
451
+ @descriptor.merge!(channel: channel, version: [major, minor, patch], capabilities: caps)
452
+ end
453
+
454
+ def send_message(command, data, deadline)
455
+ offset = 57
456
+ deadline.check!
457
+ @io.write_packet(([@channel, command | 0x80, data.bytesize].pack('NCn') + data.byteslice(0, 57)).ljust(64, "\0"))
458
+ sequence = 0
459
+ while offset < data.bytesize
460
+ deadline.check!
461
+ @io.write_packet(([@channel, sequence].pack('NC') + data.byteslice(offset, 59)).ljust(64, "\0"))
462
+ offset += 59
463
+ sequence += 1
464
+ end
465
+ end
466
+
467
+ def receive_message(command, deadline, keepalive)
468
+ response = ''.b
469
+ length = nil
470
+ sequence = 0
471
+ loop do
472
+ deadline.check!
473
+ packet = @io.read_packet(deadline.milliseconds)
474
+ next if packet.empty?
475
+ raise ResponseError, 'Invalid CTAPHID report size' unless packet.bytesize == 64
476
+ next unless packet.unpack1('N') == @channel
477
+
478
+ marker = packet.getbyte(4)
479
+ if length.nil?
480
+ length = packet.byteslice(5, 2).unpack1('n')
481
+ if marker == 0xbf
482
+ raise ResponseError, 'Invalid CTAPHID error length' unless length == 1
483
+
484
+ raise CommandError, format('CTAPHID error 0x%02x; command not retried', packet.getbyte(7))
485
+ elsif marker == 0xbb
486
+ raise ResponseError, 'Invalid CTAPHID keepalive' unless length == 1 && [1, 2].include?(packet.getbyte(7))
487
+
488
+ keepalive&.call(packet.getbyte(7))
489
+ length = nil
490
+ next
491
+ end
492
+ raise ResponseError, 'Unexpected CTAPHID command or oversized message' unless marker == (command | 0x80) && length <= MAX_PAYLOAD
493
+
494
+ response << packet.byteslice(7, [length, 57].min)
495
+ else
496
+ raise ResponseError, 'Out-of-order CTAPHID continuation' unless marker == sequence && sequence <= 127
497
+
498
+ response << packet.byteslice(5, [length - response.bytesize, 59].min)
499
+ sequence += 1
500
+ end
501
+ return response if response.bytesize == length
502
+ end
503
+ end
504
+ end
505
+
506
+ # OTP feature-report framing, distinct from the keyboard input endpoint.
507
+ class OTP
508
+ def initialize(io, descriptor)
509
+ @io = io
510
+ @descriptor = descriptor
511
+ end
512
+
513
+ def exchange(command, data, deadline, keepalive, expected = nil)
514
+ status = report(deadline)
515
+ @descriptor[:version] = status.byteslice(1, 3).bytes
516
+ return status.byteslice(1, 6) if command.zero?
517
+
518
+ previous = status.getbyte(4)
519
+ payload = data.ljust(64, "\0")
520
+ frame = payload + [command, crc(payload)].pack('Cv') + ("\0" * 3)
521
+ 10.times do |sequence|
522
+ part = frame.byteslice(sequence * 7, 7)
523
+ next if sequence != 0 && sequence != 9 && part.bytes.all?(&:zero?)
524
+
525
+ deadline.pause(0.02) until report(deadline).getbyte(7).nobits?(0x80)
526
+ deadline.check!
527
+ @io.write_feature(part + [0x80 | sequence].pack('C'))
528
+ end
529
+ receive(previous, deadline, keepalive, expected)
530
+ rescue Exception
531
+ begin
532
+ @io.write_feature(("\0" * 7) + "\xff".b) unless command.zero?
533
+ rescue StandardError
534
+ nil
535
+ end
536
+ raise
537
+ end
538
+
539
+ private
540
+
541
+ def report(deadline)
542
+ deadline.check!
543
+ bytes = @io.read_feature
544
+ raise ResponseError, 'Invalid OTP feature report size' unless bytes.bytesize == 8
545
+
546
+ bytes
547
+ end
548
+
549
+ def crc(bytes)
550
+ result = 0xffff
551
+ bytes.each_byte do |byte|
552
+ result ^= byte
553
+ 8.times { result = (result >> 1) ^ (result.odd? ? 0x8408 : 0) }
554
+ end
555
+ result
556
+ end
557
+
558
+ def receive(previous, deadline, keepalive, expected)
559
+ sequence = 0
560
+ payload = ''.b
561
+ touched = false
562
+ loop do
563
+ bytes = report(deadline)
564
+ flag = bytes.getbyte(7)
565
+ if flag.anybits?(0x40)
566
+ actual = flag & 0x1f
567
+ if actual.zero? && sequence.positive?
568
+ @io.write_feature(("\0" * 7) + "\xff".b)
569
+ return checked_payload(payload, expected)
570
+ end
571
+ raise ResponseError, 'Out-of-order OTP response' unless actual == sequence && sequence < 32
572
+
573
+ payload << bytes.byteslice(0, 7)
574
+ sequence += 1
575
+ elsif flag.zero?
576
+ raise ResponseError, 'Truncated OTP response' unless payload.empty?
577
+
578
+ next_sequence = bytes.getbyte(4)
579
+ updated = next_sequence == previous + 1 || (next_sequence.zero? && previous.positive? && bytes.getbyte(5).nobits?(3))
580
+ raise ResponseError, 'OTP returned status instead of the requested data' if updated && expected
581
+ return bytes.byteslice(1, 6) if updated
582
+ raise TimeoutError, 'OTP touch wait ended without confirmation' if touched
583
+
584
+ raise CommandError, 'OTP command rejected or no response; command not retried'
585
+ else
586
+ touched ||= flag.anybits?(0x20)
587
+ keepalive&.call(flag.anybits?(0x20) ? 2 : 1)
588
+ deadline.pause(0.02)
589
+ end
590
+ end
591
+ end
592
+
593
+ def checked_payload(bytes, expected)
594
+ if expected
595
+ valid = bytes.bytesize >= expected + 2 && bytes.bytesize < expected + 9 && crc(bytes.byteslice(0, expected + 2)) == 0xf0b8
596
+ raise ResponseError, 'Invalid OTP response length or CRC' unless valid
597
+
598
+ return bytes.byteslice(0, expected)
599
+ end
600
+
601
+ # Without an expected length, only strip an unambiguous CRC followed
602
+ # by at most six zero fill bytes in the final seven-byte packet.
603
+ sizes = ([2, bytes.bytesize - 6].max..bytes.bytesize).select do |size|
604
+ crc(bytes.byteslice(0, size)) == 0xf0b8 && bytes.byteslice(size..).bytes.all?(&:zero?)
605
+ end
606
+ raise ResponseError, 'Ambiguous or invalid OTP CRC; specify response_length' unless sizes.length == 1
607
+
608
+ bytes.byteslice(0, sizes.first - 2)
609
+ end
610
+ end
611
+
612
+ # A bounded Ruby waiter never kills a thread inside C. Drivers lacking a
613
+ # native timeout may outlive the waiter; their owner retains buffers and
614
+ # closes the handle only after the call returns (no use-after-free).
615
+ class NativeTask
616
+ attr_reader :thread
617
+
618
+ def initialize(deadline)
619
+ @deadline = deadline
620
+ @result = Queue.new
621
+ end
622
+
623
+ def start(&work)
624
+ @thread = Thread.new do
625
+ @deadline.check!
626
+ value = work.call
627
+ @deadline.check!
628
+ @result << [:ok, value]
629
+ rescue Exception => e
630
+ @result << [:error, e]
631
+ end
632
+ self
633
+ end
634
+
635
+ def await
636
+ loop do
637
+ @deadline.check!
638
+ break if thread.join([@deadline.remaining, 0.01].min)
639
+ end
640
+ @deadline.check!
641
+ kind, result = @result.pop
642
+ raise result if kind == :error
643
+
644
+ result
645
+ rescue Exception
646
+ @deadline.stop
647
+ raise
648
+ end
649
+ end
650
+
651
+ # The protocol-facing handle. Callers always supply a raw binary String.
652
+ class Connection
653
+ def initialize(descriptor:, io:, timeout: 30, prepare: nil)
654
+ Deadline.new(timeout)
655
+ @descriptor = descriptor.dup
656
+ @descriptor.merge!(io.metadata) if io.respond_to?(:metadata)
657
+ raise ArgumentError, 'Unknown transport protocol' unless %i[otp ctap apdu].include?(@descriptor[:protocol])
658
+
659
+ @io = io
660
+ @timeout = timeout
661
+ @closed = false
662
+ @state = Mutex.new
663
+ @codec = case @descriptor[:protocol]
664
+ when :ctap then CTAP.new(io, @descriptor)
665
+ when :otp then OTP.new(io, @descriptor)
666
+ end
667
+ @codec.prepare(prepare) if prepare && @descriptor[:protocol] == :ctap
668
+ end
669
+
670
+ def descriptor
671
+ @descriptor.transform_values { |value| value.is_a?(String) || value.is_a?(Array) ? value.dup : value }
672
+ end
673
+
674
+ def exchange(opts = {})
675
+ data = validate(opts)
676
+ deadline = Deadline.new(opts.fetch(:timeout, @timeout), opts[:cancelled])
677
+ deadline.check!
678
+ task = @state.synchronize do
679
+ raise Error, 'Connection is closed' if @closed
680
+ raise CommandError, 'Another exchange is active on this connection' if @active
681
+
682
+ @active = true
683
+ @deadline = deadline
684
+ @task = NativeTask.new(deadline).start do
685
+ case @descriptor[:protocol]
686
+ when :apdu then @io.transmit(data)
687
+ when :ctap then @codec.exchange(opts[:command], data, deadline, opts[:on_keepalive])
688
+ when :otp then @codec.exchange(opts[:command], data, deadline, opts[:on_keepalive], opts[:response_length])
689
+ end
690
+ end
691
+ end
692
+ begin
693
+ task.await
694
+ rescue Exception
695
+ close
696
+ raise
697
+ ensure
698
+ @state.synchronize { @active = false }
699
+ end
700
+ end
701
+
702
+ # Read-only identity probe, never authentication or a guessed serial.
703
+ def identify_serial(deadline)
704
+ if @descriptor[:protocol] == :apdu
705
+ selected = exchange(protocol: :apdu, data: ['00a4040007a0000005272001'].pack('H*'), timeout: deadline.remaining)
706
+ if selected.end_with?("\x90\x00".b)
707
+ response = exchange(protocol: :apdu, data: ['0001100000'].pack('H*'), timeout: deadline.remaining)
708
+ if response.end_with?("\x90\x00".b)
709
+ raise ResponseError, 'Invalid OTP serial response' unless response.bytesize == 6
710
+
711
+ serial = response.byteslice(0, 4).unpack1('N')
712
+ return @descriptor[:serial] = serial.to_s if serial.positive?
713
+ end
714
+ end
715
+ @descriptor[:serial] = management_serial(deadline)
716
+ elsif @descriptor[:protocol] == :otp
717
+ response = exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4, timeout: deadline.remaining)
718
+ @descriptor[:serial] = response.unpack1('N').to_s
719
+ end
720
+ end
721
+
722
+ def close
723
+ cleanup = @state.synchronize do
724
+ @closed = true
725
+ @deadline&.stop
726
+ @cleanup ||= Thread.new do
727
+ @task&.thread&.join
728
+ @io.close
729
+ :closed
730
+ rescue Exception => e
731
+ e
732
+ end
733
+ end
734
+ return :closing unless cleanup.join(0.05)
735
+
736
+ result = cleanup.value
737
+ raise result if result.is_a?(Exception)
738
+
739
+ :closed
740
+ end
741
+
742
+ private
743
+
744
+ def management_serial(deadline)
745
+ selected = exchange(protocol: :apdu, data: ['00a4040008a000000527471117'].pack('H*'), timeout: deadline.remaining)
746
+ return nil unless selected.end_with?("\x90\x00".b)
747
+
748
+ require 'pwn/plugins/yubikey/protocol'
749
+ data = Protocol.apdu(connection: self, ins: 0x1d, timeout: deadline.remaining, max_response: 256)
750
+ raise ResponseError, 'Invalid management identity response length' if data.empty? || data.getbyte(0) != data.bytesize - 1
751
+
752
+ serial = Protocol.tlv_map(data: data.byteslice(1..))[2]
753
+ return nil unless serial
754
+ raise ResponseError, 'Invalid management serial length' unless serial.bytesize == 4
755
+
756
+ number = serial.unpack1('N')
757
+ number.positive? ? number.to_s : nil
758
+ end
759
+
760
+ def validate(opts)
761
+ raise Error, 'Connection is closed' if @closed
762
+
763
+ data = opts.fetch(:data, ''.b)
764
+ command = opts[:command]
765
+ raise ArgumentError, 'data must be a String' unless data.is_a?(String)
766
+ raise ArgumentError, 'protocol does not match this connection' unless opts[:protocol] == @descriptor[:protocol]
767
+
768
+ %i[cancelled on_keepalive].each do |key|
769
+ raise ArgumentError, "#{key} must be callable" unless opts[key].nil? || opts[key].respond_to?(:call)
770
+ end
771
+ case @descriptor[:protocol]
772
+ when :apdu
773
+ raise ArgumentError, 'C-APDU length must be in 4..65544' unless data.bytesize.between?(4, 65_544)
774
+ when :ctap
775
+ raise ArgumentError, 'CTAPHID command must be an integer in 1..127' unless command.is_a?(Integer) && command.between?(1, 127)
776
+ raise ArgumentError, 'CTAPHID control command is reserved for the transport' if [0x06, 0x11, 0x3b, 0x3f].include?(command)
777
+ raise ArgumentError, 'CTAPHID payload exceeds 7609 bytes' if data.bytesize > CTAP::MAX_PAYLOAD
778
+ when :otp
779
+ raise ArgumentError, 'OTP command must be a byte' unless command.is_a?(Integer) && command.between?(0, 255)
780
+ raise ArgumentError, 'OTP payload exceeds 64 bytes' if data.bytesize > 64
781
+ raise ArgumentError, 'OTP status command takes no data' if command.zero? && !data.empty?
782
+
783
+ expected = opts[:response_length]
784
+ raise ArgumentError, 'OTP response_length must be in 0..222' unless expected.nil? || (expected.is_a?(Integer) && expected.between?(0, 222))
785
+ end
786
+ data.b
787
+ end
788
+ end
789
+
790
+ APPLICATIONS = %i[otp oath piv openpgp fido management hsmauth security_domain].freeze
791
+ BINDING_LOCK = Mutex.new
792
+
793
+ public_class_method def self.available?(opts = {})
794
+ application = opts[:application]
795
+ raise ArgumentError, 'Unknown YubiKey application' unless application.nil? || APPLICATIONS.include?(application)
796
+ raise ArgumentError, 'backend must be :hid or :pcsc' unless opts[:backend].nil? || %i[hid pcsc].include?(opts[:backend])
797
+
798
+ kinds = if opts[:backend]
799
+ [opts[:backend]]
800
+ elsif application && !%i[otp fido management].include?(application)
801
+ [:pcsc]
802
+ else
803
+ %i[hid pcsc]
804
+ end
805
+ kinds.any? do |kind|
806
+ native(kind: kind)
807
+ true
808
+ rescue DependencyError
809
+ false
810
+ end
811
+ end
812
+
813
+ public_class_method def self.devices(opts = {})
814
+ validate_selection(opts)
815
+ deadline = Deadline.new(opts.fetch(:timeout, 30))
816
+ kinds = if opts[:backend]
817
+ [opts[:backend]]
818
+ else
819
+ (opts[:reader] ? [:pcsc] : %i[hid pcsc])
820
+ end
821
+ missing = []
822
+ records = kinds.flat_map do |kind|
823
+ binding = native(kind: kind)
824
+ entries = NativeTask.new(deadline).start { binding.devices }.await
825
+ entries.select do |entry|
826
+ (!opts[:serial] || entry[:serial] == opts[:serial]) &&
827
+ (!opts[:reader] || entry[:reader] == opts[:reader]) &&
828
+ (!opts[:device_path] || entry[:device_path] == opts[:device_path])
829
+ end
830
+ rescue DependencyError => e
831
+ missing << e
832
+ []
833
+ end
834
+ raise missing.first if missing.length == kinds.length
835
+
836
+ records.uniq { |entry| [entry[:backend], entry[:protocol], entry[:device_path], entry[:reader]] }
837
+ end
838
+
839
+ public_class_method def self.open(opts = {})
840
+ validate_selection(opts)
841
+ application = opts[:application]
842
+ raise ArgumentError, 'application must name a supported YubiKey application' unless APPLICATIONS.include?(application)
843
+
844
+ deadline = Deadline.new(opts.fetch(:timeout, 30))
845
+ order = if opts[:reader]
846
+ [:pcsc]
847
+ elsif opts[:device_path]
848
+ [:hid]
849
+ elsif %i[otp fido].include?(application)
850
+ %i[hid pcsc]
851
+ else
852
+ %i[pcsc hid]
853
+ end
854
+ protocols = case application
855
+ when :otp then %i[otp apdu]
856
+ when :fido then %i[ctap apdu]
857
+ when :management then %i[apdu otp ctap]
858
+ else [:apdu]
859
+ end
860
+ missing = []
861
+ order.each do |kind|
862
+ binding = native(kind: kind)
863
+ entries = NativeTask.new(deadline).start { binding.devices }.await
864
+ entries = entries.select do |entry|
865
+ entry[:backend] == kind && protocols.include?(entry[:protocol]) &&
866
+ (!opts[:serial] || entry[:serial].nil? || entry[:serial].empty? || entry[:serial] == opts[:serial]) &&
867
+ (!opts[:device_path] || entry[:device_path] == opts[:device_path]) &&
868
+ (opts[:reader] ? entry[:reader] == opts[:reader] : (kind != :pcsc || entry[:reader].match?(/yubikey/i)))
869
+ end
870
+ next if entries.empty?
871
+
872
+ # Several application interfaces on one key are still not an excuse
873
+ # to choose an arbitrary endpoint; prefer the documented protocol.
874
+ preferred = protocols.find { |protocol| entries.any? { |entry| entry[:protocol] == protocol } }
875
+ entries = entries.select { |entry| entry[:protocol] == preferred }
876
+ if opts[:serial] && entries.any? { |entry| entry[:serial].nil? || entry[:serial].empty? }
877
+ connection = select_serial(binding: binding, entries: entries, serial: opts[:serial], deadline: deadline, timeout: opts.fetch(:timeout, 30))
878
+ return connection if connection
879
+
880
+ next
881
+ end
882
+ raise Error, 'Multiple matching YubiKeys; specify exact serial, device_path or reader' unless entries.length == 1
883
+
884
+ return acquire_connection(binding: binding, descriptor: entries.first, deadline: deadline, timeout: opts.fetch(:timeout, 30))
885
+ rescue DependencyError => e
886
+ missing << e
887
+ end
888
+ raise missing.first if missing.length == order.length
889
+
890
+ raise Error, 'No matching YubiKey; check application mode, exact selectors, permissions, or specify the NFC reader'
891
+ end
892
+
893
+ public_class_method def self.authors(opts = {})
894
+ opts[:format]
895
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
896
+ end
897
+
898
+ public_class_method def self.help(opts = {})
899
+ opts[:format]
900
+ puts <<~HELP
901
+ # Probe native shared libraries without device initialization or discovery.
902
+ #{self}.available?(
903
+ backend: 'optional - :hid or :pcsc; omitted accepts either suitable native library',
904
+ application: 'optional - application Symbol; APDU-only applications require the PC/SC library'
905
+ )
906
+ # Enumerate interface descriptors without connecting to smartcards.
907
+ #{self}.devices(
908
+ backend: 'optional - :hid or :pcsc; defaults to both available libraries',
909
+ serial: 'optional - exact observed HID serial String; reader serials are unknown until opened',
910
+ reader: 'optional - exact PC/SC reader String for USB smartcard or NFC',
911
+ device_path: 'optional - exact HIDAPI path String, including serial-less keys',
912
+ timeout: 'optional - finite positive seconds, defaults to 30'
913
+ )
914
+ # Open one unambiguously selected native YubiKey application connection.
915
+ #{self}.open(
916
+ application: 'required - :otp, :oath, :piv, :openpgp, :fido, :management, :hsmauth or :security_domain',
917
+ serial: 'optional - exact serial String; never coerced, trimmed or normalized',
918
+ reader: 'optional - exact PC/SC reader String; forces smartcard transport',
919
+ device_path: 'optional - exact HIDAPI path String; mutually exclusive with reader',
920
+ timeout: 'optional - finite positive discovery and opening deadline in seconds, defaults to 30'
921
+ )
922
+ Connection#descriptor returns protocol :otp, :ctap or :apdu.
923
+ Connection#exchange accepts protocol:, binary String data:, timeout: and optional cancelled: callable.
924
+ OTP adds command: byte, response_length: optional payload byte count, on_keepalive: callback.
925
+ OTP command 0 reads six status bytes without issuing a command. Other replies have CRC removed/checked.
926
+ CTAP adds command: 1..127 (transport control commands reserved), on_keepalive: callback.
927
+ CTAPHID INIT is completed by open; CBOR response retains its CTAP status byte.
928
+ APDU returns the entire R-APDU including SW1/SW2. No automatic authentication, retry or logging occurs.
929
+ Close in ensure. Connection#close is idempotent; :closing means deferred native cleanup.
930
+ A timed-out blocking OS driver keeps its owned thread, buffers and handle until C returns.
931
+ PC/SC SCardCancel does not portably abort SCardTransmit; no thread is killed inside C.
932
+ # Return module author information.
933
+ #{self}.authors(format: 'optional - reserved output format')
934
+ HELP
935
+ end
936
+
937
+ private_class_method def self.validate_selection(opts = {})
938
+ Deadline.new(opts.fetch(:timeout, 30))
939
+ %i[serial reader device_path].each do |key|
940
+ value = opts[key]
941
+ next if value.nil?
942
+ raise ArgumentError, "#{key} must be a nonempty exact String without NUL" unless value.is_a?(String) && !value.empty? && !value.include?("\0")
943
+ end
944
+ raise ArgumentError, 'reader and device_path cannot be combined' if opts[:reader] && opts[:device_path]
945
+ raise ArgumentError, 'backend must be :hid or :pcsc' unless opts[:backend].nil? || %i[hid pcsc].include?(opts[:backend])
946
+ end
947
+
948
+ private_class_method def self.select_serial(opts = {})
949
+ opened = []
950
+ matches = opts[:entries].filter_map do |entry|
951
+ conn = acquire_connection(binding: opts[:binding], descriptor: entry, deadline: opts[:deadline], timeout: opts[:timeout])
952
+ opened << conn
953
+ observed = entry[:serial]
954
+ observed = conn.identify_serial(opts[:deadline]) if observed.nil? || observed.empty?
955
+ observed == opts[:serial] ? conn : nil
956
+ end
957
+ raise Error, 'Multiple matching YubiKey serials; specify reader or device_path' if matches.length > 1
958
+
959
+ chosen = matches.first
960
+ chosen
961
+ rescue Exception => e
962
+ primary = e
963
+ raise
964
+ ensure
965
+ cleanup_error = nil
966
+ opened&.each do |conn|
967
+ next if conn.equal?(chosen)
968
+
969
+ begin
970
+ conn.close
971
+ rescue Exception => e
972
+ cleanup_error ||= e
973
+ end
974
+ end
975
+ if cleanup_error && !primary
976
+ begin
977
+ chosen&.close
978
+ rescue Exception
979
+ nil
980
+ end
981
+ raise cleanup_error
982
+ end
983
+ end
984
+
985
+ private_class_method def self.acquire_connection(opts = {})
986
+ io = nil
987
+ deadline = opts[:deadline]
988
+ task = NativeTask.new(deadline).start do
989
+ io = opts[:binding].open(opts[:descriptor])
990
+ Connection.new(descriptor: opts[:descriptor], io: io, timeout: opts[:timeout], prepare: deadline)
991
+ end
992
+ task.await
993
+ rescue Exception
994
+ deadline&.stop
995
+ Thread.new do
996
+ task&.thread&.join
997
+ io&.close
998
+ rescue StandardError
999
+ nil
1000
+ end
1001
+ raise
1002
+ end
1003
+
1004
+ private_class_method def self.native(opts = {})
1005
+ kind = opts[:kind]
1006
+ raise ArgumentError, 'backend must be :hid or :pcsc' unless %i[hid pcsc].include?(kind)
1007
+
1008
+ BINDING_LOCK.synchronize do
1009
+ @bindings ||= {}
1010
+ @bindings[kind] ||= NativeLibrary.new(kind)
1011
+ end
1012
+ end
1013
+ end
1014
+ end
1015
+ end
1016
+ end
1017
+ # rubocop:enable Lint/RescueException