pwn 0.5.784 → 0.5.787

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -0,0 +1,1063 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ # Applet quirks and wire layouts were checked against Yubico yubikit/openpgp.py
6
+ # from YubiKey Manager 5.9.1. Attribution for that BSD-2-Clause reference follows.
7
+ # Copyright (c) 2023 Yubico AB. All rights reserved.
8
+ # Redistribution and use in source and binary forms, with or without modification,
9
+ # are permitted provided that the following conditions are met:
10
+ # 1. Redistributions of source code must retain the above copyright notice,
11
+ # this list of conditions and the following disclaimer.
12
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ # this list of conditions and the following disclaimer in the documentation
14
+ # and/or other materials provided with the distribution.
15
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
19
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
22
+ # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
24
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25
+
26
+ module PWN
27
+ module Plugins
28
+ module YubiKey
29
+ # Native OpenPGP card protocol. No subprocesses, credential defaults or I/O at load.
30
+ # References: OpenPGP Card 3.4; Yubico yubikit/openpgp.py 5.9.1 (BSD-2-Clause).
31
+ module OpenPGP
32
+ GRAMMAR = {
33
+ 'info' => [0, [], []],
34
+ 'reset' => [0, ['--force'], []],
35
+ 'access set-retries' => [3, ['--force'], []],
36
+ 'access change-pin' => [0, [], []],
37
+ 'access change-reset-code' => [0, [], []],
38
+ 'access change-admin-pin' => [0, [], []],
39
+ 'access unblock-pin' => [0, [], []],
40
+ 'access set-signature-policy' => [1, [], []],
41
+ 'keys info' => [1, [], []],
42
+ 'keys set-touch' => [2, ['--force'], []],
43
+ 'keys import' => [2, [], []],
44
+ 'keys attest' => [2, [], ['--format']],
45
+ 'certificates export' => [2, [], ['--format']],
46
+ 'certificates delete' => [1, [], []],
47
+ 'certificates import' => [2, [], []]
48
+ }.freeze
49
+ GROUPS = ['', 'access', 'keys', 'certificates'].freeze
50
+
51
+ AID = ['d27600012401'].pack('H*').freeze
52
+ SLOTS = {
53
+ sig: { ref: 1, crt: "\xB6\0".b, attribute: 0xC1, touch: 0xD6 },
54
+ dec: { ref: 2, crt: "\xB8\0".b, attribute: 0xC2, touch: 0xD7 },
55
+ aut: { ref: 3, crt: "\xA4\0".b, attribute: 0xC3, touch: 0xD8 },
56
+ att: { ref: 0x81, crt: "\xB6\x03\x84\x01\x81".b, attribute: 0xDA, touch: 0xD9 }
57
+ }.transform_values(&:freeze).freeze
58
+ TOUCH = { off: 0, on: 1, fixed: 2, cached: 3, 'cached-fixed': 4 }.freeze
59
+ CURVES = {
60
+ secp256r1: ['2a8648ce3d030107', 'prime256v1', 256, '1.2.840.10045.3.1.7'],
61
+ secp256k1: ['2b8104000a', 'secp256k1', 256, '1.3.132.0.10'],
62
+ secp384r1: ['2b81040022', 'secp384r1', 384, '1.3.132.0.34'],
63
+ secp521r1: ['2b81040023', 'secp521r1', 521, '1.3.132.0.35'],
64
+ brainpoolp256r1: ['2b2403030208010107', 'brainpoolP256r1', 256, '1.3.36.3.3.2.8.1.1.7'],
65
+ brainpoolp384r1: ['2b240303020801010b', 'brainpoolP384r1', 384, '1.3.36.3.3.2.8.1.1.11'],
66
+ brainpoolp512r1: ['2b240303020801010d', 'brainpoolP512r1', 512, '1.3.36.3.3.2.8.1.1.13'],
67
+ x25519: ['2b060104019755010501', 'X25519', 255, '1.3.101.110'],
68
+ ed25519: ['2b06010401da470f01', 'ED25519', 255, '1.3.101.112']
69
+ }.transform_values(&:freeze).freeze
70
+
71
+ class UnsupportedError < Error; end
72
+
73
+ # OpenSSL objects stay in memory; raw card ECDSA uses fixed-width r || s.
74
+ class Crypto # rubocop:disable Metrics/ClassLength
75
+ HASH_HEADERS = {
76
+ sha1: '3021300906052b0e03021a05000414', sha224: '302d300d06096086480165030402040500041c',
77
+ sha256: '3031300d060960864801650304020105000420', sha384: '3041300d060960864801650304020205000430',
78
+ sha512: '3051300d060960864801650304020305000440'
79
+ }.freeze
80
+
81
+ def prepare_signature(attr, message, hash_algorithm, prehashed)
82
+ if attr[:algorithm] == :eddsa && attr[:curve] == :ed25519
83
+ raise ArgumentError, 'Ed25519 requires the original message, not a prehash' if prehashed
84
+
85
+ return message
86
+ end
87
+ raise UnsupportedError, 'The slot algorithm cannot sign messages' unless %i[rsa ecdsa].include?(attr[:algorithm])
88
+ raise UnsupportedError, 'Unsupported signing curve' if attr[:algorithm] == :ecdsa && (!attr[:curve] || %i[x25519 ed25519].include?(attr[:curve]))
89
+ raise UnsupportedError, 'Unsupported RSA modulus size' if attr[:algorithm] == :rsa && ![2048, 3072, 4096].include?(attr[:bits])
90
+
91
+ name = hash_algorithm.to_s.downcase.to_sym
92
+ header = HASH_HEADERS[name]
93
+ raise ArgumentError, 'Unsupported signature hash algorithm' unless header
94
+
95
+ hash = OpenSSL::Digest.new(name.to_s.upcase)
96
+ digest = prehashed ? message : hash.digest(message)
97
+ raise ArgumentError, 'Prehashed input has the wrong digest length' unless digest.bytesize == hash.digest_length
98
+
99
+ attr[:algorithm] == :rsa ? [header].pack('H*') + digest : digest
100
+ end
101
+
102
+ def signature(attr, response)
103
+ case attr[:algorithm]
104
+ when :rsa
105
+ raise ResponseError, 'Malformed RSA signature length' unless response.bytesize == attr[:bits] / 8
106
+ when :eddsa
107
+ raise ResponseError, 'Malformed Ed25519 signature length' unless response.bytesize == 64
108
+ when :ecdsa
109
+ width = (attr[:bits] + 7) / 8
110
+ raise ResponseError, 'Malformed ECDSA signature length' unless response.bytesize == width * 2
111
+
112
+ numbers = [response.byteslice(0, width), response.byteslice(width, width)].map { |bytes| OpenSSL::BN.new(bytes, 2) }
113
+ order = OpenSSL::PKey::EC::Group.new(CURVES.fetch(attr[:curve])[1]).order
114
+ raise ResponseError, 'Malformed ECDSA signature scalar' unless numbers.all? { |number| number.to_i.positive? && number < order }
115
+
116
+ return OpenSSL::ASN1::Sequence.new(numbers.map { |number| OpenSSL::ASN1::Integer.new(number) }).to_der
117
+ end
118
+ response
119
+ end
120
+
121
+ def public_from_bytes(attr, bytes)
122
+ curve = attr[:curve]
123
+ entry = CURVES[curve]
124
+ raise UnsupportedError, 'Unknown elliptic curve' unless entry
125
+
126
+ if %i[x25519 ed25519].include?(curve)
127
+ raise ArgumentError, 'Raw Curve25519 public key must be 32 bytes' unless bytes.bytesize == 32
128
+
129
+ algorithm = [OpenSSL::ASN1::ObjectId.new(entry[3])]
130
+ else
131
+ raise ArgumentError, 'EC point length or encoding does not match the curve' unless bytes.bytesize == 1 + (2 * ((entry[2] + 7) / 8)) && bytes.getbyte(0) == 4
132
+
133
+ algorithm = [OpenSSL::ASN1::ObjectId.new('1.2.840.10045.2.1'), OpenSSL::ASN1::ObjectId.new(entry[3])]
134
+ end
135
+ der = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Sequence.new(algorithm), OpenSSL::ASN1::BitString.new(bytes)]).to_der
136
+ key = OpenSSL::PKey.read(der)
137
+ raise ArgumentError, 'EC point is not on the requested curve' if key.is_a?(OpenSSL::PKey::EC) && !key.public_key.on_curve?
138
+
139
+ key
140
+ rescue OpenSSL::OpenSSLError
141
+ raise ArgumentError, 'Invalid public key for the requested curve', cause: nil
142
+ end
143
+
144
+ def parse_private(value, password)
145
+ key = value.is_a?(OpenSSL::PKey::PKey) ? value : OpenSSL::PKey.read(value, password || '')
146
+ if key.is_a?(OpenSSL::PKey::RSA)
147
+ raise ArgumentError, 'RSA private key must contain factors and use exponent 65537' unless key.private? && key.e.to_i == 65_537 && key.p && key.q
148
+ raise ArgumentError, 'Only RSA 2048, 3072 and 4096 are supported' unless [2048, 3072, 4096].include?(key.n.num_bits)
149
+ elsif key.is_a?(OpenSSL::PKey::EC)
150
+ raise ArgumentError, 'A valid supported EC private key is required' unless key.private? && key.check_key && CURVES.values.any? { |entry| entry[1] == key.group.curve_name }
151
+ elsif %w[ED25519 X25519].include?(key.oid)
152
+ raw_seed(key)
153
+ else
154
+ raise ArgumentError, 'Unsupported OpenPGP private key type'
155
+ end
156
+ key
157
+ rescue OpenSSL::OpenSSLError, TypeError
158
+ raise ArgumentError, 'Invalid or encrypted private key; supply key_password in memory', cause: nil
159
+ end
160
+
161
+ def key_algorithm(key)
162
+ if key.is_a?(OpenSSL::PKey::RSA)
163
+ :"rsa#{key.n.num_bits}"
164
+ elsif key.is_a?(OpenSSL::PKey::EC)
165
+ CURVES.find { |_name, entry| entry[1] == key.group.curve_name }.first
166
+ else
167
+ key.oid.downcase.to_sym
168
+ end
169
+ end
170
+
171
+ def raw_seed(key)
172
+ sequence = OpenSSL::ASN1.decode(key.private_to_der)
173
+ value = sequence.value
174
+ raise ArgumentError, 'Unexpected Curve25519 PKCS8 structure' unless value.is_a?(Array) && value[0].is_a?(OpenSSL::ASN1::Integer) && value[2].is_a?(OpenSSL::ASN1::OctetString)
175
+
176
+ inner = OpenSSL::ASN1.decode(value[2].value)
177
+ raise ArgumentError, 'Expected a 32-byte Curve25519 private seed' unless inner.is_a?(OpenSSL::ASN1::OctetString) && inner.value.bytesize == 32
178
+
179
+ inner.value
180
+ end
181
+
182
+ def import_template(key, slot, format)
183
+ if key.is_a?(OpenSSL::PKey::RSA)
184
+ width = key.n.num_bits / 16
185
+ components = [[0x91, key.e.to_s(2)], [0x92, key.p.to_s(2).rjust(width, "\0")], [0x93, key.q.to_s(2).rjust(width, "\0")]]
186
+ components.push([0x94, key.iqmp.to_s(2).rjust(width, "\0")], [0x95, key.dmp1.to_s(2).rjust(width, "\0")], [0x96, key.dmq1.to_s(2).rjust(width, "\0")]) if [2, 3].include?(format)
187
+ components << [0x97, key.n.to_s(2).rjust(width * 2, "\0")] if [1, 3].include?(format)
188
+ elsif key.is_a?(OpenSSL::PKey::EC)
189
+ width = (key.group.degree + 7) / 8
190
+ components = [[0x92, key.private_key.to_s(2).rjust(width, "\0")]]
191
+ components << [0x99, key.public_key.to_octet_string(:uncompressed)] if format == 255
192
+ else
193
+ seed = raw_seed(key)
194
+ seed = seed.reverse if key.oid == 'X25519'
195
+ components = [[0x92, seed]]
196
+ components << [0x99, OpenSSL::ASN1.decode(key.public_to_der).value[1].value] if format == 255
197
+ end
198
+ headers = components.map { |tag, bytes| Protocol.tlv(tag: tag, value: bytes).byteslice(0...-bytes.bytesize) }.join.b
199
+ values = components.map(&:last).join.b
200
+ Protocol.tlv(tag: 0x4D, value: SLOTS.fetch(slot)[:crt] + Protocol.tlv(tag: 0x7F48, value: headers) + Protocol.tlv(tag: 0x5F48, value: values))
201
+ end
202
+
203
+ def public_key(attr, template)
204
+ fields = Protocol.tlv_map(data: template)
205
+ if attr[:algorithm] == :rsa
206
+ modulus = fields[0x81]
207
+ exponent = fields[0x82]
208
+ raise ResponseError, 'Malformed RSA public key template' unless modulus && exponent && modulus.bytesize == attr[:bits] / 8 && exponent.bytesize.between?(1, 4)
209
+
210
+ numbers = [modulus, exponent].map { |bytes| OpenSSL::BN.new(bytes, 2) }
211
+ raise ResponseError, 'Invalid RSA public key numbers' unless numbers[0].num_bits == attr[:bits] && numbers[1].odd? && numbers[1] >= 3
212
+
213
+ OpenSSL::PKey::RSA.new(OpenSSL::ASN1::Sequence.new(numbers.map { |number| OpenSSL::ASN1::Integer.new(number) }).to_der)
214
+ else
215
+ raise ResponseError, 'Missing EC public key template' unless fields[0x86]
216
+
217
+ public_from_bytes(attr, fields.fetch(0x86))
218
+ end
219
+ rescue OpenSSL::OpenSSLError, ArgumentError
220
+ raise ResponseError, 'Invalid card public key response', cause: nil
221
+ end
222
+
223
+ def peer_bytes(attr, value)
224
+ raise UnsupportedError, 'Only ECDH decryption keys support key agreement' unless attr[:algorithm] == :ecdh && attr[:curve] && attr[:curve] != :ed25519
225
+
226
+ if value.is_a?(String)
227
+ raw = value.b
228
+ elsif value.is_a?(OpenSSL::PKey::EC)
229
+ raise ArgumentError, 'Peer curve differs from the card decryption curve' unless value.group.curve_name == CURVES.fetch(attr[:curve])[1]
230
+
231
+ raw = value.public_key.to_octet_string(:uncompressed)
232
+ elsif value.is_a?(OpenSSL::PKey::PKey) && value.oid == 'X25519' && attr[:curve] == :x25519
233
+ raw = OpenSSL::ASN1.decode(value.public_to_der).value[1].value
234
+ else
235
+ raise ArgumentError, 'Peer key type or curve does not match the card decryption key'
236
+ end
237
+ public_from_bytes(attr, raw)
238
+ raise ArgumentError, 'All-zero Curve25519 peer key is invalid' if attr[:curve] == :x25519 && raw.bytes.all?(&:zero?)
239
+
240
+ raw
241
+ end
242
+ end
243
+
244
+ # A request-local session never caches PINs between public calls.
245
+ class Session # rubocop:disable Metrics/ClassLength
246
+ def initialize(opts, reset_mode: false)
247
+ @opts = opts.dup
248
+ @connection = opts[:connection]
249
+ raise ArgumentError, 'An APDU connection is required' unless @connection.respond_to?(:exchange) && @connection.descriptor[:protocol] == :apdu
250
+
251
+ @timeout = opts.fetch(:timeout, 30)
252
+ raise ArgumentError, 'timeout must be finite and positive' unless @timeout.is_a?(Numeric) && @timeout.finite? && @timeout.positive?
253
+
254
+ stage = :select
255
+ Protocol.select(connection: @connection, aid: AID, timeout: @timeout)
256
+ stage = :version
257
+ @version = read_version
258
+ stage = :application
259
+ load_application
260
+ rescue Protocol::APDUError => e
261
+ raise unless reset_mode && (e.status == 0x6581 || (stage == :select && [0x6285, 0x6985].include?(e.status)))
262
+
263
+ @startup_error = e.status
264
+ end
265
+
266
+ def read_version
267
+ raw = apdu(0xF1)
268
+ raise ResponseError, 'Malformed OpenPGP firmware version' unless raw.bytesize == 3
269
+
270
+ raw.bytes.map { |byte| bcd(byte) }
271
+ rescue Protocol::APDUError => e
272
+ raise unless e.status == 0x6985
273
+
274
+ [1, 0, 0] # Documented pre-1.0.2 NEO applets lack GET VERSION.
275
+ end
276
+
277
+ def apdu(ins, param1 = 0, param2 = 0, data = ''.b, cla: 0)
278
+ Protocol.apdu(connection: @connection, ins: ins, p1: param1, p2: param2, data: data, cla: cla, timeout: @timeout)
279
+ end
280
+
281
+ def get_data(tag)
282
+ apdu(0xCA, tag >> 8, tag & 0xFF)
283
+ end
284
+
285
+ def put_data(tag, value)
286
+ apdu(0xDA, tag >> 8, tag & 0xFF, value)
287
+ end
288
+
289
+ def unpack(tag, bytes)
290
+ fields = Protocol.tlv_map(data: bytes)
291
+ raise ResponseError, 'Unexpected OpenPGP response template' unless fields.keys == [tag]
292
+
293
+ fields.fetch(tag)
294
+ end
295
+
296
+ def bcd(byte)
297
+ raise ResponseError, 'Invalid OpenPGP BCD version' unless (byte >> 4) <= 9 && (byte & 15) <= 9
298
+
299
+ ((byte >> 4) * 10) + (byte & 15)
300
+ end
301
+
302
+ def load_application
303
+ outer = Protocol.tlv_map(data: unpack(0x6E, get_data(0x6E)))
304
+ @aid = outer.fetch(0x4F) { raise ResponseError, 'OpenPGP AID absent' }
305
+ raise ResponseError, 'Malformed OpenPGP AID' unless @aid.bytesize == 16 && @aid.start_with?(AID)
306
+
307
+ @card_version = @aid.byteslice(6, 2).bytes.map { |byte| bcd(byte) }
308
+ @objects = outer[0x73] && outer[0x73].empty? ? outer : Protocol.tlv_map(data: outer.fetch(0x73) { raise ResponseError, 'Discretionary data absent' })
309
+ capabilities = @objects.fetch(0xC0) { raise ResponseError, 'Extended capabilities absent' }
310
+ raise ResponseError, 'Malformed extended capabilities' unless capabilities.bytesize == 10
311
+
312
+ @flags = capabilities.getbyte(0)
313
+ @certificate_max = capabilities.byteslice(4, 2).unpack1('n')
314
+ @pin_status = pin_status(@objects.fetch(0xC4) { raise ResponseError, 'PIN status absent' })
315
+ @historical = outer[0x5F52]
316
+ end
317
+
318
+ def pin_status(data = get_data(0xC4))
319
+ raise ResponseError, 'Malformed PIN status' unless data.bytesize == 7
320
+
321
+ bytes = data.bytes
322
+ { signature_policy: { 0 => :always, 1 => :once }.fetch(bytes[0], :unknown),
323
+ max_user: bytes[1], max_reset: bytes[2], max_admin: bytes[3], user: bytes[4], reset: bytes[5], admin: bytes[6] }
324
+ end
325
+
326
+ def attributes(data)
327
+ raise ResponseError, 'Empty algorithm attributes' if data.nil? || data.empty?
328
+
329
+ case data.getbyte(0)
330
+ when 1
331
+ raise ResponseError, 'Malformed RSA attributes' unless data.bytesize == 6
332
+
333
+ _, bits, exponent_bits, import_format = data.unpack('CnnC')
334
+ { algorithm: :rsa, bits: bits, exponent_bits: exponent_bits, import_format: import_format, encoded: data }
335
+ when 0x12, 0x13, 0x16
336
+ oid = data.byteslice(1..)
337
+ with_public = oid.end_with?("\xFF".b)
338
+ oid = oid.byteslice(0...-1) if with_public
339
+ curve = CURVES.find { |_name, entry| entry[0] == oid.unpack1('H*') }&.first
340
+ { algorithm: { 0x12 => :ecdh, 0x13 => :ecdsa, 0x16 => :eddsa }.fetch(data.getbyte(0)), curve: curve,
341
+ oid: oid.unpack1('H*'), bits: curve && CURVES.fetch(curve)[2], import_format: with_public ? 255 : 0, encoded: data }
342
+ else
343
+ { algorithm: :unknown, algorithm_id: data.getbyte(0), encoded: data }
344
+ end
345
+ end
346
+
347
+ def key_info(slot)
348
+ definition = SLOTS.fetch(slot)
349
+ data = @objects[definition[:attribute]]
350
+ raise UnsupportedError, 'Key slot is not supported by this card' unless data
351
+
352
+ status = @objects[0xDE].to_s.b
353
+ raise ResponseError, 'Malformed key information' unless status.bytesize.even?
354
+
355
+ origin = status.bytes.each_slice(2).to_h[definition[:ref]]
356
+ index = SLOTS.keys.index(slot)
357
+ touch_data = @objects[definition[:touch]]
358
+ touch_data = get_data(definition[:touch]) if touch_data.nil? && @card_version[0] < 3 && @version && Protocol.version_at_least?(version: @version, minimum: [4, 2, 0])
359
+ raise ResponseError, 'Malformed touch policy' if touch_data && (touch_data.bytesize != 2 || !TOUCH.value?(touch_data.getbyte(0)))
360
+
361
+ attributes(data).merge(slot: slot, origin: { 0 => :none, 1 => :generated, 2 => :imported }.fetch(origin, :unknown),
362
+ fingerprint: @objects[0xC5]&.byteslice(index * 20, 20)&.unpack1('H*'),
363
+ generated_at: @objects[0xCD]&.byteslice(index * 4, 4)&.unpack1('N'),
364
+ touch: touch_data && TOUCH.key(touch_data.getbyte(0)))
365
+ end
366
+
367
+ def secret(name)
368
+ Protocol.secret(value: @opts[name], name: name, secret_provider: @opts[:secret_provider])
369
+ end
370
+
371
+ def pin_bytes(name, role)
372
+ value = secret(name).b
373
+ raise ArgumentError, 'PIN input is too large' if value.bytesize > 4096
374
+
375
+ # The KDF object is read once per request, never inferred from PIN length.
376
+ if @flags.anybits?(1)
377
+ @kdf ||= Protocol.tlv_map(data: get_data(0xF9))
378
+ algorithm = @kdf[0x81]
379
+ unless algorithm.nil? || algorithm == "\0".b
380
+ raise UnsupportedError, 'Unsupported card PIN KDF' unless algorithm == "\x03".b
381
+
382
+ digest_name = { "\x08".b => 'SHA256', "\x0A".b => 'SHA512' }[@kdf[0x82]]
383
+ count_bytes = @kdf[0x83]
384
+ salt = @kdf[{ user: 0x84, reset: 0x85, admin: 0x86 }.fetch(role)] || @kdf[0x84]
385
+ raise ResponseError, 'Malformed card PIN KDF' unless digest_name && count_bytes&.bytesize == 4 && salt&.bytesize == 8
386
+
387
+ count = count_bytes.unpack1('N')
388
+ raise UnsupportedError, 'Card PIN KDF byte count is outside the supported bound' unless count.between?(1, 67_108_864)
389
+
390
+ source = salt + value
391
+ digest = OpenSSL::Digest.new(digest_name)
392
+ # RFC 4880 iterated-and-salted S2K hashes at least one full source.
393
+ remaining = [count, source.bytesize].max
394
+ chunk = source * [65_536 / source.bytesize, 1].max
395
+ while remaining.positive?
396
+ take = [remaining, chunk.bytesize].min
397
+ digest.update(chunk.byteslice(0, take))
398
+ remaining -= take
399
+ end
400
+ value = digest.digest
401
+ end
402
+ end
403
+ min = role == :user ? 6 : 8
404
+ max = @pin_status.fetch(:"max_#{role}")
405
+ raise ArgumentError, "#{name} length must be #{min}..#{max} bytes" unless value.bytesize.between?(min, max)
406
+
407
+ value
408
+ end
409
+
410
+ def require_version(minimum, capability)
411
+ return if Protocol.version_at_least?(version: @version, minimum: minimum)
412
+
413
+ raise UnsupportedError, "#{capability} is not supported by this firmware"
414
+ end
415
+
416
+ def verify_admin
417
+ apdu(0x20, 0, 0x83, pin_bytes(:admin_pin, :admin))
418
+ end
419
+
420
+ def configure_retries(counts)
421
+ require_version(@version[0] == 1 ? [1, 0, 7] : [4, 3, 1], 'Setting PIN retries')
422
+ verify_admin
423
+ apdu(0xF2, 0, 0, counts.pack('C*'))
424
+ status = pin_status
425
+ raise ResponseError, 'PIN retries readback mismatch; mutation was not replayed' unless status.values_at(:user, :reset, :admin) == counts
426
+
427
+ { pin_status: status, verified: true, pins_reset: @version[0] < 4 }
428
+ end
429
+
430
+ def change_reset_code
431
+ reset = pin_bytes(:reset_code, :reset)
432
+ verify_admin
433
+ put_data(0xD3, reset)
434
+ { changed: :reset_code, pin_status: pin_status, credential_verified: false }
435
+ end
436
+
437
+ def unblock_pin
438
+ replacement = pin_bytes(:new_pin, :user)
439
+ if @opts[:admin_pin] || @opts[:use_admin] == true
440
+ verify_admin
441
+ apdu(0x2C, 2, 0x81, replacement)
442
+ else
443
+ reset = pin_bytes(:reset_code, :reset)
444
+ apdu(0x2C, 0, 0x81, reset + replacement)
445
+ end
446
+ { unblocked: true, pin_status: pin_status, credential_verified: false }
447
+ end
448
+
449
+ def signature_policy(policy)
450
+ raise UnsupportedError, 'Changing the signature PIN policy is unsupported' if @flags.nobits?(0x10)
451
+
452
+ verify_admin
453
+ put_data(0xC4, [policy == :always ? 0 : 1].pack('C'))
454
+ status = pin_status
455
+ raise ResponseError, 'Signature policy readback mismatch; mutation was not replayed' unless status[:signature_policy] == policy
456
+
457
+ { signature_policy: policy, verified: true, pin_status: status }
458
+ end
459
+
460
+ def change_pin(admin: false)
461
+ role = admin ? :admin : :user
462
+ name = admin ? :admin_pin : :pin
463
+ current = pin_bytes(name, role)
464
+ replacement = pin_bytes(admin ? :new_admin_pin : :new_pin, role)
465
+ apdu(0x24, 0, admin ? 0x83 : 0x81, current + replacement)
466
+ { changed: name, pin_status: pin_status, credential_verified: false }
467
+ end
468
+
469
+ def algorithm_attributes(slot, algorithm)
470
+ if algorithm.to_s.start_with?('rsa')
471
+ [1, algorithm.to_s.delete_prefix('rsa').to_i, 17, @version[0] < 4 ? 3 : 0].pack('CnnC')
472
+ else
473
+ raise ArgumentError, 'X25519 is only valid in the decryption slot' if algorithm == :x25519 && slot != :dec
474
+ raise ArgumentError, 'Ed25519 is not valid in the decryption or attestation slot' if algorithm == :ed25519 && %i[dec att].include?(slot)
475
+
476
+ require_version([5, 2, 0], 'Elliptic curve keys')
477
+ id = if algorithm == :ed25519
478
+ 0x16
479
+ else
480
+ (slot == :dec ? 0x12 : 0x13)
481
+ end
482
+ [id].pack('C') + [CURVES.fetch(algorithm)[0]].pack('H*')
483
+ end
484
+ end
485
+
486
+ def algorithms
487
+ return SLOTS.keys.select { |slot| @objects[SLOTS[slot][:attribute]] }.to_h { |slot| [slot, [attributes(@objects[SLOTS[slot][:attribute]])]] } if @flags.nobits?(4)
488
+
489
+ unless Protocol.version_at_least?(version: @version, minimum: [5, 2, 0])
490
+ sizes = @version[0] < 4 || @version.take(2) == [4, 4] ? [2048] : [2048, 3072, 4096]
491
+ return %i[sig dec aut].to_h { |slot| [slot, sizes.map { |bits| attributes(algorithm_attributes(slot, :"rsa#{bits}")) }] }
492
+ end
493
+ raw = get_data(0xFA)
494
+ rows = Protocol.untlv(data: algorithm_information_body(raw))
495
+ result = {}
496
+ rows.each do |row|
497
+ slot = SLOTS.find { |_name, entry| entry[:attribute] == row[:tag] }&.first
498
+ raise ResponseError, 'Unknown key slot in algorithm information' unless slot
499
+
500
+ (result[slot] ||= []) << attributes(row[:value])
501
+ end
502
+ unless Protocol.version_at_least?(version: @version, minimum: [5, 6, 1])
503
+ # Published Yubico firmware erratum: X25519 was advertised as EdDSA.
504
+ bad_x = result.values.flatten.any? { |entry| entry[:curve] == :x25519 && entry[:algorithm] == :eddsa }
505
+ result.each do |slot, entries|
506
+ entries.reject! { |entry| (entry[:curve] == :x25519 && entry[:algorithm] != :ecdh) || (%i[dec att].include?(slot) && entry[:curve] == :ed25519) }
507
+ end
508
+ if bad_x && result[:dec]
509
+ fixed = attributes(algorithm_attributes(:dec, :x25519))
510
+ result[:dec] << fixed unless result[:dec].any? { |entry| entry[:encoded] == fixed[:encoded] }
511
+ end
512
+ end
513
+ result
514
+ end
515
+
516
+ def algorithm_information_body(raw)
517
+ unpack(0xFA, raw)
518
+ rescue ResponseError
519
+ raise if Protocol.version_at_least?(version: @version, minimum: [5, 6, 1])
520
+
521
+ # The old applet's outer FA length includes two nonexistent SW bytes.
522
+ # Strict unpack still rejects every length difference other than +2.
523
+ unpack(0xFA, raw + "\0\0".b).byteslice(0...-2)
524
+ end
525
+
526
+ def validate_key_attributes(slot, encoded)
527
+ current = key_info(slot)
528
+ desired = attributes(encoded)
529
+ return attributes(current[:encoded]) if @flags.nobits?(4) && current[:algorithm] == :rsa && desired[:algorithm] == :rsa && current[:bits] == 2048 && desired[:bits] == 2048
530
+
531
+ supported = algorithms.fetch(slot, [])
532
+ # Prefer standard import; some cards advertise the same curve with public-point import.
533
+ match = supported.find { |entry| entry[:encoded] == encoded }
534
+ match ||= supported.find { |entry| desired[:curve] && entry[:curve] == desired[:curve] && entry[:algorithm] == desired[:algorithm] && entry[:import_format] == 255 }
535
+ raise UnsupportedError, 'Algorithm or key slot is not supported by this card' unless match
536
+
537
+ match
538
+ end
539
+
540
+ def write_attributes(slot, attr)
541
+ tag = SLOTS.fetch(slot)[:attribute]
542
+ return if @objects[tag] == attr[:encoded]
543
+
544
+ raise UnsupportedError, 'Algorithm attributes cannot be changed on this card' if @flags.nobits?(4)
545
+
546
+ put_data(tag, attr[:encoded])
547
+ raise ResponseError, 'Key algorithm attributes readback mismatch; mutation was not replayed' unless get_data(tag) == attr[:encoded]
548
+
549
+ @objects[tag] = attr[:encoded]
550
+ end
551
+
552
+ def public_key(slot, attr = key_info(slot))
553
+ raw = apdu(0x47, 0x81, 0, SLOTS.fetch(slot)[:crt])
554
+ Crypto.new.public_key(attr, unpack(0x7F49, raw))
555
+ end
556
+
557
+ def generate_key(slot, algorithm)
558
+ raise UnsupportedError, 'RSA generation is disabled on vulnerable firmware; update or import a host-generated key' if algorithm.to_s.start_with?('rsa') && Protocol.version_at_least?(version: @version, minimum: [4, 2, 0]) && !Protocol.version_at_least?(version: @version, minimum: [4, 3, 5])
559
+
560
+ attr = validate_key_attributes(slot, algorithm_attributes(slot, algorithm))
561
+ verify_admin
562
+ write_attributes(slot, attr)
563
+ raw = apdu(0x47, 0x80, 0, SLOTS.fetch(slot)[:crt])
564
+ key = Crypto.new.public_key(attr, unpack(0x7F49, raw))
565
+ raise ResponseError, 'Generated public key readback mismatch; generation was not replayed' unless public_key(slot, attr).public_to_der == key.public_to_der
566
+
567
+ key
568
+ end
569
+
570
+ def import_key(slot, key)
571
+ raise UnsupportedError, 'Key import is not supported by this card' if @flags.nobits?(0x20)
572
+
573
+ crypto = Crypto.new
574
+ attr = validate_key_attributes(slot, algorithm_attributes(slot, crypto.key_algorithm(key)))
575
+ format = key.is_a?(OpenSSL::PKey::RSA) && @version[0] < 4 ? 3 : attr[:import_format]
576
+ template = crypto.import_template(key, slot, format)
577
+ verify_admin
578
+ write_attributes(slot, attr)
579
+ apdu(0xDB, 0x3F, 0xFF, template)
580
+ actual = public_key(slot, attr)
581
+ raise ResponseError, 'Imported public key readback mismatch; import was not replayed' unless actual.public_to_der == key.public_to_der
582
+
583
+ { imported: :key, slot: slot, verified: true, public_key: actual }
584
+ end
585
+
586
+ def block_for_reset(role, reference)
587
+ remaining = @pin_status.fetch(role)
588
+ remaining.times do
589
+ begin
590
+ apdu(0x20, 0, reference, "\0".b * 8)
591
+ raise ResponseError, 'Reset sentinel unexpectedly verified; reset aborted without guessing another PIN'
592
+ rescue Protocol::APDUError => e
593
+ raise unless [0x6982, 0x6983].include?(e.status) || (e.status & 0xFFF0) == 0x63C0
594
+ end
595
+ observed = pin_status.fetch(role)
596
+ raise ResponseError, 'Reset retry counter did not decrease; reset aborted' unless observed < remaining
597
+
598
+ remaining = observed
599
+ break if remaining.zero?
600
+ end
601
+ raise ResponseError, 'PIN did not block; reset aborted' unless remaining.zero?
602
+ end
603
+
604
+ def block_corrupt_for_reset(reference)
605
+ # Retry counters are unreadable: this is deliberate blocking, not an
606
+ # authentication retry. A one-byte card counter bounds attempts to 255.
607
+ 256.times do
608
+ apdu(0x20, 0, reference, "\0".b * 8)
609
+ raise ResponseError, 'Reset sentinel unexpectedly verified; reset aborted'
610
+ rescue Protocol::APDUError => e
611
+ return if [0x6983, 0x63C0].include?(e.status)
612
+ raise unless e.status == 0x6982 || (e.status & 0xFFF0) == 0x63C0
613
+ end
614
+ raise ResponseError, 'Reset blocking exceeded card counter bound; reset aborted'
615
+ end
616
+
617
+ def reset
618
+ if @startup_error == 0x6581
619
+ block_corrupt_for_reset(0x81)
620
+ block_corrupt_for_reset(0x83)
621
+ apdu(0xE6)
622
+ elsif !@startup_error
623
+ require_version([1, 0, 6], 'OpenPGP reset')
624
+ block_for_reset(:user, 0x81)
625
+ block_for_reset(:admin, 0x83)
626
+ apdu(0xE6)
627
+ end
628
+ apdu(0x44)
629
+ Protocol.select(connection: @connection, aid: AID, timeout: @timeout)
630
+ load_application
631
+ clear = %i[sig dec aut].all? do |slot|
632
+ info = key_info(slot)
633
+ %i[none unknown].include?(info[:origin]) && info[:fingerprint] == '00' * 20
634
+ end
635
+ raise ResponseError, 'OpenPGP reset readback still contains keys; reset was not replayed' unless clear
636
+
637
+ { reset: true, key_slots_verified_empty: true, pin_status: @pin_status, attestation_preserved: true }
638
+ end
639
+
640
+ def set_touch(slot, policy)
641
+ require_version([4, 2, 0], 'Touch policy')
642
+ require_version([5, 2, 1], 'Cached or attestation touch policy') if slot == :att || [3, 4].include?(TOUCH.fetch(policy))
643
+ key_info(slot)
644
+ tag = SLOTS.fetch(slot)[:touch]
645
+ current = get_data(tag)
646
+ raise ResponseError, 'Malformed touch policy' unless current.bytesize == 2 && TOUCH.value?(current.getbyte(0))
647
+ raise UnsupportedError, 'A fixed touch policy cannot be changed without replacing the key' if [2, 4].include?(current.getbyte(0))
648
+
649
+ verify_admin
650
+ value = [TOUCH.fetch(policy), 0x20].pack('CC')
651
+ put_data(tag, value)
652
+ raise ResponseError, 'Touch policy readback mismatch; mutation was not replayed' unless get_data(tag) == value
653
+
654
+ { slot: slot, touch: policy, irreversible: [2, 4].include?(TOUCH.fetch(policy)), verified: true }
655
+ end
656
+
657
+ def certificate_supported(slot)
658
+ return if slot == :aut
659
+
660
+ require_version([5, 2, 0], 'Certificate slot')
661
+ end
662
+
663
+ def select_certificate(slot)
664
+ certificate_supported(slot)
665
+ return if slot == :att || !Protocol.version_at_least?(version: @version, minimum: [5, 2, 0])
666
+
667
+ value = "\x60\x04\x5C\x02\x7F\x21".b
668
+ value = "\x06".b + value unless Protocol.version_at_least?(version: @version, minimum: [5, 4, 4])
669
+ apdu(0xA5, 3 - SLOTS.fetch(slot)[:ref], 4, value)
670
+ end
671
+
672
+ def certificate_bytes(slot)
673
+ select_certificate(slot)
674
+ get_data(slot == :att ? 0xFC : 0x7F21)
675
+ end
676
+
677
+ def export_certificate(slot, format)
678
+ raw = certificate_bytes(slot)
679
+ raise ResponseError, 'No certificate stored in the selected slot' if raw.empty?
680
+
681
+ cert = OpenSSL::X509::Certificate.new(raw)
682
+ format == :der ? cert.to_der : cert.to_pem
683
+ rescue OpenSSL::X509::CertificateError
684
+ raise ResponseError, 'Stored certificate is not valid X.509 DER', cause: nil
685
+ end
686
+
687
+ def write_certificate(slot, raw)
688
+ certificate_supported(slot)
689
+ raise UnsupportedError, 'Certificate exceeds the card certificate limit' if raw.bytesize > @certificate_max
690
+
691
+ verify_admin
692
+ select_certificate(slot)
693
+ put_data(slot == :att ? 0xFC : 0x7F21, raw)
694
+ raise ResponseError, 'Certificate readback mismatch; mutation was not replayed' unless certificate_bytes(slot) == raw
695
+
696
+ { (raw.empty? ? :deleted : :imported) => :certificate, slot: slot, verified: true }
697
+ end
698
+
699
+ def attest_key(slot, format)
700
+ require_version([5, 2, 0], 'Key attestation')
701
+ raise UnsupportedError, 'Only on-card generated keys can be attested' if %i[none imported].include?(key_info(slot)[:origin])
702
+
703
+ verify_user
704
+ apdu(0xFB, SLOTS.fetch(slot)[:ref], 0, ''.b, cla: 0x80)
705
+ export_certificate(slot, format)
706
+ end
707
+
708
+ def sign(message, hash_algorithm, prehashed: false, authentication: false)
709
+ attr = key_info(authentication ? :aut : :sig)
710
+ raise UnsupportedError, 'The selected key slot is empty' if attr[:origin] == :none
711
+
712
+ crypto = Crypto.new
713
+ encoded = crypto.prepare_signature(attr, message, hash_algorithm, prehashed)
714
+ raise ArgumentError, 'Signing payload length exceeds the APDU limit' if encoded.bytesize > 65_535
715
+
716
+ verify_user(authentication ? 0x82 : 0x81)
717
+ response = authentication ? apdu(0x88, 0, 0, encoded) : apdu(0x2A, 0x9E, 0x9A, encoded)
718
+ crypto.signature(attr, response)
719
+ end
720
+
721
+ def decipher(value)
722
+ attr = key_info(:dec)
723
+ raise UnsupportedError, 'The decryption key slot is empty' if attr[:origin] == :none
724
+
725
+ if attr[:algorithm] == :rsa
726
+ raise ArgumentError, 'RSA ciphertext length must equal the key modulus length' unless value.is_a?(String) && value.bytesize == attr[:bits] / 8
727
+
728
+ encoded = "\0".b + value.b
729
+ else
730
+ peer = Crypto.new.peer_bytes(attr, value)
731
+ encoded = Protocol.tlv(tag: 0xA6, value: Protocol.tlv(tag: 0x7F49, value: Protocol.tlv(tag: 0x86, value: peer)))
732
+ end
733
+ verify_user(0x82)
734
+ response = apdu(0x2A, 0x80, 0x86, encoded)
735
+ if attr[:algorithm] == :ecdh
736
+ width = (attr[:bits] + 7) / 8
737
+ raise ResponseError, 'Invalid card ECDH secret length or all-zero result' unless response.bytesize == width && response.bytes.any?(&:positive?)
738
+ end
739
+ response
740
+ end
741
+
742
+ def verify_pin(mode)
743
+ if mode == :admin
744
+ verify_admin
745
+ else
746
+ verify_user(mode == :extended ? 0x82 : 0x81)
747
+ end
748
+ { verified: true, mode: mode }
749
+ end
750
+
751
+ def unverify_pin(mode)
752
+ require_version([5, 6, 0], 'PIN reset-verification')
753
+ apdu(0x20, 0xFF, { signature: 0x81, extended: 0x82, admin: 0x83 }.fetch(mode))
754
+ { cleared: mode }
755
+ end
756
+
757
+ def verify_user(reference = 0x81)
758
+ encoded = pin_bytes(:pin, :user)
759
+ apdu(0x20, 0, reference, encoded)
760
+ end
761
+
762
+ def kdf_enabled?
763
+ return false if @flags.nobits?(1)
764
+
765
+ @kdf ||= Protocol.tlv_map(data: get_data(0xF9))
766
+ @kdf.key?(0x81) && @kdf[0x81] != "\0".b
767
+ end
768
+
769
+ def info
770
+ counter = Protocol.tlv_map(data: unpack(0x7A, get_data(0x7A))).fetch(0x93) { raise ResponseError, 'Signature counter absent' }
771
+ raise ResponseError, 'Malformed signature counter' unless counter.bytesize == 3
772
+
773
+ { application: :openpgp, firmware: @version, card_version: @card_version, aid: @aid.unpack1('H*'),
774
+ manufacturer: @aid.byteslice(8, 2).unpack1('n'), pin_status: @pin_status,
775
+ signature_counter: counter.unpack1('H*').to_i(16), extended_capabilities: @flags, kdf_enabled: kdf_enabled?,
776
+ certificate_max_length: @certificate_max, keys: SLOTS.keys.select { |slot| @objects[SLOTS[slot][:attribute]] }.to_h { |slot| [slot, key_info(slot)] } }
777
+ end
778
+ end
779
+
780
+ private_constant :Session, :Crypto
781
+
782
+ public_class_method def self.operations
783
+ GRAMMAR.keys.freeze
784
+ end
785
+
786
+ public_class_method def self.execute(opts = {})
787
+ args = opts[:args] || []
788
+ raise ArgumentError, 'args must be an Array of Strings' unless args.is_a?(Array) && args.all?(String)
789
+
790
+ group = args.join(' ')
791
+ return { application: :openpgp, group: group, operations: operations } if GROUPS.include?(group)
792
+
793
+ path = GRAMMAR.keys.sort_by { |entry| -entry.split.size }.find { |entry| args.take(entry.split.size) == entry.split }
794
+ raise ArgumentError, 'Unknown OpenPGP operation' unless path
795
+ raise ArgumentError, 'Credentials must be supplied in memory, never argv' if args.any? { |arg| arg.match?(/\A--(?:pin|admin-pin|reset-code|new-pin|new-admin-pin|password)(?:=|\z)/) }
796
+
797
+ count, flags, values = GRAMMAR.fetch(path)
798
+ parsed = Protocol.parse_args(args: args.drop(path.split.size), flags: flags, values: values)
799
+ raise ArgumentError, 'Unexpected number of OpenPGP arguments' unless parsed[:positionals].length == count
800
+
801
+ positionals = parsed[:positionals]
802
+ if path == 'access set-retries'
803
+ raise ArgumentError, 'PIN retries must be integers from 1 to 99' unless positionals.all? { |value| value.match?(/\A[1-9][0-9]?\z/) }
804
+ elsif path == 'access set-signature-policy'
805
+ raise ArgumentError, 'Signature policy must be always or once' unless %w[always once].include?(positionals[0])
806
+ elsif path == 'access unblock-pin' && opts[:reset_code] && (opts[:admin_pin] || opts[:use_admin] == true)
807
+ raise ArgumentError, 'Choose either reset code or admin PIN, not both'
808
+ end
809
+ slot = validate_slot(slot: positionals[0]) if path.start_with?('keys ', 'certificates ')
810
+ policy = positionals[1]&.to_sym if path == 'keys set-touch'
811
+ raise ArgumentError, 'Unknown touch policy' if policy && !TOUCH.key?(policy)
812
+ raise ArgumentError, 'The attestation key cannot attest itself' if path == 'keys attest' && slot == :att
813
+
814
+ raw_format = parsed[:options][:format] || 'PEM'
815
+ raise ArgumentError, 'Supply exactly one certificate format' unless raw_format.is_a?(String)
816
+
817
+ format = raw_format.downcase.to_sym
818
+ raise ArgumentError, 'Certificate format must be PEM or DER' unless %i[pem der].include?(format)
819
+
820
+ output = positionals[1] if ['keys attest', 'certificates export'].include?(path)
821
+ if output && output != '-'
822
+ raise ArgumentError, 'Output path already exists; refusing to overwrite' if File.exist?(output) || File.symlink?(output)
823
+ raise ArgumentError, 'Output parent directory must exist and be writable' unless File.directory?(File.dirname(output)) && File.writable?(File.dirname(output))
824
+ end
825
+ certificate = parse_certificate(data: input_bytes(path: positionals[1], value: opts[:certificate])) if path == 'certificates import'
826
+ Protocol.require_confirm(opts) unless ['info', 'keys info', 'certificates export'].include?(path)
827
+ key = load_private_key(opts.merge(key: input_bytes(path: positionals[1], value: opts[:key]))) if path == 'keys import'
828
+ session = Session.new(opts, reset_mode: path == 'reset')
829
+ result = case path
830
+ when 'info' then session.info
831
+ when 'reset' then session.reset
832
+ when 'access change-pin' then session.change_pin
833
+ when 'access change-admin-pin' then session.change_pin(admin: true)
834
+ when 'access set-retries' then session.configure_retries(positionals.map(&:to_i))
835
+ when 'access change-reset-code' then session.change_reset_code
836
+ when 'access unblock-pin' then session.unblock_pin
837
+ when 'access set-signature-policy' then session.signature_policy(positionals[0].to_sym)
838
+ when 'keys info' then session.key_info(slot)
839
+ when 'keys set-touch' then session.set_touch(slot, policy)
840
+ when 'keys import' then session.import_key(slot, key)
841
+ when 'keys attest' then session.attest_key(slot, format)
842
+ when 'certificates export' then session.export_certificate(slot, format)
843
+ when 'certificates import' then session.write_certificate(slot, certificate.to_der)
844
+ when 'certificates delete' then session.write_certificate(slot, ''.b)
845
+ else raise ArgumentError, 'Unknown OpenPGP operation'
846
+ end
847
+ File.open(output, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(result) } if output && output != '-'
848
+ result
849
+ end
850
+
851
+ private_class_method def self.validate_slot(opts = {})
852
+ value = opts[:slot]
853
+ slot = value.to_s.downcase.to_sym
854
+ slot = :dec if slot == :enc
855
+ raise ArgumentError, 'slot must be sig, dec, aut or att' unless SLOTS.key?(slot)
856
+
857
+ slot
858
+ end
859
+
860
+ private_class_method def self.input_bytes(opts = {})
861
+ path = opts[:path]
862
+ value = opts[:value]
863
+ if path && path != '-'
864
+ raise ArgumentError, 'Supply a file path or in-memory data, not both' if value
865
+
866
+ value = File.binread(path, 1_048_577)
867
+ end
868
+ raise ArgumentError, 'Supply bounded PEM or DER bytes in memory when using -' unless value.is_a?(String) && value.bytesize.between?(1, 1_048_576)
869
+
870
+ value.b
871
+ end
872
+
873
+ private_class_method def self.parse_certificate(opts = {})
874
+ bytes = opts[:data]
875
+ if bytes.include?('-----BEGIN')
876
+ raise ArgumentError, 'Supply exactly one PEM certificate' unless bytes.scan('-----BEGIN CERTIFICATE-----').size == 1 && bytes.match?(%r{\A\s*-----BEGIN CERTIFICATE-----[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----\s*\z})
877
+ else
878
+ OpenSSL::ASN1.decode(bytes)
879
+ end
880
+ OpenSSL::X509::Certificate.new(bytes)
881
+ rescue OpenSSL::OpenSSLError
882
+ raise ArgumentError, 'Certificate is not valid PEM or DER X.509', cause: nil
883
+ end
884
+
885
+ private_class_method def self.load_private_key(opts = {})
886
+ value = opts[:key]
887
+ raise ArgumentError, 'key must be PEM/DER bytes or an OpenSSL private key' unless value.is_a?(OpenSSL::PKey::PKey) || (value.is_a?(String) && value.bytesize.between?(1, 1_048_576))
888
+
889
+ password = opts[:key_password]
890
+ encrypted = value.is_a?(String) && value.include?('ENCRYPTED')
891
+ if value.is_a?(String) && !value.include?('-----BEGIN')
892
+ begin
893
+ data = OpenSSL::ASN1.decode(value).value
894
+ encrypted ||= data.is_a?(Array) && data.first.is_a?(OpenSSL::ASN1::Sequence) && data[1].is_a?(OpenSSL::ASN1::OctetString)
895
+ rescue OpenSSL::ASN1::ASN1Error
896
+ raise ArgumentError, 'Malformed private key encoding', cause: nil
897
+ end
898
+ end
899
+ password = Protocol.secret(value: password, name: :key_password, secret_provider: opts[:secret_provider]) if encrypted
900
+ Crypto.new.parse_private(value, password)
901
+ end
902
+
903
+ public_class_method def self.generate_key(opts = {})
904
+ slot = validate_slot(slot: opts[:slot])
905
+ algorithm = opts[:algorithm].to_s.downcase.to_sym
906
+ raise ArgumentError, 'Unknown key algorithm; use rsa2048/rsa3072/rsa4096 or a named curve' unless [:rsa2048, :rsa3072, :rsa4096, *CURVES.keys].include?(algorithm)
907
+
908
+ Protocol.require_confirm(opts)
909
+ Session.new(opts).generate_key(slot, algorithm)
910
+ end
911
+
912
+ public_class_method def self.import_key(opts = {})
913
+ slot = validate_slot(slot: opts[:slot])
914
+ Protocol.require_confirm(opts)
915
+ key = load_private_key(opts)
916
+ Session.new(opts).import_key(slot, key)
917
+ end
918
+
919
+ public_class_method def self.sign(opts = {})
920
+ message = opts[:message]
921
+ raise ArgumentError, 'message must be a bounded binary String' unless message.is_a?(String) && message.bytesize <= 1_048_576
922
+
923
+ Protocol.require_confirm(opts)
924
+ Session.new(opts).sign(message.b, opts.fetch(:hash_algorithm, :sha256), prehashed: opts[:prehashed] == true)
925
+ end
926
+
927
+ public_class_method def self.authenticate(opts = {})
928
+ message = opts[:message]
929
+ raise ArgumentError, 'message must be a bounded binary String' unless message.is_a?(String) && message.bytesize <= 1_048_576
930
+
931
+ Protocol.require_confirm(opts)
932
+ Session.new(opts).sign(message.b, opts.fetch(:hash_algorithm, :sha256), prehashed: opts[:prehashed] == true, authentication: true)
933
+ end
934
+
935
+ public_class_method def self.decipher(opts = {})
936
+ value = opts[:value]
937
+ raise ArgumentError, 'value must be ciphertext bytes or an OpenSSL peer public key' unless value.is_a?(String) || value.is_a?(OpenSSL::PKey::PKey)
938
+
939
+ Protocol.require_confirm(opts)
940
+ Session.new(opts).decipher(value)
941
+ end
942
+
943
+ public_class_method def self.algorithms(opts = {})
944
+ Session.new(opts.merge(connection: opts[:connection])).algorithms
945
+ end
946
+
947
+ public_class_method def self.public_key(opts = {})
948
+ slot = validate_slot(slot: opts[:slot])
949
+ Session.new(opts).public_key(slot)
950
+ end
951
+
952
+ public_class_method def self.verify_pin(opts = {})
953
+ mode = opts[:mode] || :signature
954
+ raise ArgumentError, 'mode must be :signature, :extended or :admin' unless %i[signature extended admin].include?(mode)
955
+
956
+ Protocol.require_confirm(opts)
957
+ Session.new(opts).verify_pin(mode)
958
+ end
959
+
960
+ public_class_method def self.unverify_pin(opts = {})
961
+ mode = opts[:mode] || :signature
962
+ raise ArgumentError, 'mode must be :signature, :extended or :admin' unless %i[signature extended admin].include?(mode)
963
+
964
+ Protocol.require_confirm(opts)
965
+ Session.new(opts).unverify_pin(mode)
966
+ end
967
+
968
+ public_class_method def self.decrypt(opts = {})
969
+ decipher(opts.merge(value: opts[:value]))
970
+ end
971
+
972
+ public_class_method def self.authors
973
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
974
+ end
975
+
976
+ public_class_method def self.help
977
+ puts <<~HELP
978
+ Native Ruby OpenPGP Card application over a caller-owned APDU Connection.
979
+ All authentication, key use, mutation and reset operations require confirm: true.
980
+ Credentials stay in memory. There are no factory credentials or automatic mutation retries.
981
+ Changing key attributes can erase an existing key; fixed touch policies are irreversible
982
+ until that key is replaced. Reset preserves the attestation key and certificate.
983
+ PIN change results report fresh counters, not proof of the new secret value.
984
+
985
+ List implemented management leaf paths without accessing hardware.
986
+ #{self}.operations
987
+ Execute a native management operation or list a local group.
988
+ #{self}.execute(
989
+ args: 'optional - Nonsecret words after openpgp; groups are access, keys and certificates',
990
+ connection: 'required - Caller-owned YubiKey Connection with descriptor protocol :apdu',
991
+ timeout: 'optional - Finite positive per-command timeout in seconds; default 30',
992
+ confirm: 'optional - Literal true required for authentication, cryptographic use and every mutation',
993
+ pin: 'optional - Current user PIN bytes supplied directly in memory',
994
+ new_pin: 'optional - New user PIN bytes for change-pin or unblock-pin',
995
+ admin_pin: 'optional - Current admin PIN bytes supplied directly in memory',
996
+ new_admin_pin: 'optional - Replacement admin PIN bytes for change-admin-pin',
997
+ reset_code: 'optional - Reset code bytes for change-reset-code or unblock-pin',
998
+ use_admin: 'optional - Literal true selects provider-supplied admin authorization for unblock-pin',
999
+ key: 'optional - PEM or DER private key bytes when keys import uses the input marker -',
1000
+ key_password: 'optional - Private key decryption password kept in memory, never argv',
1001
+ certificate: 'optional - One PEM or DER certificate when certificates import uses -',
1002
+ secret_provider: 'optional - Callable receiving a required secret field Symbol and returning bytes'
1003
+ )
1004
+ Leaves: info; reset [--force]; access set-retries USER RESET ADMIN;
1005
+ access change-pin; access change-admin-pin; access change-reset-code;
1006
+ access unblock-pin; access set-signature-policy always|once;
1007
+ keys info SLOT; keys set-touch SLOT off|on|fixed|cached|cached-fixed [--force];
1008
+ keys import SLOT FILE|-; keys attest SLOT OUTPUT|- [--format PEM|DER];
1009
+ certificates export SLOT OUTPUT|- [--format PEM|DER];
1010
+ certificates import SLOT FILE|-; certificates delete SLOT.
1011
+ --force is syntax compatibility, never consent. Input - requires in-memory data;
1012
+ output - returns bytes, never writes stdout. Named output paths are created exclusively.
1013
+
1014
+ Query firmware-specific algorithms advertised by the selected card.
1015
+ #{self}.algorithms(connection: connection)
1016
+ Export a slot public key as a native OpenSSL public-key object.
1017
+ #{self}.public_key(slot: :sig, connection: connection)
1018
+ Generate an on-card private key and verify its public-key readback.
1019
+ #{self}.generate_key(
1020
+ slot: 'required - Key slot :sig, :dec, :aut or :att; :enc aliases :dec',
1021
+ algorithm: 'required - rsa2048, rsa3072, rsa4096, secp256r1, secp256k1, secp384r1, secp521r1, brainpoolp256r1, brainpoolp384r1, brainpoolp512r1, ed25519 or x25519',
1022
+ connection: connection, admin_pin: admin_pin, confirm: true
1023
+ )
1024
+ Import a native OpenSSL private key or PEM/DER bytes and verify its public key.
1025
+ #{self}.import_key(slot: :sig, key: key, connection: connection, admin_pin: admin_pin, confirm: true)
1026
+ Sign a message with the signature slot using PSO COMPUTE DIGITAL SIGNATURE.
1027
+ #{self}.sign(
1028
+ message: 'required - Original binary message; Ed25519 is not prehashed',
1029
+ hash_algorithm: 'optional - sha1, sha224, sha256, sha384 or sha512; defaults to sha256 and is ignored for Ed25519',
1030
+ prehashed: 'optional - Literal true means message already contains the named digest; not valid for Ed25519',
1031
+ connection: connection, pin: pin, confirm: true
1032
+ )
1033
+ Sign an authentication challenge with the authentication slot using INTERNAL AUTHENTICATE.
1034
+ #{self}.authenticate(message: message, connection: connection, pin: pin, confirm: true)
1035
+ Decrypt an RSA PKCS1-v1_5 block or perform raw ECDH with the decryption slot.
1036
+ #{self}.decipher(
1037
+ value: 'required - RSA ciphertext bytes, or an OpenSSL EC/X25519 peer public key or its raw point bytes',
1038
+ connection: connection, pin: pin, confirm: true
1039
+ )
1040
+ Decrypt through the same native decipher implementation.
1041
+ #{self}.decrypt(value: value, connection: connection, pin: pin, confirm: true)
1042
+ Verify a PIN once without guessing credentials or retrying a failure.
1043
+ #{self}.verify_pin(
1044
+ mode: 'optional - :signature for PW1/81, :extended for PW1/82, or :admin for PW3/83',
1045
+ connection: connection, pin: pin, confirm: true
1046
+ )
1047
+ Clear the selected PIN verification state on firmware 5.6 or newer.
1048
+ #{self}.unverify_pin(mode: :signature, connection: connection, confirm: true)
1049
+ Show module author information.
1050
+ #{self}.authors
1051
+
1052
+ RSA signatures are raw modulus-length bytes; ECDSA signatures are ASN.1 DER;
1053
+ Ed25519 signatures are raw 64 bytes. ECDH returns a raw shared secret, not a
1054
+ decrypted OpenPGP message. No keyring, OpenPGP packet codec or trust verification
1055
+ is implied. Attestation returns a parsed certificate, not a validated trust chain.
1056
+ Unsupported firmware, curves, policies and advertised capabilities fail explicitly.
1057
+ Readback mismatches and timeouts never cause mutation replay.
1058
+ HELP
1059
+ end
1060
+ end
1061
+ end
1062
+ end
1063
+ end