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,766 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'base64'
6
+ require 'uri'
7
+ require 'rexml/document'
8
+ require 'rexml/xpath'
9
+
10
+ module PWN
11
+ module Plugins
12
+ module YubiKey
13
+ # Native OATH application protocol. Wire reference: yubikey-manager 5.9.1
14
+ # yubikit/oath.py; https://developers.yubico.com/OATH/YKOATH_Protocol.html
15
+ # Independently implemented Ruby; no external command or credential storage.
16
+ module OATH
17
+ AID = ['a0000005272101'].pack('H*').freeze
18
+ ALGORITHMS = { sha1: 1, sha256: 2, sha512: 3 }.freeze
19
+ TYPES = { hotp: 0x10, totp: 0x20 }.freeze
20
+ PSKC_NS = { 'p' => 'urn:ietf:params:xml:ns:keyprov:pskc', 'x' => 'http://www.w3.org/2001/04/xmlenc#',
21
+ 'd' => 'http://www.w3.org/2000/09/xmldsig#', 'x11' => 'http://www.w3.org/2009/xmlenc11#' }.freeze
22
+
23
+ public_class_method def self.execute(opts = {})
24
+ args = opts.fetch(:args, [])
25
+ raise ArgumentError, 'args must be an Array of Strings' unless args.is_a?(Array) && args.all?(String)
26
+ return { application: :oath, operations: operations } if args.empty? || [%w[accounts], %w[access]].include?(args)
27
+
28
+ count = %w[accounts access].include?(args.first) ? 2 : 1
29
+ operation = args.first(count).join(' ')
30
+ raise ArgumentError, 'Unknown OATH operation' unless operations.include?(operation)
31
+
32
+ grammar = {
33
+ 'info' => [0, [], []], 'reset' => [0, %w[--force], []],
34
+ 'access change' => [0, %w[--clear --remember], []], 'access remember' => [0, [], []], 'access forget' => [0, %w[--all], []],
35
+ 'accounts list' => [0, %w[--show-hidden --oath-type --period --remember], []],
36
+ 'accounts code' => [0..1, %w[--show-hidden --single --remember], []],
37
+ 'accounts add' => [1, %w[--touch --force --generate --remember], %w[--oath-type --algorithm --digits --period --counter --issuer]],
38
+ 'accounts uri' => [0, %w[--touch --force --remember], []],
39
+ 'accounts import' => [0, %w[--touch --force --remember], []],
40
+ 'accounts rename' => [2, %w[--force --remember], []], 'accounts delete' => [1, %w[--force --remember], []]
41
+ }.fetch(operation)
42
+ parsed = Protocol.parse_args(args: args.drop(count), flags: grammar[1], values: grammar[2])
43
+ positional = parsed[:positionals]
44
+ raise ArgumentError, 'Unexpected OATH positional arguments' unless Array(grammar[0]).include?(positional.length)
45
+
46
+ options = opts.merge(parsed[:options])
47
+ %i[generate touch clear all remember use_cache force overwrite].each do |control|
48
+ raise ArgumentError, "#{control} must be a boolean" if options.key?(control) && ![true, false].include?(options[control])
49
+ end
50
+ case operation
51
+ when 'reset' then return reset(options)
52
+ when 'access change' then return change_access(options)
53
+ when 'access remember', 'access forget' then return cache_access(options.merge(operation: operation))
54
+ when 'accounts code'
55
+ raise ArgumentError, 'single requires one exact account identity' if options[:single] && positional.empty?
56
+
57
+ return positional.empty? ? calculate_all(options) : code(options.merge(account: positional.first))
58
+ when 'accounts add' then return put(options.merge(name: positional.first))
59
+ when 'accounts import' then return import_credentials(options)
60
+ when 'accounts uri'
61
+ Protocol.require_confirm(options)
62
+ uri = Protocol.secret(value: opts[:uri], secret_provider: opts[:secret_provider], name: :uri)
63
+ parsed_uri = parse_uri(uri: uri)
64
+ return put(options.merge(parsed_uri).merge(touch: options.fetch(:touch, false)))
65
+ when 'accounts rename', 'accounts delete'
66
+ return modify(options.merge(operation: operation, account: positional[0], new_name: positional[1]))
67
+ end
68
+ session = session(options)
69
+ return { version: session[:version], device_id: session[:device_id], locked: !session[:challenge].nil? } if operation == 'info'
70
+
71
+ unlock(options.merge(session: session))
72
+ list(session: session)
73
+ end
74
+
75
+ public_class_method def self.operations
76
+ ['info', 'reset', 'access change', 'access remember', 'access forget',
77
+ 'accounts list', 'accounts code', 'accounts add', 'accounts uri', 'accounts import', 'accounts rename', 'accounts delete']
78
+ end
79
+
80
+ public_class_method def self.parse_uri(opts = {})
81
+ text = opts[:uri]
82
+ raise ArgumentError, 'uri must be a bounded otpauth URI' unless text.is_a?(String) && text.bytesize <= 8192 && !text.match?(/%(?![0-9a-fA-F]{2})/)
83
+
84
+ uri = URI.parse(text)
85
+ raise ArgumentError, 'Invalid otpauth URI' unless uri.scheme == 'otpauth' && %w[totp hotp].include?(uri.host) && uri.userinfo.nil? && uri.port.nil? && uri.fragment.nil? && uri.path.start_with?('/')
86
+
87
+ pairs = URI.decode_www_form(uri.query.to_s)
88
+ raise ArgumentError, 'Duplicate or unknown otpauth parameters' unless pairs.map(&:first).uniq.length == pairs.length && (pairs.map(&:first) - %w[secret issuer algorithm digits period counter]).empty?
89
+
90
+ fields = pairs.to_h
91
+ label = URI::DEFAULT_PARSER.unescape(uri.path.byteslice(1..)).force_encoding(Encoding::UTF_8)
92
+ raise ArgumentError, 'Invalid UTF-8 otpauth identity' unless label.valid_encoding?
93
+
94
+ issuer, name = label.include?(':') ? label.split(':', 2) : [nil, label]
95
+ raise ArgumentError, 'Conflicting otpauth issuer identities' if issuer && fields['issuer'] && issuer != fields['issuer']
96
+ raise ArgumentError, 'HOTP URI requires a counter' if uri.host == 'hotp' && !fields.key?('counter')
97
+
98
+ data = credential_data(name: name, issuer: fields.fetch('issuer', issuer), oath_type: uri.host, algorithm: fields.fetch('algorithm', 'SHA1'),
99
+ digits: fields.fetch('digits', 6), period: fields.fetch('period', 30), counter: fields.fetch('counter', 0))
100
+ data.merge(secret: decode_base32(value: fields['secret']))
101
+ rescue URI::InvalidURIError
102
+ raise ArgumentError, 'Invalid otpauth URI', cause: nil
103
+ end
104
+
105
+ private_class_method def self.integer(opts = {})
106
+ value = opts[:value]
107
+ value = Integer(value, 10) if value.is_a?(String) && value.match?(/\A[0-9]+\z/)
108
+ raise ArgumentError, 'Invalid numeric OATH option' unless value.is_a?(Integer) && opts.fetch(:range).cover?(value)
109
+
110
+ value
111
+ end
112
+
113
+ private_class_method def self.credential_data(opts = {})
114
+ name = identity(value: opts[:name])
115
+ issuer = opts[:issuer]
116
+ raise ArgumentError, 'issuer must be an exact nonempty String without colon' if issuer && (!issuer.is_a?(String) || issuer.empty? || issuer.include?(':'))
117
+
118
+ type = opts.fetch(:oath_type, :totp).to_s.downcase.to_sym
119
+ algorithm = opts.fetch(:algorithm, :sha1).to_s.downcase.to_sym
120
+ raise ArgumentError, 'Unknown OATH type or hash algorithm' unless TYPES.key?(type) && ALGORITHMS.key?(algorithm)
121
+
122
+ digits = integer(value: opts.fetch(:digits, 6), range: 6..8)
123
+ period = integer(value: opts.fetch(:period, 30), range: 1..0x7fffffff)
124
+ counter = integer(value: opts.fetch(:counter, 0), range: 0..0xffffffff)
125
+ raise ArgumentError, 'Initial counter is only valid for HOTP' if type != :hotp && counter.positive?
126
+ raise ArgumentError, 'touch must be a boolean' if opts.key?(:touch) && ![true, false].include?(opts[:touch])
127
+
128
+ id = (type == :totp && period != 30 ? "#{period}/".b : ''.b) + (issuer ? "#{issuer}:".b : ''.b) + name
129
+ identity(value: id)
130
+ { id: id, name: name, issuer: issuer, oath_type: type, algorithm: algorithm, digits: digits, period: period, counter: counter, touch: opts.fetch(:touch, false) }
131
+ end
132
+
133
+ private_class_method def self.decode_base32(opts = {})
134
+ value = opts[:value]
135
+ raise ArgumentError, 'secret_base32 must be canonical Base32' unless value.is_a?(String) && value.bytesize.between?(2, 8192) && value.match?(/\A[A-Za-z2-7]+={0,6}\z/)
136
+
137
+ raw = value.delete_suffix('=' * value.count('='))
138
+ padding = value.bytesize - raw.bytesize
139
+ raise ArgumentError, 'Invalid Base32 length or padding' unless [0, 2, 4, 5, 7].include?(raw.bytesize % 8) && (padding.zero? || ((value.bytesize % 8).zero? && padding == (8 - (raw.bytesize % 8)) % 8))
140
+
141
+ alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
142
+ bits = raw.upcase.chars.map { |char| alphabet.index(char).to_s(2).rjust(5, '0') }.join
143
+ remainder = bits.bytesize % 8
144
+ raise ArgumentError, 'Noncanonical Base32 padding bits' if remainder.positive? && bits.byteslice(-remainder, remainder).include?('1')
145
+
146
+ [bits.byteslice(0, bits.bytesize - remainder)].pack('B*')
147
+ end
148
+
149
+ private_class_method def self.put(opts = {})
150
+ data = credential_data(opts)
151
+ raise ArgumentError, 'generate conflicts with a supplied secret' if opts[:generate] && (opts[:secret] || opts[:secret_base32])
152
+ raise ArgumentError, 'Use either secret or secret_base32' if opts[:secret] && opts[:secret_base32]
153
+
154
+ Protocol.require_confirm(opts)
155
+ secret = if opts[:generate]
156
+ SecureRandom.random_bytes(20)
157
+ elsif opts[:secret_base32]
158
+ decode_base32(value: opts[:secret_base32])
159
+ else
160
+ Protocol.secret(value: opts[:secret], secret_provider: opts[:secret_provider], name: :secret)
161
+ end
162
+ raise ArgumentError, 'OATH secret must contain 2 to 4096 raw bytes' unless secret.is_a?(String) && secret.bytesize.between?(2, 4096)
163
+
164
+ session = session(opts)
165
+ require_version(session: session, minimum: [4, 2, 6]) if data[:touch]
166
+ require_version(session: session, minimum: [4, 3, 1]) if data[:algorithm] == :sha512
167
+ unlock(opts.merge(session: session))
168
+ existing = list(session: session)
169
+ raise ArgumentError, 'Account already exists; explicit overwrite: true is required' if existing.any? { |entry| entry[:id] == data[:id] } && opts[:overwrite] != true && opts[:force] != true
170
+ raise Error, 'Firmware prefix-overwrite defect; choose another exact account ID' if (session[:version] <=> [4, 0, 0]).positive? && (session[:version] <=> [4, 3, 5]).negative? && existing.any? { |entry| entry[:id] != data[:id] && entry[:id].start_with?(data[:id]) }
171
+
172
+ store_credential(opts.merge(session: session, data: data, secret: secret))
173
+ end
174
+
175
+ private_class_method def self.store_credential(opts = {})
176
+ session = opts.fetch(:session)
177
+ data = opts.fetch(:data)
178
+ secret = opts.fetch(:secret)
179
+ digest = OpenSSL::Digest.new(data[:algorithm].to_s)
180
+ encoded = secret.bytesize > digest.block_length ? digest.digest(secret) : secret.b
181
+ encoded = encoded.ljust(14, "\x00")
182
+ payload = tlv(tag: 0x71, value: data[:id]) + tlv(tag: 0x73, value: [TYPES.fetch(data[:oath_type]) | ALGORITHMS.fetch(data[:algorithm]), data[:digits]].pack('CC') + encoded)
183
+ payload += "\x78\x02".b if data[:touch] # This property is a tag/value pair, NOT BER-TLV.
184
+ payload += tlv(tag: 0x7a, value: [data[:counter]].pack('N')) if data[:counter].positive?
185
+ send_apdu(session: session, ins: 1, data: payload)
186
+ found = list(session: session).find { |entry| entry[:id] == data[:id] }
187
+ raise ResponseError, 'OATH import acknowledged but LIST readback did not match' unless found && found[:oath_type] == data[:oath_type] && found[:algorithm] == data[:algorithm]
188
+
189
+ result = found.merge(verified: true, verification: :identity_and_algorithm)
190
+ result[:generated_secret] = secret if opts[:generate]
191
+ result
192
+ end
193
+
194
+ private_class_method def self.import_credentials(opts = {})
195
+ raise ArgumentError, 'Choose credentials or pskc, not both' if opts[:credentials] && opts[:pskc]
196
+
197
+ Protocol.require_confirm(opts)
198
+ records = opts[:credentials] || parse_pskc(opts)
199
+ raise ArgumentError, 'Import requires 1 to 256 credential records' unless records.is_a?(Array) && records.length.between?(1, 256)
200
+
201
+ prepared = records.map do |record|
202
+ allowed = %i[name issuer oath_type algorithm digits period counter touch secret secret_base32]
203
+ raise ArgumentError, 'Invalid credential import record' unless record.is_a?(Hash) && (record.keys - allowed).empty?
204
+
205
+ data = credential_data(record.merge(touch: opts.fetch(:touch, record.fetch(:touch, false))))
206
+ raise ArgumentError, 'Choose one secret encoding per record' if record[:secret] && record[:secret_base32]
207
+
208
+ secret = record[:secret_base32] ? decode_base32(value: record[:secret_base32]) : record[:secret]
209
+ raise ArgumentError, 'Each OATH secret must contain 2 to 4096 raw bytes' unless secret.is_a?(String) && secret.bytesize.between?(2, 4096)
210
+
211
+ { data: data, secret: secret }
212
+ end
213
+ ids = prepared.map { |record| record[:data][:id] }
214
+ raise ArgumentError, 'Duplicate account identities in import' unless ids.uniq.length == ids.length
215
+
216
+ session = session(opts)
217
+ prepared.each do |record|
218
+ require_version(session: session, minimum: [4, 2, 6]) if record[:data][:touch]
219
+ require_version(session: session, minimum: [4, 3, 1]) if record[:data][:algorithm] == :sha512
220
+ end
221
+ unlock(opts.merge(session: session))
222
+ existing = list(session: session).map { |entry| entry[:id] }
223
+ raise ArgumentError, 'Import would overwrite an account; explicit overwrite: true is required' if ids.intersect?(existing) && opts[:overwrite] != true && opts[:force] != true
224
+ raise Error, 'Firmware prefix-overwrite defect prevents this import' if (session[:version] <=> [4, 0, 0]).positive? && (session[:version] <=> [4, 3, 5]).negative? && ids.any? { |id| (ids + existing).any? { |other| other != id && other.start_with?(id) } }
225
+
226
+ prepared.map { |record| store_credential(record.merge(session: session)) }
227
+ end
228
+
229
+ private_class_method def self.xml_node(opts = {})
230
+ matches = REXML::XPath.match(opts.fetch(:node), opts.fetch(:path), PSKC_NS)
231
+ raise ArgumentError, 'Ambiguous duplicate PSKC field' if matches.length > 1
232
+
233
+ matches.first
234
+ end
235
+
236
+ private_class_method def self.xml_text(opts = {})
237
+ xml_node(opts)&.text
238
+ end
239
+
240
+ private_class_method def self.parse_pskc(opts = {})
241
+ xml = opts[:pskc]
242
+ xml = Protocol.secret(secret_provider: opts[:secret_provider], name: :pskc) if xml.nil?
243
+ raise ArgumentError, 'pskc must be in-memory XML, at most one MiB, with no DTD/entities' unless xml.is_a?(String) && xml.bytesize <= 1_048_576 && !xml.match?(/<!DOCTYPE|<!ENTITY/i)
244
+
245
+ document = REXML::Document.new(xml)
246
+ root = document.root
247
+ raise ArgumentError, 'Expected a PSKC 1.0 KeyContainer' unless root && root.name == 'KeyContainer' && root.namespace == PSKC_NS['p'] && root.attributes['Version'] == '1.0'
248
+ raise ArgumentError, 'Signed PSKC containers require caller-side signature verification before import' if xml_node(node: root, path: 'd:Signature')
249
+
250
+ opts = opts.merge(crypto: pskc_crypto(opts.merge(root: root))) if REXML::XPath.match(root, './/p:EncryptedValue', PSKC_NS).any?
251
+
252
+ nodes = REXML::XPath.match(root, 'p:KeyPackage/p:Key', PSKC_NS)
253
+ raise ArgumentError, 'PSKC must contain 1 to 256 credentials' unless nodes.length.between?(1, 256)
254
+
255
+ nodes.map do |node|
256
+ type = node.attributes['Algorithm']
257
+ raise ArgumentError, 'Unsupported PSKC credential algorithm' unless ["#{PSKC_NS['p']}:totp", "#{PSKC_NS['p']}:hotp"].include?(type)
258
+
259
+ name = node.attributes['Id']
260
+ issuer = xml_text(node: node, path: 'p:Issuer')
261
+ friendly = xml_text(node: node, path: 'p:FriendlyName')
262
+ if friendly&.include?(':')
263
+ friendly_issuer, name = friendly.split(':', 2)
264
+ issuer ||= friendly_issuer
265
+ end
266
+ user = xml_text(node: node, path: 'p:UserId')
267
+ if user
268
+ name = user
269
+ if user.start_with?('CN=')
270
+ begin
271
+ dn = OpenSSL::X509::Name.parse_rfc2253(user)
272
+ name = dn.to_a.find { |field| field[0] == 'CN' }&.[](1) || user
273
+ rescue OpenSSL::X509::NameError
274
+ raise ArgumentError, 'Malformed PSKC UserId distinguished name', cause: nil
275
+ end
276
+ end
277
+ end
278
+ response = xml_node(node: node, path: 'p:AlgorithmParameters/p:ResponseFormat')
279
+ raise ArgumentError, 'Only decimal OATH response format is supported' if response && response.attributes['Encoding'] != 'DECIMAL'
280
+
281
+ secret_node = xml_node(node: node, path: 'p:Data/p:Secret')
282
+ raise ArgumentError, 'Missing PSKC secret' unless secret_node
283
+
284
+ secret = pskc_value(opts.merge(node: secret_node, binary: true))
285
+ { name: name, issuer: issuer, oath_type: type.split(':').last, secret: secret,
286
+ algorithm: xml_text(node: node, path: 'p:AlgorithmParameters/p:Suite') || 'SHA1', digits: response&.attributes&.[]('Length') || 6,
287
+ period: pskc_number(opts.merge(node: node, field: 'TimeInterval', default: 30)), counter: pskc_number(opts.merge(node: node, field: 'Counter', default: 0)) }
288
+ end
289
+ rescue REXML::ParseException
290
+ raise ArgumentError, 'Malformed PSKC XML', cause: nil
291
+ end
292
+
293
+ private_class_method def self.pskc_number(opts = {})
294
+ node = xml_node(node: opts.fetch(:node), path: "p:Data/p:#{opts.fetch(:field)}")
295
+ node ? pskc_value(opts.merge(node: node, binary: false)) : opts.fetch(:default)
296
+ end
297
+
298
+ private_class_method def self.pskc_value(opts = {})
299
+ node = opts.fetch(:node)
300
+ plain = xml_node(node: node, path: 'p:PlainValue')
301
+ encrypted = xml_node(node: node, path: 'p:EncryptedValue')
302
+ raise ArgumentError, 'PSKC field must have exactly one value encoding' unless plain.nil? != encrypted.nil?
303
+ return opts[:binary] ? Base64.strict_decode64(plain.text.to_s.gsub(/[\r\n\t ]/, '')) : plain.text if plain
304
+
305
+ algorithm = xml_node(node: encrypted, path: 'x:EncryptionMethod')&.attributes&.[]('Algorithm')
306
+ ciphertext = Base64.strict_decode64(xml_text(node: encrypted, path: 'x:CipherData/x:CipherValue').to_s.gsub(/[\r\n\t ]/, ''))
307
+ crypto = opts.fetch(:crypto)
308
+ mac = xml_text(node: node, path: 'p:ValueMAC')
309
+ if mac || algorithm.to_s.end_with?('-cbc')
310
+ raise ArgumentError, 'PSKC CBC requires authenticated ValueMAC' unless mac && crypto[:mac_key] && crypto[:mac_hash]
311
+
312
+ expected = OpenSSL::HMAC.digest(crypto[:mac_hash], crypto[:mac_key], ciphertext)
313
+ actual = Base64.strict_decode64(mac.gsub(/[\r\n\t ]/, ''))
314
+ raise ArgumentError, 'PSKC integrity check failed; no credentials imported' unless actual.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(actual, expected)
315
+ end
316
+ value = pskc_decrypt(node: encrypted, key: crypto[:key])
317
+ opts[:binary] ? value : value.bytes.reduce(0) { |total, byte| (total << 8) | byte }
318
+ end
319
+
320
+ private_class_method def self.pskc_crypto(opts = {})
321
+ root = opts.fetch(:root)
322
+ derivation = xml_node(node: root, path: 'p:EncryptionKey/x11:DerivedKey/x11:KeyDerivationMethod')
323
+ if derivation
324
+ raise ArgumentError, 'Unsupported PSKC key derivation method' unless derivation.attributes['Algorithm'] == "#{PSKC_NS['x11']}pbkdf2"
325
+ raise ArgumentError, 'PSKC derivation conflicts with a supplied raw key' if opts[:pskc_key]
326
+
327
+ params = xml_node(node: derivation, path: 'x11:PBKDF2-params')
328
+ raise ArgumentError, 'Missing PSKC PBKDF2 parameters' unless params
329
+
330
+ iterations = integer(value: xml_text(node: params, path: "*[local-name()='IterationCount']"), range: 1..1_000_000)
331
+ length = integer(value: xml_text(node: params, path: "*[local-name()='KeyLength']"), range: 16..32)
332
+ raise ArgumentError, 'Invalid PSKC AES key length' unless [16, 24, 32].include?(length)
333
+
334
+ salt = Base64.strict_decode64(xml_text(node: params, path: "*[local-name()='Salt']/*[local-name()='Specified']").to_s.gsub(/[\r\n\t ]/, ''))
335
+ raise ArgumentError, 'PSKC PBKDF2 salt must contain 1 to 1024 bytes' unless salt.bytesize.between?(1, 1024)
336
+
337
+ prf = xml_node(node: params, path: "*[local-name()='PRF']")
338
+ hash = pskc_hash(value: (prf && (prf.attributes['Algorithm'] || prf.text)) || "#{PSKC_NS['d']}hmac-sha1")
339
+ password = Protocol.secret(value: opts[:pskc_password], secret_provider: opts[:secret_provider], name: :pskc_password)
340
+ key = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, length, hash)
341
+ else
342
+ raise ArgumentError, 'PSKC has no password derivation parameters; supply pskc_key' if opts[:pskc_password]
343
+
344
+ key = Protocol.secret(value: opts[:pskc_key], secret_provider: opts[:secret_provider], name: :pskc_key)
345
+ end
346
+ method = xml_node(node: root, path: 'p:MACMethod')
347
+ return { key: key } unless method
348
+
349
+ hash = pskc_hash(value: method.attributes['Algorithm'])
350
+ encrypted = xml_node(node: method, path: 'p:MACKey')
351
+ raise ArgumentError, 'Missing encrypted PSKC MACKey' unless encrypted
352
+
353
+ { key: key, mac_key: pskc_decrypt(node: encrypted, key: key), mac_hash: hash }
354
+ end
355
+
356
+ private_class_method def self.pskc_hash(opts = {})
357
+ names = { "#{PSKC_NS['d']}hmac-sha1" => 'SHA1', 'http://www.w3.org/2001/04/xmldsig-more#hmac-sha256' => 'SHA256',
358
+ 'http://www.w3.org/2001/04/xmldsig-more#hmac-sha512' => 'SHA512' }
359
+ names.fetch(opts[:value]) { raise ArgumentError, 'Unsupported PSKC HMAC algorithm' }
360
+ end
361
+
362
+ private_class_method def self.pskc_decrypt(opts = {})
363
+ node = opts.fetch(:node)
364
+ algorithm = xml_node(node: node, path: 'x:EncryptionMethod')&.attributes&.[]('Algorithm')
365
+ match = algorithm&.match(%r{\Ahttp://www\.w3\.org/2001/04/xmlenc#(?:kw-aes(128|192|256)|aes(128|192|256)-cbc)\z})
366
+ raise ArgumentError, 'Unsupported PSKC encryption algorithm' unless match
367
+
368
+ bits = match[1] || match[2]
369
+ key = opts.fetch(:key)
370
+ raise ArgumentError, 'PSKC key has incorrect raw byte length' unless key.bytesize * 8 == Integer(bits, 10)
371
+
372
+ ciphertext = Base64.strict_decode64(xml_text(node: node, path: 'x:CipherData/x:CipherValue').to_s.gsub(/[\r\n\t ]/, ''))
373
+ cipher = OpenSSL::Cipher.new("aes-#{bits}-#{match[1] ? 'wrap' : 'cbc'}")
374
+ cipher.decrypt
375
+ cipher.key = key
376
+ cipher.iv = match[1] ? "\xa6".b * 8 : ciphertext.slice!(0, 16)
377
+ cipher.update(ciphertext) + cipher.final
378
+ rescue OpenSSL::OpenSSLError
379
+ raise ArgumentError, 'PSKC integrity/decryption failed; no credentials imported', cause: nil
380
+ end
381
+
382
+ private_class_method def self.require_version(opts = {})
383
+ raise Error, 'OATH operation is unsupported by the selected application version' unless Protocol.version_at_least?(version: opts.fetch(:session)[:version], minimum: opts.fetch(:minimum))
384
+ end
385
+
386
+ private_class_method def self.modify(opts = {})
387
+ id = identity(value: opts[:account])
388
+ rename = opts[:operation] == 'accounts rename'
389
+ new_name = identity(value: opts[:new_name]) if rename
390
+ Protocol.require_confirm(opts)
391
+ session = session(opts)
392
+ require_version(session: session, minimum: [5, 3, 1]) if rename
393
+ unlock(opts.merge(session: session))
394
+ entries = list(session: session)
395
+ found = entries.find { |entry| entry[:id] == id }
396
+ raise ResponseError, 'No exact OATH account identifier' unless found
397
+
398
+ payload = tlv(tag: 0x71, value: id)
399
+ if rename
400
+ new_id = (found[:oath_type] == :totp && found[:period] != 30 ? "#{found[:period]}/".b : ''.b) + new_name
401
+ identity(value: new_id)
402
+ raise ArgumentError, 'Target account already exists' if entries.any? { |entry| entry[:id] == new_id }
403
+
404
+ send_apdu(session: session, ins: 5, data: payload + tlv(tag: 0x71, value: new_id))
405
+ after = list(session: session)
406
+ raise ResponseError, 'OATH rename readback failed' unless after.any? { |entry| entry[:id] == new_id } && after.none? { |entry| entry[:id] == id }
407
+
408
+ { id: new_id, previous_id: id, verified: true }
409
+ else
410
+ send_apdu(session: session, ins: 2, data: payload)
411
+ raise ResponseError, 'OATH delete readback failed' if list(session: session).any? { |entry| entry[:id] == id }
412
+
413
+ { deleted: id, verified: true }
414
+ end
415
+ end
416
+
417
+ public_class_method def self.code(opts = {})
418
+ account = identity(value: opts[:account])
419
+ timestamp = timestamp(value: opts[:timestamp])
420
+ Protocol.require_confirm(opts)
421
+ session = session(opts)
422
+ unlock(opts.merge(session: session))
423
+ matches = list(session: session).select { |credential| credential[:id] == account }
424
+ raise ResponseError, 'OATH requires one exact account identifier' unless matches.length == 1
425
+
426
+ code_record(session: session, credential: matches.first, timestamp: timestamp).fetch(:value)
427
+ end
428
+
429
+ public_class_method def self.calculate(opts = {})
430
+ id = identity(value: opts[:account])
431
+ challenge = opts[:challenge]
432
+ raise ArgumentError, 'challenge must contain at most 64 raw bytes' unless challenge.is_a?(String) && challenge.bytesize <= 64
433
+
434
+ Protocol.require_confirm(opts)
435
+ session = session(opts)
436
+ unlock(opts.merge(session: session))
437
+ found = list(session: session).find { |entry| entry[:id] == id }
438
+ raise ResponseError, 'No exact OATH account identifier' unless found
439
+
440
+ response = send_apdu(session: session, ins: 0xa2, data: tlv(tag: 0x71, value: id) + tlv(tag: 0x74, value: challenge.b))
441
+ value = Protocol.tlv_map(data: response)[0x75]
442
+ length = OpenSSL::Digest.new(found[:algorithm].to_s).digest_length
443
+ raise ResponseError, 'Malformed OATH HMAC response' unless value && value.bytesize == length + 1 && value.getbyte(0).between?(6, 8)
444
+
445
+ value.byteslice(1..)
446
+ end
447
+
448
+ public_class_method def self.calculate_all(opts = {})
449
+ timestamp = timestamp(value: opts[:timestamp])
450
+ Protocol.require_confirm(opts)
451
+ session = session(opts)
452
+ unlock(opts.merge(session: session))
453
+ response = send_apdu(session: session, ins: 0xa4, p2: 1, data: tlv(tag: 0x74, value: [timestamp / 30].pack('Q>')))
454
+ fields = Protocol.untlv(data: response)
455
+ raise ResponseError, 'Malformed OATH CALCULATE ALL pairs' if fields.length.odd?
456
+
457
+ seen = []
458
+ fields.each_slice(2).map do |name, answer|
459
+ raise ResponseError, 'Malformed OATH CALCULATE ALL record' unless name[:tag] == 0x71 && [0x76, 0x77, 0x7c].include?(answer[:tag])
460
+ raise ResponseError, 'Duplicate OATH calculation identity' if seen.include?(name[:value])
461
+
462
+ seen << name[:value]
463
+ item = credential(id: name[:value], type: answer[:tag] == 0x77 ? 0x10 : 0x20, algorithm: 1)
464
+ item.delete(:algorithm) # CALCULATE ALL does not report the hash algorithm.
465
+ code = if answer[:tag] == 0x76
466
+ item[:period] == 30 ? format_code(credential: item, timestamp: timestamp, truncated: answer[:value]) : code_record(session: session, credential: item, timestamp: timestamp)
467
+ end
468
+ item.merge(touch: answer[:tag] == 0x7c, code: code)
469
+ end
470
+ end
471
+
472
+ private_class_method def self.validate_access_options(opts = {})
473
+ %i[remember use_cache].each do |control|
474
+ raise ArgumentError, "#{control} must be a boolean" if opts.key?(control) && ![true, false].include?(opts[control])
475
+ end
476
+ raise ArgumentError, 'Choose password or access_key, not both' if opts[:password] && opts[:access_key]
477
+ raise ArgumentError, 'use_cache conflicts with an explicit access credential' if opts[:use_cache] && (opts[:password] || opts[:access_key])
478
+ raise ArgumentError, 'access_key must contain exactly 16 raw bytes' if opts[:access_key] && (!opts[:access_key].is_a?(String) || opts[:access_key].bytesize != 16)
479
+
480
+ cache(opts) if opts[:remember] || opts[:use_cache]
481
+ end
482
+
483
+ private_class_method def self.cache(opts = {})
484
+ value = opts[:encrypted_cache]
485
+ required = opts.fetch(:methods, %i[fetch store delete])
486
+ raise ArgumentError, 'Supply an explicit encrypted_cache adapter; no host cache is opened' unless value.respond_to?(:encrypted?) && value.encrypted? == true && required.all? { |method| value.respond_to?(method) }
487
+
488
+ value
489
+ end
490
+
491
+ private_class_method def self.remember_key(opts = {})
492
+ store = cache(opts)
493
+ id = opts.fetch(:session)[:device_id]
494
+ key = opts.fetch(:key)
495
+ store.store(device_id: id, access_key: key.dup)
496
+ readback = store.fetch(device_id: id)
497
+ raise ResponseError, 'Encrypted cache handoff readback failed' unless readback.is_a?(String) && readback.bytesize == key.bytesize && OpenSSL.fixed_length_secure_compare(readback, key)
498
+
499
+ { cache_handoff: true, verified: true }
500
+ end
501
+
502
+ private_class_method def self.cache_access(opts = {})
503
+ all = opts[:all] == true
504
+ raise ArgumentError, 'all is only valid for access forget' if all && opts[:operation] != 'access forget'
505
+
506
+ store = cache(opts.merge(methods: all ? %i[clear empty?] : %i[fetch store delete]))
507
+ Protocol.require_confirm(opts)
508
+ if all
509
+ store.clear
510
+ raise ResponseError, 'Encrypted cache clear readback failed' unless store.empty?
511
+
512
+ return { forgotten: :all, verified: true }
513
+ end
514
+ session = session(opts)
515
+ if opts[:operation] == 'access remember'
516
+ raise ArgumentError, 'Cannot remember an unprotected OATH application' unless session[:challenge]
517
+
518
+ key = unlock(opts.merge(session: session, remember: false))
519
+ remember_key(opts.merge(session: session, key: key))
520
+ else
521
+ store.delete(device_id: session[:device_id])
522
+ raise ResponseError, 'Encrypted cache delete readback failed' unless store.fetch(device_id: session[:device_id]).nil?
523
+
524
+ { forgotten: true, verified: true }
525
+ end
526
+ end
527
+
528
+ private_class_method def self.change_access(opts = {})
529
+ clear = opts[:clear] == true
530
+ raise ArgumentError, 'clear conflicts with a new access credential or remember' if clear && (opts[:new_password] || opts[:new_access_key] || opts[:remember])
531
+ raise ArgumentError, 'Choose new_password or new_access_key, not both' if opts[:new_password] && opts[:new_access_key]
532
+
533
+ validate_access_options(opts)
534
+ Protocol.require_confirm(opts)
535
+ key = opts[:new_access_key]
536
+ raise ArgumentError, 'new_access_key must contain exactly 16 raw bytes' if key && (!key.is_a?(String) || key.bytesize != 16)
537
+
538
+ new_password = Protocol.secret(value: opts[:new_password], secret_provider: opts[:secret_provider], name: :new_password) unless clear || key
539
+ session = session(opts)
540
+ unlock(opts.merge(session: session, remember: false))
541
+ if clear
542
+ send_apdu(session: session, ins: 3, data: tlv(tag: 0x73, value: ''.b))
543
+ else
544
+ key ||= OpenSSL::PKCS5.pbkdf2_hmac(new_password, session[:salt], 1000, 16, 'SHA1')
545
+ challenge = SecureRandom.random_bytes(8)
546
+ payload = tlv(tag: 0x73, value: "\x21".b + key) + tlv(tag: 0x74, value: challenge) + tlv(tag: 0x75, value: OpenSSL::HMAC.digest('SHA1', key, challenge))
547
+ send_apdu(session: session, ins: 3, data: payload)
548
+ end
549
+ fresh = session(opts)
550
+ raise ResponseError, 'OATH access change SELECT readback failed' unless fresh[:salt] == session[:salt] && fresh[:challenge].nil? == clear
551
+
552
+ result = { locked: !clear, verified: true, verification: :protection_state_only }
553
+ result.merge!(remember_key(opts.merge(session: fresh, key: key))) if opts[:remember]
554
+ result
555
+ end
556
+
557
+ private_class_method def self.reset(opts = {})
558
+ Protocol.require_confirm(opts)
559
+ before = session(opts)
560
+ send_apdu(session: before, ins: 4, p1: 0xde, p2: 0xad)
561
+ after = session(opts)
562
+ raise ResponseError, 'OATH reset readback failed' unless after[:challenge].nil? && before[:salt] != after[:salt] && list(session: after).empty?
563
+
564
+ { reset: true, verified: true, device_id: after[:device_id] }
565
+ end
566
+
567
+ private_class_method def self.session(opts = {})
568
+ validate_access_options(opts)
569
+ connection = opts[:connection]
570
+ raise ArgumentError, 'OATH requires an APDU connection' unless connection.respond_to?(:exchange) && connection.respond_to?(:descriptor) && connection.descriptor[:protocol] == :apdu
571
+
572
+ timeout = opts.fetch(:timeout, 30)
573
+ raise ArgumentError, 'timeout must be finite and positive' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
574
+
575
+ selected = Protocol.select(connection: connection, aid: AID, timeout: timeout, get_response_ins: 0xa5, get_response_le: 0)
576
+ fields = Protocol.tlv_map(data: selected)
577
+ version = fields[0x79]
578
+ salt = fields[0x71]
579
+ challenge = fields[0x74]
580
+ raise ResponseError, 'Malformed OATH SELECT response' unless version&.bytesize == 3 && salt && !salt.empty? && (challenge.nil? || challenge.bytesize == 8)
581
+
582
+ { connection: connection, timeout: timeout, version: version.bytes, salt: salt, challenge: challenge,
583
+ device_id: Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(salt)[0, 16]).delete('=') }
584
+ end
585
+
586
+ private_class_method def self.send_apdu(opts = {})
587
+ session = opts.fetch(:session)
588
+ Protocol.apdu(connection: session[:connection], timeout: session[:timeout], ins: opts.fetch(:ins), p1: opts.fetch(:p1, 0), p2: opts.fetch(:p2, 0), data: opts.fetch(:data, ''.b), get_response_ins: 0xa5, get_response_le: 0)
589
+ end
590
+
591
+ private_class_method def self.unlock(opts = {})
592
+ session = opts.fetch(:session)
593
+ unless session[:challenge]
594
+ raise ArgumentError, 'OATH application has no access key; supplied authentication is not applicable' if opts[:password] || opts[:access_key] || opts[:remember] || opts[:use_cache]
595
+
596
+ return
597
+ end
598
+
599
+ Protocol.require_confirm(opts)
600
+ key = opts[:access_key]
601
+ key = cache(opts).fetch(device_id: session[:device_id]) if opts[:use_cache]
602
+ raise ArgumentError, 'No explicit cached key for this OATH application' if opts[:use_cache] && key.nil?
603
+
604
+ if key.nil?
605
+ password = Protocol.secret(value: opts[:password], secret_provider: opts[:secret_provider], name: :password)
606
+ key = OpenSSL::PKCS5.pbkdf2_hmac(password, session[:salt], 1000, 16, 'SHA1')
607
+ end
608
+ raise ArgumentError, 'access_key must contain exactly 16 raw bytes' unless key.is_a?(String) && key.bytesize == 16
609
+
610
+ challenge = SecureRandom.random_bytes(8)
611
+ answer = OpenSSL::HMAC.digest('SHA1', key, session[:challenge])
612
+ response = send_apdu(session: session, ins: 0xa3, data: tlv(tag: 0x75, value: answer) + tlv(tag: 0x74, value: challenge))
613
+ fields = Protocol.tlv_map(data: response)
614
+ expected = OpenSSL::HMAC.digest('SHA1', key, challenge)
615
+ actual = fields[0x75]
616
+ raise ResponseError, 'OATH mutual authentication failed' unless actual&.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(actual, expected)
617
+
618
+ session[:challenge] = nil
619
+ remember_key(opts.merge(key: key)) if opts[:remember]
620
+ key
621
+ end
622
+
623
+ private_class_method def self.tlv(opts = {})
624
+ Protocol.tlv(tag: opts.fetch(:tag), value: opts.fetch(:value))
625
+ end
626
+
627
+ private_class_method def self.identity(opts = {})
628
+ value = opts[:value]
629
+ raise ArgumentError, 'account must contain 1 to 64 exact bytes' unless value.is_a?(String) && value.bytesize.between?(1, 64)
630
+
631
+ value.b
632
+ end
633
+
634
+ private_class_method def self.timestamp(opts = {})
635
+ value = opts.fetch(:value, nil)
636
+ value = Time.now.to_i if value.nil?
637
+ raise ArgumentError, 'timestamp must be a nonnegative Integer' unless value.is_a?(Integer) && value.between?(0, 0x7fffffffffffffff)
638
+
639
+ value
640
+ end
641
+
642
+ private_class_method def self.credential(opts = {})
643
+ id = identity(value: opts.fetch(:id))
644
+ type = TYPES.key(opts.fetch(:type))
645
+ algorithm = ALGORITHMS.key(opts.fetch(:algorithm))
646
+ raise ResponseError, 'Unsupported OATH credential algorithm or type' unless type && algorithm
647
+
648
+ period = type == :totp ? 30 : 0
649
+ if type == :totp && (match = id.match(%r{\A([0-9]+)/}n))
650
+ period = Integer(match[1], 10)
651
+ raise ResponseError, 'Invalid OATH credential period' unless period.positive?
652
+ end
653
+ { id: id, account: id, oath_type: type, algorithm: algorithm, period: period }
654
+ end
655
+
656
+ private_class_method def self.list(opts = {})
657
+ response = send_apdu(session: opts.fetch(:session), ins: 0xa1)
658
+ credentials = Protocol.untlv(data: response).map do |entry|
659
+ value = entry[:value]
660
+ raise ResponseError, 'Malformed OATH LIST entry' unless entry[:tag] == 0x72 && value.bytesize.between?(2, 65)
661
+
662
+ credential(id: value.byteslice(1..), type: value.getbyte(0) & 0xf0, algorithm: value.getbyte(0) & 0x0f)
663
+ end
664
+ raise ResponseError, 'Duplicate OATH account identifiers' unless credentials.map { |entry| entry[:id] }.uniq.length == credentials.length
665
+
666
+ credentials
667
+ end
668
+
669
+ private_class_method def self.code_record(opts = {})
670
+ credential = opts.fetch(:credential)
671
+ timestamp = opts.fetch(:timestamp)
672
+ challenge = credential[:oath_type] == :hotp ? ''.b : [timestamp / credential[:period]].pack('Q>')
673
+ response = send_apdu(session: opts.fetch(:session), ins: 0xa2, p2: 1,
674
+ data: tlv(tag: 0x71, value: credential[:id]) + tlv(tag: 0x74, value: challenge))
675
+ fields = Protocol.tlv_map(data: response)
676
+ format_code(credential: credential, timestamp: timestamp, truncated: fields[0x76])
677
+ end
678
+
679
+ private_class_method def self.format_code(opts = {})
680
+ truncated = opts[:truncated]
681
+ raise ResponseError, 'Malformed OATH truncated response' unless truncated.is_a?(String) && truncated.bytesize == 5 && truncated.getbyte(0).between?(6, 8)
682
+
683
+ digits = truncated.getbyte(0)
684
+ number = (truncated.byteslice(1, 4).unpack1('N') & 0x7fffffff) % (10**digits)
685
+ credential = opts.fetch(:credential)
686
+ timestamp = opts.fetch(:timestamp)
687
+ period = credential[:period]
688
+ { value: number.to_s.rjust(digits, '0'), valid_from: period.positive? ? timestamp / period * period : timestamp,
689
+ valid_to: period.positive? ? ((timestamp / period) + 1) * period : nil }
690
+ end
691
+
692
+ public_class_method def self.authors
693
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
694
+ end
695
+
696
+ public_class_method def self.help
697
+ puts <<~HELP
698
+ Execute native OATH management using in-memory secrets.
699
+ #{self}.execute(
700
+ args: 'optional - argv after oath; empty or group names return local metadata',
701
+ connection: 'required - selected APDU handle except local help or forget --all',
702
+ confirm: 'required - literal true for authentication, calculation, cache writes or mutations',
703
+ password: 'optional - current access password as an in-memory String',
704
+ access_key: 'optional - sixteen raw bytes instead of a current access password',
705
+ new_password: 'optional - new in-memory access password for access change',
706
+ new_access_key: 'optional - sixteen raw bytes instead of a new access password',
707
+ secret: 'optional - raw 2 to 4096 byte OATH seed for accounts add',
708
+ secret_base32: 'optional - strict Base32 encoding instead of raw secret',
709
+ uri: 'optional - secret-bearing otpauth URI String for accounts uri, never argv',
710
+ credentials: 'optional - array of complete credential Hash records for accounts import',
711
+ pskc: 'optional - bounded in-memory PSKC 1.0 XML instead of credential records',
712
+ pskc_key: 'optional - raw AES key for encrypted PSKC import',
713
+ pskc_password: 'optional - password for PSKC embedded PBKDF2 parameters',
714
+ encrypted_cache: 'optional - explicit encrypted? adapter with store/fetch/delete or clear/empty?',
715
+ remember: 'optional - boolean handoff of a successfully validated access key to encrypted_cache',
716
+ use_cache: 'optional - boolean use of the caller cache without password fallback',
717
+ overwrite: 'optional - literal true to replace an existing exact OATH ID',
718
+ generate: 'optional - boolean generation of a new seed, returned only in memory',
719
+ touch: 'optional - boolean touch requirement when importing a credential',
720
+ oath_type: 'optional - totp or hotp credential type for accounts add',
721
+ algorithm: 'optional - SHA1, SHA256 or SHA512 for accounts add',
722
+ digits: 'optional - integer from six through eight for accounts add',
723
+ period: 'optional - positive integer TOTP period seconds, default thirty',
724
+ counter: 'optional - initial uint32 HOTP counter, default zero',
725
+ issuer: 'optional - exact issuer identity without colon for accounts add',
726
+ secret_provider: 'optional - callable receiving the missing secret field Symbol after consent',
727
+ timeout: 'optional - finite positive I/O timeout seconds'
728
+ )
729
+ Enumerate implemented operation leaves only.
730
+ #{self}.operations
731
+ Parse a strict otpauth URI locally without opening hardware.
732
+ #{self}.parse_uri(uri: 'required - complete secret-bearing otpauth URI String')
733
+ Calculate one exact account without substring matching.
734
+ #{self}.code(
735
+ connection: 'required - selected raw APDU transport handle',
736
+ account: 'required - complete exact credential ID bytes including issuer and period',
737
+ confirm: 'required - literal true for authentication or calculation',
738
+ timestamp: 'optional - nonnegative Integer epoch seconds; defaults to now',
739
+ password: 'optional - in-memory OATH access password when locked',
740
+ access_key: 'optional - derived sixteen-byte raw access key instead of password',
741
+ secret_provider: 'optional - callable receiving missing secret field Symbol',
742
+ timeout: 'optional - finite positive I/O timeout seconds'
743
+ )
744
+ Calculate a raw HMAC response using one exact credential.
745
+ #{self}.calculate(
746
+ account: 'required - complete exact credential ID bytes',
747
+ challenge: 'required - raw String of zero through sixty-four bytes',
748
+ confirm: 'required - literal true for calculation; same access options as code'
749
+ )
750
+ Calculate all untouchable TOTP records without incrementing HOTP.
751
+ #{self}.calculate_all(
752
+ timestamp: 'optional - nonnegative Integer epoch seconds; defaults to now',
753
+ confirm: 'required - literal true; same connection and access options as code'
754
+ )
755
+ Device writes are read back where possible; metadata verification does not prove seed contents.
756
+ Remember/forget is a caller-owned encrypted storage handoff, never a built-in plaintext cache.
757
+ Imports accept data in memory, not filenames. Unsupported PSKC algorithms/signatures fail closed.
758
+ No mutation or authentication is retried, no corrupted account is repaired or deleted implicitly.
759
+ Display module author information.
760
+ #{self}.authors
761
+ HELP
762
+ end
763
+ end
764
+ end
765
+ end
766
+ end