pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -0,0 +1,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'pwn/plugins/yubikey/protocol'
5
+
6
+ module PWN
7
+ module Plugins
8
+ module YubiKey
9
+ # Native device configuration, capabilities and legacy USB mode protocol.
10
+ # Reference: Yubico yubikit.management 5.9.1 (wire-format specification).
11
+ module Management
12
+ AID = ['a000000527471117'].pack('H*').freeze
13
+ OTP_AID = ['a0000005272001'].pack('H*').freeze
14
+ CAPABILITIES = { 'OTP' => 0x01, 'U2F' => 0x02, 'OPENPGP' => 0x08, 'PIV' => 0x10, 'OATH' => 0x20, 'HSMAUTH' => 0x100, 'FIDO2' => 0x200 }.freeze
15
+ MODES = [1, 4, 5, 2, 3, 6, 7].freeze
16
+
17
+ public_class_method def self.info(opts = {})
18
+ session = begin_session(connection: opts[:connection], timeout: opts.fetch(:timeout, 30))
19
+ read_information(session: session)
20
+ end
21
+
22
+ GRAMMAR = {
23
+ 'usb' => [0, %w[--force --list --enable-all --touch-eject --no-touch-eject], %w[--enable --disable --autoeject-timeout --chalresp-timeout]],
24
+ 'nfc' => [0, %w[--force --list --enable-all --disable-all --restrict], %w[--enable --disable]],
25
+ 'mode' => [1, %w[--force --touch-eject], %w[--autoeject-timeout --chalresp-timeout]],
26
+ 'set-lock-code' => [0, %w[--force --clear --generate], []],
27
+ 'reset' => [0, %w[--force], []]
28
+ }.freeze
29
+ ROUTES = { 'usb' => :configure_usb, 'nfc' => :configure_nfc, 'mode' => :set_mode, 'set-lock-code' => :set_lock_code, 'reset' => :reset }.freeze
30
+
31
+ public_class_method def self.operations(opts = {})
32
+ opts[:format]
33
+ GRAMMAR.keys
34
+ end
35
+
36
+ public_class_method def self.execute(opts = {})
37
+ args = opts[:args] || []
38
+ raise ArgumentError, 'args must be an Array of nonsecret Strings' unless args.is_a?(Array) && args.all?(String)
39
+ return { application: :management, operations: operations, secrets: :in_memory_only } if args.empty? || args == ['--help']
40
+
41
+ grammar = GRAMMAR[args.first]
42
+ raise ArgumentError, 'Unknown device configuration operation' unless grammar
43
+
44
+ parsed = Protocol.parse_args(args: args.drop(1), flags: grammar[1], values: grammar[2])
45
+ raise ArgumentError, 'Invalid management positional argument count' unless parsed[:positionals].length == grammar[0]
46
+ raise ArgumentError, 'Only enable and disable options can be repeated' if parsed[:options].any? { |key, value| value.is_a?(Array) && !%i[enable disable].include?(key) }
47
+
48
+ selected = opts.merge(parsed[:options])
49
+ selected[:mode] = parsed[:positionals].first if args.first == 'mode'
50
+ public_send(ROUTES.fetch(args.first), selected)
51
+ end
52
+
53
+ public_class_method def self.configure_nfc(opts = {})
54
+ configure_applications(opts.merge(transport: :nfc, connection: opts[:connection]))
55
+ end
56
+
57
+ public_class_method def self.set_lock_code(opts = {})
58
+ consent(opts.merge(confirm: opts[:confirm]))
59
+ choices = [opts[:clear], opts[:generate], !opts[:new_lock_code].nil?].count { |value| value }
60
+ raise ArgumentError, 'Specify only one of clear, generate or new_lock_code' if choices > 1
61
+
62
+ code = if opts[:clear]
63
+ "\0".b * 16
64
+ elsif opts[:generate]
65
+ raise ArgumentError, 'Generated lock code requires an explicit secret_sink callable' unless opts[:secret_sink].respond_to?(:call)
66
+
67
+ value = SecureRandom.random_bytes(16)
68
+ opts[:secret_sink].call(:new_lock_code, value.dup)
69
+ value
70
+ else
71
+ secret_value(opts.merge(value: opts[:new_lock_code], name: :new_lock_code))
72
+ end
73
+ raise ArgumentError, 'new_lock_code must contain exactly 16 raw bytes' unless code.bytesize == 16
74
+
75
+ session = begin_session(opts)
76
+ before = read_information(session: session)
77
+ require_version(version: before[:version], minimum: [5, 0, 0])
78
+ write_settings(opts.merge(session: session, before: before, changes: { 0x0a => code }, reboot: false))
79
+ end
80
+
81
+ public_class_method def self.configure_usb(opts = {})
82
+ configure_applications(opts.merge(transport: :usb, connection: opts[:connection]))
83
+ end
84
+
85
+ public_class_method def self.set_mode(opts = {})
86
+ consent(opts.merge(confirm: opts[:confirm]))
87
+ chalresp = numeric(value: opts.fetch(:chalresp_timeout, 0), maximum: 255)
88
+ autoeject = numeric(value: opts.fetch(:autoeject_timeout, 0), maximum: 65_535)
89
+ session = begin_session(opts)
90
+ before = read_information(session: session)
91
+ require_version(version: before[:version], minimum: [3, 0, 0])
92
+ current = before[:enabled_capabilities][:usb] ? interfaces(mask: before[:enabled_capabilities][:usb]) : before[:usb_interfaces]
93
+ requested = parse_mode(value: opts[:mode], current: current)
94
+ eject = opts[:touch_eject] || autoeject.positive?
95
+ raise ArgumentError, 'Touch eject requires CCID-only mode' if eject && requested != 4
96
+
97
+ if (before[:version] <=> [5, 0, 0]) >= 0
98
+ supported = before[:supported_capabilities][:usb]
99
+ raise CommandError, 'Requested USB interface is not supported' unless requested.nobits?(~interfaces(mask: supported))
100
+ raise CommandError, 'Applications can only be toggled on this device after reset' if before[:reset_blocked].positive?
101
+
102
+ desired = 0
103
+ desired |= 1 if requested.anybits?(1)
104
+ desired |= 0x202 if requested.anybits?(2)
105
+ desired |= 0x538 if requested.anybits?(4)
106
+ changes = { 3 => [desired & supported].pack('n') }
107
+ changes[7] = [chalresp].pack('C') if opts.key?(:chalresp_timeout)
108
+ changes[6] = [autoeject].pack('n') if opts.key?(:autoeject_timeout)
109
+ changes[8] = [before[:device_flags].to_i | 0x80].pack('C') if eject
110
+ write_settings(opts.merge(session: session, before: before, changes: changes, reboot: opts.fetch(:reboot, current != requested)))
111
+ else
112
+ raise ArgumentError, 'Legacy mode changes require reconnect after physical reinsertion for verification' unless opts[:reconnect].respond_to?(:call)
113
+ raise CommandError, 'Requested USB interface is not supported' if before[:supported_capabilities][:usb] && requested.anybits?(~interfaces(mask: before[:supported_capabilities][:usb]))
114
+
115
+ code = MODES.index(requested) | (eject ? 0x80 : 0)
116
+ begin
117
+ request(session: session, operation: :mode, data: [code, chalresp, autoeject].pack('CCv'))
118
+ fresh = opts[:reconnect].call(before.except(:raw_fields))
119
+ raise ResponseError, 'Legacy mode readback requires a fresh connection' if fresh.equal?(opts[:connection])
120
+
121
+ after = info(connection: fresh, timeout: opts.fetch(:timeout, 30))
122
+ verify_identity(before: before, after: after, reconnected: true)
123
+ raise ResponseError, 'Legacy USB mode readback mismatch' unless after[:usb_interfaces] == requested
124
+
125
+ { verified: true, verification: :usb_descriptor, usb_interfaces: requested, info: after, timeouts_readback: false }
126
+ rescue StandardError
127
+ raise ResponseError, 'Legacy mode write state uncertain: reinsert and inspect the exact device; do not replay', cause: nil
128
+ ensure
129
+ fresh.close if fresh && !fresh.equal?(opts[:connection]) && fresh.respond_to?(:close)
130
+ end
131
+ end
132
+ end
133
+
134
+ public_class_method def self.reset(opts = {})
135
+ consent(opts.merge(confirm: opts[:confirm]))
136
+ session = begin_session(opts)
137
+ raise CommandError, 'Full device reset is supported only over smartcard transport' unless session[:protocol] == :apdu
138
+
139
+ before = read_information(session: session)
140
+ bio_mpe = [6, 7].include?(before[:form_factor]) && before[:supported_capabilities].values.reduce(0, :|).anybits?(0x10)
141
+ raise CommandError, 'Full device reset is not supported by this device; use application-specific resets' unless bio_mpe || before[:reset_blocked].positive?
142
+
143
+ begin
144
+ request(session: session, operation: :reset)
145
+ after = read_information(session: session)
146
+ verify_identity(before: before, after: after)
147
+ raise ResponseError, 'Reset-state metadata is absent or not cleared' unless after[:raw_fields].key?(0x18) && after[:reset_blocked].zero? && after[:is_locked] == false
148
+
149
+ { verified: true, verification: :reset_state_metadata, data_erasure_verified: false, info: after }
150
+ rescue StandardError
151
+ raise ResponseError, 'Device reset state uncertain: reset-state readback failed; do not automatically reset again', cause: nil
152
+ end
153
+ end
154
+
155
+ public_class_method def self.reboot(opts = {})
156
+ consent(opts.merge(confirm: opts[:confirm]))
157
+ session = begin_session(opts)
158
+ before = read_information(session: session)
159
+ require_version(version: before[:version], minimum: [5, 0, 0])
160
+ write_settings(opts.merge(session: session, before: before, changes: {}, reboot: true))
161
+ end
162
+
163
+ private_class_method def self.parse_mode(opts = {})
164
+ value = opts[:value].to_s.upcase
165
+ return MODES[value.to_i] if value.match?(/\A[0-6]\z/)
166
+
167
+ names = { 'O' => 1, 'OTP' => 1, 'F' => 2, 'FIDO' => 2, 'C' => 4, 'CCID' => 4 }
168
+ if value.start_with?('+', '-')
169
+ raise ArgumentError, 'Current interfaces unavailable for relative mode changes' unless opts[:current]
170
+ raise ArgumentError, 'Invalid relative USB mode syntax' unless value.match?(/\A(?:[+-](?:OTP|FIDO|CCID|O|F|C))+\z/)
171
+
172
+ mask = opts[:current]
173
+ value.scan(/([+-])([A-Z]+)/).each { |sign, name| mask = sign == '+' ? mask | names.fetch(name) : mask & ~names.fetch(name) }
174
+ else
175
+ raise ArgumentError, 'Invalid USB mode syntax' unless value.match?(/\A(?:OTP|FIDO|CCID|O|F|C)(?:\+(?:OTP|FIDO|CCID|O|F|C))*\z/)
176
+
177
+ mask = value.split('+').reduce(0) { |bits, name| bits | names.fetch(name) }
178
+ end
179
+ raise ArgumentError, 'At least one USB interface must remain enabled' unless MODES.include?(mask)
180
+
181
+ mask
182
+ end
183
+
184
+ private_class_method def self.observed_interfaces(opts = {})
185
+ pid = opts[:descriptor][:product_id]
186
+ return pid & 7 if pid.is_a?(Integer) && pid.between?(0x0401, 0x0407)
187
+
188
+ { 0x0010 => 1, 0x0110 => 1, 0x0111 => 5, 0x0112 => 4, 0x0113 => 2, 0x0114 => 3, 0x0115 => 6, 0x0116 => 7, 0x0120 => 2, 0x0410 => 3 }[pid]
189
+ end
190
+
191
+ private_class_method def self.configure_applications(opts = {})
192
+ transport = opts[:transport]
193
+ modifiers = %i[enable disable enable_all disable_all touch_eject no_touch_eject autoeject_timeout chalresp_timeout restrict]
194
+ changes_requested = modifiers.any? { |key| opts.key?(key) && opts[key] != false && opts[key] != [] && !opts[key].nil? }
195
+ raise ArgumentError, 'List cannot be combined with configuration changes' if opts[:list] && changes_requested
196
+ raise ArgumentError, 'No configuration options chosen' unless opts[:list] || changes_requested
197
+
198
+ if opts[:list]
199
+ result = info(opts)
200
+ mask = result[:enabled_capabilities][transport]
201
+ raise CommandError, 'Enabled capabilities unavailable for this transport' unless mask
202
+
203
+ return { transport: transport, enabled: CAPABILITIES.filter_map { |name, bit| name if mask.anybits?(bit) }, mask: mask }
204
+ end
205
+ consent(opts.merge(confirm: opts[:confirm]))
206
+ enable = capability_mask(value: opts.fetch(:enable, []))
207
+ disable = capability_mask(value: opts.fetch(:disable, []))
208
+ raise ArgumentError, 'Conflicting enable and disable options' if enable.anybits?(disable) || (opts[:enable_all] && opts[:disable_all])
209
+ raise ArgumentError, 'Conflicting touch-eject options' if opts[:touch_eject] && opts[:no_touch_eject]
210
+
211
+ changes = {}
212
+ changes[6] = [numeric(value: opts[:autoeject_timeout], maximum: 65_535)].pack('n') if opts.key?(:autoeject_timeout)
213
+ changes[7] = [numeric(value: opts[:chalresp_timeout], maximum: 255)].pack('C') if opts.key?(:chalresp_timeout)
214
+ session = begin_session(opts)
215
+ before = read_information(session: session)
216
+ require_version(version: before[:version], minimum: [5, 0, 0])
217
+ supported = before[:supported_capabilities][transport]
218
+ enabled = before[:enabled_capabilities][transport]
219
+ raise CommandError, 'This transport is not supported or lacks capability metadata' unless supported && enabled
220
+
221
+ if opts[:restrict]
222
+ raise ArgumentError, 'restrict is valid only for NFC and conflicts with capability changes' unless transport == :nfc && enable.zero? && disable.zero? && !opts[:enable_all] && !opts[:disable_all]
223
+
224
+ require_version(version: before[:version], minimum: [5, 7, 0])
225
+ reconnect = opts[:connection].descriptor[:transport] == :nfc
226
+ return write_settings(opts.merge(session: session, before: before, changes: { 0x17 => "\1".b }, reconnect_required: reconnect, reboot: false))
227
+ end
228
+ raise CommandError, 'Applications can only be toggled on this device after reset' if before[:reset_blocked].positive?
229
+
230
+ enable = supported if opts[:enable_all]
231
+ disable = supported if opts[:disable_all]
232
+ raise ArgumentError, 'Conflicting enable and disable options' if enable.anybits?(disable)
233
+ raise CommandError, 'Requested application is not supported on this transport' if (enable | disable).anybits?(~supported)
234
+
235
+ desired = (enabled | enable) & ~disable
236
+ raise ArgumentError, 'Cannot disable all USB applications' if transport == :usb && desired.nobits?(CAPABILITIES.values.reduce(:|))
237
+
238
+ changes[transport == :usb ? 3 : 0x0e] = [desired].pack('n')
239
+ if opts[:touch_eject] || opts[:no_touch_eject]
240
+ raise ResponseError, 'Missing device flag metadata' if before[:device_flags].nil?
241
+
242
+ flags = opts[:touch_eject] ? before[:device_flags] | 0x80 : before[:device_flags] & ~0x80
243
+ changes[8] = [flags].pack('C')
244
+ end
245
+ reboot = opts.fetch(:reboot, transport == :usb && interfaces(mask: enabled) != interfaces(mask: desired))
246
+ write_settings(opts.merge(session: session, before: before, changes: changes, reboot: reboot))
247
+ end
248
+
249
+ private_class_method def self.write_settings(opts = {})
250
+ before = opts[:before]
251
+ changes = opts[:changes]
252
+ reboot = opts.fetch(:reboot, false)
253
+ raise ArgumentError, 'reboot must be true or false' unless [true, false].include?(reboot)
254
+
255
+ reconnect = reboot || opts[:reconnect_required]
256
+ raise ArgumentError, 'Disconnecting configuration changes require an explicit reconnect callable for readback' if reconnect && !opts[:reconnect].respond_to?(:call)
257
+
258
+ raise ResponseError, 'Configuration lock metadata is unavailable; no write attempted' if before[:is_locked].nil?
259
+
260
+ unlock = opts[:lock_code]
261
+ if before[:is_locked]
262
+ unlock = secret_value(opts.merge(value: unlock, name: :lock_code))
263
+ elsif unlock
264
+ raise ArgumentError, 'Device is not locked; do not supply lock_code'
265
+ end
266
+ raise ArgumentError, 'lock_code must contain exactly 16 raw bytes' if unlock && (!unlock.is_a?(String) || unlock.bytesize != 16)
267
+
268
+ body = ''.b
269
+ body << Protocol.tlv(tag: 0x0c, value: ''.b) if reboot
270
+ body << Protocol.tlv(tag: 0x0b, value: unlock.b) if unlock
271
+ changes.each { |tag, value| body << Protocol.tlv(tag: tag, value: value) }
272
+ raise ArgumentError, 'Management configuration exceeds protocol bounds' if body.bytesize > 255 || (opts[:session][:protocol] == :otp && body.bytesize > 63)
273
+
274
+ begin
275
+ request(session: opts[:session], operation: :write, data: [body.bytesize].pack('C') + body)
276
+ if reconnect
277
+ fresh = opts[:reconnect].call(before.except(:raw_fields))
278
+ raise ResponseError, 'Reconnect must return a fresh native connection' if fresh.equal?(opts[:session][:connection])
279
+
280
+ after = info(connection: fresh, timeout: opts[:session][:timeout])
281
+ else
282
+ after = read_information(session: opts[:session])
283
+ end
284
+ verify_identity(before: before, after: after, reconnected: reconnect)
285
+ changes.each do |tag, value|
286
+ matches = if tag == 0x0a
287
+ after[:is_locked] == value.bytes.any?(&:positive?)
288
+ else
289
+ after[:raw_fields][tag] == value
290
+ end
291
+ raise ResponseError, 'Device configuration readback mismatch' unless matches
292
+ end
293
+ { verified: true, verification: :device_information, info: after, reconnected: !!reconnect, lock_secret_readback: false }
294
+ rescue StandardError
295
+ raise ResponseError, 'Management write state uncertain: acknowledgment or readback failed; inspect the exact device, do not replay', cause: nil
296
+ ensure
297
+ fresh.close if fresh && !fresh.equal?(opts[:session][:connection]) && fresh.respond_to?(:close)
298
+ end
299
+ end
300
+
301
+ private_class_method def self.verify_identity(opts = {})
302
+ before = opts[:before]
303
+ after = opts[:after]
304
+ raise ResponseError, 'Readback device identity changed' unless before[:serial] == after[:serial] && before[:version] == after[:version]
305
+ raise ResponseError, 'Serial-less readback requires the same observed explicit selector' if opts[:reconnected] && before[:serial].nil? && (before[:identity].nil? || before[:identity].empty? || before[:identity] != after[:identity])
306
+
307
+ :matched
308
+ end
309
+
310
+ private_class_method def self.capability_mask(opts = {})
311
+ values = opts[:value]
312
+ values = [values] if values.is_a?(String) || values.is_a?(Symbol)
313
+ raise ArgumentError, 'Capabilities must be names in an Array' unless values.is_a?(Array)
314
+
315
+ values.reduce(0) do |mask, name|
316
+ bit = CAPABILITIES[name.to_s.upcase]
317
+ raise ArgumentError, 'Unknown application capability' unless bit
318
+
319
+ mask | bit
320
+ end
321
+ end
322
+
323
+ private_class_method def self.interfaces(opts = {})
324
+ mask = opts[:mask]
325
+ value = mask.anybits?(1) ? 1 : 0
326
+ value |= 2 if mask.anybits?(0x202)
327
+ value |= 4 if mask.anybits?(0x53c)
328
+ value
329
+ end
330
+
331
+ private_class_method def self.numeric(opts = {})
332
+ value = opts[:value]
333
+ raise ArgumentError, 'Numeric option must be an unsigned decimal integer' unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
334
+ raise ArgumentError, 'Numeric option is outside the supported range' unless value.to_i.between?(0, opts[:maximum])
335
+
336
+ value.to_i
337
+ end
338
+
339
+ private_class_method def self.secret_value(opts = {})
340
+ value = opts[:value]
341
+ value = opts[:secret_provider].call(opts[:name]) if value.nil? && opts[:secret_provider].respond_to?(:call)
342
+ raise ArgumentError, "#{opts[:name]} must be supplied in memory" unless value.is_a?(String) && !value.empty?
343
+
344
+ value.b
345
+ end
346
+
347
+ private_class_method def self.consent(opts = {})
348
+ raise ConfirmationRequired, 'Device configuration requires confirm: true' unless opts[:confirm] == true
349
+
350
+ %i[enable_all disable_all touch_eject no_touch_eject restrict clear generate reboot force list].each do |key|
351
+ raise ArgumentError, "#{key} must be true or false" if opts.key?(key) && ![true, false].include?(opts[key])
352
+ end
353
+
354
+ :confirmed
355
+ end
356
+
357
+ private_class_method def self.begin_session(opts = {})
358
+ connection = opts[:connection]
359
+ timeout = opts.fetch(:timeout, 30)
360
+ raise ArgumentError, 'connection must implement descriptor and exchange' unless connection.respond_to?(:descriptor) && connection.respond_to?(:exchange)
361
+ raise ArgumentError, 'timeout must be finite positive seconds' unless (timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?
362
+
363
+ session = { connection: connection, protocol: connection.descriptor[:protocol], timeout: timeout }
364
+ case session[:protocol]
365
+ when :otp
366
+ raw = connection.exchange(protocol: :otp, command: 0, data: ''.b, response_length: 6, timeout: timeout)
367
+ raise ResponseError, 'Malformed OTP status for management' unless raw.is_a?(String) && raw.bytesize == 6
368
+
369
+ session[:version] = raw.bytes.take(3)
370
+ when :apdu
371
+ begin
372
+ selected = Protocol.select(connection: connection, aid: AID, timeout: timeout)
373
+ rescue Protocol::APDUError => e
374
+ raise unless e.status == 0x6a82 && connection.descriptor[:transport] == :nfc
375
+
376
+ raw = Protocol.select(connection: connection, aid: OTP_AID, timeout: timeout)
377
+ raise ResponseError, 'NFC management fallback is only defined for legacy NEO' unless raw.bytesize == 6 && raw.getbyte(0) == 3
378
+
379
+ return session.merge(version: raw.bytes.take(3), neo: true)
380
+ end
381
+ selected = selected.delete_suffix("\x90\0".b) # Edge duplicate status-word quirk.
382
+ match = selected.match(/\A(?:YubiKey )?(\d+)\.(\d+)\.(\d+)\z/)
383
+ raise ResponseError, 'Malformed management firmware version' unless match
384
+
385
+ session[:version] = match.captures.map(&:to_i)
386
+ if session[:version].first == 3
387
+ # NEO arbitrator needs the documented deselect before selecting OTP.
388
+ connection.exchange(protocol: :apdu, data: [0xa4, 4, 0, 8].pack('C*'), timeout: timeout)
389
+ raw = Protocol.select(connection: connection, aid: OTP_AID, timeout: timeout)
390
+ raise ResponseError, 'Malformed NEO OTP status' unless raw.bytesize == 6
391
+
392
+ session[:neo] = true
393
+ end
394
+ when :ctap
395
+ session[:version] = connection.descriptor[:version]
396
+ else
397
+ raise ArgumentError, 'Management requires OTP HID, smartcard or CTAP HID'
398
+ end
399
+ session
400
+ end
401
+
402
+ private_class_method def self.read_information(opts = {})
403
+ session = opts[:session]
404
+ version = session[:version]
405
+ if version && (version <=> [1, 0, 0]) >= 0 && (version <=> [4, 1, 0]).negative?
406
+ descriptor = session[:connection].descriptor
407
+ return { version: version, serial: descriptor[:serial], identity: descriptor.slice(:device_path, :reader), usb_interfaces: observed_interfaces(descriptor: descriptor), supported_capabilities: {}, enabled_capabilities: {}, capabilities_source: :unavailable_on_legacy_firmware, raw_fields: {}, reset_blocked: 0 }
408
+ end
409
+ fields = {}
410
+ finished = false
411
+ 256.times do |page|
412
+ raw = request(session: session, operation: :read, page: page)
413
+ raise ResponseError, 'Malformed management information length' unless raw.is_a?(String) && !raw.empty? && raw.getbyte(0) == raw.bytesize - 1
414
+
415
+ more = false
416
+ seen = {}
417
+ Protocol.untlv(data: raw.byteslice(1..)).each do |record|
418
+ tag = record[:tag]
419
+ raise ResponseError, 'Duplicate management information field' if seen[tag]
420
+
421
+ seen[tag] = true
422
+ if tag == 0x10
423
+ raise ResponseError, 'Invalid management pagination' unless ["\0".b, "\1".b].include?(record[:value])
424
+
425
+ more = record[:value] == "\1".b
426
+ else
427
+ raise ResponseError, 'Duplicate management information field' if fields.key?(tag)
428
+
429
+ fields[tag] = record[:value]
430
+ end
431
+ end
432
+ unless more
433
+ finished = true
434
+ break
435
+ end
436
+ end
437
+ raise ResponseError, 'Too many management information pages' unless finished
438
+
439
+ descriptor = session[:connection].descriptor
440
+ decode_information(fields: fields, version: version).merge(usb_interfaces: observed_interfaces(descriptor: descriptor), identity: descriptor.slice(:device_path, :reader))
441
+ end
442
+
443
+ private_class_method def self.decode_information(opts = {})
444
+ fields = opts[:fields]
445
+ version = fields[5]&.bytes || opts[:version]
446
+ raise ResponseError, 'Missing or invalid management firmware version' unless version.is_a?(Array) && version.length == 3 && version.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) }
447
+
448
+ reported_version = version
449
+ qualifier = { version: version, type: :final, iteration: 0 }
450
+ if fields[0x19]
451
+ qualified = Protocol.tlv_map(data: fields[0x19])
452
+ qversion = version_field(value: qualified[1])
453
+ qtype = integer_field(fields: qualified, tag: 2, sizes: [1])
454
+ iteration = integer_field(fields: qualified, tag: 3, sizes: [1, 2, 3, 4])
455
+ raise ResponseError, 'Invalid qualified firmware metadata' unless qversion && qtype.between?(0, 2)
456
+
457
+ qualifier = { version: qversion, type: %i[alpha beta final][qtype], iteration: iteration }
458
+ version = qversion unless qtype == 2
459
+ end
460
+ raise ResponseError, 'Missing USB supported capabilities' unless fields[1]
461
+
462
+ supported = { usb: integer_field(fields: fields, tag: 1, sizes: [1, 2]) }
463
+ enabled = {}
464
+ supported[:usb] = 0x3f if version == [4, 2, 4] # Published firmware-reporting defect.
465
+ enabled[:usb] = integer_field(fields: fields, tag: 3, sizes: [1, 2]) if fields[3] && version.first != 4
466
+ if fields[0x0d]
467
+ supported[:nfc] = integer_field(fields: fields, tag: 0x0d, sizes: [1, 2])
468
+ enabled[:nfc] = integer_field(fields: fields, tag: 0x0e, sizes: [1, 2]) if fields[0x0e]
469
+ end
470
+ form = integer_field(fields: fields, tag: 4, sizes: [1], default: 0)
471
+ serial = integer_field(fields: fields, tag: 2, sizes: [4], default: 0)
472
+ {
473
+ version: version, reported_version: reported_version, version_qualifier: qualifier, serial: serial.zero? ? nil : serial.to_s,
474
+ form_factor: form & 15, is_fips: form.anybits?(0x80), is_sky: form.anybits?(0x40),
475
+ supported_capabilities: supported, enabled_capabilities: enabled,
476
+ is_locked: boolean_field(fields: fields, tag: 0x0a),
477
+ auto_eject_timeout: integer_field(fields: fields, tag: 6, sizes: [2], default: nil),
478
+ challenge_response_timeout: integer_field(fields: fields, tag: 7, sizes: [1], default: nil),
479
+ device_flags: integer_field(fields: fields, tag: 8, sizes: [1], default: nil),
480
+ nfc_restricted: boolean_field(fields: fields, tag: 0x17),
481
+ reset_blocked: integer_field(fields: fields, tag: 0x18, sizes: [1, 2], default: 0),
482
+ pin_complexity: boolean_field(fields: fields, tag: 0x16),
483
+ fips_capable: fips_capabilities(value: integer_field(fields: fields, tag: 0x14, sizes: [1, 2], default: 0)),
484
+ fips_approved: fips_capabilities(value: integer_field(fields: fields, tag: 0x15, sizes: [1, 2], default: 0)),
485
+ part_number: text_field(value: fields[0x13]),
486
+ fps_version: version_field(value: fields[0x20]), stm_version: version_field(value: fields[0x21]),
487
+ raw_fields: fields, capabilities_source: :device_information
488
+ }
489
+ end
490
+
491
+ private_class_method def self.text_field(opts = {})
492
+ value = opts[:value]
493
+ return nil if value.nil?
494
+
495
+ text = value.dup.force_encoding(Encoding::UTF_8)
496
+ raise ResponseError, 'Invalid management UTF8 metadata' unless text.valid_encoding?
497
+
498
+ text
499
+ end
500
+
501
+ private_class_method def self.version_field(opts = {})
502
+ value = opts[:value]
503
+ return nil if value.nil?
504
+ raise ResponseError, 'Invalid component firmware version' unless value.bytesize == 3
505
+
506
+ value.bytes
507
+ end
508
+
509
+ private_class_method def self.integer_field(opts = {})
510
+ raw = opts[:fields][opts[:tag]]
511
+ return opts[:default] if raw.nil? && opts.key?(:default)
512
+ raise ResponseError, 'Invalid management field length' unless raw && opts[:sizes].include?(raw.bytesize)
513
+
514
+ raw.bytes.reduce(0) { |number, byte| (number << 8) | byte }
515
+ end
516
+
517
+ private_class_method def self.boolean_field(opts = {})
518
+ raw = opts[:fields][opts[:tag]]
519
+ return nil if raw.nil?
520
+ raise ResponseError, 'Invalid management boolean field' unless ["\0".b, "\1".b].include?(raw)
521
+
522
+ raw == "\1".b
523
+ end
524
+
525
+ private_class_method def self.fips_capabilities(opts = {})
526
+ [0x200, 0x10, 0x08, 0x20, 0x100].each_with_index.sum { |mask, index| opts[:value].anybits?(1 << index) ? mask : 0 }
527
+ end
528
+
529
+ private_class_method def self.request(opts = {})
530
+ session = opts[:session]
531
+ operation = opts[:operation]
532
+ data = opts.fetch(:data, ''.b)
533
+ connection = session[:connection]
534
+ case session[:protocol]
535
+ when :otp
536
+ command = { read: 0x13, write: 0x15, mode: 0x11 }.fetch(operation)
537
+ data = [opts.fetch(:page, 0)].pack('C') if operation == :read
538
+ connection.exchange(protocol: :otp, command: command, data: data, timeout: session[:timeout])
539
+ when :apdu
540
+ ins = { read: 0x1d, write: 0x1c, mode: 0x16, reset: 0x1f }.fetch(operation)
541
+ ins = 1 if operation == :mode && session[:neo]
542
+ p1 = if operation == :read
543
+ opts.fetch(:page, 0)
544
+ else
545
+ operation == :mode ? 0x11 : 0
546
+ end
547
+ Protocol.apdu(connection: connection, ins: ins, p1: p1, data: data, timeout: session[:timeout])
548
+ when :ctap
549
+ command = { read: 0x42, write: 0x43, mode: 0x40 }.fetch(operation)
550
+ data = [opts.fetch(:page, 0)].pack('C') if operation == :read
551
+ connection.exchange(protocol: :ctap, command: command, data: data, timeout: session[:timeout])
552
+ end
553
+ end
554
+
555
+ private_class_method def self.require_version(opts = {})
556
+ raise CommandError, 'Operation is not supported by this device firmware' unless opts[:version].is_a?(Array) && (opts[:version] <=> opts[:minimum]) >= 0
557
+
558
+ :supported
559
+ end
560
+
561
+ public_class_method def self.authors(opts = {})
562
+ opts[:format]
563
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
564
+ end
565
+
566
+ public_class_method def self.help(opts = {})
567
+ opts[:format]
568
+ puts <<~HELP
569
+ Native Ruby only. No subprocess backend. Never log binary responses or secrets.
570
+ Writes are sent once. Uncertain readback raises; inspect rather than replay.
571
+ OTP keyboard/NDEF settings cannot export stored secrets; verification scope is explicit.
572
+ # List prior device-configuration leaf operations without device traffic.
573
+ #{self}.operations(
574
+ format: 'optional - reserved output format'
575
+ )
576
+ # Dispatch a native device-configuration operation with nonsecret arguments.
577
+ #{self}.execute(
578
+ args: 'optional - Array after config, empty returns local metadata; force never grants consent',
579
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
580
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
581
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
582
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
583
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
584
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
585
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed'
586
+ )
587
+ # Read exact firmware, supported applications and configuration metadata.
588
+ #{self}.info(
589
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
590
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds'
591
+ )
592
+ # Enable or disable USB applications with explicit field readback.
593
+ #{self}.configure_usb(
594
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
595
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
596
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
597
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
598
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
599
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
600
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
601
+ enable: 'optional - Array of exact application names to enable on this transport',
602
+ disable: 'optional - Array of exact application names to disable on this transport',
603
+ enable_all: 'optional - true enables only capabilities reported by this device',
604
+ list: 'optional - true lists enabled applications without mutation and cannot mix with changes',
605
+ reboot: 'optional - explicit boolean; USB interface changes default to reboot with reconnect verification',
606
+ touch_eject: 'optional - true enables smartcard touch-eject',
607
+ no_touch_eject: 'optional - true disables smartcard touch-eject',
608
+ autoeject_timeout: 'optional - uint16 seconds before smartcard auto-eject',
609
+ chalresp_timeout: 'optional - uint8 seconds allowed for an OTP touch'
610
+ )
611
+ # Enable or disable NFC applications and verify configuration fields.
612
+ #{self}.configure_nfc(
613
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
614
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
615
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
616
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
617
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
618
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
619
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
620
+ enable: 'optional - Array of exact application names to enable on this transport',
621
+ disable: 'optional - Array of exact application names to disable on this transport',
622
+ enable_all: 'optional - true enables only capabilities reported by this device',
623
+ list: 'optional - true lists enabled applications without mutation and cannot mix with changes',
624
+ reboot: 'optional - explicit boolean; USB interface changes default to reboot with reconnect verification',
625
+ disable_all: 'optional - true disables all reported NFC applications, not USB',
626
+ restrict: 'optional - true restricts NFC until USB power on firmware 5.7 or later'
627
+ )
628
+ # Set or clear configuration protection and read its lock flag.
629
+ #{self}.set_lock_code(
630
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
631
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
632
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
633
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
634
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
635
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
636
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
637
+ new_lock_code: 'optional - required sixteen raw replacement bytes unless clearing, generating or supplied by provider',
638
+ clear: 'optional - true clears configuration protection',
639
+ generate: 'optional - true generates sixteen bytes and requires secret_sink'
640
+ )
641
+ # Configure USB interfaces using modern capabilities or legacy mode bytes.
642
+ #{self}.set_mode(
643
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
644
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
645
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
646
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
647
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
648
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
649
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
650
+ mode: 'required - mode 0 through 6 or OTP+FIDO+CCID style string; relative forms require observed interfaces',
651
+ touch_eject: 'optional - true requests CCID-only touch-eject',
652
+ autoeject_timeout: 'optional - uint16 seconds; positive values imply touch-eject',
653
+ chalresp_timeout: 'optional - uint8 touch timeout in seconds',
654
+ reboot: 'optional - modern modes default to reboot if interfaces change; legacy needs physical reinsertion'
655
+ )
656
+ # Reset a reported supported Bio multi-protocol device and read reset metadata.
657
+ #{self}.reset(
658
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
659
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
660
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
661
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
662
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
663
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
664
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed'
665
+ )
666
+ # Reboot and verify exact-device information through a fresh connection.
667
+ #{self}.reboot(
668
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
669
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
670
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
671
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
672
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
673
+ lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
674
+ reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed'
675
+ )
676
+ # Return module author information.
677
+ #{self}.authors(
678
+ format: 'optional - reserved output format'
679
+ )
680
+ HELP
681
+ nil
682
+ end
683
+ end
684
+ end
685
+ end
686
+ end