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,1288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'pwn/plugins/yubikey'
6
+
7
+ module PWN
8
+ module Plugins
9
+ module YubiKey
10
+ # Native CTAP/U2F application logic. Transport owns CTAPHID framing.
11
+ # References: FIDO CTAP 2.1 PS (2021-06-15), U2F raw message formats 1.2,
12
+ # RFC 8949; cross-checked against python-fido2 and YubiKey Manager 5.9.1.
13
+ # No client/origin policy is fabricated: callers supply RP and client hashes.
14
+ module FIDO
15
+ class UnsupportedError < Error; end
16
+ class Cancelled < Error; end
17
+
18
+ # Status-only exception: never include packet bodies or PIN values.
19
+ class CTAPError < CommandError
20
+ attr_reader :code
21
+
22
+ NAMES = { 0x01 => 'INVALID_COMMAND', 0x02 => 'INVALID_PARAMETER', 0x03 => 'INVALID_LENGTH', 0x05 => 'TIMEOUT', 0x11 => 'CBOR_UNEXPECTED_TYPE', 0x12 => 'INVALID_CBOR', 0x14 => 'MISSING_PARAMETER', 0x19 => 'CREDENTIAL_EXCLUDED', 0x27 => 'OPERATION_DENIED', 0x28 => 'KEY_STORE_FULL', 0x2b => 'UNSUPPORTED_OPTION', 0x2c => 'INVALID_OPTION', 0x2d => 'KEEPALIVE_CANCEL', 0x2e => 'NO_CREDENTIALS', 0x2f => 'USER_ACTION_TIMEOUT', 0x30 => 'NOT_ALLOWED', 0x31 => 'PIN_INVALID', 0x32 => 'PIN_BLOCKED', 0x33 => 'PIN_AUTH_INVALID', 0x34 => 'PIN_AUTH_BLOCKED', 0x35 => 'PIN_NOT_SET', 0x36 => 'PUAT_REQUIRED', 0x37 => 'PIN_POLICY_VIOLATION', 0x38 => 'PIN_TOKEN_EXPIRED', 0x3b => 'UP_REQUIRED', 0x3c => 'UV_BLOCKED', 0x3f => 'UV_INVALID', 0x40 => 'UNAUTHORIZED_PERMISSION' }.freeze
23
+
24
+ def initialize(code)
25
+ @code = code
26
+ super(format('CTAP error 0x%<code>02x (%<name>s); operation was not retried', code: code, name: NAMES.fetch(code, 'UNKNOWN')))
27
+ end
28
+ end
29
+
30
+ # U2F/PCSC error retaining the numeric status word only.
31
+ class APDUError < CommandError
32
+ attr_reader :code
33
+
34
+ def initialize(code)
35
+ @code = code
36
+ super(format('FIDO APDU status 0x%04x; operation was not retried', code))
37
+ end
38
+ end
39
+
40
+ # Enrollment is durable before naming. Expose recovery identity, not a retry.
41
+ class EnrollmentError < Error
42
+ attr_reader :template_id
43
+
44
+ def initialize(template_id)
45
+ @template_id = template_id
46
+ super('Fingerprint enrolled, but naming failed; use template_id to rename; enrollment was not repeated or deleted')
47
+ end
48
+ end
49
+
50
+ COMMANDS = {
51
+ 'info' => [:info, [], [], []],
52
+ 'reset' => [:reset, [], %w[force], []],
53
+ 'access change-pin' => [:change_pin, [], %w[u2f], []],
54
+ 'access verify-pin' => [:verify_pin, [], [], []],
55
+ 'access force-change' => [:force_change, [], [], []],
56
+ 'access set-min-length' => [:set_min_length, [:min_length], [], %w[rp-id]],
57
+ 'credentials list' => [:list_credentials, [], %w[csv], []],
58
+ 'credentials delete' => [:delete_credential, [:credential_id_hex], %w[force], []],
59
+ 'fingerprints list' => [:list_fingerprints, [], [], []],
60
+ 'fingerprints add' => [:add_fingerprint, [:name], [], []],
61
+ 'fingerprints rename' => [:rename_fingerprint, %i[template_id_hex name], [], []],
62
+ 'fingerprints delete' => [:delete_fingerprint, [:template_id_hex], %w[force], []],
63
+ 'config toggle-always-uv' => [:toggle_always_uv, [], [], []],
64
+ 'config enable-ep-attestation' => [:enable_enterprise_attestation, [], [], []]
65
+ }.freeze
66
+ GROUPS = ['', 'access', 'credentials', 'fingerprints', 'config'].freeze
67
+
68
+ public_class_method def self.operations
69
+ COMMANDS.keys
70
+ end
71
+
72
+ public_class_method def self.execute(opts = {})
73
+ args = opts.key?(:args) ? opts[:args] : []
74
+ raise ArgumentError, 'FIDO args must be an Array of nonsecret Strings' unless args.is_a?(Array) && args.size <= 256 && args.all? { |arg| arg.is_a?(String) && arg.bytesize <= 4096 }
75
+
76
+ path = args.join(' ')
77
+ return { group: path, operations: operations.select { |entry| path.empty? || entry.start_with?("#{path} ") } } if GROUPS.include?(path)
78
+
79
+ path = COMMANDS.keys.sort_by { |entry| -entry.length }.find { |entry| args.first(entry.split.size) == entry.split }
80
+ raise ArgumentError, 'Unknown FIDO operation' unless path
81
+
82
+ method, positional_names, flags, values = COMMANDS.fetch(path)
83
+ parsed = {}
84
+ positional = []
85
+ rest = args.drop(path.split.size)
86
+ until rest.empty?
87
+ arg = rest.shift
88
+ if arg == '--'
89
+ positional.concat(rest)
90
+ break
91
+ elsif arg.start_with?('--')
92
+ name = arg.delete_prefix('--')
93
+ if flags.include?(name)
94
+ parsed[name.tr('-', '_').to_sym] = true
95
+ elsif values.include?(name)
96
+ value = rest.shift
97
+ raise ArgumentError, 'Missing FIDO option value' unless value && !value.start_with?('--')
98
+
99
+ (parsed[:rp_ids] ||= []) << value
100
+ else
101
+ raise ArgumentError, 'Unknown FIDO option; secrets belong in memory options, not args'
102
+ end
103
+ else
104
+ positional << arg
105
+ end
106
+ end
107
+ optional_name = method == :add_fingerprint && positional.empty?
108
+ raise ArgumentError, 'Incorrect FIDO positional argument count' unless positional.size == positional_names.size || optional_name
109
+
110
+ positional_names.zip(positional).each { |key, value| parsed[key] = value if value }
111
+ perform(opts.merge(parsed).merge(operation: method))
112
+ end
113
+
114
+ public_class_method def self.perform(opts = {})
115
+ operation = opts[:operation]
116
+ allowed = COMMANDS.values.map(&:first) + %i[pin_retries update_credential bio_sensor_info cancel_enrollment make_credential get_assertion u2f_register u2f_authenticate]
117
+ raise ArgumentError, 'Unknown native FIDO operation' unless allowed.include?(operation)
118
+
119
+ session = Session.new(opts)
120
+ session.run(operation)
121
+ ensure
122
+ session&.forget
123
+ end
124
+
125
+ public_class_method def self.make_credential(opts = {})
126
+ perform(opts.merge(operation: :make_credential, connection: opts[:connection]))
127
+ end
128
+
129
+ public_class_method def self.get_assertion(opts = {})
130
+ perform(opts.merge(operation: :get_assertion, connection: opts[:connection]))
131
+ end
132
+
133
+ public_class_method def self.u2f_register(opts = {})
134
+ perform(opts.merge(operation: :u2f_register, connection: opts[:connection]))
135
+ end
136
+
137
+ public_class_method def self.u2f_authenticate(opts = {})
138
+ perform(opts.merge(operation: :u2f_authenticate, connection: opts[:connection]))
139
+ end
140
+
141
+ public_class_method def self.authors
142
+ "AUTHOR(S):\n0day Inc. <request.pentest@0dayinc.com>"
143
+ end
144
+
145
+ public_class_method def self.help
146
+ puts <<~HELP
147
+ Purpose: Execute native FIDO management without subprocesses or PIN retries.
148
+ #{self}.execute(args: 'optional - Nonsecret argv after fido, or a local help group', connection: 'optional - Transport connection, required for device operations', confirm: 'optional - Literal true required for authentication and mutations', pin: 'optional - Current PIN in memory only', new_pin: 'optional - Replacement PIN in memory only', secret_provider: 'optional - Callable receiving pin or new_pin Symbol when required', timeout: 'optional - Positive finite total operation seconds, default 30', on_keepalive: 'optional - Callable receiving status; exceptions cancel', cancelled: 'optional - Callable returning true to cancel before a command or on keepalive')
149
+ Purpose: Invoke typed CTAP operations with caller-owned RP and origin policy.
150
+ #{self}.perform(operation: 'required - Symbol from operations or typed operational API', connection: 'required - Transport connection', confirm: 'optional - Literal true required for authentication and mutations')
151
+ Purpose: Create a credential from caller-supplied clientDataHash and RP/user entities.
152
+ #{self}.make_credential(connection: 'required - Transport connection', client_data_hash: 'required - Binary 32-byte SHA-256 digest of caller-created clientDataJSON', rp: 'required - Text-keyed relying-party entity with id', user: 'required - Text-keyed user entity with binary id', confirm: 'required - Literal true for credential creation')
153
+ Purpose: Obtain every assertion advertised by the authenticator for a caller-supplied RP.
154
+ #{self}.get_assertion(connection: 'required - Transport connection', rp_id: 'required - Exact relying-party ID', client_data_hash: 'required - Binary 32-byte caller-provided clientDataJSON digest', confirm: 'required - Literal true for signing and counters')
155
+ Purpose: Register a U2F credential without inventing an application or client identity.
156
+ #{self}.u2f_register(connection: 'required - Transport connection', client_data_hash: 'required - Binary 32-byte caller-provided clientData digest', app_id_hash: 'required - Binary 32-byte application parameter', confirm: 'required - Literal true for credential creation')
157
+ Purpose: Authenticate a U2F key handle or perform an explicit check-only request.
158
+ #{self}.u2f_authenticate(connection: 'required - Transport connection', client_data_hash: 'required - Binary 32-byte caller-provided clientData digest', app_id_hash: 'required - Binary 32-byte application parameter', key_handle: 'required - Binary U2F credential handle', check_only: 'optional - Literal boolean, default false', confirm: 'required - Literal true for authentication')
159
+ Purpose: List implemented management leaf paths, excluding local help groups.
160
+ #{self}.operations
161
+ Purpose: Encode bounded canonical CTAP CBOR using binary versus UTF-8 String tags.
162
+ #{self}::CBOR.encode(value: 'required - Supported CBOR value, binary Strings encode byte strings')
163
+ Purpose: Decode one complete canonical CTAP CBOR object with resource limits.
164
+ #{self}::CBOR.decode(data: 'required - Raw binary CBOR String')
165
+ Purpose: Identify the implementation authors.
166
+ #{self}.authors
167
+ PINs, tokens and key agreements are not persisted or returned by management calls.
168
+ See the paired skill for typed operation options and relying-party responsibilities.
169
+ HELP
170
+ end
171
+
172
+ # One operation per session. The connection is caller-owned and remains open.
173
+ class Session # rubocop:disable Metrics/ClassLength
174
+ def initialize(opts)
175
+ @opts = opts.dup
176
+ @connection = opts[:connection]
177
+ raise ArgumentError, 'FIDO requires a connection' unless @connection.respond_to?(:exchange) && @connection.respond_to?(:descriptor)
178
+
179
+ timeout = opts.fetch(:timeout, 30)
180
+ raise ArgumentError, 'FIDO timeout must be positive and finite' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
181
+
182
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
183
+ @secrets = []
184
+ @max_message = 1024
185
+ @descriptor = @connection.descriptor
186
+ raise ArgumentError, 'FIDO requires CTAP HID or APDU transport' unless %i[ctap apdu].include?(@descriptor[:protocol])
187
+
188
+ %i[cancelled on_keepalive secret_provider].each do |key|
189
+ raise ArgumentError, "#{key} must be callable" if !opts[key].nil? && !opts[key].respond_to?(:call)
190
+ end
191
+ %i[u2f uv csv check_only].each do |key|
192
+ raise ArgumentError, "#{key} must be boolean" if opts.key?(key) && ![true, false].include?(opts[key])
193
+ end
194
+ end
195
+
196
+ def inspect
197
+ '#<FIDO::Session secrets=redacted>'
198
+ end
199
+
200
+ def run(operation)
201
+ confirm! unless %i[info pin_retries bio_sensor_info].include?(operation)
202
+ record_limit if @opts.key?(:max_records)
203
+ public_send(operation)
204
+ end
205
+
206
+ def forget
207
+ @secrets.each { |secret| secret.replace("\x00".b * secret.bytesize) unless secret.frozen? }
208
+ @secrets.clear
209
+ @opts.clear
210
+ :forgotten
211
+ end
212
+
213
+ def info
214
+ return @info if @info
215
+
216
+ caps = @descriptor[:capabilities]
217
+ return u2f_info if caps.is_a?(Integer) && caps.nobits?(4)
218
+
219
+ data = ctap(4)
220
+ raise ResponseError, 'Invalid authenticator getInfo versions' unless data[1].is_a?(Array) && !data[1].empty? && data[1].all?(String)
221
+ raise ResponseError, 'Invalid authenticator getInfo options' unless data.fetch(4, {}).is_a?(Hash)
222
+ raise ResponseError, 'Authenticator options must be text-keyed booleans' unless data.fetch(4, {}).all? { |key, value| key.is_a?(String) && key.encoding == Encoding::UTF_8 && [true, false].include?(value) }
223
+
224
+ { 13 => 4..255, 16 => 0..1024, 29 => 4..255 }.each do |field, range|
225
+ raise ResponseError, 'Invalid authenticator PIN policy limit' if data.key?(field) && (!data[field].is_a?(Integer) || !range.cover?(data[field]))
226
+ end
227
+ raise ResponseError, 'Invalid authenticator AAGUID' unless data[3].is_a?(String) && data[3].encoding == Encoding::BINARY && data[3].bytesize == 16
228
+
229
+ @max_message = data.fetch(5, 1024)
230
+ raise ResponseError, 'Invalid authenticator message limit' unless @max_message.is_a?(Integer) && @max_message.between?(64, CBOR::MAX_BYTES)
231
+
232
+ @info = { versions: data[1], ctap2: true, options: data.fetch(4, {}), aaguid: data[3], pin_uv_protocols: data.fetch(6, []), max_message_size: @max_message, raw: data }
233
+ rescue CTAPError => e
234
+ raise unless e.code == 1
235
+
236
+ u2f_info
237
+ rescue APDUError => e
238
+ raise unless [0x6d00, 0x6a81].include?(e.code)
239
+
240
+ u2f_info
241
+ rescue CommandError
242
+ # CTAPHID INIT can populate capability bits only during the first exchange.
243
+ caps = @connection.descriptor[:capabilities]
244
+ raise unless caps.is_a?(Integer) && caps.nobits?(4)
245
+
246
+ u2f_info
247
+ end
248
+
249
+ def reset
250
+ if info[:ctap2]
251
+ ctap(7)
252
+ else
253
+ fips_status
254
+ u2f_apdu(0x45)
255
+ end
256
+ { reset: true }
257
+ end
258
+
259
+ def pin_retries
260
+ protocol = pin_protocol
261
+ pin = ctap(6, { 1 => protocol.version, 2 => 1 })
262
+ result = { pin_retries: pin[3], power_cycle_state: pin[4] }
263
+ result[:uv_retries] = ctap(6, { 1 => protocol.version, 2 => 7 })[5] if info[:options]['uv']
264
+ result
265
+ end
266
+
267
+ def change_pin
268
+ return fips_change_pin if @opts[:u2f] == true || !info[:ctap2]
269
+
270
+ protocol = pin_protocol
271
+ minimum = info[:raw].fetch(13, 4)
272
+ maximum = info[:raw].fetch(29, 63)
273
+ new_pin = pin_text(:new_pin, minimum: minimum, maximum: maximum)
274
+ initialized = info[:options]['clientPin'] == false
275
+ raise ArgumentError, 'Authenticator has no current PIN; omit pin when initializing' if initialized && !@opts[:pin].nil?
276
+
277
+ old_pin = pin_text(:pin) unless initialized
278
+ length = [64, ((new_pin.bytesize + 15) / 16) * 16].max
279
+ raise ArgumentError, 'Padded PIN exceeds the CTAP 255-byte limit' if length > 255
280
+
281
+ local, shared = key_agreement(protocol)
282
+ padded = keep_secret(new_pin.b.ljust(length, "\x00".b))
283
+ new_enc = protocol.encrypt(shared, padded)
284
+ params = { 1 => protocol.version, 2 => initialized ? 3 : 4, 3 => local, 5 => new_enc }
285
+ params[6] = protocol.encrypt(shared, keep_secret(OpenSSL::Digest::SHA256.digest(old_pin).byteslice(0, 16))) unless initialized
286
+ params[4] = protocol.authenticate(shared, new_enc + params.fetch(6, ''.b))
287
+ ctap(6, params)
288
+ { changed: true, initialized: initialized }
289
+ end
290
+
291
+ def verify_pin
292
+ raise ArgumentError, 'verify-pin requires a PIN, not built-in UV' if @opts[:uv] == true
293
+
294
+ rp_id = text(@opts[:rp_id], 'RP ID', max: 253) if @opts.key?(:rp_id)
295
+ if info[:ctap2]
296
+ permission = if rp_id
297
+ 2
298
+ elsif info[:options]['credMgmt'] || info[:options]['credentialMgmtPreview']
299
+ 4
300
+ elsif info[:options].key?('bioEnroll') || info[:options].key?('userVerificationMgmtPreview')
301
+ 8
302
+ elsif info[:options]['authnrCfg']
303
+ 32
304
+ end
305
+ authorize(permission, rp_id)
306
+ else
307
+ fips_status
308
+ u2f_apdu(0x43, pin_text(:pin, minimum: 6, maximum: 32).b)
309
+ end
310
+ { verified: true }
311
+ end
312
+
313
+ def list_credentials
314
+ command = credential_command
315
+ auth = authorize(4)
316
+ metadata = credential_call(command, 1, nil, auth)
317
+ existing = bounded_count(metadata[1], zero: true)
318
+ rps = enumerate(command, [2, 3], nil, 5, auth)
319
+ credentials = []
320
+ seen_rps = {}
321
+ seen_credentials = {}
322
+ rps.each do |rp|
323
+ hash = binary(rp[4], 'RP ID hash', length: 32, error: ResponseError)
324
+ raise ResponseError, 'Invalid or duplicate RP enumeration' unless rp[3].is_a?(Hash) && rp[3]['id'].is_a?(String) && !seen_rps[hash]
325
+
326
+ seen_rps[hash] = true
327
+ enumerate(command, [4, 5], { 1 => hash }, 9, auth).each do |entry|
328
+ descriptor = credential_descriptor(entry[7], error: ResponseError)
329
+ raise ResponseError, 'Duplicate credential in enumeration' if seen_credentials[descriptor['id']]
330
+ raise ResponseError, 'Invalid credential metadata' unless entry[6].is_a?(Hash) && entry[8].is_a?(Hash)
331
+
332
+ seen_credentials[descriptor['id']] = true
333
+ credentials << { relying_party: rp[3], rp_id_hash: hash, user: entry[6], credential: descriptor, public_key: entry[8], credential_protection: entry[10], raw: entry }
334
+ raise ResponseError, 'Credential enumeration count limit exceeded' if credentials.size > record_limit
335
+ end
336
+ end
337
+ raise ResponseError, 'Credential enumeration count disagrees with metadata' unless credentials.size == existing
338
+
339
+ result = { metadata: { existing: existing, remaining: metadata[2] }, relying_parties: rps.map { |rp| { relying_party: rp[3], rp_id_hash: rp[4] } }, credentials: credentials }
340
+ return result unless @opts[:csv] == true
341
+
342
+ require 'csv'
343
+ CSV.generate do |csv|
344
+ csv << %w[credential_id rp_id user_id user_name display_name]
345
+ credentials.each { |entry| csv << [entry[:credential]['id'].unpack1('H*'), entry[:relying_party]['id'], entry[:user]['id']&.unpack1('H*'), entry[:user]['name'], entry[:user]['displayName']] }
346
+ end
347
+ end
348
+
349
+ def delete_credential
350
+ descriptor = { 'id' => identifier(:credential_id), 'type' => 'public-key' }
351
+ command = credential_command
352
+ credential_call(command, 6, { 2 => descriptor }, authorize(4))
353
+ { deleted: true }
354
+ end
355
+
356
+ def update_credential
357
+ descriptor = { 'id' => identifier(:credential_id), 'type' => 'public-key' }
358
+ user = user_entity(@opts[:user])
359
+ command = credential_command
360
+ raise UnsupportedError, 'Credential user update is not in the preview protocol' unless command == 10
361
+
362
+ credential_call(command, 7, { 2 => descriptor, 3 => user }, authorize(4))
363
+ { updated: true }
364
+ end
365
+
366
+ def force_change
367
+ require_option!('setMinPINLength')
368
+ raise UnsupportedError, 'Force PIN change requires a configured PIN' unless info[:options]['clientPin'] == true
369
+
370
+ configure(3, { 3 => true })
371
+ end
372
+
373
+ def set_min_length
374
+ length = integer_option(@opts[:min_length], 'min_length', 4..255)
375
+ rps = @opts[:rp_ids]
376
+ raise ArgumentError, 'rp_ids must be an Array' unless rps.nil? || rps.is_a?(Array)
377
+
378
+ rps = rps.map { |rp| text(rp, 'RP ID', max: 253) } if rps
379
+ require_option!('setMinPINLength')
380
+ maximum = info[:raw].fetch(29, 63)
381
+ minimum = info[:raw].fetch(13, 4)
382
+ raise ArgumentError, 'PIN minimum cannot decrease or exceed authenticator maximum' unless length.between?(minimum, maximum)
383
+ raise ArgumentError, 'Too many minimum-PIN-length RP IDs' if rps && rps.size > info[:raw].fetch(16, 0)
384
+
385
+ params = { 1 => length, 3 => false }
386
+ params[2] = rps if rps
387
+ configure(3, params)
388
+ end
389
+
390
+ def toggle_always_uv
391
+ require_option!('alwaysUv', present: true)
392
+ configure(2)
393
+ end
394
+
395
+ def enable_enterprise_attestation
396
+ require_option!('ep', present: true)
397
+ configure(1)
398
+ end
399
+
400
+ def bio_sensor_info
401
+ command = bio_command
402
+ modality = ctap(command, { 6 => true })[1]
403
+ raise UnsupportedError, 'Authenticator biometric modality is not fingerprint' unless modality == 1
404
+
405
+ result = bio_call(command, 7)
406
+ { modality: modality, fingerprint_kind: result[2], max_samples: result[3], max_name_bytes: result.fetch(8, 64), raw: result }
407
+ end
408
+
409
+ def list_fingerprints
410
+ command = bio_command
411
+ bio_sensor_info
412
+ auth = authorize(8)
413
+ templates = begin
414
+ bio_call(command, 4, nil, auth)[7]
415
+ rescue CTAPError => e
416
+ raise unless e.code == 0x2c
417
+
418
+ []
419
+ end
420
+ raise ResponseError, 'Invalid fingerprint template list' unless templates.is_a?(Array) && templates.size <= record_limit
421
+
422
+ seen = {}
423
+ templates.map do |entry|
424
+ raise ResponseError, 'Invalid fingerprint template' unless entry.is_a?(Hash)
425
+
426
+ id = binary(entry[1], 'fingerprint template ID', error: ResponseError)
427
+ raise ResponseError, 'Duplicate fingerprint template ID' if seen[id]
428
+ raise ResponseError, 'Invalid fingerprint name' unless entry[2].nil? || (entry[2].is_a?(String) && entry[2].encoding == Encoding::UTF_8)
429
+
430
+ seen[id] = true
431
+ { template_id: id, name: entry[2] }
432
+ end
433
+ end
434
+
435
+ def rename_fingerprint
436
+ id = identifier(:template_id)
437
+ name = text(@opts[:name], 'fingerprint name')
438
+ command = bio_command
439
+ max_name = bio_sensor_info[:max_name_bytes]
440
+ raise ArgumentError, 'Fingerprint name exceeds sensor limit' if name.bytesize > max_name
441
+
442
+ bio_call(command, 5, { 1 => id, 2 => name }, authorize(8))
443
+ { renamed: true }
444
+ end
445
+
446
+ def delete_fingerprint
447
+ id = identifier(:template_id)
448
+ command = bio_command
449
+ bio_sensor_info
450
+ bio_call(command, 6, { 1 => id }, authorize(8))
451
+ { deleted: true }
452
+ end
453
+
454
+ def cancel_enrollment
455
+ bio_call(bio_command, 3)
456
+ { cancelled: true }
457
+ end
458
+
459
+ def add_fingerprint
460
+ name = text(@opts[:name], 'fingerprint name') if @opts[:name]
461
+ timeout_ms = integer_option(@opts.fetch(:capture_timeout_ms, 10_000), 'capture_timeout_ms', 1..120_000)
462
+ max_samples = integer_option(@opts.fetch(:max_samples, 128), 'max_samples', 1..1024)
463
+ callback = @opts[:on_sample]
464
+ raise ArgumentError, 'on_sample must be callable' if callback && !callback.respond_to?(:call)
465
+
466
+ command = bio_command
467
+ sensor = bio_sensor_info
468
+ raise ArgumentError, 'Fingerprint name exceeds sensor limit' if name && name.bytesize > sensor[:max_name_bytes]
469
+
470
+ auth = authorize(8)
471
+ pending = true
472
+ response = bio_call(command, 1, { 3 => timeout_ms }, auth)
473
+ id = binary(response[4], 'fingerprint template ID', error: ResponseError)
474
+ max_samples.times do |index|
475
+ status = response[5]
476
+ remaining_samples = response[6]
477
+ raise ResponseError, 'Invalid fingerprint capture status' unless status.is_a?(Integer) && status.between?(0, 14)
478
+ raise ResponseError, 'Invalid fingerprint remaining-sample count' unless remaining_samples.is_a?(Integer) && remaining_samples.between?(0, 1024)
479
+
480
+ complete = status.zero? && remaining_samples.zero?
481
+ pending = false if complete
482
+ callback&.call({ template_id: id, status: status, remaining_samples: remaining_samples })
483
+ if complete
484
+ if name
485
+ begin
486
+ bio_call(command, 5, { 1 => id, 2 => name }, auth)
487
+ rescue Error
488
+ raise EnrollmentError, id
489
+ end
490
+ end
491
+ return { enrolled: true, template_id: id, name: name }
492
+ end
493
+ raise ResponseError, 'Fingerprint already enrolled or database full' if [11, 12].include?(status)
494
+ raise TimeoutError, 'Fingerprint sample limit exceeded; enrollment cancelled' if index + 1 == max_samples
495
+
496
+ response = bio_call(command, 2, { 1 => id, 3 => timeout_ms }, auth)
497
+ end
498
+ ensure
499
+ if pending && command
500
+ begin
501
+ # Cleanup is bounded separately, and must run even if cancelled/deadline expired.
502
+ ctap(command, { 1 => 1, 2 => 3 }, cleanup: true)
503
+ rescue StandardError
504
+ # Preserve the original exception. No retry or credential deletion.
505
+ nil
506
+ end
507
+ end
508
+ end
509
+
510
+ def make_credential
511
+ client_hash = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
512
+ rp = @opts[:rp]
513
+ raise ArgumentError, 'RP must be a caller-supplied entity Hash' unless rp.is_a?(Hash)
514
+
515
+ rp_id = text(rp['id'], 'RP ID', max: 253)
516
+ text(rp['name'], 'RP name') if rp.key?('name')
517
+ user = user_entity(@opts[:user])
518
+ key_params = @opts.fetch(:key_params, [{ 'type' => 'public-key', 'alg' => -7 }])
519
+ raise ArgumentError, 'key_params must be public-key algorithm descriptors' unless key_params.is_a?(Array) && key_params.size.between?(1, 64) && key_params.all? { |param| param.is_a?(Hash) && param['type'] == 'public-key' && param['alg'].is_a?(Integer) }
520
+
521
+ params = { 1 => client_hash, 2 => rp, 3 => user, 4 => key_params }
522
+ params[5] = credential_list(@opts[:exclude_list]) if @opts.key?(:exclude_list)
523
+ add_request_options(params, 6, 7)
524
+ params[10] = integer_option(@opts[:enterprise_attestation], 'enterprise_attestation', 1..2) if @opts.key?(:enterprise_attestation)
525
+ preflight_request(params)
526
+ require_option!('rk') if params[7]&.fetch('rk', false)
527
+ require_option!('ep') if params[10]
528
+ needs_pin = info[:options]['clientPin'] && !(info[:options]['makeCredUvNotRqd'] && !params[7]&.fetch('rk', false))
529
+ if authentication_requested? || needs_pin
530
+ protocol, token = authorize(1, rp_id)
531
+ params[8] = protocol.authenticate(token, client_hash)
532
+ params[9] = protocol.version
533
+ # UV is performed through the token; CTAP forbids uv=true together with it.
534
+ params[7] = params[7].except('uv') if params[7]
535
+ end
536
+ result = ctap(1, params)
537
+ raise ResponseError, 'Invalid makeCredential response' unless result[1].is_a?(String) && result[3].is_a?(Hash)
538
+
539
+ { format: result[1], authenticator_data: authenticator_data(result[2]), attestation_statement: result[3], enterprise_attestation: result[4], large_blob_key: result[5], raw: result }
540
+ end
541
+
542
+ def get_assertion # rubocop:disable Naming/AccessorMethodName -- CTAP command name
543
+ rp_id = text(@opts[:rp_id], 'RP ID', max: 253)
544
+ client_hash = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
545
+ params = { 1 => rp_id, 2 => client_hash }
546
+ params[3] = credential_list(@opts[:allow_list]) if @opts.key?(:allow_list)
547
+ add_request_options(params, 4, 5)
548
+ preflight_request(params)
549
+ if authentication_requested?
550
+ protocol, token = authorize(2, rp_id)
551
+ params[6] = protocol.authenticate(token, client_hash)
552
+ params[7] = protocol.version
553
+ params[5] = params[5].except('uv') if params[5]
554
+ end
555
+ first = ctap(2, params)
556
+ count = bounded_count(first.fetch(5, 1))
557
+ results = [first] + Array.new(count - 1) { ctap(8) }
558
+ results.map do |result|
559
+ credential_descriptor(result[1], error: ResponseError) if result[1]
560
+ { credential: result[1], authenticator_data: authenticator_data(result[2]), signature: binary(result[3], 'assertion signature', error: ResponseError), user: result[4], user_selected: result[6], large_blob_key: result[7], raw: result }
561
+ end
562
+ end
563
+
564
+ def u2f_register
565
+ client = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
566
+ application = binary(@opts[:app_id_hash], 'application hash', length: 32)
567
+ response = u2f_apdu(1, client + application)
568
+ raise ResponseError, 'Invalid U2F registration response' unless response.bytesize > 67 && response.getbyte(0) == 5 && response.getbyte(1) == 4
569
+
570
+ key_length = response.getbyte(66)
571
+ raise ResponseError, 'Invalid U2F key handle length' if key_length.zero? || response.bytesize <= 67 + key_length
572
+
573
+ cert_offset = 67 + key_length
574
+ certificate_length = der_length(response.byteslice(cert_offset..))
575
+ certificate = response.byteslice(cert_offset, certificate_length)
576
+ OpenSSL::X509::Certificate.new(certificate)
577
+ signature = response.byteslice((cert_offset + certificate_length)..)
578
+ raise ResponseError, 'Missing U2F attestation signature' if signature.nil? || signature.empty?
579
+
580
+ { public_key: response.byteslice(1, 65), key_handle: response.byteslice(67, key_length), certificate: certificate, signature: signature, raw: response }
581
+ rescue OpenSSL::OpenSSLError
582
+ raise ResponseError, 'Malformed U2F attestation certificate'
583
+ end
584
+
585
+ def u2f_authenticate
586
+ client = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
587
+ application = binary(@opts[:app_id_hash], 'application hash', length: 32)
588
+ key_handle = binary(@opts[:key_handle], 'U2F key handle', max: 255)
589
+ check = @opts.fetch(:check_only, false)
590
+ raise ArgumentError, 'check_only must be boolean' unless [true, false].include?(check)
591
+
592
+ response = u2f_apdu(2, client + application + [key_handle.bytesize].pack('C') + key_handle, check ? 7 : 3)
593
+ raise ResponseError, 'Unexpected successful check-only response' if check
594
+ raise ResponseError, 'Invalid U2F authentication response' unless response.bytesize > 5 && response.getbyte(0).allbits?(1)
595
+
596
+ { user_presence: response.getbyte(0), counter: response.byteslice(1, 4).unpack1('N'), signature: response.byteslice(5..), raw: response }
597
+ rescue APDUError => e
598
+ raise unless check && [0x6985, 0x6a80].include?(e.code)
599
+
600
+ { recognized: e.code == 0x6985 }
601
+ end
602
+
603
+ private
604
+
605
+ def credential_list(list)
606
+ raise ArgumentError, 'Credential list must be an Array' unless list.is_a?(Array) && list.size <= record_limit
607
+
608
+ list.each { |descriptor| credential_descriptor(descriptor) }
609
+ list
610
+ end
611
+
612
+ def add_request_options(params, extensions_key, options_key)
613
+ if @opts.key?(:extensions)
614
+ raise ArgumentError, 'Extensions must be a text-keyed map' unless @opts[:extensions].is_a?(Hash) && @opts[:extensions].keys.all? { |key| key.is_a?(String) && key.encoding == Encoding::UTF_8 }
615
+
616
+ params[extensions_key] = @opts[:extensions]
617
+ end
618
+ return unless @opts.key?(:authenticator_options)
619
+
620
+ options = @opts[:authenticator_options]
621
+ raise ArgumentError, 'Authenticator options must be a text/boolean map' unless options.is_a?(Hash) && options.all? { |key, value| key.is_a?(String) && key.encoding == Encoding::UTF_8 && [true, false].include?(value) }
622
+
623
+ params[options_key] = options
624
+ end
625
+
626
+ def preflight_request(params)
627
+ encoded = CBOR.encode(value: params)
628
+ require_ctap2!
629
+ # Reserve space for protocol 2 PIN/UV authentication before requesting a token.
630
+ raise ArgumentError, 'CTAP request exceeds authenticator message limit' if encoded.bytesize + 40 > @max_message
631
+ end
632
+
633
+ def authentication_requested?
634
+ !@opts[:pin].nil? || !@opts[:secret_provider].nil? || @opts[:uv] == true
635
+ end
636
+
637
+ def authenticator_data(value)
638
+ binary(value, 'authenticator data', max: CBOR::MAX_BYTES, error: ResponseError)
639
+ raise ResponseError, 'Truncated authenticator data' if value.bytesize < 37
640
+
641
+ value
642
+ end
643
+
644
+ def der_length(data)
645
+ raise ResponseError, 'Malformed DER certificate header' unless data.bytesize >= 2 && data.getbyte(0) == 0x30
646
+
647
+ first = data.getbyte(1)
648
+ return 2 + first if first < 128 && data.bytesize >= 2 + first
649
+
650
+ width = first & 127
651
+ raise ResponseError, 'Malformed DER certificate length' unless width.between?(1, 4) && data.bytesize >= 2 + width && data.getbyte(2).positive?
652
+
653
+ length = data.byteslice(2, width).bytes.reduce(0) { |total, byte| (total << 8) | byte }
654
+ raise ResponseError, 'Truncated or noncanonical DER certificate' unless length >= 128 && length < (1 << (8 * width)) && data.bytesize >= 2 + width + length
655
+
656
+ 2 + width + length
657
+ end
658
+
659
+ def bio_command
660
+ require_ctap2!
661
+ return 9 if info[:options].key?('bioEnroll')
662
+ return 0x40 if info[:versions].include?('FIDO_2_1_PRE') && info[:options].key?('userVerificationMgmtPreview')
663
+
664
+ raise UnsupportedError, 'Authenticator lacks biometric enrollment'
665
+ end
666
+
667
+ def bio_call(command, subcommand, parameters = nil, auth = nil)
668
+ params = { 1 => 1, 2 => subcommand }
669
+ params[3] = parameters if parameters
670
+ if auth
671
+ protocol, token = auth
672
+ msg = [1, subcommand].pack('CC') + (parameters ? CBOR.encode(value: parameters) : ''.b)
673
+ params[4] = protocol.version
674
+ params[5] = protocol.authenticate(token, msg)
675
+ end
676
+ ctap(command, params)
677
+ end
678
+
679
+ def binary(value, label, length: nil, max: 4096, error: ArgumentError)
680
+ raise error, "Invalid #{label}: expected binary String of the required length" unless value.is_a?(String) && value.encoding == Encoding::BINARY && value.bytesize.positive? && value.bytesize <= max && (length.nil? || value.bytesize == length)
681
+
682
+ value
683
+ end
684
+
685
+ def text(value, label, max: 256)
686
+ raise ArgumentError, "Invalid #{label}: expected nonempty UTF-8 text" unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? && value.bytesize.between?(1, max) && !value.include?("\x00")
687
+
688
+ value
689
+ end
690
+
691
+ def integer_option(value, label, range)
692
+ value = Integer(value, 10) if value.is_a?(String) && value.match?(/\A[0-9]+\z/)
693
+ raise ArgumentError, "Invalid #{label}" unless value.is_a?(Integer) && range.cover?(value)
694
+
695
+ value
696
+ end
697
+
698
+ def identifier(name)
699
+ hex = @opts[:"#{name}_hex"]
700
+ if hex
701
+ raise ArgumentError, 'Identifier must be complete, even-length hexadecimal' unless hex.is_a?(String) && hex.match?(/\A(?:[0-9a-fA-F]{2})+\z/) && hex.bytesize <= 8192
702
+
703
+ return [hex].pack('H*')
704
+ end
705
+ binary(@opts[name], name)
706
+ end
707
+
708
+ def user_entity(user)
709
+ raise ArgumentError, 'User entity must be a Hash' unless user.is_a?(Hash)
710
+
711
+ binary(user['id'], 'user ID', max: 64)
712
+ %w[name displayName].each { |field| text(user[field], field, max: 64) if user.key?(field) }
713
+ CBOR.encode(value: user)
714
+ user
715
+ end
716
+
717
+ def credential_descriptor(descriptor, error: ArgumentError)
718
+ raise error, 'Invalid public-key credential descriptor' unless descriptor.is_a?(Hash) && descriptor['type'] == 'public-key'
719
+
720
+ binary(descriptor['id'], 'credential ID', error: error)
721
+ descriptor
722
+ end
723
+
724
+ def record_limit
725
+ integer_option(@opts.fetch(:max_records, 1024), 'max_records', 1..16_384)
726
+ end
727
+
728
+ def bounded_count(value, zero: false)
729
+ raise ResponseError, 'Invalid or excessive authenticator count' unless value.is_a?(Integer) && value.between?(zero ? 0 : 1, record_limit)
730
+
731
+ value
732
+ end
733
+
734
+ def require_option!(name, present: false)
735
+ require_ctap2!
736
+ supported = present ? info[:options].key?(name) : info[:options][name] == true
737
+ raise UnsupportedError, "Authenticator does not support #{name}" unless supported
738
+ end
739
+
740
+ def credential_command
741
+ require_ctap2!
742
+ return 10 if info[:options]['credMgmt'] == true
743
+ return 0x41 if info[:versions].include?('FIDO_2_1_PRE') && info[:options].key?('credentialMgmtPreview')
744
+
745
+ raise UnsupportedError, 'Authenticator lacks credential management'
746
+ end
747
+
748
+ def credential_call(command, subcommand, parameters, auth = nil)
749
+ params = { 1 => subcommand }
750
+ params[2] = parameters unless parameters.nil?
751
+ if auth
752
+ protocol, token = auth
753
+ message = [subcommand].pack('C') + (parameters ? CBOR.encode(value: parameters) : ''.b)
754
+ params[3] = protocol.version
755
+ params[4] = protocol.authenticate(token, message)
756
+ end
757
+ ctap(command, params)
758
+ end
759
+
760
+ def enumerate(command, sequence, parameters, total_key, auth)
761
+ begin_command, next_command = sequence
762
+ first = begin
763
+ credential_call(command, begin_command, parameters, auth)
764
+ rescue CTAPError => e
765
+ raise unless e.code == 0x2e
766
+
767
+ nil
768
+ end
769
+ return [] unless first
770
+
771
+ count = bounded_count(first.fetch(total_key, 1), zero: true)
772
+ return [] if count.zero?
773
+
774
+ [first] + Array.new(count - 1) { credential_call(command, next_command, nil) }
775
+ end
776
+
777
+ def configure(subcommand, parameters = nil)
778
+ require_option!('authnrCfg')
779
+ allowed = info[:raw][31]
780
+ raise UnsupportedError, 'Configuration subcommand not supported by authenticator' if allowed && !allowed.include?(subcommand)
781
+
782
+ params = { 1 => subcommand }
783
+ params[2] = parameters if parameters
784
+ if info[:options]['clientPin'] || @opts[:uv] == true
785
+ protocol, token = authorize(32)
786
+ message = ("\xff".b * 32) + [0x0d, subcommand].pack('CC') + (parameters ? CBOR.encode(value: parameters) : ''.b)
787
+ params[3] = protocol.version
788
+ params[4] = protocol.authenticate(token, message)
789
+ end
790
+ ctap(13, params)
791
+ { configured: true, subcommand: subcommand }
792
+ end
793
+
794
+ def confirm!
795
+ raise ConfirmationRequired, 'FIDO authentication or mutation requires literal confirm: true' unless @opts[:confirm] == true
796
+ end
797
+
798
+ def remaining
799
+ raise Cancelled, 'FIDO operation cancelled' if @opts[:cancelled]&.call
800
+
801
+ seconds = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
802
+ raise TimeoutError, 'FIDO operation deadline exceeded; not retried' unless seconds.positive?
803
+
804
+ seconds
805
+ end
806
+
807
+ def keepalive(status)
808
+ remaining
809
+ @opts[:on_keepalive]&.call(status)
810
+ end
811
+
812
+ def ctap(command, params = nil, cleanup: false)
813
+ read_only = command == 4 || (command == 6 && [1, 2, 7].include?(params&.[](2))) || ([9, 0x40].include?(command) && (params&.[](6) == true || params&.[](2) == 7))
814
+ confirm! unless read_only
815
+ data = [command].pack('C') + (params ? CBOR.encode(value: params) : ''.b)
816
+ raise ArgumentError, 'CTAP request exceeds authenticator message limit' if data.bytesize > @max_message
817
+
818
+ response = if @descriptor[:protocol] == :apdu
819
+ nfc_cbor(data, cleanup: cleanup)
820
+ else
821
+ @connection.exchange(protocol: :ctap, command: 0x10, data: data, timeout: wire_timeout(cleanup), on_keepalive: cleanup ? nil : method(:keepalive), cancelled: cleanup ? nil : @opts[:cancelled])
822
+ end
823
+ raise ResponseError, 'Empty or oversized CTAP response' unless response.is_a?(String) && response.bytesize.between?(1, CBOR::MAX_BYTES)
824
+ raise CTAPError, response.getbyte(0) unless response.getbyte(0).zero?
825
+ return {} if response.bytesize == 1
826
+
827
+ decoded = CBOR.decode(data: response.byteslice(1..))
828
+ raise ResponseError, 'CTAP response must be a map' unless decoded.is_a?(Hash)
829
+
830
+ decoded
831
+ end
832
+
833
+ def u2f_apdu(ins, data = ''.b, parameter1 = 0)
834
+ confirm! unless [3, 0x46].include?(ins)
835
+ if @descriptor[:protocol] == :apdu
836
+ select_fido
837
+ body, status = smartcard_message(0, ins, parameter1, data)
838
+ raise APDUError, status unless status == 0x9000
839
+
840
+ return body
841
+ end
842
+ caps = @descriptor[:capabilities]
843
+ raise UnsupportedError, 'Authenticator does not support CTAPHID MSG/U2F' if caps.is_a?(Integer) && caps.anybits?(8)
844
+
845
+ packet = [0, ins, parameter1, 0, 0, data.bytesize].pack('CCCCCn') + data + "\x00\x00".b
846
+ response = @connection.exchange(protocol: :ctap, command: 3, data: packet, timeout: remaining, on_keepalive: method(:keepalive), cancelled: @opts[:cancelled])
847
+ raise ResponseError, 'Truncated U2F response' unless response.is_a?(String) && response.bytesize.between?(2, CBOR::MAX_BYTES)
848
+
849
+ status = response.byteslice(-2, 2).unpack1('n')
850
+ raise APDUError, status unless status == 0x9000
851
+
852
+ response.byteslice(0, response.bytesize - 2)
853
+ end
854
+
855
+ def u2f_info
856
+ version = u2f_apdu(3)
857
+ raise ResponseError, 'Unexpected U2F version' unless version == 'U2F_V2'
858
+
859
+ @info = { versions: ['U2F_V2'], ctap2: false, options: {}, pin_uv_protocols: [], raw: {} }
860
+ end
861
+
862
+ def fips_status
863
+ u2f_apdu(0x46)
864
+ :configured
865
+ rescue APDUError => e
866
+ return :unconfigured if e.code == 0x6a81
867
+ raise unless [0x6d00, 0x6e00, 0x6a86].include?(e.code)
868
+
869
+ raise UnsupportedError, 'Authenticator does not implement YubiKey 4 FIPS U2F PIN/reset commands'
870
+ end
871
+
872
+ def fips_change_pin
873
+ new_pin = pin_text(:new_pin, minimum: 6, maximum: 32)
874
+ state = fips_status
875
+ old_pin = if state == :configured || @opts[:pin]
876
+ pin_text(:pin, minimum: 6, maximum: 32)
877
+ else
878
+ ''.b
879
+ end
880
+ data = keep_secret([new_pin.bytesize].pack('C') + old_pin.b + new_pin.b)
881
+ u2f_apdu(0x44, data)
882
+ { changed: true, initialized: state == :unconfigured }
883
+ end
884
+
885
+ def wire_timeout(cleanup)
886
+ return remaining unless cleanup
887
+
888
+ @cleanup_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
889
+ seconds = @cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
890
+ raise TimeoutError, 'FIDO cancellation cleanup timed out' unless seconds.positive?
891
+
892
+ seconds
893
+ end
894
+
895
+ def select_fido
896
+ return if @selected
897
+
898
+ aid = [0xa0, 0, 0, 6, 0x47, 0x2f, 0, 1].pack('C*')
899
+ _body, status = smartcard_message(0, 0xa4, 4, aid)
900
+ raise APDUError, status unless status == 0x9000
901
+
902
+ @selected = true
903
+ end
904
+
905
+ def smartcard_message(cla, ins, parameter1, data = ''.b, cleanup: false)
906
+ offset = 0
907
+ while data.bytesize - offset > 250
908
+ chunk = data.byteslice(offset, 250)
909
+ body, status = smartcard_exchange([cla | 0x10, ins, parameter1, 0, 250].pack('C*') + chunk, cleanup: cleanup)
910
+ raise APDUError, status unless status == 0x9000
911
+ raise ResponseError, 'Unexpected response during APDU command chaining' unless body.empty?
912
+
913
+ offset += 250
914
+ end
915
+ chunk = data.byteslice(offset..)
916
+ packet = [cla, ins, parameter1, 0].pack('C*')
917
+ packet += [chunk.bytesize].pack('C') + chunk unless chunk.empty?
918
+ body, status = smartcard_exchange(packet + "\x00".b, cleanup: cleanup)
919
+ 128.times do
920
+ return [body, status] unless status >> 8 == 0x61
921
+
922
+ extra, status = smartcard_exchange([0, 0xc0, 0, 0, status & 255].pack('C*'), cleanup: cleanup)
923
+ body += extra
924
+ raise ResponseError, 'FIDO APDU response limit exceeded' if body.bytesize > CBOR::MAX_BYTES
925
+ end
926
+ raise ResponseError, 'FIDO GET RESPONSE iteration limit exceeded'
927
+ end
928
+
929
+ def smartcard_exchange(packet, cleanup: false)
930
+ response = @connection.exchange(protocol: :apdu, data: packet, timeout: wire_timeout(cleanup), cancelled: cleanup ? nil : @opts[:cancelled])
931
+ raise ResponseError, 'Truncated or oversized FIDO APDU' unless response.is_a?(String) && response.bytesize.between?(2, CBOR::MAX_BYTES)
932
+
933
+ [response.byteslice(0, response.bytesize - 2), response.byteslice(-2, 2).unpack1('n')]
934
+ end
935
+
936
+ def nfc_cbor(data, cleanup: false)
937
+ select_fido
938
+ body, status = smartcard_message(0x80, 0x10, 0x80, data, cleanup: cleanup)
939
+ while status == 0x9100
940
+ pending = true
941
+ raise ResponseError, 'Invalid NFC keepalive status' unless body.bytesize == 1 && [1, 2].include?(body.getbyte(0))
942
+
943
+ keepalive(body.getbyte(0)) unless cleanup
944
+ body, status = smartcard_message(0x80, 0x11, 0, cleanup: cleanup)
945
+ end
946
+ pending = false
947
+ raise APDUError, status unless status == 0x9000
948
+
949
+ body
950
+ ensure
951
+ if pending
952
+ begin
953
+ smartcard_message(0x80, 0x11, 0x11, cleanup: true)
954
+ rescue StandardError
955
+ nil
956
+ end
957
+ end
958
+ end
959
+
960
+ def require_ctap2!
961
+ raise UnsupportedError, 'Authenticator supports U2F only; CTAP2 operation unavailable' unless info[:ctap2]
962
+ end
963
+
964
+ def pin_protocol
965
+ require_ctap2!
966
+ raise UnsupportedError, 'Authenticator does not support clientPin' unless info[:options].key?('clientPin') || @opts[:uv] == true
967
+
968
+ versions = info[:pin_uv_protocols]
969
+ raise ResponseError, 'Invalid PIN protocol list' unless versions.is_a?(Array)
970
+
971
+ version = [2, 1].find { |candidate| versions.include?(candidate) }
972
+ raise UnsupportedError, 'Authenticator has no supported PIN protocol (1 or 2)' unless version
973
+
974
+ PinProtocol.new(version)
975
+ end
976
+
977
+ def keep_secret(bytes)
978
+ @secrets << bytes
979
+ bytes
980
+ end
981
+
982
+ def pin_text(name, minimum: 4, maximum: 63)
983
+ confirm!
984
+ value = @opts[name]
985
+ value = @opts[:secret_provider].call(name) if value.nil? && @opts[:secret_provider]
986
+ raise ArgumentError, "#{name} must be supplied in memory" unless value.is_a?(String)
987
+
988
+ text = value.dup.force_encoding(Encoding::UTF_8)
989
+ raise ArgumentError, "#{name} has invalid UTF-8, length or NUL character" unless text.valid_encoding? && text.length >= minimum && text.bytesize <= maximum && !text.include?("\x00")
990
+
991
+ keep_secret(text)
992
+ end
993
+
994
+ def key_agreement(protocol)
995
+ peer = ctap(6, { 1 => protocol.version, 2 => 2 })[1]
996
+ local, shared = protocol.encapsulate(peer)
997
+ [local, keep_secret(shared)]
998
+ end
999
+
1000
+ def authorize(permissions, rp_id = nil)
1001
+ confirm!
1002
+ protocol = pin_protocol
1003
+ using_uv = @opts[:uv] == true
1004
+ raise UnsupportedError, 'UV tokens require configured built-in UV, permissions and pinUvAuthToken support' if using_uv && (!info[:options]['uv'] || !info[:options]['pinUvAuthToken'] || permissions.nil?)
1005
+
1006
+ pin = pin_text(:pin) unless using_uv
1007
+ raise UnsupportedError, 'Authenticator has no configured client PIN' if !using_uv && info[:options]['clientPin'] != true
1008
+
1009
+ local, shared = key_agreement(protocol)
1010
+ scoped = !permissions.nil? && info[:options]['pinUvAuthToken'] == true
1011
+ params = { 1 => protocol.version, 2 => if using_uv
1012
+ 6
1013
+ else
1014
+ (scoped ? 9 : 5)
1015
+ end, 3 => local }
1016
+ params[6] = protocol.encrypt(shared, keep_secret(OpenSSL::Digest::SHA256.digest(pin).byteslice(0, 16))) unless using_uv
1017
+ if scoped
1018
+ params[9] = permissions
1019
+ params[10] = rp_id if rp_id
1020
+ end
1021
+ encrypted = ctap(6, params)[2]
1022
+ token = keep_secret(protocol.validate_token(protocol.decrypt(shared, encrypted)))
1023
+ [protocol, token]
1024
+ end
1025
+ end
1026
+
1027
+ # Ephemeral key agreement and CTAP PIN/UV protocols 1 and 2.
1028
+ # Keys are generated with OpenSSL's OS-seeded CSPRNG, never device OTP.
1029
+ class PinProtocol
1030
+ attr_reader :version
1031
+
1032
+ def initialize(version)
1033
+ raise ArgumentError, 'PIN protocol must be 1 or 2' unless [1, 2].include?(version)
1034
+
1035
+ @version = version
1036
+ end
1037
+
1038
+ def derive(shared_x)
1039
+ raise ResponseError, 'Invalid ECDH secret length' unless shared_x.is_a?(String) && shared_x.bytesize == 32
1040
+ return OpenSSL::Digest::SHA256.digest(shared_x) if @version == 1
1041
+
1042
+ # HKDF-Extract followed by one HKDF-Expand block for each label.
1043
+ prk = OpenSSL::HMAC.digest('SHA256', "\x00".b * 32, shared_x)
1044
+ ['CTAP2 HMAC key', 'CTAP2 AES key'].map { |info| OpenSSL::HMAC.digest('SHA256', prk, info.b + "\x01".b) }.join.b
1045
+ end
1046
+
1047
+ def encapsulate(peer)
1048
+ raise ResponseError, 'Invalid PIN key agreement COSE key' unless peer.is_a?(Hash) && peer[1] == 2 && peer[3] == -25 && peer[-1] == 1 && [-2, -3].all? { |label| peer[label].is_a?(String) && peer[label].encoding == Encoding::BINARY && peer[label].bytesize == 32 }
1049
+
1050
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
1051
+ point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("\x04".b + peer[-2] + peer[-3], 2))
1052
+ raise ResponseError, 'Invalid PIN key agreement point' if point.infinity? || !point.on_curve?
1053
+
1054
+ ephemeral = OpenSSL::PKey::EC.generate('prime256v1')
1055
+ public_bytes = ephemeral.public_key.to_octet_string(:uncompressed)
1056
+ local = { 1 => 2, 3 => -25, -1 => 1, -2 => public_bytes.byteslice(1, 32), -3 => public_bytes.byteslice(33, 32) }
1057
+ [local, derive(ephemeral.dh_compute_key(point))]
1058
+ rescue OpenSSL::OpenSSLError, ArgumentError
1059
+ raise ResponseError, 'Invalid PIN key agreement point'
1060
+ end
1061
+
1062
+ def authenticate(key, message)
1063
+ mac = OpenSSL::HMAC.digest('SHA256', @version == 1 ? key : key.byteslice(0, 32), message)
1064
+ @version == 1 ? mac.byteslice(0, 16) : mac
1065
+ end
1066
+
1067
+ def encrypt(key, plaintext)
1068
+ validate_cipher_input(key, plaintext, ArgumentError)
1069
+ iv = @version == 1 ? "\x00".b * 16 : SecureRandom.random_bytes(16)
1070
+ output = cipher(key, iv, plaintext, true)
1071
+ @version == 1 ? output : iv + output
1072
+ end
1073
+
1074
+ def decrypt(key, ciphertext)
1075
+ if @version == 2
1076
+ raise ResponseError, 'Truncated protocol 2 ciphertext' unless ciphertext.is_a?(String) && ciphertext.bytesize >= 32
1077
+
1078
+ iv = ciphertext.byteslice(0, 16)
1079
+ ciphertext = ciphertext.byteslice(16..)
1080
+ else
1081
+ iv = "\x00".b * 16
1082
+ end
1083
+ validate_cipher_input(key, ciphertext, ResponseError)
1084
+ cipher(key, iv, ciphertext, false)
1085
+ end
1086
+
1087
+ def validate_token(token)
1088
+ sizes = @version == 1 ? [16, 32] : [32]
1089
+ raise ResponseError, 'Invalid PIN/UV token length' unless token.is_a?(String) && sizes.include?(token.bytesize)
1090
+
1091
+ token
1092
+ end
1093
+
1094
+ private
1095
+
1096
+ def validate_cipher_input(key, data, error)
1097
+ raise error, 'Invalid PIN cipher key length' unless key.is_a?(String) && key.bytesize == (@version == 1 ? 32 : 64)
1098
+ raise error, 'Invalid PIN cipher block length' unless data.is_a?(String) && data.bytesize.positive? && (data.bytesize % 16).zero?
1099
+ end
1100
+
1101
+ def cipher(key, iv_bytes, data, encrypt)
1102
+ aes = OpenSSL::Cipher.new('aes-256-cbc')
1103
+ encrypt ? aes.encrypt : aes.decrypt
1104
+ aes.padding = 0
1105
+ aes.key = @version == 1 ? key : key.byteslice(32, 32)
1106
+ aes.iv = iv_bytes
1107
+ aes.update(data) + aes.final
1108
+ end
1109
+ end
1110
+
1111
+ # CTAP canonical CBOR: binary Strings are bytes, UTF-8 Strings are text.
1112
+ # Reject indefinite lengths, tags/floats, duplicates and excessive work.
1113
+ module CBOR
1114
+ MAX_BYTES = 1_048_576
1115
+ MAX_ITEMS = 16_384
1116
+ MAX_DEPTH = 32
1117
+
1118
+ public_class_method def self.encode(opts = {})
1119
+ Codec.new.encode(opts[:value])
1120
+ end
1121
+
1122
+ public_class_method def self.decode(opts = {})
1123
+ Codec.new.decode(opts[:data])
1124
+ end
1125
+
1126
+ public_class_method def self.authors
1127
+ "AUTHOR(S):\n0day Inc. <request.pentest@0dayinc.com>"
1128
+ end
1129
+
1130
+ public_class_method def self.help
1131
+ puts <<~HELP
1132
+ Purpose: Encode bounded CTAP canonical CBOR without a runtime dependency.
1133
+ #{self}.encode(value: 'required - Integer, binary or UTF-8 String, Array, Hash, boolean or nil')
1134
+ Purpose: Decode exactly one bounded canonical CBOR object.
1135
+ #{self}.decode(data: 'required - Binary CBOR String')
1136
+ Purpose: Identify the implementation authors.
1137
+ #{self}.authors
1138
+ HELP
1139
+ end
1140
+
1141
+ # Per-call budget prevents recursion and allocation attacks in either direction.
1142
+ class Codec
1143
+ def initialize
1144
+ @items = 0
1145
+ @encoded_size = 0
1146
+ end
1147
+
1148
+ def encode(value, depth = 0)
1149
+ budget(depth, ArgumentError)
1150
+ out = case value
1151
+ when Integer then header(value.negative? ? 1 : 0, value.negative? ? -1 - value : value)
1152
+ when String
1153
+ binary = value.encoding == Encoding::BINARY
1154
+ text = binary ? value : value.encode(Encoding::UTF_8)
1155
+ raise ArgumentError, 'CBOR text must be valid UTF-8' unless text.valid_encoding?
1156
+ raise ArgumentError, 'CBOR byte limit exceeded' if text.bytesize > MAX_BYTES
1157
+
1158
+ header(binary ? 2 : 3, text.bytesize) + text.b
1159
+ when Array
1160
+ raise ArgumentError, 'CBOR item limit exceeded' if value.size > MAX_ITEMS
1161
+
1162
+ header(4, value.size) + value.map { |entry| encode(entry, depth + 1) }.join.b
1163
+ when Hash
1164
+ raise ArgumentError, 'CBOR item limit exceeded' if value.size > MAX_ITEMS
1165
+
1166
+ entries = value.map do |key, entry|
1167
+ validate_key(key, ArgumentError)
1168
+ [encode(key, depth + 1), encode(entry, depth + 1)]
1169
+ end
1170
+ header(5, entries.size) + entries.sort_by { |key, _| [key.getbyte(0), key.bytesize, key] }.join.b
1171
+ when true then "\xf5".b
1172
+ when false then "\xf4".b
1173
+ when nil then "\xf6".b
1174
+ else raise ArgumentError, 'Unsupported CBOR type'
1175
+ end
1176
+ # Count leaf data once; every assembled container is also bounded.
1177
+ @encoded_size += out.bytesize unless value.is_a?(Array) || value.is_a?(Hash)
1178
+ raise ArgumentError, 'CBOR byte limit exceeded' if out.bytesize > MAX_BYTES || @encoded_size > MAX_BYTES
1179
+
1180
+ out
1181
+ rescue EncodingError
1182
+ raise ArgumentError, 'CBOR text must be valid UTF-8'
1183
+ end
1184
+
1185
+ def decode(data)
1186
+ raise ResponseError, 'CBOR requires a bounded binary String' unless data.is_a?(String) && data.bytesize <= MAX_BYTES
1187
+
1188
+ @data = data.b
1189
+ @offset = 0
1190
+ value = read_value(0)
1191
+ raise ResponseError, 'Trailing CBOR bytes' unless @offset == @data.bytesize
1192
+
1193
+ value
1194
+ end
1195
+
1196
+ private
1197
+
1198
+ def header(major, value)
1199
+ raise ArgumentError, 'CBOR integer out of range' unless value.between?(0, 0xffffffffffffffff)
1200
+
1201
+ case value
1202
+ when 0..23 then [(major << 5) | value].pack('C')
1203
+ when 24..255 then [(major << 5) | 24, value].pack('CC')
1204
+ when 256..65_535 then [(major << 5) | 25, value].pack('Cn')
1205
+ when 65_536..0xffffffff then [(major << 5) | 26, value].pack('CN')
1206
+ else [(major << 5) | 27, value].pack('CQ>')
1207
+ end
1208
+ end
1209
+
1210
+ def budget(depth, error)
1211
+ @items += 1
1212
+ raise error, 'CBOR depth limit exceeded (possibly cyclic input)' if depth > MAX_DEPTH
1213
+ raise error, 'CBOR item limit exceeded' if @items > MAX_ITEMS
1214
+ end
1215
+
1216
+ def validate_key(key, error)
1217
+ return if key.is_a?(Integer) || (key.is_a?(String) && key.encoding == Encoding::UTF_8)
1218
+
1219
+ raise error, 'CTAP CBOR map keys must be integers or text'
1220
+ end
1221
+
1222
+ def take(length)
1223
+ raise ResponseError, 'Truncated CBOR' if length > @data.bytesize - @offset
1224
+
1225
+ result = @data.byteslice(@offset, length)
1226
+ @offset += length
1227
+ result
1228
+ end
1229
+
1230
+ def length_value(additional_info)
1231
+ return additional_info if additional_info < 24
1232
+
1233
+ width, fmt, minimum = { 24 => [1, 'C', 24], 25 => [2, 'n', 256], 26 => [4, 'N', 65_536], 27 => [8, 'Q>', 0x100000000] }[additional_info]
1234
+ raise ResponseError, 'Indefinite or invalid CBOR length' unless width
1235
+
1236
+ value = take(width).unpack1(fmt)
1237
+ raise ResponseError, 'Noncanonical CBOR integer or length' if value < minimum
1238
+
1239
+ value
1240
+ end
1241
+
1242
+ def read_value(depth)
1243
+ budget(depth, ResponseError)
1244
+ first = take(1).getbyte(0)
1245
+ major = first >> 5
1246
+ ai = first & 31
1247
+ return { 20 => false, 21 => true, 22 => nil }.fetch(ai) { raise ResponseError, 'Unsupported CBOR simple/float value' } if major == 7
1248
+
1249
+ length = length_value(ai)
1250
+ case major
1251
+ when 0 then length
1252
+ when 1 then -1 - length
1253
+ when 2, 3
1254
+ raise ResponseError, 'CBOR byte limit exceeded' if length > MAX_BYTES
1255
+
1256
+ value = take(length)
1257
+ value.force_encoding(Encoding::UTF_8) if major == 3
1258
+ raise ResponseError, 'Invalid CBOR UTF-8' unless value.valid_encoding?
1259
+
1260
+ value
1261
+ when 4, 5
1262
+ raise ResponseError, 'CBOR item limit exceeded' if length > MAX_ITEMS
1263
+ return Array.new(length) { read_value(depth + 1) } if major == 4
1264
+
1265
+ map = {}
1266
+ previous = nil
1267
+ length.times do
1268
+ start = @offset
1269
+ key = read_value(depth + 1)
1270
+ validate_key(key, ResponseError)
1271
+ encoded = @data.byteslice(start, @offset - start)
1272
+ order = [encoded.getbyte(0), encoded.bytesize, encoded]
1273
+ raise ResponseError, 'Noncanonical CBOR map ordering or duplicate key' if previous && (previous <=> order) >= 0
1274
+ raise ResponseError, 'Duplicate CBOR map key' if map.key?(key)
1275
+
1276
+ previous = order
1277
+ map[key] = read_value(depth + 1)
1278
+ end
1279
+ map
1280
+ else raise ResponseError, 'Unsupported CBOR tag/type'
1281
+ end
1282
+ end
1283
+ end
1284
+ end
1285
+ end
1286
+ end
1287
+ end
1288
+ end