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,646 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'openssl'
5
+ require 'pwn/plugins/yubikey'
6
+ require 'pwn/plugins/yubikey/protocol' if File.file?(File.expand_path('../../../../../lib/pwn/plugins/yubikey/protocol.rb', __dir__))
7
+ require 'pwn/plugins/yubikey/piv'
8
+
9
+ # Synthetic card boundary; independently frames TLV and ISO APDUs.
10
+ class PivWireCard # rubocop:disable Metrics/ClassLength
11
+ attr_accessor :version, :management_key, :management_algorithm, :corrupt_auth, :pin_status, :bio
12
+ attr_reader :commands, :objects, :keys
13
+
14
+ def initialize
15
+ @version = [5, 7, 1]
16
+ @management_key = (1..24).to_a.pack('C*')
17
+ @management_algorithm = 3
18
+ @commands = []
19
+ @objects = {}
20
+ @keys = {}
21
+ @chain = ''.b
22
+ @pin_status = 0x9000
23
+ @pin_tries = @pin_total = 3
24
+ @puk_tries = @puk_total = 3
25
+ @default_refs = false
26
+ end
27
+
28
+ def descriptor
29
+ { protocol: :apdu }
30
+ end
31
+
32
+ def tlv(tag, value = ''.b)
33
+ head = tag > 255 ? [tag].pack('n') : [tag].pack('C')
34
+ length = value.bytesize
35
+ head + (length < 128 ? [length].pack('C') : [0x82, length].pack('Cn')) + value.b
36
+ end
37
+
38
+ def fields(bytes)
39
+ result = {}
40
+ offset = 0
41
+ while offset < bytes.bytesize
42
+ tag = bytes.getbyte(offset)
43
+ offset += 1
44
+ if tag & 31 == 31
45
+ tag = (tag << 8) | bytes.getbyte(offset)
46
+ offset += 1
47
+ end
48
+ length = bytes.getbyte(offset)
49
+ offset += 1
50
+ if length >= 128
51
+ size = length & 127
52
+ length = bytes.byteslice(offset, size).bytes.reduce(0) { |n, b| (n << 8) | b }
53
+ offset += size
54
+ end
55
+ result[tag] = bytes.byteslice(offset, length)
56
+ offset += length
57
+ end
58
+ result
59
+ end
60
+
61
+ def exchange(opts)
62
+ raise 'wrong boundary' unless opts[:protocol] == :apdu
63
+
64
+ bytes = opts.fetch(:data)
65
+ cla, ins, p1, param2 = bytes.bytes.first(4)
66
+ length = bytes.bytesize > 5 ? bytes.getbyte(4) : 0
67
+ offset = 5
68
+ if length.zero? && bytes.bytesize > 7
69
+ length = bytes.byteslice(5, 2).unpack1('n')
70
+ offset = 7
71
+ end
72
+ data = @chain + (bytes.byteslice(offset, length) || ''.b)
73
+ if cla.anybits?(16)
74
+ @chain = data
75
+ return ['9000'].pack('H*')
76
+ end
77
+ @chain = ''.b
78
+ @commands << [ins, p1, param2, data]
79
+ value = command(ins, p1, param2, data)
80
+ value.is_a?(Integer) ? [value].pack('n') : value + ['9000'].pack('H*')
81
+ end
82
+
83
+ def command(ins, param1, param2, data)
84
+ case ins
85
+ when 0xa4, 0x24 then ''.b
86
+ when 0xfa
87
+ @pin_total = @pin_tries = param1
88
+ @puk_total = @puk_tries = param2
89
+ @default_refs = true
90
+ ''.b
91
+ when 0xfd then @version.pack('C*')
92
+ when 0xf8 then [123].pack('N')
93
+ when 0xf7
94
+ case param2
95
+ when 0x9b then tlv(1, [@management_algorithm].pack('C')) + tlv(2, "\x00\x01".b) + tlv(5, [@default_refs ? 1 : 0].pack('C'))
96
+ when 0x80, 0x81 then tlv(5, [@default_refs ? 1 : 0].pack('C')) + tlv(6, (param2 == 0x80 ? [@pin_total, @pin_tries] : [@puk_total, @puk_tries]).pack('C*'))
97
+ when 0x96 then @bio ? tlv(7, "\x01".b) + tlv(6, "\x03".b) + tlv(8, "\x00".b) : 0x6a88
98
+ else
99
+ record = @keys[param2]
100
+ record ? tlv(1, [record[:algorithm]].pack('C')) + tlv(2, record.fetch(:policy, "\x02\x01".b)) + tlv(3, [record.fetch(:origin, 1)].pack('C')) + tlv(4, public_bytes(record.fetch(:key))) : 0x6a88
101
+ end
102
+ when 0x47
103
+ values = fields(fields(data).fetch(0xac))
104
+ id = values.fetch(0x80).getbyte(0)
105
+ key = case id
106
+ when 6, 7, 5, 0x16 then OpenSSL::PKey::RSA.generate({ 6 => 1024, 7 => 2048, 5 => 3072, 0x16 => 4096 }.fetch(id))
107
+ when 0x11, 0x14 then OpenSSL::PKey::EC.generate(id == 0x11 ? 'prime256v1' : 'secp384r1')
108
+ when 0xe0, 0xe1 then OpenSSL::PKey.generate_key(id == 0xe0 ? 'ED25519' : 'X25519')
109
+ else raise 'invalid algorithm'
110
+ end
111
+ @keys[param2] = { key: key, algorithm: id, origin: 1, policy: values.fetch(0xaa, "\x02".b) + values.fetch(0xab, "\x01".b) }
112
+ tlv(0x7f49, public_bytes(key))
113
+ when 0xfe
114
+ values = fields(data)
115
+ key = import_key(param1, values)
116
+ @keys[param2] = { key: key, algorithm: param1, origin: 2, policy: values.fetch(0xaa, "\x02".b) + values.fetch(0xab, "\x01".b) }
117
+ ''.b
118
+ when 0xf6
119
+ return 0x6a88 unless @keys.key?(param2)
120
+ return 0x6a80 if param1 != 0xff && @keys.key?(param1)
121
+
122
+ removed = @keys.delete(param2)
123
+ @keys[param1] = removed unless param1 == 0xff
124
+ ''.b
125
+ when 0xf9
126
+ record = @keys[param1]
127
+ return 0x6a80 unless record && record[:origin] == 1
128
+
129
+ key = record.fetch(:key)
130
+ cert = OpenSSL::X509::Certificate.new
131
+ cert.version = 2
132
+ cert.serial = 17
133
+ cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=synthetic attestation')
134
+ cert.public_key = OpenSSL::PKey.read(key.public_to_der)
135
+ cert.not_before = Time.now
136
+ cert.not_after = Time.now + 3600
137
+ cert.sign(OpenSSL::PKey::EC.generate('prime256v1'), 'SHA256')
138
+ cert.to_der
139
+ when 0x20
140
+ if data == "\xff".b * 8
141
+ @pin_tries = [@pin_tries - 1, 0].max
142
+ 0x63c0 | @pin_tries
143
+ else
144
+ @pin_status
145
+ end
146
+ when 0x2c
147
+ if data == "\xff".b * 16
148
+ @puk_tries = [@puk_tries - 1, 0].max
149
+ 0x63c0 | @puk_tries
150
+ else
151
+ ''.b
152
+ end
153
+ when 0xfb
154
+ return 0x6985 unless @pin_tries.zero? && @puk_tries.zero?
155
+
156
+ @objects.clear
157
+ @keys.clear
158
+ @pin_tries = @puk_tries = 3
159
+ @default_refs = true
160
+ ''.b
161
+ when 0x87
162
+ auth = fields(fields(data).fetch(0x7c))
163
+ return private_operation(param2, auth) unless param2 == 0x9b
164
+
165
+ size = @management_algorithm == 3 ? 8 : 16
166
+ if auth.fetch(0x80).empty?
167
+ tlv(0x7c, tlv(0x80, cipher("\xa5".b * size)))
168
+ else
169
+ raise 'incorrect witness' unless auth.fetch(0x80) == "\xa5".b * size
170
+
171
+ tlv(0x7c, tlv(0x82, @corrupt_auth ? "\x00".b * size : cipher(auth.fetch(0x81))))
172
+ end
173
+ when 0xcb
174
+ object_id = fields(data).fetch(0x5c).unpack1('H*').to_i(16)
175
+ if @objects.key?(object_id)
176
+ tlv(object_id == 0x7e ? 0x7e : 0x53, @objects.fetch(object_id))
177
+ else
178
+ 0x6a82
179
+ end
180
+ when 0xdb
181
+ values = fields(data)
182
+ @objects[values.fetch(0x5c).unpack1('H*').to_i(16)] = values.fetch(0x53)
183
+ ''.b
184
+ when 0xff
185
+ @management_algorithm = data.getbyte(0)
186
+ @management_key = fields(data.byteslice(1..)).fetch(0x9b)
187
+ ''.b
188
+ else raise format('Unexpected PIV INS %02x', ins)
189
+ end
190
+ end
191
+
192
+ def private_operation(slot, values)
193
+ record = @keys.fetch(slot)
194
+ key = record.fetch(:key)
195
+ result = if values.key?(0x85)
196
+ bytes = values.fetch(0x85)
197
+ if key.is_a?(OpenSSL::PKey::EC)
198
+ peer = OpenSSL::PKey::EC::Point.new(key.group, OpenSSL::BN.new(bytes, 2))
199
+ key.dh_compute_key(peer)
200
+ else
201
+ asn1 = OpenSSL::ASN1
202
+ peer = OpenSSL::PKey.read(asn1::Sequence.new([asn1::Sequence.new([asn1::ObjectId.new('X25519')]), asn1::BitString.new(bytes)]).to_der)
203
+ key.derive(peer)
204
+ end
205
+ elsif key.is_a?(OpenSSL::PKey::RSA)
206
+ key.private_decrypt(values.fetch(0x81), OpenSSL::PKey::RSA::NO_PADDING)
207
+ elsif key.is_a?(OpenSSL::PKey::EC)
208
+ key.dsa_sign_asn1(values.fetch(0x81))
209
+ else
210
+ key.sign(nil, values.fetch(0x81))
211
+ end
212
+ tlv(0x7c, tlv(0x82, result))
213
+ end
214
+
215
+ def public_bytes(key)
216
+ if key.is_a?(OpenSSL::PKey::RSA)
217
+ tlv(0x81, key.n.to_s(2)) + tlv(0x82, key.e.to_s(2))
218
+ elsif key.is_a?(OpenSSL::PKey::EC)
219
+ tlv(0x86, key.public_key.to_octet_string(:uncompressed))
220
+ else
221
+ tlv(0x86, key.raw_public_key)
222
+ end
223
+ end
224
+
225
+ def import_key(id, values)
226
+ asn1 = OpenSSL::ASN1
227
+ if [6, 7, 5, 0x16].include?(id)
228
+ p, q, dmp, dmq, iqmp = (1..5).map { |tag| OpenSSL::BN.new(values.fetch(tag), 2) }
229
+ e = OpenSSL::BN.new('65537')
230
+ d = e.mod_inverse((p - 1) * (q - 1))
231
+ OpenSSL::PKey.read(asn1::Sequence.new([0, p * q, e, d, p, q, dmp, dmq, iqmp].map { |n| asn1::Integer.new(n) }).to_der)
232
+ elsif [0x11, 0x14].include?(id)
233
+ OpenSSL::PKey.read(asn1::Sequence.new([asn1::Integer.new(1), asn1::OctetString.new(values.fetch(6)), asn1::ASN1Data.new([asn1::ObjectId.new(id == 0x11 ? 'prime256v1' : 'secp384r1')], 0, :CONTEXT_SPECIFIC)]).to_der)
234
+ else
235
+ OpenSSL::PKey.read(asn1::Sequence.new([asn1::Integer.new(0), asn1::Sequence.new([asn1::ObjectId.new(id == 0xe0 ? 'ED25519' : 'X25519')]), asn1::OctetString.new(asn1::OctetString.new(values.fetch(id == 0xe0 ? 7 : 8)).to_der)]).to_der)
236
+ end
237
+ end
238
+
239
+ def cipher(data)
240
+ cipher = OpenSSL::Cipher.new({ 3 => 'DES-EDE3-ECB', 8 => 'AES-128-ECB', 10 => 'AES-192-ECB', 12 => 'AES-256-ECB' }.fetch(@management_algorithm))
241
+ cipher.encrypt
242
+ cipher.padding = 0
243
+ cipher.key = @management_key
244
+ cipher.update(data) + cipher.final
245
+ end
246
+ end
247
+
248
+ RSpec.describe 'Native PIV' do
249
+ let(:piv) { PWN::Plugins::YubiKey::PIV }
250
+ let(:card) { PivWireCard.new }
251
+ let(:authorized) { { connection: card, confirm: true, management_key: card.management_key, pin: '765432' } }
252
+ # Literal C/R-APDUs from the PIV wire specification, not the production codec.
253
+ def wire_card(pairs)
254
+ double('PCSC connection').tap do |card|
255
+ pairs.each do |command, response|
256
+ expect(card).to receive(:exchange).with(hash_including(protocol: :apdu, data: [command].pack('H*'))).ordered.and_return([response].pack('H*'))
257
+ end
258
+ end
259
+ end
260
+
261
+ it 'verifies a supplied PIN with an ISO padded VERIFY command' do
262
+ card = wire_card([
263
+ %w[00a404000ba000000308000010000100 9000],
264
+ %w[0020008008373635343332ffff 9000]
265
+ ])
266
+ expect(PWN::Plugins::YubiKey::PIV.verify_pin(connection: card, pin: '765432', confirm: true)).to eq(:verified)
267
+ end
268
+
269
+ { 3 => 24, 8 => 16, 10 => 24, 12 => 32 }.each do |algorithm, size|
270
+ it "mutually authenticates algorithm #{algorithm} with a fresh verified challenge" do
271
+ card.management_algorithm = algorithm
272
+ card.management_key = (1..size).to_a.pack('C*')
273
+ 2.times { expect(piv.authenticate(authorized)).to eq(:authenticated) }
274
+ challenges = card.commands.select { |ins, _, slot, data| ins == 0x87 && slot == 0x9b && data.bytesize > 4 }
275
+ expect(challenges.size).to eq(2)
276
+ expect(challenges.first.last).not_to eq(challenges.last.last)
277
+ end
278
+ end
279
+
280
+ it 'rejects an invalid management witness response without performing a write' do
281
+ card.corrupt_auth = true
282
+ expect { piv.authenticate(authorized) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /authentication/)
283
+ end
284
+
285
+ it 'changes and unblocks references with literal ISO7816 command bytes' do
286
+ [[%w[access change-pin], :new_pin, 0x24, 0x80, '765432'],
287
+ [%w[access change-puk], :new_puk, 0x24, 0x81, '87654321'],
288
+ [%w[access unblock-pin], :new_pin, 0x2c, 0x80, '87654321']].each do |args, field, instruction, reference, old|
289
+ expect(piv.execute(authorized.merge(args: args, puk: '87654321', field => '246810'))).to include(status: :changed)
290
+ expect(card.commands).to include([instruction, 0, reference, old.b.ljust(8, "\xff".b) + "246810\xff\xff".b])
291
+ end
292
+ end
293
+
294
+ it 'reports remaining retries without echoing credentials or retrying' do
295
+ card.pin_status = 0x63c2
296
+ expect { piv.verify_pin(authorized) }.to raise_error(PWN::Plugins::YubiKey::PIV::PinError) { |error| expect(error.attempts_remaining).to eq(2) }
297
+ expect(card.commands.count { |ins,| ins == 0x20 }).to eq(1)
298
+ end
299
+
300
+ it 'sets retry limits only after management and PIN authentication' do
301
+ expect(piv.execute(authorized.merge(args: %w[access set-retries 5 0]))).to include(status: :changed)
302
+ expect(card.commands.select { |ins,| [0x87, 0x20, 0xfa].include?(ins) }.map(&:first)).to eq([0x87, 0x87, 0x20, 0xfa])
303
+ expect(card.commands).to include([0xfa, 5, 0, ''.b])
304
+ end
305
+
306
+ it 'rejects argv secrets and invalid retry limits before authenticating' do
307
+ [%w[access change-pin --pin 123456], %w[access set-retries 0 3]].each do |args|
308
+ expect { piv.execute(authorized.merge(args: args)) }.to raise_error(ArgumentError)
309
+ end
310
+ expect(card.commands).to be_empty
311
+ end
312
+
313
+ it 'round-trips raw objects without text conversion and guards the protected-key object' do
314
+ data = "\x00\xffobject\x80".b
315
+ expect(piv.execute(authorized.merge(args: %w[objects import 5f1234 -], input: data))).to include(status: :written)
316
+ expect(piv.execute(connection: card, args: %w[objects export 5f1234 -])).to eq(data)
317
+ expect(card.commands).to include([0xdb, 0x3f, 0xff, ['5c035f1234530900ff6f626a65637480'].pack('H*')])
318
+ card.objects[0x5fff00] = ['8003810102'].pack('H*')
319
+ expect { piv.execute(authorized.merge(args: %w[objects import printed -], input: 'not a key')) }.to raise_error(ArgumentError, /protected/)
320
+ end
321
+
322
+ it 'generates valid CHUID and CCC objects and verifies each persisted value' do
323
+ %w[CHUID CCC].each do |object|
324
+ expect(piv.execute(authorized.merge(args: ['objects', 'generate', object]))).to include(status: :written)
325
+ end
326
+ chuid = card.fields(card.objects.fetch(0x5fc102))
327
+ expect(chuid.fetch(0x30)).to eq(['d4e739da739ced39ce739d836858210842108421c84210c3eb'].pack('H*'))
328
+ expect(chuid.fetch(0x34).bytesize).to eq(16)
329
+ expect(chuid.fetch(0x35)).to eq('20300101')
330
+ expect(card.fields(card.objects.fetch(0x5fc107)).fetch(0xf0).bytesize).to eq(21)
331
+ expect(card.commands.select { |ins,| ins == 0xdb }.size).to eq(2)
332
+ end
333
+
334
+ it 'rotates and stores an AES management key under PIN protection without returning it' do
335
+ replacement = 'Z'.b * 32
336
+ result = piv.execute(authorized.merge(args: %w[access change-management-key --algorithm AES256 --protect], new_management_key: replacement))
337
+ expect(result).to include(status: :changed, protected: true)
338
+ expect(result.inspect).not_to include(replacement)
339
+ expect(card.management_key).to eq(replacement)
340
+ expect(card.management_algorithm).to eq(12)
341
+ expect(card.objects.fetch(0x5fc109)).to eq(card.tlv(0x88, card.tlv(0x89, replacement)))
342
+ expect(card.objects.fetch(0x5fff00)).to eq(['8003810102'].pack('H*'))
343
+ expect(piv.execute(connection: card, confirm: true, pin: '765432', args: %w[objects generate CCC], use_protected_key: true)).to include(status: :written)
344
+ end
345
+
346
+ it 'rejects AES management rotation on older firmware before authentication' do
347
+ card.version = [5, 3, 1]
348
+ expect { piv.execute(authorized.merge(args: %w[access change-management-key --algorithm AES128], new_management_key: 'Z' * 16)) }.to raise_error(piv::UnsupportedError)
349
+ expect(!card.commands.map(&:first).intersect?([0x87, 0xff])).to be(true)
350
+ end
351
+
352
+ %w[RSA1024 RSA2048 RSA3072 RSA4096 ECCP256 ECCP384 ED25519 X25519].each do |algorithm|
353
+ it "generates, exports and imports #{algorithm} with independent key reconstruction" do
354
+ pem = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm, '--pin-policy', 'ALWAYS', '--touch-policy', 'CACHED']))
355
+ public_key = OpenSSL::PKey.read(pem)
356
+ expect(public_key.public_to_der).to eq(card.keys.fetch(0x9a).fetch(:key).public_to_der)
357
+ expect(piv.execute(connection: card, args: %w[keys info 9a])).to include(algorithm: algorithm, pin_policy: 'ALWAYS', touch_policy: 'CACHED', generated: true)
358
+ expect(OpenSSL::PKey.read(piv.execute(connection: card, args: %w[keys export 9a -])).public_to_der).to eq(public_key.public_to_der)
359
+ private_key = card.keys.fetch(0x9a).fetch(:key)
360
+ expect(piv.execute(authorized.merge(args: %w[keys import 9c -], input: private_key.private_to_pem))).to include(status: :imported, algorithm: algorithm)
361
+ expect(card.keys.fetch(0x9c).fetch(:key).public_to_der).to eq(public_key.public_to_der)
362
+ expect(piv.execute(connection: card, args: %w[keys info 9c])).to include(generated: false)
363
+ end
364
+ end
365
+
366
+ it 'attests, moves and deletes only the selected key with 5.7 wire commands' do
367
+ pem = piv.execute(authorized.merge(args: %w[keys generate 9a - --algorithm ECCP256]))
368
+ attestation = OpenSSL::X509::Certificate.new(piv.execute(connection: card, args: %w[keys attest 9a -]))
369
+ expect(attestation.public_key.public_to_der).to eq(OpenSSL::PKey.read(pem).public_to_der)
370
+ expect(piv.execute(authorized.merge(args: %w[keys move 9a 82]))).to include(status: :moved)
371
+ expect(card.commands).to include([0xf6, 0x82, 0x9a, ''.b])
372
+ expect(piv.execute(authorized.merge(args: %w[keys delete 82]))).to include(status: :deleted)
373
+ expect(card.commands).to include([0xf6, 0xff, 0x82, ''.b])
374
+ expect(card.keys).to be_empty
375
+ end
376
+
377
+ it 'applies version, ROCA, FIPS and biometric policy restrictions before key mutation' do
378
+ [[[5, 6, 3], 'ED25519', []], [[4, 3, 4], 'RSA2048', []], [[3, 4, 0], 'ECCP384', []],
379
+ [[4, 4, 1], 'RSA1024', []], [[5, 7, 0], 'ECCP256', %w[--pin-policy MATCH_ALWAYS]]].each do |version, algorithm, flags|
380
+ card.version = version
381
+ expect { piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm] + flags)) }.to raise_error(piv::UnsupportedError)
382
+ end
383
+ expect(!card.commands.map(&:first).intersect?([0x47, 0x87])).to be(true)
384
+ end
385
+
386
+ %w[RSA1024 RSA2048 RSA3072 RSA4096 ECCP256 ECCP384 ED25519].each do |algorithm|
387
+ it "signs actual messages with card-backed #{algorithm} and validates with OpenSSL" do
388
+ pem = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm]))
389
+ key = OpenSSL::PKey.read(pem)
390
+ message = "device-backed signature\x00".b
391
+ signature = piv.sign(authorized.merge(slot: '9a', algorithm: algorithm, message: message))
392
+ expect(key.verify(algorithm == 'ED25519' ? nil : 'SHA256', signature, message)).to be(true)
393
+ if algorithm.start_with?('RSA')
394
+ signature = piv.sign(authorized.merge(slot: '9a', algorithm: algorithm, message: message, padding: :pss))
395
+ expect(key.verify_pss('SHA256', signature, message, salt_length: :digest, mgf1_hash: 'SHA256')).to be(true)
396
+ end
397
+ end
398
+ end
399
+
400
+ it 'decrypts RSA OAEP and PKCS1 blocks through the real card operation' do
401
+ key = OpenSSL::PKey.read(piv.execute(authorized.merge(args: %w[keys generate 9d - --algorithm RSA2048])))
402
+ message = "encrypted by the host\x00".b
403
+ ciphertext = key.encrypt(message, 'rsa_padding_mode' => 'oaep', 'rsa_oaep_md' => 'SHA256')
404
+ expect(piv.decrypt(authorized.merge(slot: '9d', ciphertext: ciphertext, padding: :oaep, hash_algorithm: 'SHA256'))).to eq(message)
405
+ ciphertext = key.public_encrypt(message)
406
+ expect(piv.decrypt(authorized.merge(slot: '9d', ciphertext: ciphertext, padding: :pkcs1))).to eq(message)
407
+ end
408
+
409
+ %w[ECCP256 ECCP384 X25519].each do |algorithm|
410
+ it "derives matching shared secrets with #{algorithm}" do
411
+ key = OpenSSL::PKey.read(piv.execute(authorized.merge(args: ['keys', 'generate', '9d', '-', '--algorithm', algorithm])))
412
+ peer = if algorithm == 'X25519'
413
+ OpenSSL::PKey.generate_key('X25519')
414
+ else
415
+ OpenSSL::PKey::EC.generate(algorithm == 'ECCP256' ? 'prime256v1' : 'secp384r1')
416
+ end
417
+ actual = piv.calculate_secret(authorized.merge(slot: '9d', peer_public_key: peer.public_to_pem))
418
+ expect(actual).to eq(peer.derive(key))
419
+ expect(card.commands.select { |ins, _, slot,| ins == 0x87 && slot == 0x9d }.last.last).to include([0x85].pack('C'))
420
+ end
421
+ end
422
+
423
+ %w[RSA2048 ECCP256 ECCP384 ED25519].each do |algorithm|
424
+ it "builds verified certificates and CSRs signed by the #{algorithm} device key" do
425
+ pem = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm]))
426
+ result = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=Native PIV,O=Test', '--valid-days', '10'], input: pem))
427
+ cert = OpenSSL::X509::Certificate.new(result.fetch(:certificate))
428
+ expect(cert.verify(cert.public_key)).to be(true)
429
+ expect(cert.subject.to_s).to include('CN=Native PIV')
430
+ expect(cert.not_after - cert.not_before).to eq(864_000)
431
+ exported = OpenSSL::X509::Certificate.new(piv.execute(connection: card, args: %w[certificates export 9a -]))
432
+ expect(exported.to_der).to eq(cert.to_der)
433
+ csr = OpenSSL::X509::Request.new(piv.execute(authorized.merge(args: ['certificates', 'request', '9a', '-', '-', '--subject', 'CN=Native CSR'], input: pem)))
434
+ expect(csr.verify(csr.public_key)).to be(true)
435
+ expect(csr.public_key.public_to_der).to eq(cert.public_key.public_to_der)
436
+ expect(card.objects).to have_key(0x5fc102)
437
+ end
438
+ end
439
+
440
+ it 'imports compressed certificates, verifies key ownership and deletes without deleting the key' do
441
+ pem = piv.execute(authorized.merge(args: %w[keys generate 9a - --algorithm ECCP256]))
442
+ generated = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=test'], input: pem))
443
+ cert = OpenSSL::X509::Certificate.new(generated.fetch(:certificate))
444
+ expect(piv.execute(authorized.merge(args: %w[certificates import 9a - --compress --verify --no-update-chuid], input: cert.to_der))).to include(status: :written)
445
+ body = card.fields(card.objects.fetch(0x5fc105))
446
+ expect(body.fetch(0x71)).to eq("\x01".b)
447
+ expect(OpenSSL::X509::Certificate.new(piv.execute(connection: card, args: %w[certificates export 9a - --format DER])).to_der).to eq(cert.to_der)
448
+ expect(piv.execute(authorized.merge(args: %w[certificates delete 9a --no-update-chuid]))).to include(status: :written)
449
+ expect(card.objects.fetch(0x5fc105)).to be_empty
450
+ expect(card.keys).to have_key(0x9a)
451
+ end
452
+
453
+ it 'preserves signed or corrupt CHUID data rather than replacing it during certificate writes' do
454
+ pem = piv.execute(authorized.merge(args: %w[keys generate 9a - --algorithm ECCP256]))
455
+ ['broken'.b, card.tlv(0x34, 'x' * 16) + card.tlv(0x3e, 'signed')].each do |chuid|
456
+ card.objects[0x5fc102] = chuid
457
+ piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=test'], input: pem))
458
+ expect(card.objects.fetch(0x5fc102)).to eq(chuid)
459
+ end
460
+ end
461
+
462
+ it 'resets only after explicitly authorized blocking and verifies the reset metadata' do
463
+ expect { piv.execute(connection: card, confirm: true, args: ['reset']) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
464
+ expect(!card.commands.map(&:first).intersect?([0x20, 0x2c])).to be(true)
465
+ expect(piv.execute(connection: card, confirm: true, block_retries: true, args: ['reset'])).to include(status: :reset)
466
+ expect(card.commands.count { |ins,| ins == 0x20 }).to eq(3)
467
+ expect(card.commands.count { |ins,| ins == 0x2c }).to eq(3)
468
+ end
469
+
470
+ it 'refuses reset blocking on biometric devices with enrolled fingerprints' do
471
+ card.bio = true
472
+ expect { piv.execute(connection: card, confirm: true, block_retries: true, args: ['reset']) }.to raise_error(piv::UnsupportedError)
473
+ expect(!card.commands.map(&:first).intersect?([0x20, 0x2c, 0xfb])).to be(true)
474
+ end
475
+
476
+ it 'returns a complete read-only PIV inventory and local group help' do
477
+ %w[access keys certificates objects].each { |group| expect(piv.execute(args: [group])).to include(application: :piv) }
478
+ expect(piv.execute(connection: card, args: ['info'])).to include(version: [5, 7, 1], serial: 123, pin: { default: false, total_attempts: 3, attempts_remaining: 3 }, slots: {})
479
+ expect(!card.commands.map(&:first).intersect?([0x20, 0x87, 0xdb])).to be(true)
480
+ end
481
+
482
+ it 'preserves legacy PIN-derived management access when changing the PIN' do
483
+ salt = 'S' * 16
484
+ card.objects[0x5fff00] = card.tlv(0x80, card.tlv(0x82, salt))
485
+ card.management_key = OpenSSL::PKCS5.pbkdf2_hmac('765432', salt, 10_000, 24, 'SHA1')
486
+ expect(piv.execute(authorized.merge(args: %w[access change-pin], new_pin: '246810'))).to include(status: :changed)
487
+ new_salt = card.fields(card.fields(card.objects.fetch(0x5fff00)).fetch(0x80)).fetch(0x82)
488
+ expect(new_salt).not_to eq(salt)
489
+ expect(card.management_key).to eq(OpenSSL::PKCS5.pbkdf2_hmac('246810', new_salt, 10_000, 24, 'SHA1'))
490
+ end
491
+ end
492
+
493
+ RSpec.describe 'Native PIV safety and compatibility' do
494
+ let(:piv) { PWN::Plugins::YubiKey::PIV }
495
+ let(:card) { PivWireCard.new }
496
+ let(:authorized) { { connection: card, confirm: true, management_key: card.management_key, pin: '765432' } }
497
+
498
+ it 'preflights formats, names, secrets and policies before irreversible commands' do
499
+ invalid = [
500
+ { args: %w[keys generate 9a - --format junk] },
501
+ { args: %w[keys generate 9a - --algorithm ECCP256], pin_policy: 'invented' },
502
+ { args: %w[certificates delete 9a --update-chuid --no-update-chuid] },
503
+ { args: %w[access change-management-key --generate], new_management_key: 'x' * 24 },
504
+ { args: %w[keys import 9a -], input: 'not a private key' }
505
+ ]
506
+ invalid.each { |options| expect { piv.execute(authorized.merge(options)) }.to raise_error(ArgumentError) }
507
+ expect(card.commands.select { |ins,| [0x20, 0x24, 0x47, 0x87, 0xdb, 0xff, 0xfe].include?(ins) }).to be_empty
508
+ end
509
+
510
+ it 'fails malformed private responses and invalid RSA padding without returning plausible bytes' do
511
+ key = OpenSSL::PKey::RSA.generate(2048)
512
+ card.keys[0x9d] = { key: key, algorithm: 7 }
513
+ invalid = key.public_encrypt("\x00".b * 256, OpenSSL::PKey::RSA::NO_PADDING)
514
+ expect { piv.decrypt(authorized.merge(slot: '9d', ciphertext: invalid, padding: :pkcs1)) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /decryption failed/)
515
+ end
516
+
517
+ it 'rejects unsupported padding parameters before using a private key' do
518
+ expect { piv.decrypt(authorized.merge(slot: '9d', ciphertext: 'x' * 256, mgf1_hash: 'bad')) }.to raise_error(ArgumentError)
519
+ expect(card.commands).to be_empty
520
+ end
521
+
522
+ it 'uses encrypted PEM and PKCS12 key imports without implicit password prompts' do
523
+ key = OpenSSL::PKey::EC.generate('prime256v1')
524
+ encrypted = key.private_to_pem(OpenSSL::Cipher.new('aes-256-cbc'), 'synthetic-passphrase')
525
+ expect(piv.execute(authorized.merge(args: %w[keys import 9a -], input: encrypted, password: 'synthetic-passphrase'))).to include(status: :imported)
526
+ cert = OpenSSL::X509::Certificate.new(piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=pkcs12'], input: key.public_to_pem)).fetch(:certificate))
527
+ archive = OpenSSL::PKCS12.create('synthetic-passphrase', 'fixture', key, cert).to_der
528
+ expect(piv.execute(authorized.merge(args: %w[keys import 9c -], input: archive, password: 'synthetic-passphrase'))).to include(status: :imported)
529
+ expect(piv.execute(authorized.merge(args: %w[certificates import 9c - --no-update-chuid], input: archive, password: 'synthetic-passphrase'))).to include(status: :written)
530
+ end
531
+
532
+ it 'keeps retry metadata synchronized and refuses Bio and configured FIPS retry changes' do
533
+ card.objects[0x5fff00] = ['8003810101'].pack('H*')
534
+ piv.execute(authorized.merge(args: %w[access set-retries 4 2]))
535
+ expect(card.fields(card.fields(card.objects.fetch(0x5fff00)).fetch(0x80)).fetch(0x81).getbyte(0) & 1).to eq(0)
536
+ card.commands.clear
537
+ card.bio = true
538
+ expect { piv.execute(authorized.merge(args: %w[access set-retries 4 2])) }.to raise_error(piv::UnsupportedError)
539
+ card.bio = false
540
+ card.instance_variable_set(:@default_refs, false)
541
+ expect { piv.execute(authorized.merge(args: %w[access set-retries 4 2], fips_restrictions: true)) }.to raise_error(piv::UnsupportedError)
542
+ expect(card.commands.select { |ins,| [0x87, 0x20, 0xfa].include?(ins) }).to be_empty
543
+ end
544
+
545
+ it 'does not retry uncertain object writes or silently accept mismatched readback' do
546
+ expect(card).to receive(:exchange).with(hash_including(protocol: :apdu)).at_least(:once).and_call_original
547
+ allow(card).to receive(:command).and_wrap_original do |original, instruction, *rest|
548
+ if instruction == 0xdb
549
+ original.call(instruction, *rest)
550
+ raise PWN::Plugins::YubiKey::TimeoutError, 'uncertain write'
551
+ end
552
+ original.call(instruction, *rest)
553
+ end
554
+ expect { piv.execute(authorized.merge(args: %w[objects import 5f1234 -], input: 'bytes')) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
555
+ expect(card.commands.count { |ins,| ins == 0xdb }).to eq(1)
556
+ expect(card.objects.fetch(0x5f1234)).to eq('bytes')
557
+ end
558
+
559
+ it 'rejects a timeout without trying another public key source' do
560
+ allow(card).to receive(:command).and_wrap_original do |original, ins, *rest|
561
+ raise PWN::Plugins::YubiKey::TimeoutError if ins == 0xf7
562
+
563
+ original.call(ins, *rest)
564
+ end
565
+ expect { piv.execute(connection: card, args: %w[keys export 9a -]) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
566
+ expect(card.commands.select { |ins,| [0xf9, 0xcb].include?(ins) }).to be_empty
567
+ end
568
+
569
+ it 'supports biometric verification and session-local temporary PIN verification' do
570
+ card.bio = true
571
+ allow(card).to receive(:command).and_wrap_original do |original, ins, first, second, data|
572
+ if ins == 0x20 && second == 0x96
573
+ data == ['0200'].pack('H*') ? ('T'.b * 16) : ''.b
574
+ else
575
+ original.call(ins, first, second, data)
576
+ end
577
+ end
578
+ expect(piv.verify_uv(connection: card, confirm: true)).to eq(:verified)
579
+ temporary = piv.verify_uv(connection: card, confirm: true, request_temporary_pin: true)
580
+ expect(temporary).to eq('T' * 16)
581
+ expect(piv.verify_temporary_pin(connection: card, confirm: true, temporary_pin: temporary)).to eq(:verified)
582
+ expect(card.commands).to include([0x20, 0, 0x96, ['0110'].pack('H*') + ('T' * 16)])
583
+ end
584
+
585
+ it 'does not confuse the transport serial selector with the certificate serial' do
586
+ key = OpenSSL::PKey::EC.generate('prime256v1')
587
+ card.keys[0x9a] = { key: key, algorithm: 0x11 }
588
+ result = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=test'], input: key.public_to_pem, serial: '00123', certificate_serial: 987))
589
+ expect(OpenSSL::X509::Certificate.new(result.fetch(:certificate)).serial.to_i).to eq(987)
590
+ end
591
+
592
+ it 'publishes exact public output with mode 0600 and refuses occupied destinations before device mutation' do
593
+ Dir.mktmpdir('piv-output-') do |directory|
594
+ path = File.join(directory, 'public.pem')
595
+ output = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', path, '--algorithm', 'ECCP256']))
596
+ expect(File.binread(path)).to eq(output)
597
+ expect(File.stat(path).mode & 0o777).to eq(0o600)
598
+ card.commands.clear
599
+ expect { piv.execute(authorized.merge(args: ['keys', 'generate', '9a', path])) }.to raise_error(Errno::EEXIST)
600
+ expect(card.commands).to be_empty
601
+ end
602
+ end
603
+
604
+ it 'generates and delivers a management key to an explicit sink and applies touch policy' do
605
+ delivered = {}
606
+ result = piv.execute(authorized.merge(args: %w[access change-management-key --algorithm AES128 --generate --touch], secret_sink: ->(field, bytes) { delivered[field] = bytes }))
607
+ expect(result).to include(status: :changed, algorithm: 'AES128', protected: false)
608
+ expect(delivered.fetch(:new_management_key)).to eq(card.management_key)
609
+ expect(delivered.fetch(:new_management_key).bytesize).to eq(16)
610
+ expect(card.commands.select { |ins,| ins == 0xff }.last[1..2]).to eq([0xff, 0xfe])
611
+ expect(card.objects).not_to have_key(0x5fc109)
612
+ end
613
+
614
+ it 'selects legacy public-key sources explicitly rather than falling back on failure' do
615
+ key = OpenSSL::PKey::EC.generate('prime256v1')
616
+ card.keys[0x9a] = { key: key, algorithm: 0x11, origin: 1 }
617
+ card.version = [5, 2, 0]
618
+ expect { piv.execute(connection: card, args: %w[keys export 9a -]) }.to raise_error(piv::UnsupportedError)
619
+ expect(card.commands.select { |ins,| [0xf9, 0xcb].include?(ins) }).to be_empty
620
+ pem = piv.execute(connection: card, args: %w[keys export 9a -], public_key_source: :attestation)
621
+ expect(OpenSSL::PKey.read(pem).public_to_der).to eq(key.public_to_der)
622
+ expect { piv.execute(connection: card, args: %w[keys export 9a -], public_key_source: :private) }.to raise_error(ArgumentError, /private keys cannot be exported/)
623
+ end
624
+
625
+ it 'decodes Net ID compressed certificates and rejects mismatched declared lengths' do
626
+ key = OpenSSL::PKey::EC.generate('prime256v1')
627
+ card.keys[0x9a] = { key: key, algorithm: 0x11 }
628
+ result = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=netid'], input: key.public_to_pem))
629
+ der = OpenSSL::X509::Certificate.new(result.fetch(:certificate)).to_der
630
+ compressed = "\x01\x00".b + [der.bytesize].pack('v') + Zlib.deflate(der)
631
+ card.objects[0x5fc105] = card.tlv(0x70, compressed) + card.tlv(0x71, "\x01".b)
632
+ expect(piv.execute(connection: card, args: %w[certificates export 9a - --format DER])).to eq(der)
633
+ compressed.setbyte(2, 0)
634
+ compressed.setbyte(3, 0)
635
+ card.objects[0x5fc105] = card.tlv(0x70, compressed) + card.tlv(0x71, "\x01".b)
636
+ expect { piv.execute(connection: card, args: %w[certificates export 9a -]) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /length mismatch/)
637
+ end
638
+
639
+ it 'refuses an authentication attempt without literal consent before device IO' do
640
+ card = double('untouched connection')
641
+ expect(card).not_to receive(:exchange)
642
+ [nil, false, 'true', 1].each do |consent|
643
+ expect { PWN::Plugins::YubiKey::PIV.verify_pin(connection: card, pin: '765432', confirm: consent) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
644
+ end
645
+ end
646
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/plugins/yubikey/security_domain'
5
+ require_relative 'security_domain/support'
6
+
7
+ RSpec.describe PWN::Plugins::YubiKey::Protocol, 'secure-channel integration' do
8
+ it 'protects one complete command before any transport-level fragmentation' do
9
+ card = Scp03Card.new
10
+ card.handler = ->(_header, _body) { ['accepted'.b, "\x90\x00".b] }
11
+ key = (0...16).to_a.pack('C*')
12
+ channel = PWN::Plugins::YubiKey::SecurityDomain.secure_connection(connection: card, scp: :scp03, kvn: 1, key_enc: key, key_mac: key, key_dek: key, confirm: true)
13
+ before = card.commands.length
14
+ payload = 'x'.b * 300
15
+ expect(described_class.apdu(connection: channel, ins: 0xda, data: payload)).to eq('accepted'.b)
16
+ expect(card.received).to eq(payload)
17
+ expect(card.commands.length - before).to eq(1)
18
+ expect(card.commands.last.getbyte(4)).to eq(0)
19
+ ensure
20
+ channel&.close
21
+ end
22
+
23
+ it 'rejects an explicit short-framing override for a secure connection before I/O' do
24
+ connection = double('secure connection', descriptor: { secure_channel: :scp03, extended_apdu: true }, exchange: nil)
25
+ expect(connection).not_to receive(:exchange)
26
+ expect { described_class.apdu(connection: connection, ins: 0xda, extended: false) }.to raise_error(ArgumentError, /extended/)
27
+ end
28
+ end