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,897 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'time'
6
+ require 'pwn/plugins/yubikey/protocol'
7
+ require 'pwn/plugins/yubikey/management'
8
+
9
+ module PWN
10
+ module Plugins
11
+ module YubiKey
12
+ # Native OTP application protocol; no process or interpreter backend.
13
+ # Wire references: Yubico OTP specifications / ykdef.h, and yubikit 5.9.1.
14
+ module OTP
15
+ AID = ['a0000005272001'].pack('H*').freeze
16
+ GRAMMAR = {
17
+ 'info' => [0, [], []],
18
+ 'swap' => [0, %w[force], []],
19
+ 'delete' => [1, %w[force], []],
20
+ 'settings' => [1, %w[force delete-access-code enter no-enter use-numeric-keypad serial-usb-visible], %w[pacing]],
21
+ 'ndef' => [1, [], %w[prefix ndef-type]],
22
+ 'yubiotp' => [1, %w[enter no-enter serial-public-id generate-private-id generate-key upload force], %w[public-id config-output]],
23
+ 'static' => [1, %w[generate enter no-enter force], %w[length keyboard-layout]],
24
+ 'chalresp' => [1, %w[touch totp generate force], []],
25
+ 'calculate' => [2, %w[totp], %w[digits]],
26
+ 'hotp' => [1, %w[enter no-enter force], %w[digits counter identifier]]
27
+ }.freeze
28
+ ROUTES = { 'info' => :info, 'swap' => :swap, 'delete' => :delete_slot, 'settings' => :settings, 'ndef' => :configure_ndef, 'yubiotp' => :provision_yubiotp, 'static' => :provision_static, 'chalresp' => :provision_challenge_response, 'calculate' => :calculate, 'hotp' => :provision_hotp }.freeze
29
+
30
+ public_class_method def self.operations(opts = {})
31
+ opts[:format]
32
+ GRAMMAR.keys
33
+ end
34
+
35
+ public_class_method def self.execute(opts = {})
36
+ args = opts[:args] || []
37
+ raise ArgumentError, 'args must be an Array of nonsecret Strings' unless args.is_a?(Array) && args.all?(String)
38
+ return { application: :otp, operations: operations, secrets: :in_memory_only } if args.empty? || args == ['--help']
39
+
40
+ operation = args.first
41
+ grammar = GRAMMAR[operation]
42
+ raise ArgumentError, 'Unknown OTP operation' unless grammar
43
+
44
+ parsed = parse_arguments(args: args.drop(1), flags: grammar[1], values: grammar[2])
45
+ positions = parsed[:positionals]
46
+ limit = grammar[0]
47
+ valid_count = operation == 'calculate' ? positions.length.between?(1, 2) : positions.length == limit
48
+ raise ArgumentError, 'Invalid number of nonsecret OTP arguments; credentials belong in options' unless valid_count
49
+
50
+ selected = opts.merge(parsed[:options])
51
+ if limit.positive?
52
+ raise ArgumentError, 'OTP slot must be 1 or 2' unless %w[1 2].include?(positions.first)
53
+
54
+ selected[:slot] = positions.first.to_i
55
+ end
56
+ selected[:touch] = parsed[:options].fetch(:touch, opts.fetch(:touch, false)) if operation == 'chalresp'
57
+ selected[:details] = true if operation == 'chalresp'
58
+ selected[:challenge_argument] = positions[1] if operation == 'calculate'
59
+ raise ArgumentError, 'Conflicting enter options' if selected[:enter] && selected[:no_enter]
60
+
61
+ public_send(ROUTES.fetch(operation), selected)
62
+ end
63
+
64
+ public_class_method def self.info(opts = {})
65
+ selected = { connection: opts[:connection], timeout: opts.fetch(:timeout, 30) }
66
+ state = status(selected)
67
+ if state[:version].first == 4
68
+ metadata = Management.info(selected)
69
+ if metadata[:is_fips]
70
+ state = status(selected) if opts[:connection].descriptor[:protocol] == :apdu
71
+ approved = transfer(selected.merge(command: 0x14, data: ''.b, response_length: 1))
72
+ raise ResponseError, 'Invalid OTP FIPS approval response' unless ["\0".b, "\1".b].include?(approved)
73
+
74
+ state = state.merge(fips_approved: approved == "\1".b)
75
+ end
76
+ end
77
+ state
78
+ end
79
+
80
+ public_class_method def self.delete_slot(opts = {})
81
+ slot = slot_number(slot: opts[:slot])
82
+ consent(opts.merge(confirm: opts[:confirm]))
83
+ access = access_codes(opts)
84
+ before = status(opts)
85
+ require_version(version: before[:version], minimum: [2, 1, 0])
86
+ write_verified(opts.merge(slot: slot, command: slot == 1 ? 1 : 3, data: ("\0".b * 52) + access[:current], before: before, configured: false))
87
+ end
88
+
89
+ public_class_method def self.swap(opts = {})
90
+ consent(opts.merge(confirm: opts[:confirm]))
91
+ before = status(opts)
92
+ require_version(version: before[:version], minimum: [2, 3, 0])
93
+ result = write_verified(opts.merge(command: 6, data: "\0".b * 6, before: before))
94
+ expected = ((before[:flags] & 1) << 1) | ((before[:flags] & 2) >> 1)
95
+ raise ResponseError, 'OTP swap state uncertain: slot readback mismatch; do not replay' unless (result[:status][:flags] & 3) == expected
96
+
97
+ result
98
+ end
99
+
100
+ public_class_method def self.settings(opts = {})
101
+ slot = slot_number(slot: opts[:slot])
102
+ consent(opts.merge(confirm: opts[:confirm]))
103
+ raise ArgumentError, 'Conflicting access-code replacement options' if opts[:delete_access_code] && opts[:new_access_code]
104
+ raise ArgumentError, 'Deleting access protection requires the current access_code' if opts[:delete_access_code] && !opts[:access_code]
105
+
106
+ access = access_codes(opts.merge(new_access_code: opts[:delete_access_code] ? "\0".b * 6 : opts[:new_access_code]))
107
+ fields = keyboard_flags(opts)
108
+ config = pack_config(fields.merge(fixed: "\0".b * 16, uid: "\0".b * 6, key: "\0".b * 16, access_code: access[:new]))
109
+ before = status(opts)
110
+ require_version(version: before[:version], minimum: [2, 3, 0])
111
+ raise CommandError, 'Cannot update an empty OTP slot' unless before[:slots][slot][:configured]
112
+ raise CommandError, 'Firmware 4.3.2 through 4.3.5 cannot update access codes; no write attempted' if (before[:version] <=> [4, 3, 2]) >= 0 && (before[:version] <=> [4, 3, 6]).negative? && access[:new] != access[:current]
113
+
114
+ write_verified(opts.merge(slot: slot, command: slot == 1 ? 4 : 5, data: config + access[:current], before: before, configured: true))
115
+ end
116
+
117
+ public_class_method def self.provision_yubiotp(opts = {})
118
+ slot_number(slot: opts[:slot])
119
+ consent(opts.merge(confirm: opts[:confirm]))
120
+ raise ArgumentError, 'YubiCloud automated upload has ended; no network request is performed' if opts[:upload]
121
+ raise ArgumentError, 'public_id and serial_public_id conflict' if opts[:public_id] && opts[:serial_public_id]
122
+
123
+ fixed = opts[:public_id] ? modhex_decode(value: opts[:public_id]) : nil
124
+ raise ArgumentError, 'public_id must contain at most 16 bytes' if fixed && fixed.bytesize > 16
125
+ raise ArgumentError, 'public_id or serial_public_id is required' unless fixed || opts[:serial_public_id]
126
+
127
+ uid = obtain_secret(opts.merge(name: :private_id, value: opts[:private_id], generate: opts[:generate_private_id], length: 6))
128
+ key = obtain_secret(opts.merge(name: :key, value: opts[:key], generate: opts[:generate_key], length: 16))
129
+ raise ArgumentError, 'private_id must contain six raw bytes and key sixteen raw bytes' unless uid.bytesize == 6 && key.bytesize == 16
130
+
131
+ access_codes(opts)
132
+ keyboard_flags(opts)
133
+ export = open_export(path: opts[:config_output]) if opts[:config_output]
134
+ serial = serial_number(opts) if opts[:serial_public_id] || export
135
+ fixed ||= [0xff, 0].pack('C*') + [serial].pack('N')
136
+ result = program_keyboard(opts.merge(fixed: fixed, uid: uid, key: key, mode_flags: 0))
137
+ if export
138
+ csv = "#{[serial.to_s, fixed.unpack1('H*').tr('0123456789abcdef', MODHEX), uid.unpack1('H*'), key.unpack1('H*'), (opts[:new_access_code] || opts[:access_code])&.unpack1('H*').to_s, Time.now.iso8601, ''].join(',')}\n"
139
+ export.write(csv)
140
+ export.flush
141
+ export.fsync
142
+ result = result.merge(configuration_exported: true)
143
+ end
144
+ result
145
+ rescue IOError, SystemCallError
146
+ raise ResponseError, 'OTP programming/export incomplete; inspect the exact slot and private output file before retrying', cause: nil
147
+ ensure
148
+ export&.close
149
+ end
150
+
151
+ public_class_method def self.provision_static(opts = {})
152
+ slot_number(slot: opts[:slot])
153
+ consent(opts.merge(confirm: opts[:confirm]))
154
+ layout = opts.fetch(:keyboard_layout, 'MODHEX').to_s.upcase
155
+ map = scan_map(layout: layout)
156
+ length = strict_integer(value: opts.fetch(:length, 38), minimum: 1, maximum: 38)
157
+ if opts[:generate]
158
+ raise ArgumentError, 'generate conflicts with password' if opts[:password]
159
+ raise ArgumentError, 'Generated credentials require an explicit secret_sink callable' unless opts[:secret_sink].respond_to?(:call)
160
+
161
+ alphabet = map.keys.grep_v(/\s/)
162
+ password = Array.new(length) { alphabet[SecureRandom.random_number(alphabet.length)] }.join
163
+ opts[:secret_sink].call(:password, password.dup)
164
+ else
165
+ password = secret_value(opts.merge(name: :password, value: opts[:password])).force_encoding(Encoding::UTF_8)
166
+ end
167
+ raise ArgumentError, 'password must contain 1 to 38 supported characters' unless password.valid_encoding? && password.length.between?(1, 38)
168
+
169
+ codes = password.each_char.map do |char|
170
+ raise ArgumentError, 'Password contains a character unavailable in the selected keyboard layout' unless map.key?(char)
171
+
172
+ map[char]
173
+ end.pack('C*').ljust(38, "\0")
174
+ program_keyboard(opts.merge(fixed: codes.byteslice(0, 16), uid: codes.byteslice(16, 6), key: codes.byteslice(22, 16), mode_flags: 2))
175
+ end
176
+
177
+ public_class_method def self.provision_hotp(opts = {})
178
+ slot_number(slot: opts[:slot])
179
+ consent(opts.merge(confirm: opts[:confirm]))
180
+ digits = strict_integer(value: opts.fetch(:digits, 6), minimum: 6, maximum: 8)
181
+ raise ArgumentError, 'HOTP digits must be 6 or 8' unless [6, 8].include?(digits)
182
+
183
+ counter = strict_integer(value: opts.fetch(:counter, 0), minimum: 0, maximum: 0xffff0)
184
+ raise ArgumentError, 'HOTP counter must be divisible by 16' unless (counter % 16).zero?
185
+
186
+ identifier = hotp_identifier(opts.merge(identifier: opts[:identifier]))
187
+ key = secret_value(opts.merge(name: :key, value: opts[:key]))
188
+ key = OpenSSL::Digest::SHA1.digest(key) if key.bytesize > 64
189
+ raise ArgumentError, 'HOTP key must contain 1 to 20 bytes, or more than 64 bytes' unless key.bytesize.between?(1, 20)
190
+
191
+ uid = key.byteslice(16..).to_s.b.ljust(4, "\0") + [counter >> 4].pack('n')
192
+ program_keyboard(opts.merge(fixed: identifier[:fixed], uid: uid, key: key.byteslice(0, 16).ljust(16, "\0"), mode_flags: identifier[:flags] | (digits == 8 ? 2 : 0), ticket_flags: 0x40))
193
+ end
194
+
195
+ public_class_method def self.configure_ndef(opts = {})
196
+ slot = slot_number(slot: opts[:slot])
197
+ consent(opts.merge(confirm: opts[:confirm]))
198
+ type = opts.fetch(:ndef_type, 'URI').to_s.upcase
199
+ raise ArgumentError, 'ndef_type must be URI or TEXT' unless %w[URI TEXT].include?(type)
200
+
201
+ prefix = opts.fetch(:prefix, type == 'URI' ? 'https://my.yubico.com/yk/#' : '')
202
+ raise ArgumentError, 'NDEF prefix must be a valid UTF8 String' unless prefix.is_a?(String) && prefix.encoding == Encoding::UTF_8 && prefix.valid_encoding?
203
+
204
+ payload = if type == 'TEXT'
205
+ "\2en".b + prefix.b
206
+ else
207
+ index = NDEF_PREFIXES.index { |candidate| prefix.start_with?(candidate) }
208
+ index ? [index + 1].pack('C') + prefix.delete_prefix(NDEF_PREFIXES[index]).b : "\0".b + prefix.b
209
+ end
210
+ raise ArgumentError, 'NDEF payload must not exceed 54 bytes' if payload.bytesize > 54
211
+
212
+ access = access_codes(opts)
213
+ validate_nfc(connection: opts[:connection], timeout: opts.fetch(:timeout, 30))
214
+ before = status(opts)
215
+ require_version(version: before[:version], minimum: [3, 0, 0])
216
+ raise CommandError, 'NDEF requires a configured slot' unless before[:slots][slot][:configured]
217
+ raise CommandError, 'NDEF is unavailable on this device' if opts[:connection].descriptor[:nfc] == false
218
+
219
+ data = [payload.bytesize, type == 'URI' ? 0x55 : 0x54].pack('C*') + payload.ljust(54, "\0") + access[:current]
220
+ write_verified(opts.merge(slot: slot, command: slot == 1 ? 8 : 9, data: data, before: before, configured: true))
221
+ end
222
+
223
+ public_class_method def self.calculate(opts = {})
224
+ argument = opts[:challenge_argument]
225
+ if opts[:totp]
226
+ digits = strict_integer(value: opts.fetch(:digits, 6), minimum: 6, maximum: 8)
227
+ raise ArgumentError, 'TOTP digits must be 6 or 8' unless [6, 8].include?(digits)
228
+
229
+ timestamp = strict_integer(value: argument || opts.fetch(:timestamp) { Time.now.to_i }, minimum: 0, maximum: 0xffffffffffffffff)
230
+ challenge = [timestamp / 30].pack('Q>')
231
+ else
232
+ challenge = opts[:challenge]
233
+ if argument
234
+ raise ArgumentError, 'Challenge argument must be an even-length hexadecimal String' unless argument.match?(/\A(?:[0-9a-fA-F]{2}){1,64}\z/)
235
+
236
+ challenge = [argument].pack('H*')
237
+ end
238
+ end
239
+ response = challenge_response(opts.merge(challenge: challenge))
240
+ return response unless opts[:totp]
241
+
242
+ offset = response.getbyte(19) & 15
243
+ value = response.byteslice(offset, 4).unpack1('N') & 0x7fffffff
244
+ (value % (10**digits)).to_s.rjust(digits, '0')
245
+ end
246
+
247
+ NDEF_PREFIXES = %w[http://www. https://www. http:// https:// tel: mailto: ftp://anonymous:anonymous@ ftp://ftp. ftps:// sftp:// smb:// nfs:// ftp:// dav:// news: telnet:// imap: rtsp:// urn: pop: sip: sips: tftp: btspp:// btl2cap:// btgoep:// tcpobex:// irdaobex:// file:// urn:epc:id: urn:epc:tag: urn:epc:pat: urn:epc:raw: urn:epc: urn:nfc:].freeze
248
+ # Keyboard layout data adapted from yubikey-manager 5.9.1.
249
+ # Copyright (c) 2018 Yubico AB. All rights reserved.
250
+ # Redistribution and use in source and binary forms, with or without
251
+ # modification, are permitted provided that the following conditions are met:
252
+ # 1. Redistributions of source code must retain the above copyright notice,
253
+ # this list of conditions and the following disclaimer.
254
+ # 2. Redistributions in binary form must reproduce the above copyright
255
+ # notice, this list of conditions and the following disclaimer in the
256
+ # documentation and/or other materials provided with the distribution.
257
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
258
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
259
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
260
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
261
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
262
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
263
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
264
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
265
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
266
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
267
+ # POSSIBILITY OF SUCH DAMAGE.
268
+
269
+ KEYBOARD_LAYOUTS = {
270
+ 'US' => {
271
+ 97 => 0x04, 98 => 0x05, 99 => 0x06, 100 => 0x07, 101 => 0x08, 102 => 0x09, 103 => 0x0a, 104 => 0x0b,
272
+ 105 => 0x0c, 106 => 0x0d, 107 => 0x0e, 108 => 0x0f, 109 => 0x10, 110 => 0x11, 111 => 0x12, 112 => 0x13,
273
+ 113 => 0x14, 114 => 0x15, 115 => 0x16, 116 => 0x17, 117 => 0x18, 118 => 0x19, 119 => 0x1a, 120 => 0x1b,
274
+ 121 => 0x1c, 122 => 0x1d, 65 => 0x84, 66 => 0x85, 67 => 0x86, 68 => 0x87, 69 => 0x88, 70 => 0x89,
275
+ 71 => 0x8a, 72 => 0x8b, 73 => 0x8c, 74 => 0x8d, 75 => 0x8e, 76 => 0x8f, 77 => 0x90, 78 => 0x91,
276
+ 79 => 0x92, 80 => 0x93, 81 => 0x94, 82 => 0x95, 83 => 0x96, 84 => 0x97, 85 => 0x98, 86 => 0x99,
277
+ 87 => 0x9a, 88 => 0x9b, 89 => 0x9c, 90 => 0x9d, 48 => 0x27, 49 => 0x1e, 50 => 0x1f, 51 => 0x20,
278
+ 52 => 0x21, 53 => 0x22, 54 => 0x23, 55 => 0x24, 56 => 0x25, 57 => 0x26, 9 => 0x2b, 10 => 0x28,
279
+ 33 => 0x9e, 34 => 0xb4, 35 => 0xa0, 36 => 0xa1, 37 => 0xa2, 38 => 0xa4, 39 => 0x34, 96 => 0x35,
280
+ 40 => 0xa6, 41 => 0xa7, 42 => 0xa5, 43 => 0xae, 44 => 0x36, 45 => 0x2d, 46 => 0x37, 47 => 0x38,
281
+ 58 => 0xb3, 59 => 0x33, 60 => 0xb6, 61 => 0x2e, 62 => 0xb7, 63 => 0xb8, 64 => 0x9f, 91 => 0x2f,
282
+ 92 => 0x32, 93 => 0x30, 94 => 0xa3, 95 => 0xad, 123 => 0xaf, 125 => 0xb0, 124 => 0xb2, 126 => 0xb5,
283
+ 32 => 0x2c
284
+ },
285
+ 'MODHEX' => {
286
+ 98 => 0x05, 99 => 0x06, 100 => 0x07, 101 => 0x08, 102 => 0x09, 103 => 0x0a, 104 => 0x0b, 105 => 0x0c,
287
+ 106 => 0x0d, 107 => 0x0e, 108 => 0x0f, 110 => 0x11, 114 => 0x15, 116 => 0x17, 117 => 0x18, 118 => 0x19,
288
+ 66 => 0x85, 67 => 0x86, 68 => 0x87, 69 => 0x88, 70 => 0x89, 71 => 0x8a, 72 => 0x8b, 73 => 0x8c,
289
+ 74 => 0x8d, 75 => 0x8e, 76 => 0x8f, 78 => 0x91, 82 => 0x95, 84 => 0x97, 85 => 0x98, 86 => 0x99
290
+ },
291
+ 'UK' => {
292
+ 97 => 0x04, 98 => 0x05, 99 => 0x06, 100 => 0x07, 101 => 0x08, 102 => 0x09, 103 => 0x0a, 104 => 0x0b,
293
+ 105 => 0x0c, 106 => 0x0d, 107 => 0x0e, 108 => 0x0f, 109 => 0x10, 110 => 0x11, 111 => 0x12, 112 => 0x13,
294
+ 113 => 0x14, 114 => 0x15, 115 => 0x16, 116 => 0x17, 117 => 0x18, 118 => 0x19, 119 => 0x1a, 120 => 0x1b,
295
+ 121 => 0x1c, 122 => 0x1d, 65 => 0x84, 66 => 0x85, 67 => 0x86, 68 => 0x87, 69 => 0x88, 70 => 0x89,
296
+ 71 => 0x8a, 72 => 0x8b, 73 => 0x8c, 74 => 0x8d, 75 => 0x8e, 76 => 0x8f, 77 => 0x90, 78 => 0x91,
297
+ 79 => 0x92, 80 => 0x93, 81 => 0x94, 82 => 0x95, 83 => 0x96, 84 => 0x97, 85 => 0x98, 86 => 0x99,
298
+ 87 => 0x9a, 88 => 0x9b, 89 => 0x9c, 90 => 0x9d, 48 => 0x27, 49 => 0x1e, 50 => 0x1f, 51 => 0x20,
299
+ 52 => 0x21, 53 => 0x22, 54 => 0x23, 55 => 0x24, 56 => 0x25, 57 => 0x26, 9 => 0x2b, 10 => 0x28,
300
+ 33 => 0x9e, 64 => 0xb4, 163 => 0xa0, 36 => 0xa1, 37 => 0xa2, 38 => 0xa4, 39 => 0x34, 96 => 0x35,
301
+ 40 => 0xa6, 41 => 0xa7, 42 => 0xa5, 43 => 0xae, 44 => 0x36, 45 => 0x2d, 46 => 0x37, 47 => 0x38,
302
+ 58 => 0xb3, 59 => 0x33, 60 => 0xb6, 61 => 0x2e, 62 => 0xb7, 63 => 0xb8, 34 => 0x9f, 91 => 0x2f,
303
+ 35 => 0x32, 93 => 0x30, 94 => 0xa3, 95 => 0xad, 123 => 0xaf, 125 => 0xb0, 126 => 0xb2, 172 => 0xb5,
304
+ 32 => 0x2c
305
+ },
306
+ 'DE' => {
307
+ 97 => 0x04, 98 => 0x05, 99 => 0x06, 100 => 0x07, 101 => 0x08, 102 => 0x09, 103 => 0x0a, 104 => 0x0b,
308
+ 105 => 0x0c, 106 => 0x0d, 107 => 0x0e, 108 => 0x0f, 109 => 0x10, 110 => 0x11, 111 => 0x12, 112 => 0x13,
309
+ 113 => 0x14, 114 => 0x15, 115 => 0x16, 116 => 0x17, 117 => 0x18, 118 => 0x19, 119 => 0x1a, 120 => 0x1b,
310
+ 121 => 0x1d, 122 => 0x1c, 65 => 0x84, 66 => 0x85, 67 => 0x86, 68 => 0x87, 69 => 0x88, 70 => 0x89,
311
+ 71 => 0x8a, 72 => 0x8b, 73 => 0x8c, 74 => 0x8d, 75 => 0x8e, 76 => 0x8f, 77 => 0x90, 78 => 0x91,
312
+ 79 => 0x92, 80 => 0x93, 81 => 0x94, 82 => 0x95, 83 => 0x96, 84 => 0x97, 85 => 0x98, 86 => 0x99,
313
+ 87 => 0x9a, 88 => 0x9b, 89 => 0x9d, 90 => 0x9c, 48 => 0x27, 49 => 0x1e, 50 => 0x1f, 51 => 0x20,
314
+ 52 => 0x21, 53 => 0x22, 54 => 0x23, 55 => 0x24, 56 => 0x25, 57 => 0x26, 9 => 0x2b, 10 => 0x28,
315
+ 33 => 0x9e, 34 => 0x9f, 35 => 0x32, 36 => 0xa1, 37 => 0xa2, 38 => 0xa3, 39 => 0xb2, 40 => 0xa5,
316
+ 41 => 0xa6, 42 => 0xb0, 43 => 0x30, 44 => 0x36, 45 => 0x38, 46 => 0x37, 47 => 0xa4, 58 => 0xb7,
317
+ 59 => 0xb6, 60 => 0x64, 61 => 0xa7, 62 => 0xe4, 63 => 0xad, 94 => 0x35, 95 => 0xb8, 32 => 0x2c,
318
+ 96 => 0xad, 167 => 0xa0, 180 => 0x2e, 196 => 0xb4, 214 => 0xb3, 220 => 0xaf, 223 => 0x2d, 228 => 0x34,
319
+ 246 => 0x33, 252 => 0x2f
320
+ },
321
+ 'FR' => {
322
+ 97 => 0x14, 98 => 0x05, 99 => 0x06, 100 => 0x07, 101 => 0x08, 102 => 0x09, 103 => 0x0a, 104 => 0x0b,
323
+ 105 => 0x0c, 106 => 0x0d, 107 => 0x0e, 108 => 0x0f, 109 => 0x33, 110 => 0x11, 111 => 0x12, 112 => 0x13,
324
+ 113 => 0x04, 114 => 0x15, 115 => 0x16, 116 => 0x17, 117 => 0x18, 118 => 0x19, 119 => 0x1d, 120 => 0x1b,
325
+ 121 => 0x1c, 122 => 0x1a, 65 => 0x94, 66 => 0x85, 67 => 0x86, 68 => 0x87, 69 => 0x88, 70 => 0x89,
326
+ 71 => 0x8a, 72 => 0x8b, 73 => 0x8c, 74 => 0x8d, 75 => 0x8e, 76 => 0x8f, 77 => 0xb3, 78 => 0x91,
327
+ 79 => 0x92, 80 => 0x93, 81 => 0x84, 82 => 0x95, 83 => 0x96, 84 => 0x97, 85 => 0x98, 86 => 0x99,
328
+ 87 => 0x9d, 88 => 0x9b, 89 => 0x9c, 90 => 0x9a, 48 => 0xa7, 49 => 0x9e, 50 => 0x9f, 51 => 0xa0,
329
+ 52 => 0xa1, 53 => 0xa2, 54 => 0xa3, 55 => 0xa4, 56 => 0xa5, 57 => 0xa6, 9 => 0x2b, 10 => 0x28,
330
+ 32 => 0x2c, 33 => 0x38, 34 => 0x20, 36 => 0x30, 37 => 0xb4, 38 => 0x1e, 39 => 0x21, 40 => 0x22,
331
+ 41 => 0x2d, 42 => 0x31, 43 => 0xae, 44 => 0x10, 45 => 0x23, 46 => 0xb6, 47 => 0xb7, 58 => 0x37,
332
+ 59 => 0x36, 60 => 0x64, 61 => 0x2e, 95 => 0x25, 127 => 0x2a, 163 => 0xb0, 167 => 0xb8, 176 => 0xad,
333
+ 178 => 0x35, 181 => 0xb1, 224 => 0x27, 231 => 0x26, 232 => 0x24, 233 => 0x1f, 249 => 0x34
334
+ },
335
+ 'IT' => {
336
+ 9 => 0x2b, 10 => 0x28, 32 => 0x2c, 33 => 0x9e, 34 => 0x9f, 35 => 0x32, 36 => 0xa1, 37 => 0xa2,
337
+ 38 => 0xa3, 39 => 0x2d, 40 => 0xa5, 41 => 0xa6, 42 => 0x55, 43 => 0x30, 44 => 0x36, 45 => 0x38,
338
+ 46 => 0x63, 47 => 0xa4, 48 => 0x27, 49 => 0x1e, 50 => 0x1f, 51 => 0x20, 52 => 0x21, 53 => 0x22,
339
+ 54 => 0x23, 55 => 0x24, 56 => 0x25, 57 => 0x26, 58 => 0xb7, 59 => 0xb6, 60 => 0x64, 61 => 0xa7,
340
+ 62 => 0xe4, 63 => 0xad, 64 => 0x24, 65 => 0x84, 66 => 0x85, 67 => 0x86, 68 => 0x87, 69 => 0x88,
341
+ 70 => 0x89, 71 => 0x8a, 72 => 0x8b, 73 => 0x8c, 74 => 0x8d, 75 => 0x8e, 76 => 0x8f, 77 => 0x90,
342
+ 78 => 0x91, 79 => 0x92, 80 => 0x93, 81 => 0x94, 82 => 0x95, 83 => 0x96, 84 => 0x97, 85 => 0x98,
343
+ 86 => 0x99, 87 => 0x9a, 88 => 0x9b, 89 => 0x9c, 90 => 0x9d, 92 => 0x35, 94 => 0xae, 95 => 0xb8,
344
+ 96 => 0xad, 97 => 0x04, 98 => 0x05, 99 => 0x06, 100 => 0x07, 101 => 0x08, 102 => 0x09, 103 => 0x0a,
345
+ 104 => 0x0b, 105 => 0x0c, 106 => 0x0d, 107 => 0x0e, 108 => 0x0f, 109 => 0x10, 110 => 0x11, 111 => 0x12,
346
+ 112 => 0x13, 113 => 0x14, 114 => 0x15, 115 => 0x16, 116 => 0x17, 117 => 0x18, 118 => 0x19, 119 => 0x1a,
347
+ 120 => 0x1b, 121 => 0x1c, 122 => 0x1d, 124 => 0xb5, 163 => 0xa0, 167 => 0xb2, 176 => 0xb4, 231 => 0xb3,
348
+ 232 => 0x2f, 233 => 0xaf, 224 => 0x34, 236 => 0x2e, 242 => 0x33, 249 => 0x31
349
+ },
350
+ 'BEPO' => {
351
+ 9 => 0xab, 10 => 0xa8, 32 => 0x2c, 33 => 0x9c, 34 => 0x1e, 35 => 0xb5, 36 => 0x35, 37 => 0x2e,
352
+ 39 => 0x11, 40 => 0x21, 41 => 0x22, 42 => 0x27, 43 => 0x24, 44 => 0x0a, 45 => 0x25, 46 => 0x19,
353
+ 47 => 0x26, 48 => 0xa7, 49 => 0x9e, 50 => 0x9f, 51 => 0xa0, 52 => 0xa1, 53 => 0xa2, 54 => 0xa3,
354
+ 55 => 0xa4, 56 => 0xa5, 57 => 0xa6, 58 => 0x99, 59 => 0x8a, 61 => 0x2d, 63 => 0x91, 64 => 0x23,
355
+ 65 => 0x84, 66 => 0x94, 67 => 0x8b, 68 => 0x8c, 69 => 0x89, 70 => 0xb8, 71 => 0xb6, 72 => 0xb7,
356
+ 73 => 0x87, 74 => 0x93, 75 => 0x85, 76 => 0x92, 77 => 0xb4, 78 => 0xb3, 79 => 0x95, 80 => 0x88,
357
+ 81 => 0x90, 82 => 0x8f, 83 => 0x8e, 84 => 0x8d, 85 => 0x96, 86 => 0x98, 87 => 0xb0, 88 => 0x86,
358
+ 89 => 0x9b, 90 => 0xaf, 96 => 0xae, 97 => 0x04, 98 => 0x14, 99 => 0x0b, 100 => 0x0c, 101 => 0x09,
359
+ 102 => 0x38, 103 => 0x36, 104 => 0x37, 105 => 0x07, 106 => 0x13, 107 => 0x05, 108 => 0x12, 109 => 0x34,
360
+ 110 => 0x33, 111 => 0x15, 112 => 0x08, 113 => 0x10, 114 => 0x0f, 115 => 0x0e, 116 => 0x0d, 117 => 0x16,
361
+ 118 => 0x18, 119 => 0x30, 120 => 0x06, 121 => 0x1b, 122 => 0x2f, 160 => 0xac, 171 => 0x1f, 176 => 0xad,
362
+ 187 => 0x20, 192 => 0x9d, 199 => 0xb1, 200 => 0x97, 201 => 0x9a, 202 => 0xe4, 224 => 0x1d, 231 => 0x31,
363
+ 232 => 0x17, 233 => 0x1a, 234 => 0x64
364
+ },
365
+ 'NORMAN' => {
366
+ 97 => 0x04, 98 => 0x05, 99 => 0x06, 100 => 0x08, 101 => 0x07, 102 => 0x15, 103 => 0x0a, 104 => 0x33,
367
+ 105 => 0x0e, 106 => 0x1c, 107 => 0x17, 108 => 0x12, 109 => 0x10, 110 => 0x0d, 111 => 0x0f, 112 => 0x11,
368
+ 113 => 0x14, 114 => 0x0c, 115 => 0x16, 116 => 0x09, 117 => 0x18, 118 => 0x19, 119 => 0x1a, 120 => 0x1b,
369
+ 121 => 0x0b, 122 => 0x1d, 65 => 0x84, 66 => 0x85, 67 => 0x86, 68 => 0x88, 69 => 0x87, 70 => 0x95,
370
+ 71 => 0x8a, 72 => 0xb3, 73 => 0x8e, 74 => 0x9c, 75 => 0x97, 76 => 0x92, 77 => 0x90, 78 => 0x8d,
371
+ 79 => 0x8f, 80 => 0x91, 81 => 0x94, 82 => 0x8c, 83 => 0x96, 84 => 0x89, 85 => 0x98, 86 => 0x99,
372
+ 87 => 0x9a, 88 => 0x9b, 89 => 0x8b, 90 => 0x9d, 48 => 0x27, 49 => 0x1e, 50 => 0x1f, 51 => 0x20,
373
+ 52 => 0x21, 53 => 0x22, 54 => 0x23, 55 => 0x24, 56 => 0x25, 57 => 0x26, 9 => 0x2b, 10 => 0x28,
374
+ 33 => 0x9e, 34 => 0xb4, 35 => 0xa0, 36 => 0xa1, 37 => 0xa2, 38 => 0xa4, 39 => 0x34, 96 => 0x35,
375
+ 40 => 0xa6, 41 => 0xa7, 42 => 0xa5, 43 => 0xae, 44 => 0x36, 45 => 0x2d, 46 => 0x37, 47 => 0x38,
376
+ 58 => 0xb3, 59 => 0x13, 60 => 0xb6, 61 => 0x2e, 62 => 0xb7, 63 => 0xb8, 64 => 0x9f, 91 => 0x2f,
377
+ 92 => 0x32, 93 => 0x30, 94 => 0xa3, 95 => 0xad, 123 => 0xaf, 125 => 0xb0, 124 => 0xb2, 126 => 0xb5,
378
+ 32 => 0x2c
379
+ }
380
+ }.transform_values { |mapping| mapping.transform_keys { |codepoint| codepoint.chr(Encoding::UTF_8) }.freeze }.freeze
381
+
382
+ MODHEX = 'cbdefghijklnrtuv'
383
+
384
+ private_class_method def self.program_keyboard(opts = {})
385
+ slot = opts[:slot]
386
+ access = access_codes(opts)
387
+ fields = keyboard_flags(opts)
388
+ fields[:cfg] |= opts[:mode_flags]
389
+ fields[:tkt] |= opts.fetch(:ticket_flags, 0)
390
+ config = pack_config(fields.merge(fixed: opts[:fixed], uid: opts[:uid], key: opts[:key], access_code: access[:new]))
391
+ before = status(opts)
392
+ require_version(version: before[:version], minimum: [2, 2, 0])
393
+ write_verified(opts.merge(command: slot == 1 ? 1 : 3, data: config + access[:current], before: before, configured: true))
394
+ end
395
+
396
+ private_class_method def self.hotp_identifier(opts = {})
397
+ value = opts[:identifier]
398
+ return { fixed: ''.b, flags: 0 } if value.nil?
399
+ raise ArgumentError, 'identifier must be a String' unless value.is_a?(String)
400
+
401
+ value = 'ubhe' if value == '-'
402
+ value += format('%08d', serial_number(opts)) if value.length == 4
403
+ value = "ubhe#{value}" if value.length == 8
404
+ raise ArgumentError, 'identifier must encode 12 modhex/BCD characters' unless value.length == 12
405
+
406
+ parts = [value[0, 2], value[2, 2], value[4, 8]]
407
+ modes = parts.map { |part| part.each_char.all? { |char| MODHEX.include?(char) } }
408
+ raise ArgumentError, 'Identifier modhex components must form a leading prefix' if (!modes[0] && modes[1]) || (modes[2] && !modes.take(2).all?)
409
+
410
+ fixed = parts.each_with_index.map do |part, index|
411
+ next modhex_decode(value: part) if modes[index]
412
+ raise ArgumentError, 'Identifier component is neither modhex nor BCD' unless part.match?(/\A[0-9]+\z/)
413
+
414
+ [part].pack('H*')
415
+ end.join.b
416
+ { fixed: fixed, flags: if modes[2]
417
+ 0x50
418
+ elsif modes[1]
419
+ 0x40
420
+ else
421
+ modes[0] ? 0x10 : 0
422
+ end }
423
+ end
424
+
425
+ private_class_method def self.modhex_decode(opts = {})
426
+ value = opts[:value]
427
+ raise ArgumentError, 'public_id must be even-length lowercase modhex' unless value.is_a?(String) && value.match?(/\A(?:[cbdefghijklnrtuv]{2})*\z/)
428
+
429
+ [value.tr(MODHEX, '0123456789abcdef')].pack('H*')
430
+ end
431
+
432
+ private_class_method def self.serial_number(opts = {})
433
+ status(opts)
434
+ raw = transfer(opts.merge(command: 0x10, data: ''.b, response_length: 4))
435
+ raise ResponseError, 'Device serial is unavailable' unless raw.is_a?(String) && raw.bytesize == 4 && raw.unpack1('N').positive?
436
+
437
+ raw.unpack1('N')
438
+ end
439
+
440
+ private_class_method def self.strict_integer(opts = {})
441
+ value = opts[:value]
442
+ raise ArgumentError, 'Expected an unsigned decimal integer' unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
443
+
444
+ value = value.to_i
445
+ raise ArgumentError, 'Numeric option outside its supported range' unless value.between?(opts[:minimum], opts[:maximum])
446
+
447
+ value
448
+ end
449
+
450
+ private_class_method def self.open_export(opts = {})
451
+ path = opts[:path]
452
+ raise ArgumentError, 'config_output must be an explicit nonempty file path' unless path.is_a?(String) && !path.empty? && !path.include?("\0")
453
+ raise DependencyError, 'Secure configuration export requires no-follow file support' unless File.const_defined?(:NOFOLLOW)
454
+
455
+ # Returned handle is owned and closed by provision_yubiotp's ensure.
456
+ file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600) # rubocop:disable Style/FileOpen
457
+ stat = file.stat
458
+ unless stat.file? && stat.nlink == 1 && stat.uid == Process.euid && stat.mode.nobits?(0o077) && file.flock(File::LOCK_EX | File::LOCK_NB)
459
+ file.close
460
+ raise ArgumentError, 'Configuration export requires an owned private regular file without other links or writers'
461
+ end
462
+ checked = true
463
+ file
464
+ ensure
465
+ file.close if file && !checked && !file.closed?
466
+ end
467
+
468
+ private_class_method def self.decode_secret(opts = {})
469
+ key = opts[:key]
470
+ case opts.fetch(:encoding, :raw).to_s
471
+ when 'raw'
472
+ key
473
+ when 'hex'
474
+ raise ArgumentError, 'Encoded key must contain an even number of hexadecimal digits' unless key.match?(/\A(?:[0-9a-fA-F]{2})+\z/)
475
+
476
+ [key].pack('H*')
477
+ when 'base32'
478
+ text = key.upcase
479
+ raise ArgumentError, 'Encoded key is not canonical Base32' unless text.match?(/\A[A-Z2-7]+={0,6}\z/)
480
+
481
+ bare = text.delete_suffix('=' * text.count('='))
482
+ raise ArgumentError, 'Invalid Base32 key length or padding' unless [0, 2, 4, 5, 7].include?(bare.length % 8) && (!text.include?('=') || (text.length % 8).zero?)
483
+
484
+ alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
485
+ bits = bare.each_char.map { |char| alphabet.index(char).to_s(2).rjust(5, '0') }.join
486
+ whole = (bits.length / 8) * 8
487
+ raise ArgumentError, 'Base32 key has nonzero unused bits' if bits[whole..].include?('1')
488
+
489
+ [bits[0, whole]].pack('B*')
490
+ else
491
+ raise ArgumentError, 'key_encoding must be raw, hex or base32'
492
+ end
493
+ end
494
+
495
+ private_class_method def self.obtain_secret(opts = {})
496
+ if opts[:generate]
497
+ raise ArgumentError, 'Generation conflicts with the provided secret' if opts[:value]
498
+ raise ArgumentError, 'Generated credentials require an explicit secret_sink callable' unless opts[:secret_sink].respond_to?(:call)
499
+
500
+ value = SecureRandom.random_bytes(opts[:length])
501
+ opts[:secret_sink].call(opts[:name], value.dup)
502
+ value
503
+ else
504
+ secret_value(opts)
505
+ end
506
+ end
507
+
508
+ private_class_method def self.scan_map(opts = {})
509
+ map = KEYBOARD_LAYOUTS[opts[:layout]]
510
+ raise ArgumentError, 'Unsupported keyboard layout' unless map
511
+
512
+ map
513
+ end
514
+
515
+ private_class_method def self.keyboard_flags(opts = {})
516
+ pacing = opts.fetch(:pacing, 0).to_s
517
+ raise ArgumentError, 'pacing must be 0, 20, 40 or 60 milliseconds' unless %w[0 20 40 60].include?(pacing)
518
+
519
+ ext = 0x34
520
+ ext |= 2 if opts[:serial_usb_visible]
521
+ ext |= 8 if opts[:use_numeric_keypad]
522
+ { ext: ext, tkt: opts[:no_enter] || opts[:enter] == false ? 0 : 0x20, cfg: (pacing.to_i / 20) << 2 }
523
+ end
524
+
525
+ private_class_method def self.parse_arguments(opts = {})
526
+ parsed = Protocol.parse_args(args: opts[:args], flags: opts[:flags].map { |name| "--#{name}" }, values: opts[:values].map { |name| "--#{name}" })
527
+ raise ArgumentError, 'OTP value options cannot be repeated' if parsed[:options].values.any?(Array)
528
+
529
+ parsed
530
+ end
531
+
532
+ public_class_method def self.challenge_response(opts = {})
533
+ challenge = opts[:challenge]
534
+ slot = slot_number(slot: opts.fetch(:slot, 2))
535
+ raise ArgumentError, 'challenge must be a raw String of 1 to 64 bytes' unless challenge.is_a?(String) && challenge.bytesize.between?(1, 64)
536
+
537
+ Protocol.require_confirm(opts) if opts.key?(:confirm)
538
+ state = status(opts)
539
+ require_version(version: state[:version], minimum: [2, 2, 0])
540
+ raise CommandError, 'OTP slot is empty' unless state[:slots][slot][:configured]
541
+
542
+ padding = challenge.end_with?("\0") ? "\1" : "\0"
543
+ response = transfer(opts.merge(command: slot == 1 ? 0x30 : 0x38, data: challenge.b.ljust(64, padding), response_length: 20))
544
+ raise ResponseError, 'OTP response must contain exactly 20 raw bytes' unless response.is_a?(String) && response.bytesize == 20
545
+
546
+ response.b
547
+ end
548
+
549
+ public_class_method def self.provision_challenge_response(opts = {})
550
+ slot = slot_number(slot: opts.fetch(:slot, 2))
551
+ consent(opts.merge(confirm: opts[:confirm]))
552
+ validate_hmac_transport(connection: opts[:connection])
553
+ key = obtain_secret(opts.merge(name: :key, value: opts[:key], length: 20))
554
+ key = decode_secret(key: key, encoding: opts.fetch(:key_encoding, opts[:totp] && !opts[:generate] ? :base32 : :raw))
555
+ key = OpenSSL::Digest::SHA1.digest(key) if key.bytesize > 64
556
+ raise ArgumentError, 'HMAC key must contain 1 to 20 bytes, or more than 64 bytes' unless key.bytesize.between?(1, 20)
557
+
558
+ challenge = opts.fetch(:verification_challenge) { SecureRandom.random_bytes(32) }
559
+ raise ArgumentError, 'verification_challenge must contain 1 to 64 raw bytes' unless challenge.is_a?(String) && challenge.bytesize.between?(1, 64)
560
+ raise ArgumentError, 'touch must be true or false' unless [true, false].include?(opts.fetch(:touch, true))
561
+
562
+ access = access_codes(opts)
563
+ config = pack_config(fixed: ''.b, uid: key.byteslice(16..).to_s.b.ljust(6, "\0"), key: key.byteslice(0, 16).ljust(16, "\0"), access_code: access[:new], ext: 0x24, tkt: 0x40, cfg: opts.fetch(:touch, true) ? 0x2e : 0x26)
564
+ before = status(opts)
565
+ require_version(version: before[:version], minimum: [2, 2, 0])
566
+ result = write_verified(opts.merge(slot: slot, command: slot == 1 ? 1 : 3, data: config + access[:current], before: before, configured: true))
567
+ begin
568
+ response = challenge_response(opts.merge(slot: slot, challenge: challenge))
569
+ expected = OpenSSL::HMAC.digest('SHA1', key, challenge)
570
+ raise ResponseError, 'HMAC readback mismatch' unless OpenSSL.fixed_length_secure_compare(response, expected)
571
+ rescue StandardError
572
+ raise ResponseError, 'OTP provisioning state uncertain: HMAC readback failed; do not automatically reprogram', cause: nil
573
+ end
574
+ opts[:details] ? result.merge(verified: true, verification: :hmac_sha1) : :verified
575
+ end
576
+
577
+ private_class_method def self.write_verified(opts = {})
578
+ before = opts[:before]
579
+ transfer(opts)
580
+ after = status(opts)
581
+ sequence_ok = after[:programming_sequence] == before[:programming_sequence] + 1
582
+ sequence_ok ||= after[:programming_sequence].zero? && before[:programming_sequence].positive? && after[:flags].nobits?(3)
583
+ quirk = opts[:connection].descriptor[:protocol] == :apdu && (after[:version] <=> [5, 0, 0]) >= 0 && (after[:version] <=> [5, 4, 3]).negative? && after[:programming_sequence].zero? && before[:programming_sequence].positive?
584
+ raise ResponseError, 'OTP programming readback is stale' unless before[:version] == after[:version] && (sequence_ok || quirk)
585
+ raise ResponseError, 'OTP slot state readback mismatch' if opts.key?(:configured) && after[:slots][opts[:slot]][:configured] != opts[:configured]
586
+
587
+ { slot: opts[:slot], status: after, verified: true, verification: sequence_ok ? :programming_sequence_and_slot_state : :firmware_quirk_slot_state, secret_readback: false }
588
+ rescue StandardError
589
+ raise ResponseError, 'OTP write state uncertain: acknowledgment or explicit readback failed; inspect the device, do not replay', cause: nil
590
+ end
591
+
592
+ private_class_method def self.pack_config(opts = {})
593
+ fixed = opts[:fixed]
594
+ body = fixed.ljust(16, "\0") + opts[:uid] + opts[:key] + opts[:access_code] + [fixed.bytesize, opts[:ext], opts[:tkt], opts[:cfg], 0, 0].pack('C*')
595
+ raise ArgumentError, 'Invalid OTP configuration size' unless body.bytesize == 50
596
+
597
+ crc = 0xffff
598
+ body.each_byte do |byte|
599
+ crc ^= byte
600
+ 8.times { crc = (crc >> 1) ^ (crc.odd? ? 0x8408 : 0) }
601
+ end
602
+ body + [(~crc) & 0xffff].pack('v')
603
+ end
604
+
605
+ private_class_method def self.secret_value(opts = {})
606
+ value = opts[:value]
607
+ value = opts[:secret_provider].call(opts[:name]) if value.nil? && opts[:secret_provider].respond_to?(:call)
608
+ raise ArgumentError, "#{opts[:name]} must be supplied as nonempty in-memory bytes" unless value.is_a?(String) && !value.empty?
609
+
610
+ value.b
611
+ end
612
+
613
+ private_class_method def self.access_codes(opts = {})
614
+ current = opts[:access_code] || ("\0".b * 6)
615
+ replacement = opts[:new_access_code] || current
616
+ [current, replacement].each do |value|
617
+ raise ArgumentError, 'OTP access codes must contain exactly six raw bytes' unless value.is_a?(String) && value.bytesize == 6
618
+ end
619
+ { current: current.b, new: replacement.b }
620
+ end
621
+
622
+ private_class_method def self.status(opts = {})
623
+ raw = transfer(opts.merge(command: 0, data: ''.b, response_length: 6))
624
+ raise ResponseError, 'Malformed OTP status' unless raw.is_a?(String) && raw.bytesize == 6
625
+
626
+ version = raw.bytes.take(3)
627
+ flags = raw.byteslice(4, 2).unpack1('v')
628
+ configured = (version <=> [2, 1, 0]) >= 0
629
+ touch = (version <=> [3, 0, 0]) >= 0
630
+ {
631
+ version: version, programming_sequence: raw.getbyte(3), flags: flags,
632
+ slots: {
633
+ 1 => { configured: configured ? flags.anybits?(1) : nil, touch_triggered: touch ? flags.anybits?(4) : nil },
634
+ 2 => { configured: configured ? flags.anybits?(2) : nil, touch_triggered: touch ? flags.anybits?(8) : nil }
635
+ }
636
+ }
637
+ end
638
+
639
+ private_class_method def self.validate_hmac_transport(opts = {})
640
+ connection = opts[:connection]
641
+ raise ArgumentError, 'connection must implement descriptor and exchange' unless connection.respond_to?(:descriptor) && connection.respond_to?(:exchange)
642
+ raise CommandError, 'HMAC over USB requires OTP HID; use NFC for smartcard challenge-response' if connection.descriptor[:protocol] == :apdu && connection.descriptor[:transport] == :usb
643
+
644
+ :supported
645
+ end
646
+
647
+ private_class_method def self.validate_nfc(opts = {})
648
+ descriptor = opts[:connection].descriptor
649
+ return :supported if descriptor[:nfc] == true || descriptor[:transport] == :nfc
650
+ raise CommandError, 'NFC is not supported on this device' if descriptor[:nfc] == false
651
+
652
+ information = Management.info(connection: opts[:connection], timeout: opts.fetch(:timeout, 30))
653
+ raise CommandError, 'NFC support is not reported by this device; do not assume every model supports NFC' unless information[:supported_capabilities].key?(:nfc)
654
+
655
+ :supported
656
+ end
657
+
658
+ private_class_method def self.transfer(opts = {})
659
+ connection = opts[:connection]
660
+ raise ArgumentError, 'connection must implement exchange and descriptor' unless connection.respond_to?(:exchange) && connection.respond_to?(:descriptor)
661
+
662
+ timeout = opts.fetch(:timeout, 30)
663
+ raise ArgumentError, 'timeout must be finite positive seconds' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
664
+
665
+ case connection.descriptor[:protocol]
666
+ when :otp
667
+ connection.exchange(protocol: :otp, command: opts[:command], data: opts[:data], response_length: opts[:response_length], timeout: timeout)
668
+ when :apdu
669
+ if opts[:command].zero?
670
+ Protocol.select(connection: connection, aid: AID, timeout: timeout)
671
+ else
672
+ raise CommandError, 'HMAC challenge-response over USB requires OTP HID; smartcard HMAC is NFC-only' if [0x30, 0x38].include?(opts[:command]) && connection.descriptor[:transport] == :usb
673
+
674
+ Protocol.apdu(connection: connection, ins: 1, p1: opts[:command], data: opts[:data], timeout: timeout)
675
+ end
676
+ else
677
+ raise ArgumentError, 'OTP requires OTP HID or smartcard/NFC transport'
678
+ end
679
+ end
680
+
681
+ private_class_method def self.slot_number(opts = {})
682
+ slot = opts[:slot]
683
+ raise ArgumentError, 'slot must be integer 1 or 2' unless slot.is_a?(Integer) && [1, 2].include?(slot)
684
+
685
+ slot
686
+ end
687
+
688
+ private_class_method def self.require_version(opts = {})
689
+ raise CommandError, 'Operation not supported by this OTP firmware version' unless (opts[:version] <=> opts[:minimum]) >= 0
690
+
691
+ :supported
692
+ end
693
+
694
+ private_class_method def self.consent(opts = {})
695
+ raise ConfirmationRequired, 'This OTP operation requires confirm: true' unless opts[:confirm] == true
696
+
697
+ %i[generate generate_key generate_private_id enter no_enter serial_public_id upload delete_access_code serial_usb_visible use_numeric_keypad touch totp force].each do |key|
698
+ raise ArgumentError, "#{key} must be true or false" if opts.key?(key) && ![true, false].include?(opts[key])
699
+ end
700
+
701
+ :confirmed
702
+ end
703
+
704
+ public_class_method def self.authors(opts = {})
705
+ opts[:format]
706
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
707
+ end
708
+
709
+ public_class_method def self.help(opts = {})
710
+ opts[:format]
711
+ puts <<~HELP
712
+ Native Ruby only. No subprocess backend. Never log binary responses or secrets.
713
+ Writes are sent once. Uncertain readback raises; inspect rather than replay.
714
+ OTP keyboard/NDEF settings cannot export stored secrets; verification scope is explicit.
715
+ # List implemented OTP leaf operations without opening a device.
716
+ #{self}.operations(
717
+ format: 'optional - reserved output format'
718
+ )
719
+ # Dispatch one native OTP operation using nonsecret arguments only.
720
+ #{self}.execute(
721
+ args: 'optional - Array after otp, empty returns local metadata; credentials belong in memory',
722
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
723
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
724
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
725
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
726
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
727
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
728
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them'
729
+ )
730
+ # Read firmware and slot configuration state without authentication.
731
+ #{self}.info(
732
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
733
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds'
734
+ )
735
+ # Delete the selected slot and verify its empty state.
736
+ #{self}.delete_slot(
737
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
738
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
739
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
740
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
741
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
742
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
743
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
744
+ slot: 'required - integer slot 1 or 2'
745
+ )
746
+ # Swap both slot configurations and verify fresh slot state.
747
+ #{self}.swap(
748
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
749
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
750
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
751
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
752
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
753
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
754
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them'
755
+ )
756
+ # Update keyboard and access settings without replacing the stored key.
757
+ #{self}.settings(
758
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
759
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
760
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
761
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
762
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
763
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
764
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
765
+ slot: 'required - integer slot 1 or 2',
766
+ delete_access_code: 'optional - true clears protection using the supplied current access code',
767
+ enter: 'optional - append Enter by default; false disables it',
768
+ no_enter: 'optional - true disables appended Enter',
769
+ pacing: 'optional - 0, 20, 40 or 60 milliseconds between emitted keys',
770
+ use_numeric_keypad: 'optional - true selects numeric keypad scan codes',
771
+ serial_usb_visible: 'optional - true exposes serial through the USB descriptor'
772
+ )
773
+ # Provision YubiOTP with in-memory AES material and fresh status readback.
774
+ #{self}.provision_yubiotp(
775
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
776
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
777
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
778
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
779
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
780
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
781
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
782
+ slot: 'required - integer slot 1 or 2',
783
+ public_id: 'optional - lowercase even-length modhex public prefix up to 32 characters',
784
+ serial_public_id: 'optional - true derives the prefix from the actual device serial',
785
+ private_id: 'optional - required six raw bytes unless provider or explicit generation supplies them',
786
+ key: 'optional - required sixteen raw AES key bytes unless provider or generation supplies them',
787
+ generate_private_id: 'optional - true generates six private bytes and requires secret_sink',
788
+ generate_key: 'optional - true generates sixteen AES key bytes and requires secret_sink',
789
+ enter: 'optional - append Enter by default',
790
+ no_enter: 'optional - true suppresses appended Enter',
791
+ config_output: 'optional - explicit owned private CSV path; append only after verified programming, no symlinks',
792
+ upload: 'optional - retired upstream flag; true raises before programming and never sends network data'
793
+ )
794
+ # Provision static password scan codes for the selected keyboard layout.
795
+ #{self}.provision_static(
796
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
797
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
798
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
799
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
800
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
801
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
802
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
803
+ slot: 'required - integer slot 1 or 2',
804
+ password: 'optional - required in-memory String of 1 to 38 supported characters unless generated or supplied by provider',
805
+ keyboard_layout: 'optional - MODHEX default, US, UK, DE, FR, IT, BEPO or NORMAN',
806
+ length: 'optional - generated password length from 1 through 38, defaults to 38',
807
+ generate: 'optional - true generates a password and requires secret_sink',
808
+ enter: 'optional - append Enter by default',
809
+ no_enter: 'optional - true suppresses appended Enter'
810
+ )
811
+ # Provision a keyboard HOTP credential with explicit counter placement.
812
+ #{self}.provision_hotp(
813
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
814
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
815
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
816
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
817
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
818
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
819
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
820
+ slot: 'required - integer slot 1 or 2',
821
+ key: 'optional - required raw HMAC key unless provided in memory by secret_provider',
822
+ digits: 'optional - 6 or 8 decimal output digits, defaults to 6',
823
+ counter: 'optional - initial counter 0 through 1048560, divisible by 16',
824
+ identifier: 'optional - 4, 8 or 12-character modhex/BCD token identifier; omitted means no prefix',
825
+ enter: 'optional - append Enter by default',
826
+ no_enter: 'optional - true suppresses appended Enter'
827
+ )
828
+ # Configure an NFC OTP URI or text record on a supported device.
829
+ #{self}.configure_ndef(
830
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
831
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
832
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
833
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
834
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
835
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
836
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
837
+ slot: 'required - integer slot 1 or 2 with an existing credential',
838
+ prefix: 'optional - UTF8 URI or text prefix; URI defaults to the Yubico URL',
839
+ ndef_type: 'optional - URI default or TEXT; complete encoded prefix must fit 54 bytes'
840
+ )
841
+ # Calculate raw challenge bytes or an explicitly requested TOTP code.
842
+ #{self}.calculate(
843
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
844
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
845
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
846
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
847
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
848
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
849
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
850
+ slot: 'optional - integer slot 1 or 2, defaults to 2',
851
+ challenge: 'optional - raw 1 to 64-byte input for non-TOTP calculations',
852
+ challenge_argument: 'optional - nonsecret hexadecimal input, or decimal timestamp when totp is true',
853
+ totp: 'optional - true returns a decimal code rather than raw bytes',
854
+ timestamp: 'optional - explicit nonnegative Unix seconds for TOTP, defaults to current time',
855
+ digits: 'optional - 6 or 8 TOTP digits, defaults to 6'
856
+ )
857
+ # Calculate raw HMAC-SHA1 through an already provisioned OTP slot.
858
+ #{self}.challenge_response(
859
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
860
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
861
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
862
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
863
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
864
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
865
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
866
+ challenge: 'required - raw binary String of 1 through 64 bytes; return is exactly 20 raw bytes',
867
+ slot: 'optional - integer slot 1 or 2, defaults to 2'
868
+ )
869
+ # Provision HMAC-SHA1 and verify a fresh challenge using native cryptography.
870
+ #{self}.provision_challenge_response(
871
+ connection: 'required - exact selected native transport handle; caller owns its lifetime',
872
+ confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
873
+ timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
874
+ secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
875
+ access_code: 'optional - current six-byte raw slot access code; absence means no supplied access credential',
876
+ new_access_code: 'optional - replacement six-byte raw access code; defaults to preserving the supplied current code',
877
+ secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
878
+ slot: 'optional - integer slot 1 or 2, defaults to 2',
879
+ key: 'optional - required raw HMAC material unless provider or explicit generation supplies it',
880
+ key_encoding: 'optional - raw default, hex or base32; never guessed',
881
+ totp: 'optional - true decodes a supplied Base32 key unless key_encoding overrides it',
882
+ generate: 'optional - true generates a 20-byte key and requires secret_sink',
883
+ touch: 'optional - true by default in this typed API; execute chalresp defaults false unless --touch',
884
+ verification_challenge: 'optional - explicit raw 1 to 64-byte test challenge; defaults to 32 random bytes',
885
+ details: 'optional - true returns verification metadata; default returns the legacy verified Symbol'
886
+ )
887
+ # Return module author information.
888
+ #{self}.authors(
889
+ format: 'optional - reserved output format'
890
+ )
891
+ HELP
892
+ nil
893
+ end
894
+ end
895
+ end
896
+ end
897
+ end