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,1315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'stringio'
6
+ require 'zlib'
7
+
8
+ module PWN
9
+ module Plugins
10
+ module YubiKey
11
+ # Native PIV application. Wire reference: NIST SP 800-73 and Yubico
12
+ # yubikey-manager 5.9.1, yubikit/piv.py and ykman/{piv,_cli/piv}.py.
13
+ # Those sources are specifications only; no executable backend is used.
14
+ module PIV
15
+ AID = ['a000000308000010000100'].pack('H*').freeze
16
+
17
+ MANAGEMENT = { 'TDES' => [3, 24, 'DES-EDE3-ECB'], 'AES128' => [8, 16, 'AES-128-ECB'], 'AES192' => [10, 24, 'AES-192-ECB'], 'AES256' => [12, 32, 'AES-256-ECB'] }.freeze
18
+ GRAMMAR = {
19
+ 'info' => [0, [], []], 'reset' => [0, %w[force], []],
20
+ 'access change-pin' => [0, [], []], 'access change-puk' => [0, [], []],
21
+ 'access unblock-pin' => [0, [], []], 'access set-retries' => [2, %w[force], []],
22
+ 'access change-management-key' => [0, %w[touch protect generate force], %w[algorithm]],
23
+ 'objects import' => [2, [], []], 'objects export' => [2, [], []], 'objects generate' => [1, [], []],
24
+ 'keys generate' => [2, [], %w[algorithm format pin-policy touch-policy]],
25
+ 'keys import' => [2, [], %w[pin-policy touch-policy]], 'keys info' => [1, [], []],
26
+ 'keys export' => [2, %w[verify], %w[format]], 'keys attest' => [2, [], %w[format]],
27
+ 'keys move' => [2, [], []], 'keys delete' => [1, [], []],
28
+ 'certificates import' => [2, %w[verify compress update-chuid no-update-chuid], []],
29
+ 'certificates export' => [2, [], %w[format]],
30
+ 'certificates generate' => [1..2, %w[update-chuid no-update-chuid], %w[subject valid-days hash-algorithm]],
31
+ 'certificates request' => [3, [], %w[subject hash-algorithm]],
32
+ 'certificates delete' => [1, %w[update-chuid no-update-chuid], []]
33
+ }.freeze
34
+
35
+ ALGORITHMS = { 'RSA1024' => [6, 128], 'RSA2048' => [7, 256], 'RSA3072' => [5, 384], 'RSA4096' => [0x16, 512],
36
+ 'ECCP256' => [0x11, 32], 'ECCP384' => [0x14, 48], 'ED25519' => [0xe0, 32], 'X25519' => [0xe1, 32] }.freeze
37
+ PIN_POLICIES = { 'DEFAULT' => 0, 'NEVER' => 1, 'ONCE' => 2, 'ALWAYS' => 3, 'MATCH_ONCE' => 4, 'MATCH_ALWAYS' => 5 }.freeze
38
+ TOUCH_POLICIES = { 'DEFAULT' => 0, 'NEVER' => 1, 'ALWAYS' => 2, 'CACHED' => 3 }.freeze
39
+ SLOTS = { 'AUTHENTICATION' => 0x9a, 'SIGNATURE' => 0x9c, 'KEY_MANAGEMENT' => 0x9d, 'CARD_AUTH' => 0x9e, 'ATTESTATION' => 0xf9 }.merge((1..20).to_h { |n| ["RETIRED#{n}", 0x81 + n] }).freeze
40
+ OBJECTS = { 'CCC' => 0x5fc107, 'CAPABILITY' => 0x5fc107, 'CHUID' => 0x5fc102, 'DISCOVERY' => 0x7e,
41
+ 'AUTHENTICATION' => 0x5fc105, 'SIGNATURE' => 0x5fc10a, 'KEY_MANAGEMENT' => 0x5fc10b, 'CARD_AUTH' => 0x5fc101,
42
+ 'FINGERPRINTS' => 0x5fc103, 'SECURITY' => 0x5fc106, 'FACIAL' => 0x5fc108, 'PRINTED' => 0x5fc109,
43
+ 'KEY_HISTORY' => 0x5fc10c, 'IRIS' => 0x5fc121, 'ATTESTATION' => 0x5fff01, 'PIVMAN' => 0x5fff00 }.merge((1..20).to_h { |n| ["RETIRED#{n}", 0x5fc10c + n] }).freeze
44
+
45
+ # Firmware does not support the requested operation.
46
+ class UnsupportedError < Error; end
47
+
48
+ # A failed reference verification; the secret is never included.
49
+ class PinError < Error
50
+ attr_reader :attempts_remaining
51
+
52
+ def initialize(attempts_remaining)
53
+ @attempts_remaining = attempts_remaining
54
+ super("PIV verification failed; #{attempts_remaining} attempts remaining")
55
+ end
56
+ end
57
+
58
+ # Execute a native PIV leaf with secrets passed only in memory.
59
+ # @param opts [Hash] args and connection plus operation-specific options
60
+ # @return [Hash, String] structured outcome or explicit binary export
61
+ public_class_method def self.execute(opts = {})
62
+ Session.new(opts).execute(opts[:args] || [])
63
+ end
64
+
65
+ # Authenticate both card and host with a fresh random challenge.
66
+ # @param opts [Hash] connection, management_key and literal confirmation
67
+ # @return [Symbol] :authenticated
68
+ public_class_method def self.authenticate(opts = {})
69
+ Session.new(opts).authenticate(opts[:management_key])
70
+ end
71
+
72
+ # Verify the caller's PIN without guessing or retrying credentials.
73
+ # @param opts [Hash] native connection, pin, confirmation and timeout
74
+ # @return [Symbol] :verified
75
+ public_class_method def self.verify_pin(opts = {})
76
+ Session.new(opts).verify_pin(opts[:pin])
77
+ end
78
+
79
+ # Sign a message with a non-exportable device private key.
80
+ # @param opts [Hash] slot, algorithm, message, PIN and padding options
81
+ # @return [String] raw signature
82
+ public_class_method def self.sign(opts = {})
83
+ Session.new(opts).sign(opts[:slot], opts[:message])
84
+ end
85
+
86
+ # Decrypt an RSA ciphertext on the device and unpad using OpenSSL.
87
+ # @param opts [Hash] slot, ciphertext, PIN and OAEP or PKCS1 options
88
+ # @return [String] binary plaintext
89
+ public_class_method def self.decrypt(opts = {})
90
+ Session.new(opts).decrypt(opts[:slot], opts[:ciphertext])
91
+ end
92
+
93
+ # Derive an ECDH or X25519 shared secret using the device key.
94
+ # @param opts [Hash] slot, peer_public_key and authentication options
95
+ # @return [String] binary shared secret
96
+ public_class_method def self.calculate_secret(opts = {})
97
+ Session.new(opts).calculate_secret(opts[:slot], opts[:peer_public_key])
98
+ end
99
+
100
+ # Verify a biometric and optionally obtain a session-local temporary PIN.
101
+ # @param opts [Hash] connection, confirmation and temporary PIN request
102
+ # @return [Symbol, String] verified status or temporary PIN bytes
103
+ public_class_method def self.verify_uv(opts = {})
104
+ Session.new(opts).verify_uv(request_pin: opts[:request_temporary_pin])
105
+ end
106
+
107
+ # Verify a session-local temporary PIN previously issued by the card.
108
+ # @param opts [Hash] connection, confirmation and temporary_pin bytes
109
+ # @return [Symbol] :verified
110
+ public_class_method def self.verify_temporary_pin(opts = {})
111
+ Session.new(opts).verify_temporary_pin(opts[:temporary_pin])
112
+ end
113
+
114
+ # List native leaf names without selecting a device.
115
+ # @param opts [Hash] optional operation prefix
116
+ # @return [Array<String>] implemented operations
117
+ public_class_method def self.operations(opts = {})
118
+ prefix = opts[:prefix].to_s
119
+ GRAMMAR.keys.select { |name| name.start_with?(prefix) }
120
+ end
121
+
122
+ # Show native PIV usage and secret-handling boundaries.
123
+ # @return [nil]
124
+ public_class_method def self.help
125
+ puts <<~HELP
126
+ USAGE:
127
+ # Execute a native PIV leaf; group paths return local help.
128
+ #{self}.execute(
129
+ args: 'required - family-relative Array of nonsecret arguments',
130
+ connection: 'required - native APDU connection selected by the parent transport',
131
+ confirm: 'optional - literal true required for authentication, writes and private-key use',
132
+ pin: 'optional - current PIN in memory, never an argument token',
133
+ puk: 'optional - current PUK in memory for change or unblock operations',
134
+ new_pin: 'optional - replacement PIN in memory for change or unblock',
135
+ new_puk: 'optional - replacement PUK in memory for change-puk',
136
+ management_key: 'optional - raw binary management key supplied only in memory',
137
+ new_management_key: 'optional - raw binary replacement management key',
138
+ password: 'optional - in-memory PEM or PKCS12 import password',
139
+ secret_provider: 'optional - callable receiving a missing secret field Symbol',
140
+ secret_sink: 'optional - callable receiving field and newly generated unprotected management key',
141
+ input: 'optional - binary input for a dash filename; no implicit terminal reads',
142
+ use_protected_key: 'optional - literal true selects PIN-protected PIVMAN authentication',
143
+ block_retries: 'optional - literal true explicitly authorizes PIN and PUK blocking before reset',
144
+ public_key_source: 'optional - metadata, attestation or certificate; no error-triggered fallback',
145
+ fips_restrictions: 'optional - Boolean for newer FIPS device capability restrictions',
146
+ not_before: 'optional - Time for generated certificate start, default current UTC time',
147
+ not_after: 'optional - Time for generated certificate expiry, default valid-days',
148
+ certificate_serial: 'optional - positive integer certificate serial under 160 bits',
149
+ extensions: 'optional - Array of OpenSSL X509 Extension objects',
150
+ timeout: 'optional - finite positive APDU timeout seconds, default 30'
151
+ )
152
+ # Verify the supplied PIN exactly once without guessing credentials.
153
+ #{self}.verify_pin(connection: 'required - APDU connection', pin: 'required - PIN in memory', confirm: 'required - literal true')
154
+ # Mutually authenticate with the configured 3DES or AES management key.
155
+ #{self}.authenticate(connection: 'required - APDU connection', management_key: 'required - raw binary secret', confirm: 'required - literal true')
156
+ # Create a device-backed RSA, ECDSA or Ed25519 signature.
157
+ #{self}.sign(
158
+ connection: 'required - APDU connection', slot: 'required - PIV slot hex String or Integer',
159
+ message: 'required - binary message String', algorithm: 'optional - algorithm name, otherwise slot metadata',
160
+ hash_algorithm: 'optional - SHA1, SHA256, SHA384 or SHA512; default SHA256',
161
+ padding: 'optional - pkcs1 or pss for RSA; default pkcs1',
162
+ prehashed: 'optional - Boolean to use a digest instead of hashing the input',
163
+ salt_length: 'optional - PSS salt length in bytes; default digest size',
164
+ mgf1_hash: 'optional - PSS or OAEP MGF1 hash; default hash_algorithm',
165
+ pin: 'optional - current PIN; the card enforces its policy if omitted', confirm: 'required - literal true'
166
+ )
167
+ # Decrypt a device-backed RSA ciphertext with native OpenSSL padding.
168
+ #{self}.decrypt(
169
+ connection: 'required - APDU connection', slot: 'required - PIV key slot',
170
+ ciphertext: 'required - binary RSA ciphertext', padding: 'optional - oaep, pkcs1 or none; default oaep',
171
+ label: 'optional - binary OAEP label', pin: 'optional - current PIN', confirm: 'required - literal true'
172
+ )
173
+ # Derive a device-backed ECDH or X25519 shared secret.
174
+ #{self}.calculate_secret(
175
+ connection: 'required - APDU connection', slot: 'required - PIV key slot',
176
+ peer_public_key: 'required - PEM, DER or OpenSSL peer public key', pin: 'optional - current PIN', confirm: 'required - literal true'
177
+ )
178
+ # Verify an enrolled biometric and optionally request temporary PIN bytes.
179
+ #{self}.verify_uv(connection: 'required - APDU connection', confirm: 'required - literal true', request_temporary_pin: 'optional - Boolean to request a session-local PIN')
180
+ # Verify a card-issued temporary PIN during its session lifetime.
181
+ #{self}.verify_temporary_pin(connection: 'required - APDU connection', confirm: 'required - literal true', temporary_pin: 'required - sixteen raw bytes in memory')
182
+ # List the implemented PIV leaf catalog without device operations.
183
+ #{self}.operations(prefix: 'optional - exact operation prefix filter')
184
+ # Identify the module authors.
185
+ #{self}.authors
186
+ HELP
187
+ end
188
+
189
+ # Identify the module authors.
190
+ # @return [String] authors
191
+ public_class_method def self.authors
192
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
193
+ end
194
+
195
+ # A selected connection owns authentication state for its lifetime.
196
+ class Session # rubocop:disable Metrics/ClassLength
197
+ def initialize(opts)
198
+ raise ArgumentError, 'PIV options must be a Hash' unless opts.is_a?(Hash)
199
+
200
+ @opts = opts
201
+ validate_options
202
+ @connection = opts[:connection]
203
+ @selected = false
204
+ end
205
+
206
+ def execute(args)
207
+ raise ArgumentError, 'args must be an Array of Strings' unless args.is_a?(Array) && args.all?(String)
208
+
209
+ return { application: :piv, operations: GRAMMAR.keys } if args.empty? || %w[access keys certificates objects].include?(args.join(' ')) || args == ['--help']
210
+
211
+ operation = GRAMMAR.keys.sort_by(&:length).reverse.find { |name| args.first(name.split.length) == name.split }
212
+ raise ArgumentError, 'Unknown PIV operation' unless operation
213
+
214
+ count, flags, values = GRAMMAR.fetch(operation)
215
+ parsed = Protocol.parse_args(args: args.drop(operation.split.length), flags: flags.map { |flag| "--#{flag}" }, values: values.map { |value| "--#{value}" })
216
+ positions = parsed.fetch(:positionals)
217
+ raise ArgumentError, 'Incorrect number of PIV arguments' unless count.is_a?(Range) ? count.cover?(positions.length) : positions.length == count
218
+
219
+ @opts = @opts.merge(parsed.fetch(:options))
220
+ validate_options
221
+ output_index = { 'keys generate' => 1, 'keys export' => 1, 'keys attest' => 1, 'objects export' => 1,
222
+ 'certificates export' => 1, 'certificates request' => 2 }[operation]
223
+ if output_index && positions[output_index] != '-'
224
+ confirm!
225
+ path = positions.fetch(output_index)
226
+ raise Errno::EEXIST, 'PIV output already exists' if File.exist?(path) || File.symlink?(path)
227
+ raise Errno::ENOENT, 'PIV output parent does not exist' unless File.directory?(File.dirname(path))
228
+ end
229
+ case operation
230
+ when 'info' then info
231
+ when 'reset' then reset
232
+ when 'access change-pin' then change_reference(0x24, 0x80, :pin, :new_pin)
233
+ when 'access change-puk' then change_reference(0x24, 0x81, :puk, :new_puk)
234
+ when 'access unblock-pin' then change_reference(0x2c, 0x80, :puk, :new_pin)
235
+ when 'access set-retries' then set_retries(*positions)
236
+ when 'access change-management-key' then change_management_key
237
+ when 'objects import' then import_object(*positions)
238
+ when 'objects export'
239
+ id = parse_object_id(positions[0])
240
+ verify_pin if @opts[:pin]
241
+ output(read_object(id), positions[1])
242
+ when 'objects generate' then generate_object(positions[0])
243
+ when 'keys generate' then output(encode(generate_key(positions[0])), positions[1])
244
+ when 'keys import' then import_key(positions[0], input(positions[1]))
245
+ when 'keys info' then metadata(slot(positions[0]))
246
+ when 'keys export' then output(encode(export_key(positions[0])), positions[1])
247
+ when 'keys attest' then output(encode(attest(positions[0])), positions[1])
248
+ when 'keys move' then move_key(*positions)
249
+ when 'keys delete' then move_key(positions[0], nil)
250
+ when 'certificates import' then import_certificate(positions[0], input(positions[1]))
251
+ when 'certificates export' then output(encode(read_certificate(positions[0])), positions[1])
252
+ when 'certificates generate' then generate_certificate(positions[0], positions[1] ? input(positions[1]) : nil)
253
+ when 'certificates request' then output(encode(certificate_request(positions[0], input(positions[1]))), positions[2])
254
+ when 'certificates delete' then delete_certificate(positions[0])
255
+ else raise ArgumentError, 'Unknown PIV operation'
256
+ end
257
+ end
258
+
259
+ def verify_pin(pin = nil)
260
+ confirm!
261
+ reference_apdu(0x20, p2: 0x80, data: pin_bytes(pin || secret(:pin)))
262
+ :verified
263
+ end
264
+
265
+ def verify_uv(request_pin: false)
266
+ raise ArgumentError, 'request_temporary_pin must be a Boolean' unless [true, false, nil].include?(request_pin)
267
+
268
+ confirm!
269
+ biometric_support
270
+ response = reference_apdu(0x20, p2: 0x96, data: tlv(request_pin ? 2 : 3))
271
+ raise ResponseError, 'Invalid temporary PIN length' if request_pin && response.bytesize != 16
272
+
273
+ request_pin ? response : :verified
274
+ end
275
+
276
+ def verify_temporary_pin(value)
277
+ confirm!
278
+ value ||= secret(:temporary_pin)
279
+ raise ArgumentError, 'temporary_pin must contain exactly 16 bytes' unless value.is_a?(String) && value.bytesize == 16
280
+
281
+ biometric_support
282
+ reference_apdu(0x20, p2: 0x96, data: tlv(1, value))
283
+ :verified
284
+ end
285
+
286
+ def authenticate(key = nil)
287
+ confirm!
288
+ key ||= secret(:management_key)
289
+ algorithm = management_algorithm
290
+ id, size, cipher_name = MANAGEMENT.fetch(algorithm)
291
+ require_version([5, 4, 0]) unless id == 3
292
+ raise ArgumentError, 'Invalid management key length' unless key.is_a?(String) && key.bytesize == size
293
+
294
+ witness = unpack(0x80, unpack(0x7c, apdu(0x87, p1: id, p2: 0x9b, data: tlv(0x7c, tlv(0x80)))))
295
+ challenge_size = id == 3 ? 8 : 16
296
+ raise ResponseError, 'Malformed management authentication witness' unless witness.bytesize == challenge_size
297
+
298
+ cipher = OpenSSL::Cipher.new(cipher_name)
299
+ cipher.decrypt
300
+ cipher.padding = 0
301
+ cipher.key = key
302
+ plain = cipher.update(witness) + cipher.final
303
+ challenge = SecureRandom.random_bytes(challenge_size)
304
+ response = unpack(0x82, unpack(0x7c, apdu(0x87, p1: id, p2: 0x9b, data: tlv(0x7c, tlv(0x80, plain) + tlv(0x81, challenge)))))
305
+ cipher.encrypt
306
+ cipher.padding = 0
307
+ cipher.key = key
308
+ expected = cipher.update(challenge) + cipher.final
309
+ raise ResponseError, 'Management authentication response does not verify' unless response.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(response, expected)
310
+
311
+ :authenticated
312
+ end
313
+
314
+ def change_reference(ins, reference, current_name, new_name)
315
+ confirm!
316
+ current_value = secret(current_name)
317
+ replacement_value = secret(new_name)
318
+ current = pin_bytes(current_value)
319
+ replacement = pin_bytes(replacement_value)
320
+ data = pivman if current_name == :pin
321
+ reference_apdu(ins, p2: reference, data: current + replacement)
322
+ if data&.key?(0x82)
323
+ old_key = OpenSSL::PKCS5.pbkdf2_hmac(current_value, data.fetch(0x82), 10_000, 24, 'SHA1')
324
+ authenticate(old_key)
325
+ verify_pin(replacement_value)
326
+ salt = SecureRandom.random_bytes(16)
327
+ new_key = OpenSSL::PKCS5.pbkdf2_hmac(replacement_value, salt, 10_000, 24, 'SHA1')
328
+ set_management_key('TDES', new_key)
329
+ authenticate(new_key)
330
+ data[0x82] = salt
331
+ write_object(0x5fff00, container(0x80, data))
332
+ end
333
+ { status: :changed, reference: reference }
334
+ end
335
+
336
+ def set_retries(pin_retries, puk_retries)
337
+ pin_count = integer(pin_retries, 1..255)
338
+ puk_count = integer(puk_retries, 0..255)
339
+ confirm!
340
+ pin = secret(:pin)
341
+ pin_bytes(pin)
342
+ if version_at_least?([5, 3, 0])
343
+ begin
344
+ metadata(0x96)
345
+ raise UnsupportedError, 'PIN/PUK retries cannot be changed on biometric PIV'
346
+ rescue Error => e
347
+ raise unless [0x6a88, 0x6d00].include?(status_word(e))
348
+ end
349
+ raise UnsupportedError, 'FIPS retry limits must be set before changing PIN/PUK' if @opts[:fips_restrictions] && !(metadata(0x80).fetch(:default) && metadata(0x81).fetch(:default))
350
+ end
351
+ data = pivman
352
+ admin_authenticate
353
+ verify_pin(pin)
354
+ apdu(0xfa, p1: pin_count, p2: puk_count)
355
+ raise ResponseError, 'PIN/PUK retry-limit readback mismatch' if version_at_least?([5, 3, 0]) && !(metadata(0x80).fetch(:total_attempts) == pin_count && metadata(0x81).fetch(:total_attempts) == puk_count)
356
+
357
+ if data.fetch(0x81, "\x00".b).getbyte(0).anybits?(1)
358
+ data[0x81] = [data.fetch(0x81).getbyte(0) & ~1].pack('C')
359
+ write_object(0x5fff00, container(0x80, data))
360
+ end
361
+ { status: :changed, pin_retries: pin_count, puk_retries: puk_count, references_reset: true }
362
+ end
363
+
364
+ def import_object(identifier, source)
365
+ id = parse_object_id(identifier)
366
+ bytes = input(source)
367
+ confirm!
368
+ if id == 0x5fc109
369
+ data = pivman
370
+ raise ArgumentError, 'Cannot overwrite protected management-key object' if data.key?(0x82) || data.fetch(0x81, "\x00".b).getbyte(0).anybits?(2)
371
+ end
372
+ admin_authenticate
373
+ write_object(id, bytes)
374
+ end
375
+
376
+ def generate_object(identifier)
377
+ id = parse_object_id(identifier)
378
+ raise ArgumentError, 'Only CHUID and CCC can be generated' unless [0x5fc102, 0x5fc107].include?(id)
379
+
380
+ confirm!
381
+ data = id == 0x5fc102 ? chuid : ccc
382
+ admin_authenticate
383
+ write_object(id, data)
384
+ end
385
+
386
+ def read_object(identifier)
387
+ id = parse_object_id(identifier)
388
+ unpack(id == 0x7e ? 0x7e : 0x53, apdu(0xcb, p1: 0x3f, p2: 0xff, data: tlv(0x5c, number_bytes(id))))
389
+ end
390
+
391
+ def write_object(identifier, bytes)
392
+ confirm!
393
+ id = parse_object_id(identifier)
394
+ raise ArgumentError, 'Object data must be a String' unless bytes.is_a?(String)
395
+
396
+ apdu(0xdb, p1: 0x3f, p2: 0xff, data: tlv(0x5c, number_bytes(id)) + tlv(0x53, bytes))
397
+ observed = optional_object(id)
398
+ raise ResponseError, 'PIV object write readback mismatch; do not retry automatically' unless observed == bytes || (bytes.empty? && observed.nil?)
399
+
400
+ { status: :written, object_id: id, bytes: bytes.bytesize }
401
+ end
402
+
403
+ def change_management_key
404
+ confirm!
405
+ name = @opts[:algorithm] ? enum_name(@opts[:algorithm], MANAGEMENT) : management_algorithm
406
+ id, size = MANAGEMENT.fetch(name)
407
+ require_version([5, 4, 0]) unless id == 3
408
+ require_version([4, 0, 0]) if @opts[:touch]
409
+ raise UnsupportedError, 'TDES management keys are not allowed by FIPS restrictions' if @opts[:fips_restrictions] && id == 3
410
+ raise ArgumentError, 'generate conflicts with new_management_key' if @opts[:generate] && @opts[:new_management_key]
411
+
412
+ generated = @opts[:generate] || (@opts[:protect] && !@opts[:new_management_key])
413
+ raise ArgumentError, 'Generated unprotected management key requires secret_sink to retain it securely' if generated && !@opts[:protect] && !@opts[:secret_sink].respond_to?(:call)
414
+
415
+ replacement = generated ? SecureRandom.random_bytes(size) : secret(:new_management_key)
416
+ raise ArgumentError, 'Invalid new management key length' unless replacement.bytesize == size
417
+
418
+ data = pivman
419
+ stored = data.fetch(0x81, "\x00".b).getbyte(0).anybits?(2)
420
+ pin = secret(:pin) if @opts[:protect] || stored
421
+ pin_bytes(pin) if pin
422
+ admin_authenticate
423
+ verify_pin(pin) if pin
424
+ protected_fields = protected_pivman if @opts[:protect] || stored
425
+ @opts[:secret_sink].call(:new_management_key, replacement.dup) if generated && !@opts[:protect]
426
+ set_management_key(name, replacement, touch: @opts[:touch] == true)
427
+ authenticate(replacement)
428
+ verify_pin(pin) if pin
429
+ data.delete(0x82)
430
+ flags = data.fetch(0x81, "\x00".b).getbyte(0)
431
+ flags = @opts[:protect] ? flags | 2 : flags & ~2
432
+ data[0x81] = [flags].pack('C')
433
+ if protected_fields
434
+ @opts[:protect] ? protected_fields[0x89] = replacement : protected_fields.delete(0x89)
435
+ write_object(0x5fc109, container(0x88, protected_fields))
436
+ end
437
+ write_object(0x5fff00, container(0x80, data))
438
+ { status: :changed, algorithm: name, protected: @opts[:protect] == true }
439
+ end
440
+
441
+ def sign(identifier, message, algorithm: nil)
442
+ selected = slot(identifier)
443
+ raise ArgumentError, 'message must be a String' unless message.is_a?(String)
444
+
445
+ confirm!
446
+ algorithm ||= @opts[:algorithm] || metadata(selected).fetch(:algorithm)
447
+ algorithm = enum_name(algorithm, ALGORITHMS)
448
+ raise ArgumentError, 'X25519 is a key-agreement algorithm, not a signature algorithm' if algorithm == 'X25519'
449
+
450
+ policies(algorithm, generate: false)
451
+ padded = signature_input(algorithm, message.b)
452
+ crypto_pin
453
+ private_operation(selected, algorithm, padded)
454
+ end
455
+
456
+ def decrypt(identifier, ciphertext)
457
+ selected = slot(identifier)
458
+ raise ArgumentError, 'ciphertext must be a String' unless ciphertext.is_a?(String)
459
+
460
+ algorithm = "RSA#{ciphertext.bytesize * 8}"
461
+ raise ArgumentError, 'Invalid RSA ciphertext size' unless ALGORITHMS.key?(algorithm)
462
+
463
+ padding = @opts.fetch(:padding, :oaep).to_sym
464
+ raise ArgumentError, 'RSA decryption padding must be oaep, pkcs1 or none' unless %i[oaep pkcs1 none].include?(padding)
465
+
466
+ digest = digest_name
467
+ confirm!
468
+ policies(algorithm, generate: false)
469
+ crypto_pin
470
+ padded = private_operation(selected, algorithm, ciphertext.b)
471
+ raise ResponseError, 'Invalid RSA result size' unless padded.bytesize == ciphertext.bytesize
472
+ return padded if padding == :none
473
+
474
+ # The host only handles padding: an unrelated ephemeral key sends the
475
+ # raw device result through OpenSSL's hardened padding implementation.
476
+ dummy = OpenSSL::PKey::RSA.generate(ciphertext.bytesize * 8)
477
+ wrapped = dummy.encrypt(padded, 'rsa_padding_mode' => 'none')
478
+ options = { 'rsa_padding_mode' => padding == :oaep ? 'oaep' : 'pkcs1' }
479
+ options['rsa_pkcs1_implicit_rejection'] = '0' if padding == :pkcs1 && OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30200000
480
+ if padding == :oaep
481
+ options['rsa_oaep_md'] = digest
482
+ options['rsa_mgf1_md'] = @opts.fetch(:mgf1_hash, digest)
483
+ options['rsa_oaep_label'] = @opts[:label].b.unpack1('H*') if @opts[:label]
484
+ end
485
+ dummy.decrypt(wrapped, options)
486
+ rescue OpenSSL::OpenSSLError
487
+ raise ResponseError, 'RSA decryption failed', cause: nil
488
+ end
489
+
490
+ def calculate_secret(identifier, peer)
491
+ selected = slot(identifier)
492
+ public_part = parse_key(peer)
493
+ algorithm = key_algorithm(public_part)
494
+ raise ArgumentError, 'Key agreement requires P-256, P-384 or X25519' unless %w[ECCP256 ECCP384 X25519].include?(algorithm)
495
+
496
+ confirm!
497
+ policies(algorithm, generate: false)
498
+ bytes = algorithm == 'X25519' ? public_part.raw_public_key : public_part.public_key.to_octet_string(:uncompressed)
499
+ crypto_pin
500
+ result = private_operation(selected, algorithm, bytes, exponentiation: true)
501
+ raise ResponseError, 'Invalid key agreement result' unless result.bytesize == ALGORITHMS.fetch(algorithm)[1] && result.bytes.any?(&:positive?)
502
+
503
+ result
504
+ end
505
+
506
+ def generate_key(identifier)
507
+ selected = slot(identifier)
508
+ algorithm = enum_name(@opts.fetch(:algorithm, 'RSA2048'), ALGORITHMS)
509
+ policy = policies(algorithm, generate: true)
510
+ confirm!
511
+ admin_authenticate
512
+ response = apdu(0x47, p2: selected, data: tlv(0xac, tlv(0x80, [ALGORITHMS.fetch(algorithm)[0]].pack('C')) + policy))
513
+ public_key(algorithm, unpack(0x7f49, response))
514
+ end
515
+
516
+ def import_key(identifier, bytes)
517
+ selected = slot(identifier)
518
+ confirm!
519
+ key = parse_key(bytes, private_key: true)
520
+ algorithm = key_algorithm(key)
521
+ policy = policies(algorithm, generate: false)
522
+ id, length = ALGORITHMS.fetch(algorithm)
523
+ data = if key.is_a?(OpenSSL::PKey::RSA)
524
+ raise ArgumentError, 'RSA public exponent must be 65537' unless key.e.to_i == 65_537
525
+
526
+ [key.p, key.q, key.dmp1, key.dmq1, key.iqmp].each_with_index.map { |value, index| tlv(index + 1, number_bytes(value.to_i, length / 2)) }.join.b
527
+ elsif key.is_a?(OpenSSL::PKey::EC)
528
+ tlv(6, number_bytes(key.private_key.to_i, length))
529
+ else
530
+ tlv(algorithm == 'ED25519' ? 7 : 8, key.raw_private_key)
531
+ end
532
+ admin_authenticate
533
+ apdu(0xfe, p1: id, p2: selected, data: data + policy)
534
+ if version_at_least?([5, 3, 0])
535
+ observed = metadata(selected).fetch(:public_key)
536
+ raise ResponseError, 'Imported public key readback mismatch' unless observed.public_to_der == key.public_to_der
537
+ end
538
+ { status: :imported, slot: selected, algorithm: algorithm }
539
+ end
540
+
541
+ def export_key(identifier)
542
+ selected = slot(identifier)
543
+ source = @opts.fetch(:public_key_source, :metadata).to_sym
544
+ key = case source
545
+ when :metadata then metadata(selected).fetch(:public_key)
546
+ when :attestation then attest(selected).public_key
547
+ when :certificate then read_certificate(selected).public_key
548
+ else raise ArgumentError, 'public_key_source must be metadata, attestation or certificate; private keys cannot be exported'
549
+ end
550
+ verify_key(selected, key) if @opts[:verify]
551
+ key
552
+ end
553
+
554
+ def attest(identifier)
555
+ selected = slot(identifier)
556
+ require_version([4, 3, 0])
557
+ OpenSSL::X509::Certificate.new(apdu(0xf9, p1: selected))
558
+ rescue OpenSSL::X509::CertificateError
559
+ raise ResponseError, 'Malformed PIV attestation certificate', cause: nil
560
+ end
561
+
562
+ def move_key(source, destination)
563
+ from = slot(source)
564
+ to = destination.nil? ? 0xff : slot(destination)
565
+ raise ArgumentError, 'Source and destination slots must differ' if from == to
566
+
567
+ confirm!
568
+ require_version([5, 7, 0])
569
+ original = metadata(from).fetch(:public_key)
570
+ admin_authenticate
571
+ apdu(0xf6, p1: to, p2: from)
572
+ begin
573
+ metadata(from)
574
+ raise ResponseError, 'PIV source key still present after move/delete'
575
+ rescue Error => e
576
+ raise unless status_word(e) == 0x6a88
577
+ end
578
+ raise ResponseError, 'PIV moved key readback mismatch' if destination && metadata(to).fetch(:public_key).public_to_der != original.public_to_der
579
+
580
+ { status: destination ? :moved : :deleted, source: from, destination: destination ? to : nil }
581
+ end
582
+
583
+ def reset
584
+ confirm!
585
+ begin
586
+ bio = metadata(0x96)
587
+ raise UnsupportedError, 'Cannot reset PIV with enrolled biometrics; a full-device reset is required' if bio[:configured]
588
+ rescue UnsupportedError
589
+ raise if bio
590
+ rescue Error => e
591
+ raise unless [0x6a88, 0x6d00].include?(status_word(e))
592
+ end
593
+ if @opts[:block_retries] == true
594
+ block_reference(0x20, "\xff".b * 8)
595
+ block_reference(0x2c, "\xff".b * 16)
596
+ end
597
+ apdu(0xfb)
598
+ @management_algorithm = nil
599
+ raise ResponseError, 'PIV reset metadata is not default' if version_at_least?([5, 3, 0]) && !(metadata(0x80).fetch(:default) && metadata(0x9b).fetch(:default))
600
+
601
+ { status: :reset, blocked_retries_authorized: @opts[:block_retries] == true }
602
+ end
603
+
604
+ def info
605
+ result = { version: version, slots: {} }
606
+ result[:serial] = apdu(0xf8).unpack1('H*').to_i(16) if version_at_least?([5, 0, 3])
607
+ if version_at_least?([5, 3, 0])
608
+ result[:pin] = metadata(0x80)
609
+ result[:management_key] = metadata(0x9b)
610
+ begin
611
+ result[:biometrics] = metadata(0x96)
612
+ rescue Error => e
613
+ raise unless [0x6a88, 0x6d00].include?(status_word(e))
614
+
615
+ result[:puk] = metadata(0x81)
616
+ end
617
+ else
618
+ begin
619
+ reference_apdu(0x20, p2: 0x80)
620
+ result[:pin] = { verified: true, attempts_remaining: nil }
621
+ rescue PinError => e
622
+ result[:pin] = { attempts_remaining: e.attempts_remaining }
623
+ end
624
+ result[:management_key] = { algorithm: 'TDES' }
625
+ end
626
+ data = pivman
627
+ result[:management_key][:protected] = data.key?(0x82) || data.fetch(0x81, "\x00".b).getbyte(0).anybits?(2)
628
+ result[:chuid] = optional_object(0x5fc102)
629
+ result[:ccc] = optional_object(0x5fc107)
630
+ SLOTS.values.reject { |selected| selected == 0xf9 }.each do |selected|
631
+ entry = {}
632
+ if version_at_least?([5, 3, 0])
633
+ begin
634
+ entry[:key] = metadata(selected)
635
+ rescue Error => e
636
+ raise unless status_word(e) == 0x6a88
637
+ end
638
+ end
639
+ begin
640
+ certificate = read_certificate(selected)
641
+ entry[:certificate] = { subject: certificate.subject.to_s, issuer: certificate.issuer.to_s, serial: certificate.serial.to_i,
642
+ not_before: certificate.not_before, not_after: certificate.not_after, sha256: OpenSSL::Digest.hexdigest('SHA256', certificate.to_der) }
643
+ rescue Error => e
644
+ raise unless status_word(e) == 0x6a82
645
+ end
646
+ result[:slots][format('%02x', selected)] = entry unless entry.empty?
647
+ end
648
+ result
649
+ end
650
+
651
+ def read_certificate(identifier)
652
+ fields = Protocol.tlv_map(data: read_object(certificate_object(slot(identifier))))
653
+ bytes = field(fields, 0x70)
654
+ compression = fields.fetch(0x71, "\x00".b)
655
+ raise ResponseError, 'Unsupported certificate compression' unless ["\x00".b, "\x01".b].include?(compression)
656
+
657
+ bytes = decompress(bytes) if compression == "\x01".b
658
+ OpenSSL::X509::Certificate.new(bytes)
659
+ rescue OpenSSL::X509::CertificateError
660
+ raise ResponseError, 'Malformed PIV certificate', cause: nil
661
+ end
662
+
663
+ def import_certificate(identifier, bytes)
664
+ selected = slot(identifier)
665
+ confirm!
666
+ certificate = parse_certificate(bytes)
667
+ admin_authenticate
668
+ verify_key(selected, certificate.public_key) if @opts[:verify]
669
+ store_certificate(selected, certificate)
670
+ end
671
+
672
+ def delete_certificate(identifier)
673
+ selected = slot(identifier)
674
+ confirm!
675
+ admin_authenticate
676
+ result = write_object(certificate_object(selected), ''.b)
677
+ result[:chuid] = update_chuid unless @opts[:no_update_chuid] || @opts[:update_chuid] == false
678
+ result
679
+ end
680
+
681
+ def generate_certificate(identifier, bytes)
682
+ selected = slot(identifier)
683
+ confirm!
684
+ key = bytes ? parse_key(bytes) : export_key(selected)
685
+ algorithm = key_algorithm(key)
686
+ raise ArgumentError, 'X25519 cannot self-sign a certificate' if algorithm == 'X25519'
687
+
688
+ subject = certificate_subject
689
+ days = integer(@opts.fetch(:valid_days, 365), 1..36_500)
690
+ not_before = @opts.fetch(:not_before, Time.now.utc)
691
+ not_after = @opts.fetch(:not_after, not_before + (days * 86_400))
692
+ raise ArgumentError, 'Invalid certificate validity times' unless not_before.is_a?(Time) && not_after.is_a?(Time) && not_after > not_before
693
+
694
+ asn1 = OpenSSL::ASN1
695
+ signature_algorithm = signature_identifier(algorithm)
696
+ serial = @opts.fetch(:certificate_serial, SecureRandom.random_number((2**159) - 1) + 1)
697
+ raise ArgumentError, 'Certificate serial must be a positive integer under 160 bits' unless serial.is_a?(Integer) && serial.positive? && serial.bit_length <= 159
698
+
699
+ fields = [asn1::ASN1Data.new([asn1::Integer.new(2)], 0, :CONTEXT_SPECIFIC), asn1::Integer.new(serial), signature_algorithm,
700
+ asn1.decode(subject.to_der), asn1::Sequence.new([asn1_time(not_before), asn1_time(not_after)]),
701
+ asn1.decode(subject.to_der), asn1.decode(key.public_to_der)]
702
+ extensions = @opts.fetch(:extensions, [])
703
+ raise ArgumentError, 'extensions must be an Array of X509 extensions' unless extensions.is_a?(Array) && extensions.all?(OpenSSL::X509::Extension)
704
+
705
+ fields << asn1::ASN1Data.new([asn1::Sequence.new(extensions.map { |ext| asn1.decode(ext.to_der) })], 3, :CONTEXT_SPECIFIC) unless extensions.empty?
706
+ tbs = asn1::Sequence.new(fields)
707
+ admin_authenticate
708
+ signature = sign(selected, tbs.to_der, algorithm: algorithm)
709
+ certificate = OpenSSL::X509::Certificate.new(asn1::Sequence.new([tbs, signature_algorithm, asn1::BitString.new(signature)]).to_der)
710
+ raise ResponseError, 'Device-signed certificate does not verify against supplied public key' unless certificate.verify(key)
711
+
712
+ store_certificate(selected, certificate).merge(certificate: encode(certificate))
713
+ end
714
+
715
+ def certificate_request(identifier, bytes)
716
+ selected = slot(identifier)
717
+ confirm!
718
+ key = parse_key(bytes)
719
+ algorithm = key_algorithm(key)
720
+ raise ArgumentError, 'X25519 cannot sign a CSR' if algorithm == 'X25519'
721
+
722
+ asn1 = OpenSSL::ASN1
723
+ info = asn1::Sequence.new([asn1::Integer.new(0), asn1.decode(certificate_subject.to_der), asn1.decode(key.public_to_der), asn1::ASN1Data.new([], 0, :CONTEXT_SPECIFIC)])
724
+ signature_algorithm = signature_identifier(algorithm)
725
+ signature = sign(selected, info.to_der, algorithm: algorithm)
726
+ csr = OpenSSL::X509::Request.new(asn1::Sequence.new([info, signature_algorithm, asn1::BitString.new(signature)]).to_der)
727
+ raise ResponseError, 'Device-signed CSR does not verify against supplied public key' unless csr.verify(key)
728
+
729
+ csr
730
+ end
731
+
732
+ def version
733
+ @version ||= begin
734
+ response = apdu(0xfd)
735
+ raise ResponseError, 'Invalid PIV version response' unless response.bytesize == 3
736
+
737
+ response.bytes
738
+ end
739
+ end
740
+
741
+ def metadata(reference)
742
+ require_version([5, 3, 0])
743
+ fields = Protocol.tlv_map(data: apdu(0xf7, p2: reference))
744
+ case reference
745
+ when 0x80, 0x81
746
+ attempts = field(fields, 6, 2).bytes
747
+ { default: field(fields, 5, 1) != "\x00".b, total_attempts: attempts[0], attempts_remaining: attempts[1] }
748
+ when 0x9b
749
+ id = fields.fetch(1, "\x03".b).getbyte(0)
750
+ name = MANAGEMENT.keys.find { |key| MANAGEMENT[key][0] == id }
751
+ raise ResponseError, 'Unknown management algorithm' unless name
752
+
753
+ { algorithm: name, default: field(fields, 5, 1) != "\x00".b, touch_policy: field(fields, 2, 2).getbyte(1) }
754
+ when 0x96
755
+ { configured: field(fields, 7, 1).getbyte(0) == 1, attempts_remaining: field(fields, 6, 1).getbyte(0), temporary_pin: fields.fetch(8, "\x00".b).getbyte(0) == 1 }
756
+ else
757
+ id = field(fields, 1, 1).getbyte(0)
758
+ algorithm = ALGORITHMS.keys.find { |key| ALGORITHMS[key][0] == id }
759
+ raise ResponseError, 'Unknown PIV key algorithm' unless algorithm
760
+
761
+ policy = field(fields, 2, 2).bytes
762
+ { algorithm: algorithm, pin_policy: PIN_POLICIES.key(policy[0]), touch_policy: TOUCH_POLICIES.key(policy[1]),
763
+ generated: field(fields, 3, 1).getbyte(0) == 1, public_key: public_key(algorithm, field(fields, 4)) }
764
+ end
765
+ end
766
+
767
+ private
768
+
769
+ def biometric_support
770
+ metadata(0x96)
771
+ rescue Error => e
772
+ raise UnsupportedError, 'Biometric verification is not supported by this PIV device', cause: nil if [0x6a88, 0x6d00].include?(status_word(e))
773
+
774
+ raise
775
+ end
776
+
777
+ def validate_options
778
+ flags = %i[protect generate touch use_protected_key verify compress update_chuid no_update_chuid block_retries prehashed fips_restrictions]
779
+ flags.each do |flag|
780
+ raise ArgumentError, "#{flag} must be a Boolean" if @opts.key?(flag) && ![true, false].include?(@opts[flag])
781
+ end
782
+ raise ArgumentError, 'Conflicting CHUID update options' if @opts[:update_chuid] && @opts[:no_update_chuid]
783
+ raise ArgumentError, 'format must be PEM or DER' if @opts[:format] && !%w[PEM DER].include?(@opts[:format].to_s.upcase)
784
+ raise ArgumentError, 'label must be binary String' if @opts[:label] && !@opts[:label].is_a?(String)
785
+ raise ArgumentError, 'mgf1_hash must be SHA1, SHA256, SHA384 or SHA512' if @opts[:mgf1_hash] && !%w[SHA1 SHA256 SHA384 SHA512].include?(@opts[:mgf1_hash].to_s.upcase)
786
+
787
+ digest_name if @opts[:hash_algorithm]
788
+ end
789
+
790
+ def block_reference(ins, data)
791
+ # Only reachable with BOTH confirm:true and block_retries:true. Empty
792
+ # padded references are deliberately invalid, never factory guesses.
793
+ previous = 256
794
+ 256.times do
795
+ reference_apdu(ins, p2: 0x80, data: data)
796
+ raise ResponseError, 'Unexpected successful blocking attempt; reset stopped'
797
+ rescue PinError => e
798
+ return :blocked if e.attempts_remaining.zero?
799
+ # Firmware caps SW retries at 15; allow that plateau only.
800
+ raise ResponseError, 'PIN/PUK retry counter did not decrease; reset stopped' if e.attempts_remaining > previous || (e.attempts_remaining == previous && previous != 15)
801
+
802
+ previous = e.attempts_remaining
803
+ end
804
+ raise ResponseError, 'PIV blocking limit exceeded; reset stopped'
805
+ end
806
+
807
+ def certificate_object(selected)
808
+ return 0x5fc10d + selected - 0x82 if (0x82..0x95).cover?(selected)
809
+
810
+ { 0x9a => 0x5fc105, 0x9c => 0x5fc10a, 0x9d => 0x5fc10b, 0x9e => 0x5fc101, 0xf9 => 0x5fff01 }.fetch(selected)
811
+ end
812
+
813
+ def store_certificate(selected, certificate)
814
+ bytes = certificate.to_der
815
+ if @opts[:compress]
816
+ buffer = StringIO.new(''.b)
817
+ gzip = Zlib::GzipWriter.new(buffer)
818
+ gzip.write(bytes)
819
+ gzip.close
820
+ bytes = buffer.string
821
+ end
822
+ result = write_object(certificate_object(selected), tlv(0x70, bytes) + tlv(0x71, @opts[:compress] ? "\x01".b : "\x00".b) + tlv(0xfe))
823
+ result[:chuid] = update_chuid unless @opts[:no_update_chuid] || @opts[:update_chuid] == false
824
+ result
825
+ end
826
+
827
+ def update_chuid
828
+ original = optional_object(0x5fc102)
829
+ if original && !original.empty?
830
+ begin
831
+ fields = Protocol.tlv_map(data: original)
832
+ return :preserved unless fields.key?(0x30) && fields.key?(0x35) && fields[0x34]&.bytesize == 16 && fields[0x3e] == ''.b
833
+
834
+ fields[0x34] = [SecureRandom.uuid.delete('-')].pack('H*')
835
+ replacement = fields.map { |tag, data| tlv(tag, data) }.join.b
836
+ rescue ResponseError
837
+ return :preserved
838
+ end
839
+ else
840
+ replacement = chuid
841
+ end
842
+ write_object(0x5fc102, replacement)
843
+ :updated
844
+ end
845
+
846
+ def decompress(bytes)
847
+ limit = 65_536
848
+ if bytes.start_with?("\x1f\x8b".b)
849
+ reader = Zlib::GzipReader.new(StringIO.new(bytes))
850
+ begin
851
+ result = reader.read(limit + 1)
852
+ ensure
853
+ reader.close
854
+ end
855
+ elsif bytes.start_with?("\x01\x00".b) && bytes.bytesize >= 4
856
+ expected = bytes.byteslice(2, 2).unpack1('v')
857
+ inflater = Zlib::Inflate.new
858
+ begin
859
+ result = ''.b
860
+ inflater.inflate(bytes.byteslice(4..)) do |chunk|
861
+ result << chunk
862
+ raise ResponseError, 'Decompressed certificate exceeds limit' if result.bytesize > limit
863
+ end
864
+ raise ResponseError, 'Compressed certificate length mismatch' unless result.bytesize == expected && inflater.finished?
865
+ ensure
866
+ inflater.close
867
+ end
868
+ else
869
+ raise ResponseError, 'Unknown certificate compression format'
870
+ end
871
+ raise ResponseError, 'Decompressed certificate exceeds limit' if result.bytesize > limit
872
+
873
+ result
874
+ rescue Zlib::Error, EOFError
875
+ raise ResponseError, 'Invalid compressed certificate', cause: nil
876
+ end
877
+
878
+ def parse_certificate(bytes)
879
+ return bytes if bytes.is_a?(OpenSSL::X509::Certificate)
880
+
881
+ blocks = bytes.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m)
882
+ certificates = if blocks.empty?
883
+ begin
884
+ [OpenSSL::X509::Certificate.new(bytes)]
885
+ rescue OpenSSL::X509::CertificateError
886
+ archive = OpenSSL::PKCS12.new(bytes, @opts[:password] || '')
887
+ [archive.certificate, *archive.ca_certs].compact
888
+ end
889
+ else
890
+ blocks.map { |block| OpenSSL::X509::Certificate.new(block) }
891
+ end
892
+ leaves = certificates.reject { |candidate| certificates.any? { |other| other != candidate && other.issuer.to_der == candidate.subject.to_der } }
893
+ raise ArgumentError, 'Certificate input must contain one unambiguous leaf' unless leaves.size == 1
894
+
895
+ leaves.first
896
+ rescue OpenSSL::OpenSSLError
897
+ raise ArgumentError, 'Invalid certificate or PKCS12 password', cause: nil
898
+ end
899
+
900
+ def certificate_subject
901
+ value = @opts[:subject]
902
+ return value if value.is_a?(OpenSSL::X509::Name)
903
+ raise ArgumentError, 'subject is required' unless value.is_a?(String) && !value.empty?
904
+
905
+ value = "CN=#{value}" unless value.include?('=')
906
+ OpenSSL::X509::Name.parse_rfc2253(value)
907
+ rescue OpenSSL::X509::NameError
908
+ raise ArgumentError, 'Invalid RFC4514 certificate subject', cause: nil
909
+ end
910
+
911
+ def signature_identifier(algorithm)
912
+ raise ArgumentError, 'Certificates and CSRs use PKCS1 signature padding' if @opts[:padding] && @opts[:padding].to_sym != :pkcs1
913
+ raise ArgumentError, 'Certificates and CSRs cannot sign prehashed inputs' if @opts[:prehashed]
914
+
915
+ asn1 = OpenSSL::ASN1
916
+ digest = digest_name
917
+ oid = if algorithm == 'ED25519'
918
+ 'ED25519'
919
+ elsif algorithm.start_with?('RSA')
920
+ "#{digest.downcase}WithRSAEncryption"
921
+ else
922
+ "ecdsa-with-#{digest}"
923
+ end
924
+ values = [asn1::ObjectId.new(oid)]
925
+ values << asn1::Null.new(nil) if algorithm.start_with?('RSA')
926
+ asn1::Sequence.new(values)
927
+ end
928
+
929
+ def asn1_time(time)
930
+ (1950..2049).cover?(time.year) ? OpenSSL::ASN1::UTCTime.new(time.utc) : OpenSSL::ASN1::GeneralizedTime.new(time.utc)
931
+ end
932
+
933
+ def crypto_pin
934
+ verify_pin if @opts[:pin] || @opts[:secret_provider]
935
+ end
936
+
937
+ def private_operation(selected, algorithm, bytes, exponentiation: false)
938
+ confirm!
939
+ unpack(0x82, unpack(0x7c, apdu(0x87, p1: ALGORITHMS.fetch(algorithm)[0], p2: selected, data: tlv(0x7c, tlv(0x82) + tlv(exponentiation ? 0x85 : 0x81, bytes)))))
940
+ end
941
+
942
+ def digest_name
943
+ name = @opts.fetch(:hash_algorithm, 'SHA256').to_s.upcase
944
+ raise ArgumentError, 'hash_algorithm must be SHA1, SHA256, SHA384 or SHA512' unless %w[SHA1 SHA256 SHA384 SHA512].include?(name)
945
+
946
+ name
947
+ end
948
+
949
+ def signature_input(algorithm, message)
950
+ return message if algorithm == 'ED25519'
951
+
952
+ digest = digest_name
953
+ hashed = @opts[:prehashed] ? message : OpenSSL::Digest.digest(digest, message)
954
+ raise ArgumentError, 'Prehashed message has the wrong digest size' unless hashed.bytesize == OpenSSL::Digest.new(digest).digest_length
955
+
956
+ size = ALGORITHMS.fetch(algorithm)[1]
957
+ return hashed.byteslice(0, size).rjust(size, "\x00".b) if algorithm.start_with?('ECC')
958
+
959
+ padding = @opts.fetch(:padding, :pkcs1).to_sym
960
+ return pss_encode(hashed, size, digest) if padding == :pss
961
+ raise ArgumentError, 'RSA signature padding must be pkcs1 or pss' unless padding == :pkcs1
962
+
963
+ asn1 = OpenSSL::ASN1
964
+ info = asn1::Sequence.new([asn1::Sequence.new([asn1::ObjectId.new(digest), asn1::Null.new(nil)]), asn1::OctetString.new(hashed)]).to_der
965
+ raise ArgumentError, 'Digest too large for RSA signature' if size < info.bytesize + 11
966
+
967
+ "\x00\x01".b + ("\xff".b * (size - info.bytesize - 3)) + "\x00".b + info
968
+ end
969
+
970
+ def pss_encode(hashed, size, digest)
971
+ salt_length = integer(@opts.fetch(:salt_length, hashed.bytesize), 0..(size - hashed.bytesize - 2))
972
+ salt = SecureRandom.random_bytes(salt_length)
973
+ h = OpenSSL::Digest.digest(digest, ("\x00".b * 8) + hashed + salt)
974
+ data = ("\x00".b * (size - hashed.bytesize - salt_length - 2)) + "\x01".b + salt
975
+ masked = xor_bytes(data, mgf1(h, data.bytesize, @opts.fetch(:mgf1_hash, digest)))
976
+ masked.setbyte(0, masked.getbyte(0) & 0x7f)
977
+ masked + h + "\xbc".b
978
+ end
979
+
980
+ def mgf1(seed, length, digest)
981
+ output = ''.b
982
+ counter = 0
983
+ while output.bytesize < length
984
+ output << OpenSSL::Digest.digest(digest, seed + [counter].pack('N'))
985
+ counter += 1
986
+ end
987
+ output.byteslice(0, length)
988
+ end
989
+
990
+ def xor_bytes(left, right)
991
+ left.bytes.zip(right.bytes).map { |a, b| a ^ b }.pack('C*')
992
+ end
993
+
994
+ def verify_key(selected, key)
995
+ confirm!
996
+ algorithm = key_algorithm(key)
997
+ if algorithm == 'X25519'
998
+ peer = OpenSSL::PKey.generate_key('X25519')
999
+ shared = calculate_secret(selected, OpenSSL::PKey.read(peer.public_to_der))
1000
+ expected = peer.derive(key)
1001
+ valid = OpenSSL.fixed_length_secure_compare(shared, expected)
1002
+ else
1003
+ challenge = SecureRandom.random_bytes(32)
1004
+ signature = sign(selected, challenge, algorithm: algorithm)
1005
+ valid = key.verify(algorithm == 'ED25519' ? nil : digest_name, signature, challenge)
1006
+ end
1007
+ raise ResponseError, 'Public key does not match the device private key' unless valid
1008
+
1009
+ :verified
1010
+ end
1011
+
1012
+ def slot(value)
1013
+ return value if value.is_a?(Integer) && SLOTS.value?(value)
1014
+
1015
+ name = value.to_s.upcase.tr('-', '_')
1016
+ return SLOTS.fetch(name) if SLOTS.key?(name)
1017
+ raise ArgumentError, 'Invalid PIV key slot' unless value.is_a?(String) && value.match?(/\A(?:0x)?[0-9a-fA-F]{2}\z/) && SLOTS.value?(value.to_i(16))
1018
+
1019
+ value.to_i(16)
1020
+ end
1021
+
1022
+ def policies(algorithm, generate:)
1023
+ pin = PIN_POLICIES.fetch(enum_name(@opts.fetch(:pin_policy, 'DEFAULT'), PIN_POLICIES))
1024
+ touch = TOUCH_POLICIES.fetch(enum_name(@opts.fetch(:touch_policy, 'DEFAULT'), TOUCH_POLICIES))
1025
+ require_version([4, 0, 0]) if algorithm == 'ECCP384' || pin.positive? || touch.positive?
1026
+ require_version([4, 3, 0]) if touch == 3
1027
+ require_version([5, 7, 0]) if %w[RSA3072 RSA4096 ED25519 X25519].include?(algorithm)
1028
+ raise UnsupportedError, 'RSA generation is disabled for ROCA-vulnerable firmware' if generate && algorithm.start_with?('RSA') && version_at_least?([4, 2, 0]) && !version_at_least?([4, 3, 5])
1029
+
1030
+ raise UnsupportedError, 'Algorithm or PIN policy forbidden by FIPS restrictions' if (@opts[:fips_restrictions] || (version_at_least?([4, 4, 0]) && !version_at_least?([4, 5, 0]))) && (%w[RSA1024 X25519].include?(algorithm) || pin == 1)
1031
+
1032
+ if pin >= 4
1033
+ begin
1034
+ metadata(0x96)
1035
+ rescue Error => e
1036
+ raise UnsupportedError, 'MATCH policies require biometric PIV firmware', cause: nil if [0x6a88, 0x6d00].include?(status_word(e))
1037
+
1038
+ raise
1039
+ end
1040
+ end
1041
+ (pin.positive? ? tlv(0xaa, [pin].pack('C')) : ''.b) + (touch.positive? ? tlv(0xab, [touch].pack('C')) : ''.b)
1042
+ end
1043
+
1044
+ def public_key(algorithm, encoded)
1045
+ fields = Protocol.tlv_map(data: encoded)
1046
+ asn1 = OpenSSL::ASN1
1047
+ if algorithm.start_with?('RSA')
1048
+ n = OpenSSL::BN.new(field(fields, 0x81), 2)
1049
+ e = OpenSSL::BN.new(field(fields, 0x82), 2)
1050
+ rsa = asn1::Sequence.new([asn1::Integer.new(n), asn1::Integer.new(e)]).to_der
1051
+ der = asn1::Sequence.new([asn1::Sequence.new([asn1::ObjectId.new('rsaEncryption'), asn1::Null.new(nil)]), asn1::BitString.new(rsa)]).to_der
1052
+ else
1053
+ point = field(fields, 0x86)
1054
+ oids = case algorithm
1055
+ when 'ECCP256' then %w[id-ecPublicKey prime256v1]
1056
+ when 'ECCP384' then %w[id-ecPublicKey secp384r1]
1057
+ else [algorithm]
1058
+ end
1059
+ der = asn1::Sequence.new([asn1::Sequence.new(oids.map { |oid| asn1::ObjectId.new(oid) }), asn1::BitString.new(point)]).to_der
1060
+ end
1061
+ key = OpenSSL::PKey.read(der)
1062
+ raise ResponseError, 'Public key algorithm mismatch' unless key_algorithm(key) == algorithm
1063
+
1064
+ key
1065
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error
1066
+ raise ResponseError, 'Malformed PIV public key', cause: nil
1067
+ end
1068
+
1069
+ def key_algorithm(key)
1070
+ name = if key.is_a?(OpenSSL::PKey::RSA)
1071
+ "RSA#{key.n.num_bits}"
1072
+ elsif key.is_a?(OpenSSL::PKey::EC)
1073
+ { 'prime256v1' => 'ECCP256', 'secp384r1' => 'ECCP384' }.fetch(key.group.curve_name, '')
1074
+ else
1075
+ key.oid
1076
+ end
1077
+ enum_name(name, ALGORITHMS)
1078
+ end
1079
+
1080
+ def parse_key(bytes, private_key: false)
1081
+ return bytes if bytes.is_a?(OpenSSL::PKey::PKey) && !private_key
1082
+
1083
+ password = @opts[:password]
1084
+ password ||= secret(:password) if bytes.is_a?(String) && bytes.include?('ENCRYPTED') && @opts[:secret_provider]
1085
+ key = if bytes.is_a?(OpenSSL::PKey::PKey)
1086
+ bytes
1087
+ else
1088
+ begin
1089
+ OpenSSL::PKey.read(bytes, password || '')
1090
+ rescue OpenSSL::PKey::PKeyError
1091
+ raise unless private_key
1092
+
1093
+ OpenSSL::PKCS12.new(bytes, password || '').key || (raise ArgumentError, 'PKCS12 contains no private key')
1094
+ end
1095
+ end
1096
+ key.private_to_der if private_key
1097
+ key_algorithm(key)
1098
+ key
1099
+ rescue OpenSSL::OpenSSLError
1100
+ raise ArgumentError, 'Invalid key data or key password; private import requires a private key', cause: nil
1101
+ end
1102
+
1103
+ def encode(value)
1104
+ encoding = @opts.fetch(:format, 'PEM').to_s.upcase
1105
+ raise ArgumentError, 'format must be PEM or DER' unless %w[PEM DER].include?(encoding)
1106
+
1107
+ if value.is_a?(OpenSSL::PKey::PKey)
1108
+ encoding == 'PEM' ? value.public_to_pem : value.public_to_der
1109
+ else
1110
+ encoding == 'PEM' ? value.to_pem : value.to_der
1111
+ end
1112
+ end
1113
+
1114
+ def input(path)
1115
+ data = path == '-' ? @opts[:input] : File.binread(path)
1116
+ raise ArgumentError, 'Binary input is required in opts[:input] for -' unless data.is_a?(String)
1117
+
1118
+ data.b
1119
+ end
1120
+
1121
+ def output(bytes, path)
1122
+ return bytes if path.nil? || path == '-'
1123
+
1124
+ confirm!
1125
+ # Never clobber a caller's existing file on an uncertain operation.
1126
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
1127
+ file.binmode
1128
+ file.write(bytes)
1129
+ file.flush
1130
+ file.fsync
1131
+ end
1132
+ raise IOError, 'PIV export readback mismatch' unless File.binread(path) == bytes
1133
+
1134
+ bytes
1135
+ end
1136
+
1137
+ def enum_name(value, table)
1138
+ name = value.to_s.upcase.tr('-', '_')
1139
+ raise ArgumentError, 'Invalid PIV algorithm or policy' unless table.key?(name)
1140
+
1141
+ name
1142
+ end
1143
+
1144
+ def parse_object_id(value)
1145
+ return value if value.is_a?(Integer) && (1..0xffffff).cover?(value)
1146
+
1147
+ name = value.to_s.upcase.tr('-', '_')
1148
+ return OBJECTS.fetch(name) if OBJECTS.key?(name)
1149
+ raise ArgumentError, 'Invalid PIV object identifier' unless value.is_a?(String) && value.match?(/\A(?:0x)?[0-9a-fA-F]{1,6}\z/)
1150
+
1151
+ number = value.to_i(16)
1152
+ raise ArgumentError, 'Invalid PIV object identifier' if number.zero?
1153
+
1154
+ number
1155
+ end
1156
+
1157
+ def number_bytes(number, length = nil)
1158
+ bytes = [number.to_s(16).rjust(2, '0').then { |hex| hex.length.odd? ? "0#{hex}" : hex }].pack('H*')
1159
+ length ? bytes.rjust(length, "\x00".b) : bytes
1160
+ end
1161
+
1162
+ def optional_object(id)
1163
+ read_object(id)
1164
+ rescue Error => e
1165
+ raise unless status_word(e) == 0x6a82
1166
+
1167
+ nil
1168
+ end
1169
+
1170
+ def pivman
1171
+ bytes = optional_object(0x5fff00)
1172
+ bytes.nil? || bytes.empty? ? {} : Protocol.tlv_map(data: unpack(0x80, bytes))
1173
+ end
1174
+
1175
+ def protected_pivman
1176
+ bytes = optional_object(0x5fc109)
1177
+ bytes.nil? || bytes.empty? ? {} : Protocol.tlv_map(data: unpack(0x88, bytes))
1178
+ end
1179
+
1180
+ def container(tag, fields)
1181
+ tlv(tag, fields.map { |key, value| tlv(key, value) }.join.b)
1182
+ end
1183
+
1184
+ def admin_authenticate
1185
+ return authenticate unless @opts[:use_protected_key] == true
1186
+
1187
+ confirm!
1188
+ pin = secret(:pin)
1189
+ pin_bytes(pin)
1190
+ data = pivman
1191
+ verify_pin(pin)
1192
+ key = if data.key?(0x82)
1193
+ OpenSSL::PKCS5.pbkdf2_hmac(pin, data.fetch(0x82), 10_000, 24, 'SHA1')
1194
+ elsif data.fetch(0x81, "\x00".b).getbyte(0).anybits?(2)
1195
+ protected_pivman.fetch(0x89) { raise ResponseError, 'Protected management key is absent' }
1196
+ else
1197
+ raise ArgumentError, 'No protected management key is configured'
1198
+ end
1199
+ authenticate(key)
1200
+ end
1201
+
1202
+ def set_management_key(name, key, touch: false)
1203
+ apdu(0xff, p1: 0xff, p2: touch ? 0xfe : 0xff, data: [MANAGEMENT.fetch(name)[0]].pack('C') + tlv(0x9b, key))
1204
+ @management_algorithm = name
1205
+ end
1206
+
1207
+ def chuid
1208
+ # Non-federal issuer FASC-N from the SP 800-73 CHUID layout.
1209
+ digits = %w[9999 9999 999999 0 1 0000000000 3 0000 1]
1210
+ pieces = digits.map do |value|
1211
+ value.chars.map do |char|
1212
+ bits = char.to_i.to_s(2).rjust(4, '0').reverse
1213
+ bits + ((bits.count('1') + 1) % 2).to_s
1214
+ end.join
1215
+ end
1216
+ bits = "11010#{pieces.first(5).join('10110')}10110#{pieces.drop(5).join}11111"
1217
+ lrc = bits.scan(/.{5}/).map { |part| part.to_i(2) }.reduce(0, :^)
1218
+ fascn = [bits + lrc.to_s(2).rjust(5, '0')].pack('B*')
1219
+ guid = [SecureRandom.uuid.delete('-')].pack('H*')
1220
+ tlv(0x30, fascn) + tlv(0x34, guid) + tlv(0x35, '20300101') + tlv(0x3e) + tlv(0xfe)
1221
+ end
1222
+
1223
+ def ccc
1224
+ tlv(0xf0, ['a000000116ff02'].pack('H*') + SecureRandom.random_bytes(14)) + tlv(0xf1, "\x21".b) + tlv(0xf2, "\x21".b) + tlv(0xf3) + tlv(0xf4, "\x00".b) + tlv(0xf5, "\x10".b) + [0xf6, 0xf7, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe].map { |tag| tlv(tag) }.join.b
1225
+ end
1226
+
1227
+ def field(fields, tag, size = nil)
1228
+ value = fields[tag]
1229
+ raise ResponseError, 'Missing or malformed PIV response field' unless value.is_a?(String) && (size.nil? || value.bytesize == size)
1230
+
1231
+ value
1232
+ end
1233
+
1234
+ def pin_bytes(value)
1235
+ raise ArgumentError, 'PIN/PUK must contain 6 to 8 bytes' unless value.is_a?(String) && (6..8).cover?(value.bytesize)
1236
+
1237
+ value.b.ljust(8, "\xff".b)
1238
+ end
1239
+
1240
+ def integer(value, range)
1241
+ raise ArgumentError, 'Invalid integer argument' unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
1242
+
1243
+ number = value.to_i
1244
+ raise ArgumentError, 'Integer argument out of range' unless range.cover?(number)
1245
+
1246
+ number
1247
+ end
1248
+
1249
+ def management_algorithm
1250
+ @management_algorithm ||= version_at_least?([5, 3, 0]) ? metadata(0x9b).fetch(:algorithm) : 'TDES'
1251
+ end
1252
+
1253
+ def version_at_least?(minimum)
1254
+ Protocol.version_at_least?(version: version, minimum: minimum)
1255
+ end
1256
+
1257
+ def require_version(minimum)
1258
+ raise UnsupportedError, "PIV operation requires firmware #{minimum.join('.')} or later" unless version_at_least?(minimum)
1259
+ end
1260
+
1261
+ def tlv(tag, value = ''.b)
1262
+ Protocol.tlv(tag: tag, value: value)
1263
+ end
1264
+
1265
+ def unpack(tag, value)
1266
+ fields = Protocol.untlv(data: value)
1267
+ raise ResponseError, 'Malformed PIV response container' unless fields.length == 1 && fields.first[:tag] == tag
1268
+
1269
+ fields.first.fetch(:value)
1270
+ end
1271
+
1272
+ def status_word(error)
1273
+ return error.status if error.respond_to?(:status)
1274
+ return error.sw if error.respond_to?(:sw)
1275
+ return error.status_word if error.respond_to?(:status_word)
1276
+
1277
+ nil
1278
+ end
1279
+
1280
+ def reference_apdu(ins, **)
1281
+ apdu(ins, **)
1282
+ rescue Error => e
1283
+ sw = status_word(e)
1284
+ retries = if sw == 0x6983
1285
+ 0
1286
+ elsif sw && sw & 0xfff0 == 0x63c0
1287
+ sw & 15
1288
+ elsif sw && sw & 0xff00 == 0x6300
1289
+ sw & 255
1290
+ end
1291
+ raise PinError.new(retries), cause: nil unless retries.nil?
1292
+
1293
+ raise
1294
+ end
1295
+
1296
+ def confirm!
1297
+ Protocol.require_confirm(@opts)
1298
+ end
1299
+
1300
+ def secret(name)
1301
+ Protocol.secret(value: @opts[name], secret_provider: @opts[:secret_provider], name: name)
1302
+ end
1303
+
1304
+ def apdu(ins, p1: 0, p2: 0, data: ''.b) # rubocop:disable Naming/MethodParameterName
1305
+ unless @selected
1306
+ Protocol.select(connection: @connection, aid: AID, timeout: @opts.fetch(:timeout, 30))
1307
+ @selected = true
1308
+ end
1309
+ Protocol.apdu(connection: @connection, ins: ins, p1: p1, p2: p2, data: data, timeout: @opts.fetch(:timeout, 30))
1310
+ end
1311
+ end
1312
+ end
1313
+ end
1314
+ end
1315
+ end