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,844 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'fcntl'
5
+ require 'timeout'
6
+
7
+ module PWN
8
+ module Plugins
9
+ # Native Ruby YubiKey applications over bounded HID and smartcard transports.
10
+ module YubiKey
11
+ class Error < StandardError
12
+ end
13
+
14
+ class DependencyError < Error
15
+ end
16
+
17
+ class CommandError < Error
18
+ end
19
+
20
+ class TimeoutError < Error
21
+ end
22
+
23
+ class OutputLimitError < Error
24
+ end
25
+
26
+ class ConfirmationRequired < Error
27
+ end
28
+
29
+ class ResponseError < Error
30
+ end
31
+
32
+ autoload :Protocol, 'pwn/plugins/yubikey/protocol'
33
+ autoload :Transport, 'pwn/plugins/yubikey/transport'
34
+ autoload :OTP, 'pwn/plugins/yubikey/otp'
35
+ autoload :Management, 'pwn/plugins/yubikey/management'
36
+ autoload :OATH, 'pwn/plugins/yubikey/oath'
37
+ autoload :HSMAuth, 'pwn/plugins/yubikey/hsmauth'
38
+ autoload :PIV, 'pwn/plugins/yubikey/piv'
39
+ autoload :FIDO, 'pwn/plugins/yubikey/fido'
40
+ autoload :OpenPGP, 'pwn/plugins/yubikey/openpgp'
41
+ autoload :SecurityDomain, 'pwn/plugins/yubikey/security_domain'
42
+
43
+ # ykman 5.9.1 grammar: maximum nonsecret positionals, switches, value options.
44
+ # Long options only: short aliases collide across application families.
45
+ # Credential values are deliberately absent; use the operator terminal workflow.
46
+ COMMANDS = {
47
+ 'list' => [0, %w[--serials --readers], %w[]],
48
+ 'info' => [0, %w[--check-fips], %w[]],
49
+ 'otp' => [0, %w[], %w[]],
50
+ 'otp info' => [0, %w[], %w[]],
51
+ 'otp swap' => [0, %w[--force], %w[]],
52
+ 'otp ndef' => [1, %w[], %w[--prefix --ndef-type]],
53
+ 'otp delete' => [1, %w[--force], %w[]],
54
+ 'otp yubiotp' => [1, %w[--enter --no-enter --serial-public-id --generate-private-id --generate-key --upload --force], %w[--public-id --config-output]],
55
+ 'otp static' => [1, %w[--generate --enter --no-enter --force], %w[--length --keyboard-layout]],
56
+ 'otp chalresp' => [1, %w[--touch --totp --generate --force], %w[]],
57
+ 'otp calculate' => [2, %w[--totp], %w[--digits]],
58
+ 'otp hotp' => [1, %w[--enter --no-enter --force], %w[--digits --counter --identifier]],
59
+ 'otp settings' => [1, %w[--force --delete-access-code --enter --no-enter --use-numeric-keypad --serial-usb-visible], %w[--pacing]],
60
+ 'openpgp' => [0, %w[], %w[]],
61
+ 'openpgp info' => [0, %w[], %w[]],
62
+ 'openpgp reset' => [0, %w[--force], %w[]],
63
+ 'openpgp access' => [0, %w[], %w[]],
64
+ 'openpgp access set-retries' => [3, %w[--force], %w[]],
65
+ 'openpgp access change-pin' => [0, %w[], %w[]],
66
+ 'openpgp access change-reset-code' => [0, %w[], %w[]],
67
+ 'openpgp access change-admin-pin' => [0, %w[], %w[]],
68
+ 'openpgp access unblock-pin' => [0, %w[], %w[]],
69
+ 'openpgp access set-signature-policy' => [1, %w[], %w[]],
70
+ 'openpgp keys' => [0, %w[], %w[]],
71
+ 'openpgp keys info' => [1, %w[], %w[]],
72
+ 'openpgp keys set-touch' => [2, %w[--force], %w[]],
73
+ 'openpgp keys import' => [2, %w[], %w[]],
74
+ 'openpgp keys attest' => [2, %w[], %w[--format]],
75
+ 'openpgp certificates' => [0, %w[], %w[]],
76
+ 'openpgp certificates export' => [2, %w[], %w[--format]],
77
+ 'openpgp certificates delete' => [1, %w[], %w[]],
78
+ 'openpgp certificates import' => [2, %w[], %w[]],
79
+ 'oath' => [0, %w[], %w[]],
80
+ 'oath info' => [0, %w[], %w[]],
81
+ 'oath reset' => [0, %w[--force], %w[]],
82
+ 'oath access' => [0, %w[], %w[]],
83
+ 'oath access change' => [0, %w[--clear --remember], %w[]],
84
+ 'oath access remember' => [0, %w[], %w[]],
85
+ 'oath access forget' => [0, %w[--all], %w[]],
86
+ 'oath accounts' => [0, %w[], %w[]],
87
+ 'oath accounts add' => [1, %w[--generate --touch --force --remember], %w[--oath-type --digits --algorithm --counter --issuer --period --output]],
88
+ 'oath accounts uri' => [0, %w[--touch --force --remember], %w[]],
89
+ 'oath accounts import' => [1, %w[--touch --force --remember], %w[]],
90
+ 'oath accounts list' => [0, %w[--show-hidden --oath-type --period --remember], %w[]],
91
+ 'oath accounts code' => [1, %w[--show-hidden --single --remember], %w[]],
92
+ 'oath accounts rename' => [2, %w[--force --remember], %w[]],
93
+ 'oath accounts delete' => [1, %w[--force --remember], %w[]],
94
+ 'piv' => [0, %w[], %w[]],
95
+ 'piv info' => [0, %w[], %w[]],
96
+ 'piv reset' => [0, %w[--force], %w[]],
97
+ 'piv access' => [0, %w[], %w[]],
98
+ 'piv access set-retries' => [2, %w[--force], %w[]],
99
+ 'piv access change-pin' => [0, %w[], %w[]],
100
+ 'piv access change-puk' => [0, %w[], %w[]],
101
+ 'piv access change-management-key' => [0, %w[--touch --protect --generate --force], %w[--algorithm]],
102
+ 'piv access unblock-pin' => [0, %w[], %w[]],
103
+ 'piv keys' => [0, %w[], %w[]],
104
+ 'piv keys generate' => [2, %w[], %w[--algorithm --format --pin-policy --touch-policy]],
105
+ 'piv keys import' => [2, %w[], %w[--pin-policy --touch-policy]],
106
+ 'piv keys attest' => [2, %w[], %w[--format]],
107
+ 'piv keys info' => [1, %w[], %w[]],
108
+ 'piv keys export' => [2, %w[--verify], %w[--format]],
109
+ 'piv keys move' => [2, %w[], %w[]],
110
+ 'piv keys delete' => [1, %w[], %w[]],
111
+ 'piv certificates' => [0, %w[], %w[]],
112
+ 'piv certificates import' => [2, %w[--verify --compress --update-chuid --no-update-chuid], %w[]],
113
+ 'piv certificates export' => [2, %w[], %w[--format]],
114
+ 'piv certificates generate' => [2, %w[--update-chuid --no-update-chuid], %w[--subject --valid-days --hash-algorithm]],
115
+ 'piv certificates request' => [3, %w[], %w[--subject --hash-algorithm]],
116
+ 'piv certificates delete' => [1, %w[--update-chuid --no-update-chuid], %w[]],
117
+ 'piv objects' => [0, %w[], %w[]],
118
+ 'piv objects export' => [2, %w[], %w[]],
119
+ 'piv objects import' => [2, %w[], %w[]],
120
+ 'piv objects generate' => [1, %w[], %w[]],
121
+ 'fido' => [0, %w[], %w[]],
122
+ 'fido info' => [0, %w[], %w[]],
123
+ 'fido reset' => [0, %w[--force], %w[]],
124
+ 'fido access' => [0, %w[], %w[]],
125
+ 'fido access change-pin' => [0, %w[--u2f], %w[]],
126
+ 'fido access verify-pin' => [0, %w[], %w[]],
127
+ 'fido access force-change' => [0, %w[], %w[]],
128
+ 'fido access set-min-length' => [1, %w[], %w[--rp-id]],
129
+ 'fido credentials' => [0, %w[], %w[]],
130
+ 'fido credentials list' => [0, %w[--csv], %w[]],
131
+ 'fido credentials delete' => [1, %w[--force], %w[]],
132
+ 'fido fingerprints' => [0, %w[], %w[]],
133
+ 'fido fingerprints list' => [0, %w[], %w[]],
134
+ 'fido fingerprints add' => [1, %w[], %w[]],
135
+ 'fido fingerprints rename' => [2, %w[], %w[]],
136
+ 'fido fingerprints delete' => [1, %w[--force], %w[]],
137
+ 'fido config' => [0, %w[], %w[]],
138
+ 'fido config toggle-always-uv' => [0, %w[], %w[]],
139
+ 'fido config enable-ep-attestation' => [0, %w[], %w[]],
140
+ 'config' => [0, %w[], %w[]],
141
+ 'config reset' => [0, %w[--force], %w[]],
142
+ 'config set-lock-code' => [0, %w[--force --clear --generate], %w[]],
143
+ 'config usb' => [0, %w[--force --list --enable-all --touch-eject --no-touch-eject], %w[--enable --disable --autoeject-timeout --chalresp-timeout]],
144
+ 'config nfc' => [0, %w[--force --enable-all --disable-all --list --restrict], %w[--enable --disable]],
145
+ 'config mode' => [1, %w[--touch-eject --force], %w[--autoeject-timeout --chalresp-timeout]],
146
+ 'apdu' => [256, %w[--no-pretty --short], %w[--app --send-apdu]],
147
+ 'hsmauth' => [0, %w[], %w[]],
148
+ 'hsmauth info' => [0, %w[], %w[]],
149
+ 'hsmauth reset' => [0, %w[--force], %w[]],
150
+ 'hsmauth credentials' => [0, %w[], %w[]],
151
+ 'hsmauth credentials list' => [0, %w[], %w[]],
152
+ 'hsmauth credentials generate' => [1, %w[--touch], %w[]],
153
+ 'hsmauth credentials import' => [2, %w[--touch], %w[]],
154
+ 'hsmauth credentials export' => [2, %w[], %w[--format]],
155
+ 'hsmauth credentials symmetric' => [1, %w[--generate --touch], %w[]],
156
+ 'hsmauth credentials derive' => [1, %w[--touch], %w[]],
157
+ 'hsmauth credentials delete' => [1, %w[--force], %w[]],
158
+ 'hsmauth credentials change-password' => [1, %w[], %w[]],
159
+ 'hsmauth access' => [0, %w[], %w[]],
160
+ 'hsmauth access change-management-key' => [0, %w[--generate], %w[]],
161
+ 'hsmauth access change-management-password' => [0, %w[], %w[]],
162
+ 'sd' => [0, %w[], %w[]],
163
+ 'sd info' => [0, %w[], %w[]],
164
+ 'sd reset' => [0, %w[--force], %w[]],
165
+ 'sd keys' => [0, %w[], %w[]],
166
+ 'sd keys generate' => [3, %w[], %w[--replace-kvn]],
167
+ 'sd keys import' => [3, %w[], %w[--replace-kvn]],
168
+ 'sd keys export' => [3, %w[], %w[]],
169
+ 'sd keys delete' => [2, %w[--force], %w[]],
170
+ 'sd keys set-allowlist' => [256, %w[], %w[]]
171
+ }.transform_values { |entry| entry.each(&:freeze).freeze }.freeze
172
+
173
+ SELECTOR_OPTIONS = %i[serial reader device_path timeout].freeze
174
+ COMMON_OPTIONS = (SELECTOR_OPTIONS + %i[connection confirm secure_channel secret_provider]).freeze
175
+ NATIVE_OPTIONS = {
176
+ 'otp' => %i[key password access_code new_access_code private_id public_id generate generate_key generate_private_id secret_sink touch totp digits counter identifier key_encoding keyboard_layout length prefix ndef_type config_output enter no_enter serial_public_id delete_access_code use_numeric_keypad serial_usb_visible pacing verification_challenge challenge timestamp],
177
+ 'config' => %i[lock_code new_lock_code secret_sink reconnect enable disable enable_all disable_all touch_eject no_touch_eject autoeject_timeout chalresp_timeout restrict clear generate reboot list mode],
178
+ 'oath' => %i[password new_password access_key new_access_key timestamp encrypted_cache remember use_cache secret secret_base32 uri credentials pskc pskc_password pskc_key oath_type algorithm digits period counter issuer touch generate overwrite],
179
+ 'hsmauth' => %i[management_key new_management_key new_management_password credential_password new_credential_password password derivation_password key_enc key_mac private_key key_password public_key touch generate],
180
+ 'piv' => %i[pin new_pin puk new_puk management_key new_management_key management_algorithm algorithm input password certificate_serial extensions fips_restrictions secret_sink generate protect touch pin_policy touch_policy use_protected_key temporary_pin request_temporary_pin block_retries subject valid_days hash_algorithm not_before not_after compress update_chuid no_update_chuid verify format public_key_source],
181
+ 'fido' => %i[pin new_pin u2f uv rp_ids min_length on_keepalive cancelled on_sample max_records max_samples capture_timeout_ms],
182
+ 'openpgp' => %i[pin new_pin admin_pin new_admin_pin reset_code new_reset_code use_admin key key_password certificate],
183
+ 'sd' => %i[key key_enc key_mac key_dek certificate certificates ski curve replace_kvn serials]
184
+ }.transform_values(&:freeze).freeze
185
+ FAMILIES = {
186
+ 'otp' => %i[OTP otp], 'config' => %i[Management management],
187
+ 'oath' => %i[OATH oath], 'hsmauth' => %i[HSMAuth hsmauth],
188
+ 'piv' => %i[PIV piv], 'fido' => %i[FIDO fido],
189
+ 'openpgp' => %i[OpenPGP openpgp], 'sd' => %i[SecurityDomain security_domain]
190
+ }.freeze
191
+
192
+ public_class_method def self.required_bins
193
+ []
194
+ end
195
+
196
+ public_class_method def self.available?(opts = {})
197
+ validate_options(options: opts, allowed: %i[backend application])
198
+ Transport.available?(opts.slice(:backend, :application))
199
+ end
200
+
201
+ public_class_method def self.devices(opts = {})
202
+ validate_options(options: opts, allowed: SELECTOR_OPTIONS + [:backend])
203
+ validate_selection(opts.merge(serial: opts[:serial]))
204
+ Transport.devices(opts)
205
+ end
206
+
207
+ public_class_method def self.challenge_response(opts = {})
208
+ validate_options(options: opts, allowed: COMMON_OPTIONS + %i[challenge slot])
209
+ validate_selection(opts)
210
+ validate_slot(slot: opts.fetch(:slot, 2))
211
+ challenge = opts[:challenge]
212
+ raise ArgumentError, 'challenge must contain 1 to 64 raw bytes, not pre-encoded hex' unless challenge.is_a?(String) && challenge.bytesize.between?(1, 64)
213
+
214
+ # Omitted confirm is the vault unlock path. An explicit flag must still be literal true.
215
+ Protocol.require_confirm(opts) if opts.key?(:confirm)
216
+ result = with_connection(opts.merge(application: :otp)) { |connection| OTP.challenge_response(opts.merge(connection: connection)) }
217
+ raise ResponseError, 'OTP must return exactly 20 raw HMAC bytes' unless result.is_a?(String) && result.bytesize == 20
218
+
219
+ result.b
220
+ end
221
+
222
+ public_class_method def self.provision_challenge_response(opts = {})
223
+ validate_options(options: opts, allowed: COMMON_OPTIONS + %i[key slot access_code new_access_code touch verification_challenge])
224
+ validate_selection(opts)
225
+ Protocol.require_confirm(opts)
226
+ validate_slot(slot: opts.fetch(:slot, 2))
227
+ key = opts[:key]
228
+ raise ArgumentError, 'key must contain exactly 20 raw bytes' unless key.is_a?(String) && key.bytesize == 20
229
+ raise ArgumentError, 'Provisioning requires an explicit serial, reader, device_path or connection' unless opts[:serial] || opts[:reader] || opts[:device_path] || opts[:connection]
230
+ raise ArgumentError, 'touch must be a boolean' unless [true, false].include?(opts.fetch(:touch, true))
231
+
232
+ %i[access_code new_access_code].each do |name|
233
+ value = opts[name]
234
+ raise ArgumentError, 'OTP access codes must contain six raw bytes' unless value.nil? || (value.is_a?(String) && value.bytesize == 6)
235
+ end
236
+ challenge = opts[:verification_challenge]
237
+ raise ArgumentError, 'verification_challenge must contain 1 to 64 raw bytes' unless challenge.nil? || (challenge.is_a?(String) && challenge.bytesize.between?(1, 64))
238
+
239
+ result = with_connection(opts.merge(application: :otp)) { |connection| OTP.provision_challenge_response(opts.merge(connection: connection)) }
240
+ raise ResponseError, 'OTP provisioning verification failed; inspect the device before retrying' unless result == :verified || (result.is_a?(Hash) && result[:verified] == true && result[:verification] == :hmac_sha1)
241
+
242
+ :verified
243
+ end
244
+
245
+ public_class_method def self.oath_code(opts = {})
246
+ validate_options(options: opts, allowed: COMMON_OPTIONS + %i[account timestamp password access_key encrypted_cache remember use_cache])
247
+ validate_selection(opts)
248
+ account = opts[:account]
249
+ raise ArgumentError, 'account must contain 1 to 64 exact control-free bytes' unless account.is_a?(String) && account.bytesize.between?(1, 64) && account.valid_encoding? && !account.b.match?(/[\x00-\x1f\x7f]/)
250
+
251
+ timestamp = opts[:timestamp]
252
+ raise ArgumentError, 'timestamp must be a nonnegative Integer' unless timestamp.nil? || (timestamp.is_a?(Integer) && timestamp.between?(0, 0x7fffffffffffffff))
253
+
254
+ validate_oath_options(opts)
255
+ Protocol.require_confirm(opts)
256
+ result = with_connection(opts.merge(application: :oath)) { |connection| OATH.code(opts.merge(connection: connection)) }
257
+ raise ResponseError, 'OATH must return exactly one numeric code' unless result.is_a?(String) && result.match?(/\A[0-9]{6,8}\z/)
258
+
259
+ result
260
+ end
261
+
262
+ public_class_method def self.info(opts = {})
263
+ validate_options(options: opts, allowed: COMMON_OPTIONS)
264
+ validate_selection(opts.merge(connection: opts[:connection]))
265
+ with_connection(opts.merge(application: :management)) { |connection| Management.info(opts.merge(connection: connection)) }
266
+ end
267
+
268
+ public_class_method def self.cli_help(opts = {})
269
+ validate_options(options: opts, allowed: SELECTOR_OPTIONS + [:path])
270
+ validate_selection(opts)
271
+ path = opts[:path] || []
272
+ raise ArgumentError, 'path must contain only supported native command names' unless path.is_a?(Array) && path.all? { |part| part.is_a?(String) && part.match?(/\A[a-z][a-z0-9-]*\z/) }
273
+ return { backend: :native, families: FAMILIES.keys, operations: %w[list info apdu] } if path.empty?
274
+ return { backend: :native, operations: [path.first] } if path.length == 1 && %w[list info apdu].include?(path.first)
275
+
276
+ route = FAMILIES[path.first]
277
+ raise ArgumentError, 'Unknown native help path' unless route
278
+
279
+ prefix = path.drop(1).join(' ')
280
+ operations = const_get(route[0], false).operations.select { |entry| prefix.empty? || entry == prefix || entry.start_with?("#{prefix} ") }
281
+ raise ArgumentError, 'Unknown native help path' if operations.empty?
282
+
283
+ { backend: :native, application: route[1], path: path.dup, operations: operations }
284
+ end
285
+
286
+ public_class_method def self.config(opts = {})
287
+ family(opts.merge(family: 'config', args: opts[:args]))
288
+ end
289
+
290
+ public_class_method def self.otp(opts = {})
291
+ family(opts.merge(family: 'otp', args: opts[:args]))
292
+ end
293
+
294
+ public_class_method def self.oath(opts = {})
295
+ family(opts.merge(family: 'oath', args: opts[:args]))
296
+ end
297
+
298
+ public_class_method def self.fido(opts = {})
299
+ family(opts.merge(family: 'fido', args: opts[:args]))
300
+ end
301
+
302
+ public_class_method def self.piv(opts = {})
303
+ family(opts.merge(family: 'piv', args: opts[:args]))
304
+ end
305
+
306
+ public_class_method def self.openpgp(opts = {})
307
+ family(opts.merge(family: 'openpgp', args: opts[:args]))
308
+ end
309
+
310
+ public_class_method def self.hsmauth(opts = {})
311
+ family(opts.merge(family: 'hsmauth', args: opts[:args]))
312
+ end
313
+
314
+ public_class_method def self.security_domain(opts = {})
315
+ family(opts.merge(family: 'sd', args: opts[:args]))
316
+ end
317
+
318
+ public_class_method def self.apdu(opts = {})
319
+ validate_options(options: opts, allowed: COMMON_OPTIONS + %i[args application ins cla p1 p2 data le extended max_response])
320
+ validate_selection(opts)
321
+ args = opts[:args]
322
+ return cli_help(path: ['apdu']) if !opts.key?(:ins) && (args.nil? || args == [] || args == ['--help'])
323
+
324
+ Protocol.require_confirm(opts)
325
+ if opts.key?(:ins)
326
+ raise ArgumentError, 'Do not combine typed APDU fields and argv' unless args.nil?
327
+
328
+ validate_apdu_fields(opts)
329
+ application = opts.fetch(:application, :security_domain)
330
+ raise ArgumentError, 'Unknown APDU application selector' unless FAMILIES.values.map(&:last).include?(application)
331
+
332
+ return with_connection(opts.merge(application: application)) do |connection|
333
+ Protocol.apdu(opts.slice(:ins, :cla, :p1, :p2, :data, :le, :extended, :max_response, :timeout).merge(connection: connection))
334
+ end
335
+ end
336
+ raise ArgumentError, 'Typed APDU fields require ins' if opts.keys.intersect?(%i[data cla p1 p2 le extended max_response application])
337
+
338
+ prepared = prepare_apdus(args: validate_args(args: args))
339
+ with_connection(opts.merge(application: prepared[:application])) do |connection|
340
+ raise ArgumentError, 'Raw APDUs require smartcard transport' unless connection.descriptor[:protocol] == :apdu
341
+
342
+ Protocol.select(connection: connection, aid: prepared[:aid], timeout: opts.fetch(:timeout, 30)) if prepared[:aid]
343
+ prepared[:frames].map do |frame|
344
+ response = connection.exchange(protocol: :apdu, data: frame[:data], timeout: opts.fetch(:timeout, 30))
345
+ raise ResponseError, 'Malformed raw APDU response' unless response.is_a?(String) && response.bytesize.between?(2, Protocol::MAX_BYTES)
346
+
347
+ status = response.byteslice(-2, 2).unpack1('n')
348
+ raise Protocol::APDUError.new(status: status) if frame[:expected] && status != frame[:expected]
349
+
350
+ { data: response.byteslice(0, response.bytesize - 2).b, status: status }
351
+ end
352
+ end
353
+ end
354
+
355
+ public_class_method def self.command(opts = {})
356
+ args = validate_args(args: opts[:args])
357
+ return cli_help(opts.except(:args).merge(path: [])) if [%w[--help], %w[--full-help]].include?(args)
358
+ return cli_help(opts.except(:args, :confirm).merge(path: args[0...-1])) if args.last == '--help'
359
+ return info(opts.except(:args)) if args == ['info']
360
+ return apdu(opts.merge(args: args.drop(1))) if args.first == 'apdu'
361
+ return devices(opts.except(:args)) if args == ['list']
362
+
363
+ if [%w[list --serials], %w[list --readers]].include?(args)
364
+ key = args.last == '--serials' ? :serial : :reader
365
+ return devices(opts.except(:args)).filter_map { |entry| entry[key] }.uniq
366
+ end
367
+ validate_options(options: opts, allowed: COMMON_OPTIONS + [:args] + NATIVE_OPTIONS.fetch(args.first, []))
368
+ validate_selection(opts)
369
+ family = args.first
370
+ route = FAMILIES[family]
371
+ raise ArgumentError, 'Unknown native YubiKey command' unless route
372
+
373
+ child = const_get(route[0], false)
374
+ path = child.operations.sort_by { |entry| -entry.split.length }.find { |entry| args.drop(1).take(entry.split.length) == entry.split }
375
+ return cli_help(opts.slice(*SELECTOR_OPTIONS).merge(path: args)) unless path
376
+
377
+ validate_command(args: args, path: "#{family} #{path}")
378
+ Protocol.require_confirm(opts) unless read_only?(opts.merge(path: "#{family} #{path}", args: args))
379
+ with_connection(opts.merge(application: route[1])) do |connection|
380
+ child.execute(opts.merge(args: args.drop(1), connection: connection))
381
+ end
382
+ end
383
+
384
+ public_class_method def self.interact(opts = {})
385
+ Protocol.require_confirm(opts)
386
+ raise ArgumentError, 'Private terminal use requires operator: true outside agent requests' unless opts[:operator] == true && Thread.current[:pwn_request_runtime].nil?
387
+ raise ArgumentError, 'interact owns its private credential provider' if opts.key?(:secret_provider)
388
+
389
+ args = validate_args(args: opts[:args])
390
+ validate_options(options: opts, allowed: COMMON_OPTIONS + %i[args terminal operator] + NATIVE_OPTIONS.fetch(args.first, []))
391
+ validate_selection(opts)
392
+ terminal = opts[:terminal].nil? ? IO.console : opts[:terminal]
393
+ validate_terminal(terminal: terminal)
394
+ flags = terminal.fcntl(Fcntl::F_GETFL)
395
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + opts.fetch(:timeout, 30)
396
+ secrets = []
397
+ active = true
398
+ provider = lambda do |name|
399
+ raise ArgumentError, 'Private input callback is no longer active' unless active
400
+ raise ArgumentError, 'Invalid private credential field name' unless name.is_a?(Symbol) && name.to_s.match?(/\A[a-z_]{1,64}\z/)
401
+
402
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
403
+ raise TimeoutError, 'Private YubiKey input timed out' unless remaining.positive?
404
+
405
+ value = ::Timeout.timeout(remaining, TimeoutError, 'Private YubiKey input timed out') do
406
+ terminal.noecho { terminal.getpass("YubiKey #{name.to_s.tr('_', ' ')}: ") }
407
+ end
408
+ raise CommandError, 'Private YubiKey input ended without a credential' unless value.is_a?(String) && !value.empty?
409
+
410
+ secrets << value
411
+ value
412
+ end
413
+ command(opts.except(:terminal, :operator).merge(args: args, secret_provider: provider,
414
+ timeout: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)))
415
+ raise TimeoutError, 'Private YubiKey operation timed out' unless Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
416
+
417
+ :completed
418
+ rescue IOError, SystemCallError
419
+ raise CommandError, 'Private YubiKey terminal failed; device state may need inspection', cause: nil
420
+ ensure
421
+ active = false
422
+ secrets&.each { |value| value.replace("\0".b * value.bytesize) unless value.frozen? }
423
+ secrets&.clear
424
+ terminal.fcntl(Fcntl::F_SETFL, flags) if flags && terminal && !terminal.closed?
425
+ end
426
+
427
+ public_class_method def self.authors
428
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
429
+ end
430
+
431
+ public_class_method def self.help
432
+ puts <<~HELP
433
+ USAGE:
434
+ Native Ruby YubiKey applications over HID and smartcard transports.
435
+ No ykman process is started. Secrets stay in memory. One total deadline
436
+ covers open, authentication and commands. Existing-slot HMAC does not
437
+ require confirm; mutations, OATH and provisioning do.
438
+
439
+ # Return the empty executable dependency list.
440
+ #{self}.required_bins
441
+
442
+ # Probe host libraries without discovering or opening a device.
443
+ #{self}.available?(
444
+ backend: 'optional - hid or pcsc',
445
+ application: 'optional - otp, oath, piv, openpgp, fido, management, hsmauth or security_domain'
446
+ )
447
+
448
+ # Explicitly list native descriptors.
449
+ #{self}.devices(
450
+ serial: 'optional - exact decimal serial String',
451
+ reader: 'optional - nonempty smartcard reader String',
452
+ device_path: 'optional - nonempty device path String',
453
+ timeout: 'optional - finite positive seconds, defaults to 30',
454
+ backend: 'optional - hid or pcsc'
455
+ )
456
+
457
+ # Return 20 raw HMAC-SHA1 bytes from an already provisioned slot.
458
+ #{self}.challenge_response(
459
+ challenge: 'required - raw String of 1 to 64 bytes',
460
+ slot: 'optional - integer 1 or 2, defaults to 2',
461
+ serial: 'optional - exact decimal serial String',
462
+ reader: 'optional - nonempty smartcard reader String',
463
+ device_path: 'optional - nonempty device path String',
464
+ timeout: 'optional - finite positive seconds, defaults to 30',
465
+ connection: 'optional - caller-owned native exchange connection'
466
+ )
467
+
468
+ # Overwrite an OTP slot and verify a fresh HMAC before returning.
469
+ #{self}.provision_challenge_response(
470
+ key: 'required - exactly 20 raw key bytes',
471
+ confirm: 'required - literal true',
472
+ slot: 'optional - integer 1 or 2, defaults to 2',
473
+ serial: 'optional - exact decimal serial String; one selector is required',
474
+ reader: 'optional - nonempty smartcard reader String',
475
+ device_path: 'optional - nonempty device path String',
476
+ connection: 'optional - caller-owned native exchange connection',
477
+ timeout: 'optional - finite positive seconds, defaults to 30',
478
+ touch: 'optional - boolean, defaults to true',
479
+ access_code: 'optional - six raw access-code bytes',
480
+ new_access_code: 'optional - six raw replacement access-code bytes',
481
+ verification_challenge: 'optional - raw challenge of 1 to 64 bytes'
482
+ )
483
+
484
+ # Return one exact-account OATH code, preserving leading zeros.
485
+ #{self}.oath_code(
486
+ account: 'required - exact account String of 1 to 64 bytes',
487
+ confirm: 'required - literal true',
488
+ timestamp: 'optional - nonnegative Integer epoch seconds',
489
+ password: 'optional - nonempty in-memory OATH password',
490
+ access_key: 'optional - 16 raw access-key bytes',
491
+ serial: 'optional - exact decimal serial String',
492
+ reader: 'optional - nonempty smartcard reader String',
493
+ device_path: 'optional - nonempty device path String',
494
+ timeout: 'optional - finite positive seconds, defaults to 30',
495
+ connection: 'optional - caller-owned native exchange connection',
496
+ remember: 'optional - boolean cache control; requires encrypted_cache',
497
+ use_cache: 'optional - boolean cache control; requires encrypted_cache',
498
+ encrypted_cache: 'optional - caller adapter with encrypted?, fetch, store and delete'
499
+ )
500
+
501
+ # Read structured management information.
502
+ #{self}.info(
503
+ serial: 'optional - exact decimal serial String',
504
+ reader: 'optional - nonempty smartcard reader String',
505
+ device_path: 'optional - nonempty device path String',
506
+ timeout: 'optional - finite positive seconds, defaults to 30',
507
+ connection: 'optional - caller-owned native exchange connection'
508
+ )
509
+
510
+ # Return local native help metadata without opening a device.
511
+ #{self}.cli_help(
512
+ path: 'optional - Array of supported command name Strings'
513
+ )
514
+
515
+ # Dispatch a config leaf through the native management application.
516
+ #{self}.config(args: 'optional - argv after the family name')
517
+
518
+ # Dispatch an otp leaf through the native OTP application.
519
+ #{self}.otp(args: 'optional - argv after the family name')
520
+
521
+ # Dispatch an oath leaf through the native OATH application.
522
+ #{self}.oath(args: 'optional - argv after the family name')
523
+
524
+ # Dispatch a fido leaf through the native FIDO application.
525
+ #{self}.fido(args: 'optional - argv after the family name')
526
+
527
+ # Dispatch a piv leaf through the native PIV application.
528
+ #{self}.piv(args: 'optional - argv after the family name')
529
+
530
+ # Dispatch an openpgp leaf through the native OpenPGP application.
531
+ #{self}.openpgp(args: 'optional - argv after the family name')
532
+
533
+ # Dispatch an hsmauth leaf through the native HSM Auth application.
534
+ #{self}.hsmauth(args: 'optional - argv after the family name')
535
+
536
+ # Dispatch a security_domain leaf through the native Security Domain application.
537
+ #{self}.security_domain(args: 'optional - argv after the family name')
538
+
539
+ # Send an explicit smartcard command. Mutations require confirm: true.
540
+ #{self}.apdu(
541
+ args: 'optional - compact or full APDU argv; omit with ins for typed fields',
542
+ ins: 'optional - integer instruction byte for a typed APDU',
543
+ cla: 'optional - integer class byte, defaults to zero',
544
+ p1: 'optional - integer parameter byte, defaults to zero',
545
+ p2: 'optional - integer parameter byte, defaults to zero',
546
+ data: 'optional - raw command bytes',
547
+ le: 'optional - expected response length',
548
+ extended: 'optional - boolean extended framing',
549
+ application: 'optional - native application Symbol for a typed APDU',
550
+ max_response: 'optional - positive response byte limit',
551
+ confirm: 'required - literal true before a device APDU',
552
+ timeout: 'optional - finite positive seconds, defaults to 30',
553
+ connection: 'optional - caller-owned native exchange connection'
554
+ )
555
+
556
+ # Dispatch a supported leaf to its native Ruby application.
557
+ #{self}.command(
558
+ args: 'required - nonsecret command Array including the family name',
559
+ confirm: 'required - literal true for mutations and OATH calculation',
560
+ serial: 'optional - exact decimal serial String',
561
+ reader: 'optional - nonempty smartcard reader String',
562
+ device_path: 'optional - nonempty device path String',
563
+ timeout: 'optional - finite positive seconds, defaults to 30',
564
+ connection: 'optional - caller-owned native exchange connection',
565
+ secret_provider: 'optional - callable receiving a field Symbol',
566
+ secure_channel: 'optional - explicit SCP selector Hash'
567
+ )
568
+
569
+ # Prompt on a private operator terminal and run one native command.
570
+ #{self}.interact(
571
+ args: 'required - nonsecret command Array',
572
+ confirm: 'required - literal true',
573
+ operator: 'required - literal true outside an agent request',
574
+ terminal: 'optional - readable and writable IO terminal',
575
+ timeout: 'optional - finite positive seconds including human input, defaults to 30',
576
+ serial: 'optional - exact decimal serial String',
577
+ reader: 'optional - nonempty smartcard reader String',
578
+ device_path: 'optional - nonempty device path String',
579
+ connection: 'optional - caller-owned native exchange connection'
580
+ )
581
+
582
+ # Return module author information without device access.
583
+ #{self}.authors
584
+ HELP
585
+ nil
586
+ end
587
+
588
+ private_class_method def self.validate_terminal(opts = {})
589
+ terminal = opts[:terminal]
590
+ raise ArgumentError, 'A private readable and writable IO terminal is required' unless terminal.is_a?(IO) && !terminal.closed? && terminal.tty?
591
+
592
+ # Ruby's wrapper modes can be narrower than the descriptor's O_RDWR.
593
+ # These probes consume no bytes and do not alter descriptor ownership.
594
+ terminal.read(0)
595
+ terminal.syswrite('')
596
+ rescue IOError, SystemCallError
597
+ raise ArgumentError, 'A private readable and writable IO terminal is required', cause: nil
598
+ end
599
+
600
+ private_class_method def self.validate_apdu_fields(opts = {})
601
+ %i[ins cla p1 p2].each do |key|
602
+ value = opts.fetch(key, 0)
603
+ raise ArgumentError, 'APDU header fields must be integer bytes' unless value.is_a?(Integer) && value.between?(0, 255)
604
+ end
605
+ data = opts.fetch(:data, ''.b)
606
+ raise ArgumentError, 'APDU data must be a bounded raw String' unless data.is_a?(String) && data.bytesize <= Protocol::MAX_BYTES
607
+ raise ArgumentError, 'extended must be a boolean' unless [true, false].include?(opts.fetch(:extended, false))
608
+
609
+ le = opts[:le]
610
+ raise ArgumentError, 'APDU Le must fit its frame encoding' unless le.nil? || (le.is_a?(Integer) && le.between?(0, opts[:extended] ? 65_536 : 256))
611
+
612
+ limit = opts.fetch(:max_response, Protocol::MAX_BYTES)
613
+ raise ArgumentError, 'max_response must be a bounded positive Integer' unless limit.is_a?(Integer) && limit.between?(1, Protocol::MAX_BYTES)
614
+ end
615
+
616
+ private_class_method def self.prepare_apdus(opts = {})
617
+ parsed = Protocol.parse_args(args: opts[:args], flags: %w[--no-pretty --short], values: %w[--app --send-apdu])
618
+ values = parsed[:options]
619
+ application = :security_domain
620
+ if values[:app]
621
+ route = FAMILIES[values[:app]]
622
+ raise ArgumentError, 'Unknown APDU application name' unless route
623
+
624
+ application = route[1]
625
+ child = const_get(route[0], false)
626
+ raise ArgumentError, 'Application has no smartcard AID' unless child.const_defined?(:AID, false)
627
+
628
+ aid = child.const_get(:AID, false)
629
+ end
630
+ if values[:send_apdu]
631
+ raise ArgumentError, 'Full APDUs cannot be combined with application or compact commands' if aid || !parsed[:positionals].empty?
632
+
633
+ frames = Array(values[:send_apdu]).map do |value|
634
+ raise ArgumentError, 'A full APDU must be even hex containing 4 to 65544 bytes' unless value.match?(/\A(?:[0-9a-fA-F]{2}){4,65544}\z/)
635
+
636
+ { data: [value].pack('H*'), expected: nil }
637
+ end
638
+ else
639
+ frames = parsed[:positionals].map { |value| compact_apdu(value: value, short: values[:short]) }
640
+ end
641
+ raise ArgumentError, 'At least one APDU or explicit application is required' if frames.empty? && aid.nil?
642
+
643
+ { application: application, aid: aid, frames: frames }
644
+ end
645
+
646
+ private_class_method def self.compact_apdu(opts = {})
647
+ # Compatibility syntax is a local parser, never executable input.
648
+ match = opts[:value].match(%r{\A(?<cla>[0-9a-f]{2})?(?<ins>[0-9a-f]{2})(?<params>[0-9a-f]{4})?(?::(?<body>(?:[0-9a-f]{2})+))?(?:/(?<le>[0-9a-f]{2}))?(?<check>=(?<sw>[0-9a-f]{4})?)?\z}i)
649
+ raise ArgumentError, 'Malformed compact APDU syntax' unless match
650
+
651
+ header = [match[:cla] ? match[:cla].to_i(16) : 0, match[:ins].to_i(16), *(match[:params] || '0000').scan(/../).map { |value| value.to_i(16) }].pack('C*')
652
+ data = [match[:body] || ''].pack('H*')
653
+ le = match[:le] ? match[:le].to_i(16) : 0
654
+ if data.bytesize > 255
655
+ raise ArgumentError, 'Compact APDU data exceeds selected encoding' if opts[:short] || data.bytesize > 65_535
656
+
657
+ encoded = header + "\0".b + [data.bytesize].pack('n') + data + [le].pack('n')
658
+ else
659
+ encoded = header + (data.empty? ? ''.b : [data.bytesize].pack('C') + data) + [le].pack('C')
660
+ end
661
+ expected = if match[:check]
662
+ match[:sw].to_s.empty? ? 0x9000 : match[:sw].to_i(16)
663
+ end
664
+ { data: encoded, expected: expected }
665
+ end
666
+
667
+ private_class_method def self.validate_slot(opts = {})
668
+ slot = opts[:slot]
669
+ raise ArgumentError, 'slot must be integer 1 or 2' unless slot.is_a?(Integer) && [1, 2].include?(slot)
670
+ end
671
+
672
+ private_class_method def self.validate_oath_options(opts = {})
673
+ password = opts[:password]
674
+ key = opts[:access_key]
675
+ raise ArgumentError, 'password must be a nonempty in-memory String' unless password.nil? || (password.is_a?(String) && !password.empty?)
676
+ raise ArgumentError, 'access_key must contain 16 raw bytes' unless key.nil? || (key.is_a?(String) && key.bytesize == 16)
677
+ raise ArgumentError, 'Choose password or access_key, not both' if password && key
678
+ raise ArgumentError, 'use_cache conflicts with an explicit credential' if opts[:use_cache] && (password || key)
679
+
680
+ %i[remember use_cache].each do |name|
681
+ raise ArgumentError, 'OATH cache controls must be booleans' if opts.key?(name) && ![true, false].include?(opts[name])
682
+ end
683
+ return unless opts[:remember] || opts[:use_cache]
684
+
685
+ cache = opts[:encrypted_cache]
686
+ raise ArgumentError, 'Supply an explicit encrypted_cache adapter; no host cache is opened' unless cache.respond_to?(:encrypted?) && cache.encrypted? == true && %i[fetch store delete].all? { |name| cache.respond_to?(name) }
687
+ end
688
+
689
+ private_class_method def self.family(opts = {})
690
+ args = opts[:args]
691
+ raise ArgumentError, 'args must be an Array' unless args.nil? || args.is_a?(Array)
692
+
693
+ command(opts.except(:family).merge(args: [opts[:family]] + (args || [])))
694
+ end
695
+
696
+ # One deadline spans acquisition, secure-channel setup and all application I/O.
697
+ # Closing this lease never closes a caller-owned underlying descriptor.
698
+ class DeadlineConnection
699
+ def initialize(opts, deadline)
700
+ @connection = opts[:connection]
701
+ @owned = @connection.nil?
702
+ @selection = opts.slice(*SELECTOR_OPTIONS).merge(application: opts[:application])
703
+ @deadline = deadline
704
+ end
705
+
706
+ def acquire
707
+ @connection ||= Transport.open(@selection.merge(timeout: remaining))
708
+ remaining
709
+ @connection
710
+ end
711
+
712
+ def release
713
+ handle = @connection
714
+ @connection = nil
715
+ handle.close if @owned && handle
716
+ :released
717
+ end
718
+
719
+ def remaining
720
+ seconds = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
721
+ raise TimeoutError, 'Native YubiKey operation timed out; inspect device state before retrying' unless seconds.positive?
722
+
723
+ seconds
724
+ end
725
+
726
+ def exchange(opts = {})
727
+ timeout = opts.fetch(:timeout, remaining)
728
+ raise ArgumentError, 'exchange timeout must be finite positive seconds' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
729
+
730
+ handle = acquire
731
+ result = handle.exchange(**opts, timeout: [timeout, remaining].min)
732
+ remaining
733
+ result
734
+ end
735
+
736
+ def descriptor
737
+ remaining
738
+ value = acquire.descriptor
739
+ remaining
740
+ value
741
+ end
742
+
743
+ def close
744
+ :released
745
+ end
746
+
747
+ def inspect
748
+ '#<YubiKey deadline connection>'
749
+ end
750
+ end
751
+ private_constant :DeadlineConnection
752
+
753
+ private_class_method def self.with_connection(opts = {})
754
+ timeout = opts.fetch(:timeout, 30)
755
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
756
+ lease = DeadlineConnection.new(opts, deadline)
757
+ wrapped = nil
758
+ lease.remaining
759
+ if opts[:secure_channel]
760
+ lease.acquire
761
+ wrapped = SecurityDomain.secure_connection(opts[:secure_channel].merge(connection: lease, confirm: opts[:confirm], timeout: lease.remaining))
762
+ raise ResponseError, 'Secure-channel handshake did not return a native connection' unless wrapped.respond_to?(:exchange)
763
+ end
764
+ result = yield(wrapped || lease)
765
+ lease.remaining
766
+ result
767
+ rescue IOError, SystemCallError
768
+ raise CommandError, 'Native YubiKey I/O failed; inspect device state before retrying', cause: nil
769
+ ensure
770
+ begin
771
+ wrapped.close if wrapped
772
+ ensure
773
+ lease&.release
774
+ end
775
+ end
776
+
777
+ private_class_method def self.read_only?(opts = {})
778
+ path = opts[:path]
779
+ args = opts[:args]
780
+ return true if path.end_with?(' info') || path == 'hsmauth credentials list'
781
+
782
+ if path == 'oath accounts list'
783
+ return false if args.include?('--remember') || %i[password access_key secret_provider remember use_cache].any? { |name| opts[name] }
784
+
785
+ return true # A protected application still checks consent before authenticating.
786
+ end
787
+ return false unless %w[config].include?(args.first) && %w[usb nfc].include?(args[1])
788
+ return false unless args.drop(2) == ['--list'] || (args.length == 2 && opts[:list] == true)
789
+
790
+ !opts.keys.intersect?(NATIVE_OPTIONS.fetch('config') - [:list])
791
+ end
792
+
793
+ private_class_method def self.validate_options(opts = {})
794
+ raise ArgumentError, 'Unknown YubiKey option; use native operation options' unless opts[:options].is_a?(Hash) && (opts[:options].keys - opts[:allowed]).empty?
795
+ end
796
+
797
+ private_class_method def self.validate_selection(opts = {})
798
+ serial = opts[:serial]
799
+ raise ArgumentError, 'serial must be an exact decimal String' unless serial.nil? || (serial.is_a?(String) && serial.match?(/\A[0-9]+\z/))
800
+
801
+ %i[reader device_path].each do |key|
802
+ value = opts[key]
803
+ raise ArgumentError, 'reader and device_path must be nonempty control-free Strings' unless value.nil? || (value.is_a?(String) && !value.empty? && value.valid_encoding? && !value.b.match?(/[\x00-\x1f\x7f]/))
804
+ end
805
+ timeout = opts.fetch(:timeout, 30)
806
+ raise ArgumentError, 'timeout must be finite positive seconds' unless (timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?
807
+ raise ArgumentError, 'connection must implement native exchange' if opts.key?(:connection) && !opts[:connection].nil? && !opts[:connection].respond_to?(:exchange)
808
+ raise ArgumentError, 'secret_provider must be callable' unless opts[:secret_provider].nil? || opts[:secret_provider].respond_to?(:call)
809
+
810
+ validate_secure_channel(opts) if opts.key?(:secure_channel)
811
+ end
812
+
813
+ private_class_method def self.validate_secure_channel(opts = {})
814
+ channel = opts[:secure_channel]
815
+ allowed = %i[scp kid kvn key_enc key_mac key_dek card_public_key ca_certificates card_certificates oce_private_key oce_certificates oce_kid oce_kvn]
816
+ raise ArgumentError, 'secure_channel must be an explicit nonempty Hash of native key options' unless channel.is_a?(Hash) && !channel.empty? && (channel.keys - allowed).empty?
817
+ raise ArgumentError, 'secure_channel requires an explicit scp protocol and integer kvn' unless %i[scp03 scp11a scp11b scp11c].include?(channel[:scp]) && channel[:kvn].is_a?(Integer) && channel[:kvn].between?(0, 255)
818
+
819
+ Protocol.require_confirm(opts)
820
+ return unless channel[:scp] == :scp03
821
+
822
+ %i[key_enc key_mac].each do |key|
823
+ raise ArgumentError, 'SCP03 requires explicit 16-byte in-memory keys' unless channel[key].is_a?(String) && channel[key].bytesize == 16
824
+ end
825
+ end
826
+
827
+ private_class_method def self.validate_args(opts = {})
828
+ args = opts[:args]
829
+ raise ArgumentError, 'args must be a bounded nonempty Array of control-free Strings' unless args.is_a?(Array) && args.length.between?(1, 256) && args.all? { |value| value.is_a?(String) && !value.empty? && value.valid_encoding? && !value.b.match?(/[\x00-\x1f\x7f]/) } && args.sum(&:bytesize) <= 65_536
830
+
831
+ args.map(&:dup)
832
+ end
833
+
834
+ private_class_method def self.validate_command(opts = {})
835
+ grammar = COMMANDS[opts[:path]]
836
+ return unless grammar
837
+
838
+ parsed = Protocol.parse_args(args: opts[:args].drop(opts[:path].split.length), flags: grammar[1], values: grammar[2])
839
+ maximum = { 'sd keys import' => 2, 'oath accounts import' => 0 }.fetch(opts[:path], grammar[0])
840
+ raise ArgumentError, 'Unexpected or secret positional argument; supply credentials in memory' if parsed[:positionals].length > maximum
841
+ end
842
+ end
843
+ end
844
+ end