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,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+
6
+ module PWN
7
+ module Plugins
8
+ module YubiKey
9
+ # Native YubiHSM Auth APDUs. Protocol reference: yubikey-manager 5.9.1
10
+ # yubikit/hsmauth.py and ykman/_cli/hsmauth.py. Independent Ruby encoding.
11
+ module HSMAuth
12
+ AID = ['a000000527210701'].pack('H*').freeze
13
+ ALGORITHMS = { symmetric: 0x26, asymmetric: 0x27 }.freeze
14
+
15
+ # Retain the device's ephemeral state; do not reselect another application.
16
+ class Session
17
+ def initialize(connection, timeout)
18
+ raise ArgumentError, 'HSM Auth requires an APDU connection' unless connection.respond_to?(:exchange) && connection.respond_to?(:descriptor) && connection.descriptor[:protocol] == :apdu
19
+ raise ArgumentError, 'timeout must be finite and positive' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
20
+
21
+ selected = Protocol.select(connection: connection, aid: AID, timeout: timeout)
22
+ version = Protocol.tlv_map(data: selected)[0x79]
23
+ raise ResponseError, 'Malformed HSM Auth SELECT version' unless version&.bytesize == 3
24
+
25
+ @state = { connection: connection, timeout: timeout, version: version.bytes.freeze }.freeze
26
+ end
27
+
28
+ def [](key)
29
+ @state.fetch(key)
30
+ end
31
+ end
32
+
33
+ public_class_method def self.open_session(opts = {})
34
+ Session.new(opts[:connection], opts.fetch(:timeout, 30))
35
+ end
36
+
37
+ public_class_method def self.execute(opts = {})
38
+ args = opts.fetch(:args, [])
39
+ raise ArgumentError, 'args must be an Array of Strings' unless args.is_a?(Array) && args.all?(String)
40
+ return { application: :hsmauth, operations: operations } if args.empty? || [%w[credentials], %w[access]].include?(args)
41
+
42
+ size = %w[credentials access].include?(args.first) ? 2 : 1
43
+ operation = args.first(size).join(' ')
44
+ raise ArgumentError, 'Unknown HSM Auth operation' unless operations.include?(operation)
45
+
46
+ flags = case operation
47
+ when 'credentials symmetric' then %w[--touch --generate]
48
+ when 'credentials generate', 'credentials derive', 'credentials import' then %w[--touch]
49
+ when 'reset', 'credentials delete' then %w[--force]
50
+ when 'access change-management-key' then %w[--generate]
51
+ else []
52
+ end
53
+ parsed = Protocol.parse_args(args: args.drop(size), flags: flags, values: operation == 'credentials export' ? %w[--format] : [])
54
+ positional = parsed[:positionals]
55
+ count = operation.start_with?('credentials ') && operation != 'credentials list' ? 1 : 0
56
+ raise ArgumentError, 'Unexpected HSM Auth positional arguments' unless positional.length == count
57
+
58
+ options = opts.merge(parsed[:options]).merge(label: positional.first, operation: operation)
59
+ %i[generate touch force].each do |control|
60
+ raise ArgumentError, "#{control} must be a boolean" if options.key?(control) && ![true, false].include?(options[control])
61
+ end
62
+ return list(session: session(options)) if operation == 'credentials list'
63
+ return put(options) if ['credentials symmetric', 'credentials derive', 'credentials generate', 'credentials import'].include?(operation)
64
+ return delete(options) if operation == 'credentials delete'
65
+ return export(options) if operation == 'credentials export'
66
+ return change_password(options) if operation == 'credentials change-password'
67
+ return change_management(options) if operation.start_with?('access ')
68
+ return reset(options) if operation == 'reset'
69
+
70
+ session = session(options)
71
+ { version: session[:version], management_key_retries: retries(session: session) }
72
+ end
73
+
74
+ public_class_method def self.operations
75
+ ['info', 'reset', 'credentials list', 'credentials symmetric', 'credentials derive', 'credentials delete',
76
+ 'credentials generate', 'credentials import', 'credentials export', 'credentials change-password',
77
+ 'access change-management-key', 'access change-management-password']
78
+ end
79
+
80
+ public_class_method def self.calculate(opts = {})
81
+ label = label(value: opts[:label])
82
+ algorithm = opts.fetch(:algorithm, :symmetric)
83
+ raise ArgumentError, 'algorithm must be :symmetric or :asymmetric' unless ALGORITHMS.key?(algorithm)
84
+
85
+ context = bytes(value: opts[:context], size: algorithm == :symmetric ? 16 : 130)
86
+ crypto = opts[:card_crypto]
87
+ if algorithm == :asymmetric
88
+ public_key = public_point(value: opts[:public_key])
89
+ public_point(value: context.byteslice(0, 65))
90
+ public_point(value: context.byteslice(65, 65))
91
+ bytes(value: crypto, size: 16)
92
+ else
93
+ bytes(value: crypto, size: 8) if crypto
94
+ raise ArgumentError, 'public_key is only valid for asymmetric credentials' if opts[:public_key]
95
+ end
96
+ Protocol.require_confirm(opts)
97
+ password = password(opts.merge(name: :credential_password))
98
+ session = session(opts)
99
+ require_version(session: session, minimum: [5, 6, 0]) if algorithm == :asymmetric
100
+ payload = tlv(tag: 0x71, value: label) + tlv(tag: 0x77, value: context)
101
+ payload += tlv(tag: 0x7c, value: public_key) if public_key
102
+ payload += tlv(tag: 0x78, value: crypto) if crypto
103
+ payload += tlv(tag: 0x73, value: password)
104
+ response = send_apdu(session: session, ins: 3, data: payload)
105
+ raise ResponseError, 'HSM Auth must return exactly three 16-byte session keys' unless response.bytesize == 48
106
+
107
+ { key_senc: response.byteslice(0, 16), key_smac: response.byteslice(16, 16), key_srmac: response.byteslice(32, 16) }
108
+ end
109
+
110
+ private_class_method def self.session(opts = {})
111
+ supplied = opts[:session]
112
+ return open_session(opts) if supplied.nil?
113
+ raise ArgumentError, 'session must be an HSMAuth.open_session handle' unless supplied.is_a?(Session)
114
+ raise ArgumentError, 'connection differs from supplied session' if opts[:connection] && !supplied[:connection].equal?(opts[:connection])
115
+
116
+ supplied
117
+ end
118
+
119
+ public_class_method def self.challenge(opts = {})
120
+ label = label(value: opts[:label])
121
+ algorithm = opts.fetch(:algorithm, :symmetric)
122
+ raise ArgumentError, 'algorithm must be :symmetric or :asymmetric' unless ALGORITHMS.key?(algorithm)
123
+
124
+ Protocol.require_confirm(opts)
125
+ password = password(opts.merge(name: :credential_password)) if algorithm == :asymmetric || opts.key?(:credential_password)
126
+ session = session(opts)
127
+ require_version(session: session, minimum: [5, 6, 0])
128
+ payload = tlv(tag: 0x71, value: label)
129
+ payload += tlv(tag: 0x73, value: password) if password && Protocol.version_at_least?(version: session[:version], minimum: [5, 7, 1])
130
+ response = send_apdu(session: session, ins: 4, data: payload)
131
+ if algorithm == :asymmetric
132
+ public_point(value: response)
133
+ else
134
+ raise ResponseError, 'Malformed HSM Auth host challenge' unless response.bytesize == 8
135
+
136
+ response
137
+ end
138
+ end
139
+
140
+ private_class_method def self.require_version(opts = {})
141
+ raise Error, 'HSM Auth operation is unsupported by the selected application version' unless Protocol.version_at_least?(version: opts.fetch(:session)[:version], minimum: opts.fetch(:minimum))
142
+ end
143
+
144
+ private_class_method def self.public_point(opts = {})
145
+ value = opts[:value]
146
+ if value.is_a?(OpenSSL::PKey::EC)
147
+ raise ArgumentError, 'Only P-256 public keys are supported' unless value.group.curve_name == 'prime256v1'
148
+
149
+ value = value.public_key.to_octet_string(:uncompressed)
150
+ end
151
+ bytes(value: value, size: 65)
152
+ raise ArgumentError, 'Expected an uncompressed P-256 point' unless value.getbyte(0) == 4
153
+
154
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
155
+ point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(value, 2))
156
+ raise ArgumentError, 'Invalid P-256 public key' unless point.on_curve? && !point.infinity?
157
+
158
+ value.b
159
+ rescue OpenSSL::OpenSSLError
160
+ raise ArgumentError, 'Invalid P-256 public key', cause: nil
161
+ end
162
+
163
+ private_class_method def self.private_scalar(opts = {})
164
+ value = opts[:private_key]
165
+ value = Protocol.secret(secret_provider: opts[:secret_provider], name: :private_key) if value.nil?
166
+ if value.is_a?(String) && value.bytesize == 32
167
+ scalar = value.b
168
+ else
169
+ if value.is_a?(String)
170
+ raise ArgumentError, 'Private key input is too large' if value.bytesize > 65_536
171
+
172
+ key_password = opts[:key_password]
173
+ key_password = Protocol.secret(secret_provider: opts[:secret_provider], name: :key_password) if value.include?('ENCRYPTED') && key_password.nil?
174
+ value = OpenSSL::PKey.read(value, key_password || '')
175
+ end
176
+ raise ArgumentError, 'Expected a private P-256 key' unless value.is_a?(OpenSSL::PKey::EC) && value.private? && value.group.curve_name == 'prime256v1'
177
+
178
+ scalar = value.private_key.to_s(2).rjust(32, "\x00")
179
+ end
180
+ number = OpenSSL::BN.new(scalar, 2)
181
+ order = OpenSSL::PKey::EC::Group.new('prime256v1').order
182
+ raise ArgumentError, 'Invalid P-256 private scalar' unless number.to_i.positive? && number < order
183
+
184
+ scalar
185
+ rescue OpenSSL::OpenSSLError
186
+ raise ArgumentError, 'Invalid or encrypted private key; supply the correct key_password once', cause: nil
187
+ end
188
+
189
+ private_class_method def self.export(opts = {})
190
+ label = label(value: opts[:label])
191
+ format = opts.fetch(:format, 'PEM').to_s.upcase
192
+ raise ArgumentError, 'format must be RAW, PEM or DER' unless %w[RAW PEM DER].include?(format)
193
+
194
+ session = session(opts)
195
+ require_version(session: session, minimum: [5, 6, 0])
196
+ point = public_point(value: send_apdu(session: session, ins: 10, data: tlv(tag: 0x71, value: label)))
197
+ return point if format == 'RAW'
198
+
199
+ algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new('id-ecPublicKey'), OpenSSL::ASN1::ObjectId.new('prime256v1')])
200
+ der = OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(point)]).to_der
201
+ format == 'DER' ? der : OpenSSL::PKey.read(der).public_to_pem
202
+ end
203
+
204
+ private_class_method def self.change_password(opts = {})
205
+ label = label(value: opts[:label])
206
+ admin = opts.key?(:management_key) || opts.key?(:management_password) || opts[:auth_method] == :management
207
+ raise ArgumentError, 'Management credential conflicts with explicit credential auth_method' if admin && opts[:auth_method] == :credential
208
+ raise ArgumentError, 'Cannot combine credential and management authentication' if admin && opts.key?(:credential_password)
209
+ raise ArgumentError, 'auth_method must be :management or :credential' if opts[:auth_method] && !%i[management credential].include?(opts[:auth_method])
210
+
211
+ Protocol.require_confirm(opts)
212
+ new_password = password(opts.merge(name: :new_credential_password))
213
+ auth = admin ? management_key(opts) : password(opts.merge(name: :credential_password))
214
+ session = session(opts)
215
+ require_version(session: session, minimum: [5, 8, 0])
216
+ payload = tlv(tag: 0x71, value: label) + tlv(tag: admin ? 0x7b : 0x73, value: auth) + tlv(tag: 0x73, value: new_password)
217
+ send_apdu(session: session, ins: 11, p1: admin ? 1 : 0, data: payload)
218
+ { status: :acknowledged, verification: :not_readable }
219
+ end
220
+
221
+ private_class_method def self.change_management(opts = {})
222
+ raise ArgumentError, 'generate conflicts with supplied new management key' if opts[:generate] && (opts[:new_management_key] || opts[:new_management_password])
223
+ raise ArgumentError, 'Specify only one new management credential' if opts.key?(:new_management_key) && opts.key?(:new_management_password)
224
+
225
+ Protocol.require_confirm(opts)
226
+ new_key = if opts[:generate]
227
+ SecureRandom.random_bytes(16)
228
+ elsif opts[:operation] == 'access change-management-password'
229
+ password(opts.merge(name: :new_management_password))
230
+ else
231
+ bytes(value: Protocol.secret(value: opts[:new_management_key], secret_provider: opts[:secret_provider], name: :new_management_key), size: 16)
232
+ end
233
+ current = management_key(opts)
234
+ session = session(opts)
235
+ raise Error, 'Random management key generation is unsupported on HSM Auth 5.8 and later' if opts[:generate] && Protocol.version_at_least?(version: session[:version], minimum: [5, 8, 0])
236
+
237
+ send_apdu(session: session, ins: 8, data: tlv(tag: 0x7b, value: current) + tlv(tag: 0x7b, value: new_key))
238
+ result = { status: :acknowledged, verification: :not_readable }
239
+ result[:generated_management_key] = new_key if opts[:generate]
240
+ result
241
+ end
242
+
243
+ private_class_method def self.retries(opts = {})
244
+ value = send_apdu(session: opts.fetch(:session), ins: 9)
245
+ raise ResponseError, 'Malformed HSM Auth management retry counter' unless value.bytesize == 1 && value.getbyte(0) <= 8
246
+
247
+ value.getbyte(0)
248
+ end
249
+
250
+ private_class_method def self.reset(opts = {})
251
+ Protocol.require_confirm(opts)
252
+ selected = session(opts)
253
+ send_apdu(session: selected, ins: 6, p1: 0xde, p2: 0xad)
254
+ fresh = open_session(connection: selected[:connection], timeout: selected[:timeout])
255
+ raise ResponseError, 'HSM Auth reset readback failed' unless list(session: fresh).empty? && retries(session: fresh) == 8
256
+
257
+ { reset: true, verified: true, verification: :empty_inventory_and_retries }
258
+ end
259
+
260
+ private_class_method def self.send_apdu(opts = {})
261
+ session = opts.fetch(:session)
262
+ 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))
263
+ end
264
+
265
+ private_class_method def self.tlv(opts = {})
266
+ Protocol.tlv(tag: opts.fetch(:tag), value: opts.fetch(:value))
267
+ end
268
+
269
+ private_class_method def self.label(opts = {})
270
+ value = opts[:value]
271
+ raise ArgumentError, 'label must contain 1 to 64 exact bytes' unless value.is_a?(String) && value.bytesize.between?(1, 64)
272
+
273
+ value.b
274
+ end
275
+
276
+ private_class_method def self.bytes(opts = {})
277
+ value = opts[:value]
278
+ raise ArgumentError, "Expected exactly #{opts[:size]} raw bytes" unless value.is_a?(String) && value.bytesize == opts[:size]
279
+
280
+ value.b
281
+ end
282
+
283
+ private_class_method def self.password(opts = {})
284
+ name = opts.fetch(:name)
285
+ value = opts[name]
286
+ value = Protocol.secret(secret_provider: opts[:secret_provider], name: name) if value.nil?
287
+ raise ArgumentError, 'HSM Auth password must be a String of at most 16 bytes' unless value.is_a?(String) && value.bytesize <= 16
288
+
289
+ value.b.ljust(16, "\x00")
290
+ end
291
+
292
+ private_class_method def self.management_key(opts = {})
293
+ raise ArgumentError, 'Choose management_key or management_password, not both' if opts.key?(:management_key) && opts.key?(:management_password)
294
+ return password(opts.merge(name: :management_password)) if opts.key?(:management_password)
295
+
296
+ value = Protocol.secret(value: opts[:management_key], secret_provider: opts[:secret_provider], name: :management_key)
297
+ bytes(value: value, size: 16)
298
+ end
299
+
300
+ private_class_method def self.list(opts = {})
301
+ result = Protocol.untlv(data: send_apdu(session: opts.fetch(:session), ins: 5)).map do |entry|
302
+ value = entry[:value]
303
+ raise ResponseError, 'Malformed HSM Auth LIST entry' unless entry[:tag] == 0x72 && value.bytesize.between?(4, 67) && [0, 1].include?(value.getbyte(1)) && value.getbyte(-1) <= 8
304
+
305
+ algorithm = ALGORITHMS.key(value.getbyte(0))
306
+ raise ResponseError, 'Unsupported HSM Auth credential algorithm' unless algorithm
307
+
308
+ { label: value.byteslice(2, value.bytesize - 3), algorithm: algorithm, touch: value.getbyte(1) == 1, retries: value.getbyte(-1) }
309
+ end
310
+ raise ResponseError, 'Duplicate HSM Auth labels' unless result.map { |entry| entry[:label] }.uniq.length == result.length
311
+
312
+ result
313
+ end
314
+
315
+ private_class_method def self.put(opts = {})
316
+ label = label(value: opts[:label])
317
+ touch = opts.fetch(:touch, false)
318
+ raise ArgumentError, 'touch must be a boolean' unless [true, false].include?(touch)
319
+ raise ArgumentError, 'generate conflicts with explicit key material' if opts[:generate] && (opts[:key_enc] || opts[:key_mac])
320
+
321
+ Protocol.require_confirm(opts)
322
+ asymmetric = ['credentials generate', 'credentials import'].include?(opts[:operation])
323
+ if asymmetric
324
+ scalar = opts[:operation] == 'credentials generate' ? ''.b : private_scalar(opts)
325
+ elsif opts[:operation] == 'credentials derive'
326
+ value = Protocol.secret(value: opts[:derivation_password], secret_provider: opts[:secret_provider], name: :derivation_password)
327
+ key = OpenSSL::PKCS5.pbkdf2_hmac(value, 'Yubico', 10_000, 32, 'SHA256')
328
+ key_enc = key.byteslice(0, 16)
329
+ key_mac = key.byteslice(16, 16)
330
+ elsif opts[:generate]
331
+ key_enc = SecureRandom.random_bytes(16)
332
+ key_mac = SecureRandom.random_bytes(16)
333
+ else
334
+ key_enc = bytes(value: Protocol.secret(value: opts[:key_enc], secret_provider: opts[:secret_provider], name: :key_enc), size: 16)
335
+ key_mac = bytes(value: Protocol.secret(value: opts[:key_mac], secret_provider: opts[:secret_provider], name: :key_mac), size: 16)
336
+ end
337
+ password = password(opts.merge(name: :credential_password))
338
+ management = management_key(opts)
339
+ session = session(opts)
340
+ require_version(session: session, minimum: [5, 6, 0]) if asymmetric
341
+ raise ArgumentError, 'HSM Auth label already exists; delete explicitly before replacing it' if list(session: session).any? { |entry| entry[:label] == label }
342
+
343
+ algorithm = asymmetric ? :asymmetric : :symmetric
344
+ payload = tlv(tag: 0x7b, value: management) + tlv(tag: 0x71, value: label) + tlv(tag: 0x74, value: [ALGORITHMS.fetch(algorithm)].pack('C'))
345
+ payload += asymmetric ? tlv(tag: 0x7d, value: scalar) : tlv(tag: 0x75, value: key_enc) + tlv(tag: 0x76, value: key_mac)
346
+ payload += tlv(tag: 0x73, value: password) + tlv(tag: 0x7a, value: [touch ? 1 : 0].pack('C'))
347
+ send_apdu(session: session, ins: 1, data: payload)
348
+ result = list(session: session).find { |entry| entry[:label] == label }
349
+ raise ResponseError, 'HSM Auth import readback did not match metadata' unless result && result[:algorithm] == algorithm && result[:touch] == touch
350
+
351
+ result = result.merge(verified: true, verification: :metadata_only)
352
+ result[:generated_keys] = { key_enc: key_enc, key_mac: key_mac } if opts[:generate]
353
+ result
354
+ end
355
+
356
+ private_class_method def self.delete(opts = {})
357
+ label = label(value: opts[:label])
358
+ Protocol.require_confirm(opts)
359
+ management = management_key(opts)
360
+ session = session(opts)
361
+ raise ResponseError, 'No exact HSM Auth credential label' unless list(session: session).any? { |entry| entry[:label] == label }
362
+
363
+ send_apdu(session: session, ins: 2, data: tlv(tag: 0x7b, value: management) + tlv(tag: 0x71, value: label))
364
+ raise ResponseError, 'HSM Auth delete readback failed' if list(session: session).any? { |entry| entry[:label] == label }
365
+
366
+ { deleted: label, verified: true }
367
+ end
368
+
369
+ public_class_method def self.authors
370
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
371
+ end
372
+
373
+ public_class_method def self.help
374
+ puts <<~HELP
375
+ Execute native credential management without a subprocess.
376
+ #{self}.execute(
377
+ args: 'optional - operation argv after hsmauth; empty returns local metadata',
378
+ connection: 'required - selected APDU connection except local help',
379
+ session: 'optional - existing open_session handle instead of reselecting connection',
380
+ confirm: 'required - literal true for mutations and authentication',
381
+ credential_password: 'optional - current in-memory password up to sixteen bytes; explicit empty is allowed',
382
+ new_credential_password: 'optional - new in-memory credential password up to sixteen bytes',
383
+ management_key: 'optional - exactly sixteen raw authorization key bytes, never guessed',
384
+ management_password: 'optional - at most sixteen password bytes instead of management_key',
385
+ new_management_key: 'optional - exactly sixteen raw bytes for change-management-key',
386
+ new_management_password: 'optional - at most sixteen password bytes for change-management-password',
387
+ key_enc: 'optional - sixteen raw AES encryption key bytes for symmetric import',
388
+ key_mac: 'optional - sixteen raw AES authentication key bytes for symmetric import',
389
+ derivation_password: 'optional - in-memory password for PBKDF2 symmetric derivation',
390
+ private_key: 'optional - raw thirty-two byte P-256 scalar, PEM/DER String or private OpenSSL EC object',
391
+ key_password: 'optional - in-memory PEM/DER decryption password; never retried',
392
+ auth_method: 'optional - credential or management Symbol for password-change authorization',
393
+ generate: 'optional - boolean symmetric key generation or pre-5.8 management-key generation',
394
+ touch: 'optional - boolean touch requirement on credential creation',
395
+ format: 'optional - RAW point, PEM or DER for public-key export, returned in memory',
396
+ secret_provider: 'optional - callable receiving a missing secret field Symbol after consent',
397
+ timeout: 'optional - finite positive I/O timeout seconds'
398
+ )
399
+ Enumerate implemented operation leaves only.
400
+ #{self}.operations
401
+ Select the application once for a multi-command cryptographic session.
402
+ #{self}.open_session(
403
+ connection: 'required - caller-owned raw APDU transport handle',
404
+ timeout: 'optional - finite positive I/O timeout seconds'
405
+ )
406
+ Obtain a host challenge without losing ephemeral session state.
407
+ #{self}.challenge(
408
+ session: 'required - open_session handle shared with the following calculate call',
409
+ label: 'required - full exact credential label bytes',
410
+ algorithm: 'optional - symmetric or asymmetric Symbol, default symmetric',
411
+ credential_password: 'optional - in-memory credential password; required for asymmetric mode',
412
+ confirm: 'required - literal true for challenge generation or authentication'
413
+ )
414
+ Calculate three in-memory session keys using one credential.
415
+ #{self}.calculate(
416
+ connection: 'optional - raw APDU handle when no existing session is supplied',
417
+ session: 'optional - shared open_session handle retaining an earlier challenge',
418
+ label: 'required - full exact credential label bytes',
419
+ algorithm: 'optional - symmetric or asymmetric Symbol, default symmetric',
420
+ context: 'required - sixteen symmetric challenge bytes or two uncompressed P-256 points (130 bytes)',
421
+ public_key: 'optional - required asymmetric HSM public key, raw 65-byte point or OpenSSL EC object',
422
+ card_crypto: 'optional - eight-byte symmetric or required sixteen-byte asymmetric card cryptogram',
423
+ credential_password: 'required - in-memory password, at most sixteen raw bytes',
424
+ confirm: 'required - literal true for one authentication attempt',
425
+ timeout: 'optional - finite positive I/O timeout seconds'
426
+ )
427
+ Import/export use data in memory, not filenames. No credential or default key is guessed.
428
+ Reuse the same session between challenge and calculate; do not reselect another application.
429
+ Creation/deletion read back metadata. Password/key changes return acknowledged/not_readable;
430
+ they do not perform a second authentication attempt to pretend secret contents were read back.
431
+ Asymmetric operations require 5.6; credential-password changes require 5.8.
432
+ Display module author information.
433
+ #{self}.authors
434
+ HELP
435
+ end
436
+ end
437
+ end
438
+ end
439
+ end