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,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require_relative '../protocol'
6
+
7
+ module PWN
8
+ module Plugins
9
+ module YubiKey
10
+ module SecurityDomain
11
+ # GlobalPlatform SCP03 (S8/AES128) and SCP11 session primitives.
12
+ # Protocol reference: GP Card Specification 2.3 Amendments D/F;
13
+ # interoperability reference: Yubico yubikit 5.9.1 core/smartcard/scp.py.
14
+ # Independently implemented Ruby; no executable or Python backend.
15
+ module SCP
16
+ class ChannelError < YubiKey::ResponseError
17
+ end
18
+
19
+ # Native AES-CMAC, EC encoding and offline X509 verification.
20
+ class Crypto
21
+ def ec_key(value, private_key: false)
22
+ key = value.is_a?(OpenSSL::PKey::EC) ? value : OpenSSL::PKey.read(value, '')
23
+ raise ArgumentError, 'An EC key of the required kind is needed' unless key.is_a?(OpenSSL::PKey::EC) && (!private_key || key.private?) && key.check_key
24
+
25
+ key
26
+ rescue OpenSSL::OpenSSLError, TypeError
27
+ raise ArgumentError, 'Invalid EC key encoding', cause: nil
28
+ end
29
+
30
+ def public_point(point, curve)
31
+ group = OpenSSL::PKey::EC::Group.new(curve)
32
+ raise ChannelError, 'Invalid uncompressed EC point' unless point.is_a?(String) && point.getbyte(0) == 4 && point.bytesize == 1 + (2 * ((group.degree + 7) / 8))
33
+
34
+ algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new('id-ecPublicKey'), OpenSSL::ASN1::ObjectId.new(curve)])
35
+ ec_key(OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(point)]).to_der)
36
+ rescue OpenSSL::OpenSSLError, ArgumentError
37
+ raise ChannelError, 'Invalid peer EC point', cause: nil
38
+ end
39
+
40
+ def certificates(values)
41
+ raise ArgumentError, 'Certificates must be a nonempty bounded Array' unless values.is_a?(Array) && values.length.between?(1, 32)
42
+
43
+ values.map do |value|
44
+ cert = OpenSSL::X509::Certificate.new(value.is_a?(OpenSSL::X509::Certificate) ? value.to_der : value)
45
+ raise ArgumentError, 'Certificate is too large' if cert.to_der.bytesize > 65_000
46
+
47
+ cert
48
+ end
49
+ rescue OpenSSL::OpenSSLError, TypeError
50
+ raise ArgumentError, 'Invalid X509 certificate encoding', cause: nil
51
+ end
52
+
53
+ def validate_chain(chain, roots = nil)
54
+ now = Time.now
55
+ raise ChannelError, 'Certificate validity check failed' if chain.any? { |cert| now < cert.not_before || now > cert.not_after }
56
+
57
+ chain.each_cons(2) do |parent, child|
58
+ ca = parent.extensions.find { |ext| ext.oid == 'basicConstraints' }
59
+ usage = parent.extensions.find { |ext| ext.oid == 'keyUsage' }
60
+ valid = child.issuer == parent.subject && child.verify(parent.public_key) && ca&.value&.include?('CA:TRUE') && (!usage || usage.value.include?('Certificate Sign'))
61
+ raise ChannelError, 'Certificate chain verification failed' unless valid
62
+ end
63
+ if roots
64
+ store = OpenSSL::X509::Store.new
65
+ roots.each { |root| store.add_cert(root) }
66
+ raise ChannelError, 'Untrusted card certificate chain' unless store.verify(chain.last, chain[0...-1])
67
+ end
68
+ usage = chain.last.extensions.find { |ext| ext.oid == 'keyUsage' }
69
+ raise ChannelError, 'SCP11 leaf certificate requires keyAgreement usage' unless usage&.value&.include?('Key Agreement')
70
+
71
+ ec_key(chain.last.public_key)
72
+ rescue OpenSSL::OpenSSLError
73
+ raise ChannelError, 'Certificate verification failed', cause: nil
74
+ end
75
+
76
+ def derive(key, type, context, bits = 128)
77
+ cmac(key, ("\0".b * 11) + [type, 0, bits, 1].pack('CCnC') + context).byteslice(0, bits / 8)
78
+ end
79
+
80
+ def equal?(left, right)
81
+ left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
82
+ end
83
+
84
+ def cipher_iv(key, counter, response: false)
85
+ aes(key, [response ? 0x80 : 0].pack('C') + [counter.to_s(16).rjust(30, '0')].pack('H*'))
86
+ end
87
+
88
+ def pad(data)
89
+ (data + "\x80".b).ljust(((data.bytesize / 16) + 1) * 16, "\0".b)
90
+ end
91
+
92
+ def unpad(data)
93
+ value = data.sub(/\x00*\z/n, ''.b)
94
+ raise ChannelError, 'Invalid secure response padding' unless value.end_with?("\x80".b) && data.bytesize - value.bytesize < 16
95
+
96
+ value.byteslice(0, value.bytesize - 1)
97
+ end
98
+
99
+ def aes(key, data, initialization_vector: nil, decrypt: false)
100
+ cipher = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-#{initialization_vector ? 'cbc' : 'ecb'}")
101
+ decrypt ? cipher.decrypt : cipher.encrypt
102
+ cipher.padding = 0
103
+ cipher.key = key
104
+ cipher.iv = initialization_vector if initialization_vector
105
+ (data.empty? ? ''.b : cipher.update(data)) + cipher.final
106
+ end
107
+
108
+ def xor(left, right)
109
+ left.bytes.zip(right.bytes).map { |a, b| a ^ b }.pack('C*')
110
+ end
111
+
112
+ def double(block)
113
+ value = block.unpack1('H*').to_i(16)
114
+ value = ((value << 1) & ((1 << 128) - 1)) ^ (value[127] == 1 ? 0x87 : 0)
115
+ [value.to_s(16).rjust(32, '0')].pack('H*')
116
+ end
117
+
118
+ def cmac(key, data)
119
+ first = double(aes(key, "\0".b * 16))
120
+ complete = !data.empty? && (data.bytesize % 16).zero?
121
+ count = [1, (data.bytesize + 15) / 16].max
122
+ last = data.byteslice((count - 1) * 16, 16) || ''.b
123
+ last = complete ? xor(last, first) : xor((last + "\x80".b).ljust(16, "\0".b), double(first))
124
+ chain = "\0".b * 16
125
+ (count - 1).times { |index| chain = aes(key, xor(chain, data.byteslice(index * 16, 16))) }
126
+ aes(key, xor(chain, last))
127
+ end
128
+ end
129
+
130
+ # The connection owns the MAC chain, encryption counter and lifecycle.
131
+ class Connection # rubocop:disable Metrics/ClassLength
132
+ attr_reader :scp
133
+
134
+ def initialize(opts)
135
+ raise YubiKey::ConfirmationRequired, 'Secure-channel authentication requires confirm: true' unless opts[:confirm] == true
136
+
137
+ @connection = opts.fetch(:connection)
138
+ raise ArgumentError, 'Expected a plain APDU connection' unless @connection.descriptor[:protocol] == :apdu && !@connection.is_a?(Connection)
139
+
140
+ @scp = opts.fetch(:scp)
141
+ raise ArgumentError, 'Unsupported secure-channel protocol' unless %i[scp03 scp11a scp11b scp11c].include?(@scp)
142
+
143
+ expected_kid = { scp03: 1, scp11a: 0x11, scp11b: 0x13, scp11c: 0x15 }.fetch(@scp)
144
+ raise ArgumentError, 'KID does not match selected SCP variant' if opts.key?(:kid) && byte(opts[:kid]) != expected_kid
145
+
146
+ @kvn = byte(opts.fetch(:kvn))
147
+ @timeout = timeout(opts.fetch(:timeout, 30))
148
+ @crypto = Crypto.new
149
+ @mutex = Mutex.new
150
+ @enc_counter = 1
151
+ @chain = "\0".b * 16
152
+ if @scp == :scp03
153
+ keys = %i[key_enc key_mac].map { |name| aes_key(opts.fetch(name)) }
154
+ @dek = aes_key(opts[:key_dek]) if opts[:key_dek]
155
+ else
156
+ prepare_scp11(opts)
157
+ end
158
+ @auth_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
159
+ begin
160
+ plain([0, 0xa4, 4, 0], ['a000000151000000'].pack('H*'))
161
+ @scp == :scp03 ? initialize_scp03(keys) : initialize_scp11
162
+ @ready = true
163
+ ensure
164
+ @oce_key = nil
165
+ invalidate unless @ready
166
+ end
167
+ end
168
+
169
+ def inspect
170
+ "#<#{self.class} #{@scp} redacted>"
171
+ end
172
+
173
+ def descriptor
174
+ @connection.descriptor.merge(secure_channel: @scp, extended_apdu: true, authenticated: @ready == true && !@closed && %i[scp03 scp11a].include?(@scp))
175
+ end
176
+
177
+ def authenticated?
178
+ @ready == true && !@closed && %i[scp03 scp11a].include?(@scp)
179
+ end
180
+
181
+ def close
182
+ @mutex.synchronize { invalidate }
183
+ :closed
184
+ end
185
+
186
+ def exchange(opts = {})
187
+ raise ArgumentError, 'Secure channel accepts only APDUs' unless opts[:protocol] == :apdu
188
+
189
+ header, data, le, extended = decode(opts[:data])
190
+ seconds = timeout(opts.fetch(:timeout, @timeout))
191
+ @mutex.synchronize do
192
+ raise ChannelError, 'Secure channel is closed' if @closed || !@ready
193
+
194
+ complete = false
195
+ begin
196
+ raise ChannelError, 'Secure-channel counter exhausted' if @enc_counter > 0xffffffff
197
+
198
+ counter = @enc_counter
199
+ encrypted = @crypto.aes(@enc, @crypto.pad(data), initialization_vector: @crypto.cipher_iv(@enc, counter))
200
+ @enc_counter += 1
201
+ header[0] |= 4
202
+ frame = encode(header, encrypted + ("\0".b * 8), nil, extended: extended)
203
+ @chain = @crypto.cmac(@mac, @chain + frame.byteslice(0, frame.bytesize - 8))
204
+ command = encode(header, encrypted + @chain.byteslice(0, 8), le, extended: extended)
205
+ response = transmit(command, Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds)
206
+ value = verify_response(response, counter)
207
+ complete = true
208
+ value
209
+ ensure
210
+ invalidate unless complete
211
+ end
212
+ end
213
+ end
214
+
215
+ # Key wrapping never exports the session DEK. The caller must already
216
+ # hold SD administrative authentication and explicit mutation consent.
217
+ def wrap_key(key)
218
+ @mutex.synchronize do
219
+ raise ChannelError, 'Authenticated channel with a DEK required' unless authenticated? && @dek
220
+ raise ArgumentError, 'Key material must be whole AES blocks' unless key.is_a?(String) && !key.empty? && (key.bytesize % 16).zero?
221
+
222
+ @crypto.aes(@dek, key.b, initialization_vector: "\0".b * 16)
223
+ end
224
+ end
225
+
226
+ private
227
+
228
+ def byte(value)
229
+ raise ArgumentError, 'KID/KVN must be integers in 0..255' unless value.is_a?(Integer) && value.between?(0, 255)
230
+
231
+ value
232
+ end
233
+
234
+ def timeout(value)
235
+ raise ArgumentError, 'Timeout must be finite and positive' unless value.is_a?(Numeric) && value.finite? && value.positive?
236
+
237
+ value
238
+ end
239
+
240
+ def aes_key(value)
241
+ raise ArgumentError, 'SCP03 requires explicit 16-byte AES keys' unless value.is_a?(String) && value.bytesize == 16
242
+
243
+ value.b.dup
244
+ end
245
+
246
+ def encode(header, data, expected_length = nil, extended: false)
247
+ raise ArgumentError, 'APDU too large for secure messaging' if data.bytesize > 65_535
248
+
249
+ extended ||= data.bytesize > 255 || (expected_length && expected_length > 256)
250
+ result = header.pack('C*')
251
+ if extended
252
+ result += [0, data.bytesize].pack('Cn') unless data.empty?
253
+ result += data
254
+ result += data.empty? ? [0, expected_length & 0xffff].pack('Cn') : [expected_length & 0xffff].pack('n') if expected_length
255
+ else
256
+ result += [data.bytesize].pack('C') + data unless data.empty?
257
+ result += [expected_length & 0xff].pack('C') if expected_length
258
+ end
259
+ result
260
+ end
261
+
262
+ def decode(command)
263
+ raise ArgumentError, 'Malformed APDU' unless command.is_a?(String) && command.bytesize.between?(4, 65_544)
264
+
265
+ bytes = command.b
266
+ header = bytes.byteslice(0, 4).bytes
267
+ raise ArgumentError, 'Pre-wrapped secure APDUs are not accepted' unless header[0].nobits?(4)
268
+ return [header, ''.b, nil, false] if bytes.bytesize == 4
269
+ return [header, ''.b, bytes.getbyte(4).zero? ? 256 : bytes.getbyte(4), false] if bytes.bytesize == 5
270
+
271
+ extended = bytes.getbyte(4).zero?
272
+ offset = extended ? 7 : 5
273
+ raise ArgumentError, 'Truncated extended APDU' if bytes.bytesize < offset
274
+
275
+ length = extended ? bytes.byteslice(5, 2).unpack1('n') : bytes.getbyte(4)
276
+ return [header, ''.b, length.zero? ? 65_536 : length, true] if extended && bytes.bytesize == 7
277
+
278
+ remaining = bytes.bytesize - offset - length
279
+ raise ArgumentError, 'Malformed APDU length' unless length.positive? && [0, extended ? 2 : 1].include?(remaining)
280
+
281
+ le = if remaining.zero?
282
+ nil
283
+ else
284
+ bytes.byteslice(-remaining, remaining).unpack1(extended ? 'n' : 'C')
285
+ end
286
+ le = extended ? 65_536 : 256 if le&.zero?
287
+ [header, bytes.byteslice(offset, length), le, extended]
288
+ end
289
+
290
+ # GET RESPONSE retrieves fragments of the existing protected reply;
291
+ # it does not start another secure command or consume its counter.
292
+ def transmit(command, deadline)
293
+ result = ''.b
294
+ 64.times do
295
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
296
+ raise YubiKey::TimeoutError, 'Secure-channel exchange timed out' unless remaining.positive?
297
+
298
+ response = @connection.exchange(protocol: :apdu, data: command, timeout: remaining)
299
+ raise ChannelError, 'Malformed or oversized APDU response' unless response.is_a?(String) && response.bytesize.between?(2, 1_048_586)
300
+
301
+ result += response.byteslice(0, response.bytesize - 2)
302
+ raise ChannelError, 'Secure response size limit exceeded' if result.bytesize > 1_048_584
303
+ return result + response.byteslice(-2, 2) unless response.getbyte(-2) == 0x61
304
+
305
+ command = "\0\xc0\0\0".b
306
+ end
307
+ raise ChannelError, 'Secure response fragmentation limit exceeded'
308
+ end
309
+
310
+ def plain(header, data)
311
+ response = transmit(encode(header, data), @auth_deadline)
312
+ raise ChannelError, 'Truncated authentication response' unless response.is_a?(String) && response.bytesize >= 2
313
+ raise ChannelError, 'Secure-channel authentication rejected' unless response.byteslice(-2, 2) == "\x90\0".b
314
+
315
+ response.byteslice(0, response.bytesize - 2)
316
+ end
317
+
318
+ def initialize_scp03(keys)
319
+ challenge = SecureRandom.random_bytes(8)
320
+ response = plain([0x80, 0x50, @kvn, 0], challenge)
321
+ raise ChannelError, 'Invalid SCP03 INITIALIZE UPDATE' unless [29, 32].include?(response.bytesize) && response.getbyte(11) == 3 && response.getbyte(12).nobits?(1)
322
+ raise ChannelError, 'Unexpected SCP03 key version' unless @kvn.zero? || @kvn == response.getbyte(10)
323
+
324
+ context = challenge + response.byteslice(13, 8)
325
+ @enc = @crypto.derive(keys[0], 4, context)
326
+ @mac = @crypto.derive(keys[1], 6, context)
327
+ @rmac = @crypto.derive(keys[1], 7, context)
328
+ expected = @crypto.derive(@mac, 0, context, 64)
329
+ raise ChannelError, 'SCP03 card cryptogram verification failed' unless @crypto.equal?(expected, response.byteslice(21, 8))
330
+
331
+ host = @crypto.derive(@mac, 1, context, 64)
332
+ frame = encode([0x84, 0x82, 0x33, 0], host + ("\0".b * 8))
333
+ @chain = @crypto.cmac(@mac, @chain + frame.byteslice(0, frame.bytesize - 8))
334
+ reply = plain([0x84, 0x82, 0x33, 0], host + @chain.byteslice(0, 8))
335
+ # EXTERNAL AUTHENTICATE may acknowledge with bare 9000. All
336
+ # subsequent responses MUST authenticate their status and payload.
337
+ verify_response(reply + "\x90\0".b, 0) unless reply.empty?
338
+ end
339
+
340
+ def prepare_scp11(opts)
341
+ @kid = { scp11a: 0x11, scp11b: 0x13, scp11c: 0x15 }.fetch(@scp)
342
+ @card_key = @crypto.ec_key(opts[:card_public_key]) if opts[:card_public_key]
343
+ @roots = @crypto.certificates(opts[:ca_certificates]) if opts[:ca_certificates]
344
+ raise ArgumentError, 'SCP11 needs a pinned card_public_key or explicit ca_certificates' unless @card_key || @roots
345
+
346
+ @card_certs = @crypto.certificates(opts[:card_certificates]) if opts[:card_certificates]
347
+ if @scp == :scp11b
348
+ raise ArgumentError, 'SCP11b does not use OCE credentials' if opts[:oce_private_key] || opts[:oce_certificates]
349
+ else
350
+ @oce_key = @crypto.ec_key(opts.fetch(:oce_private_key), private_key: true)
351
+ @oce_certs = @crypto.certificates(opts.fetch(:oce_certificates))
352
+ public_key = @crypto.validate_chain(@oce_certs)
353
+ raise ArgumentError, 'OCE private key does not match leaf certificate' unless @crypto.equal?(@oce_key.public_to_der, public_key.public_to_der)
354
+
355
+ @oce_kid = byte(opts.fetch(:oce_kid, 0))
356
+ @oce_kvn = byte(opts.fetch(:oce_kvn, 0))
357
+ raise ArgumentError, 'Invalid OCE CA KID' unless @oce_kid.zero? || @oce_kid == 0x10 || @oce_kid.between?(0x20, 0x2f)
358
+ end
359
+ validate_card_certificates if @card_certs
360
+ end
361
+
362
+ def validate_card_certificates
363
+ key = @crypto.validate_chain(@card_certs, @roots)
364
+ raise ChannelError, 'Card public-key pin mismatch' if @card_key && !@crypto.equal?(@card_key.public_to_der, key.public_to_der)
365
+
366
+ @card_key = key
367
+ end
368
+
369
+ def initialize_scp11
370
+ if @roots && !@card_certs
371
+ reference = Protocol.tlv(tag: 0xa6, value: Protocol.tlv(tag: 0x83, value: [@kid, @kvn].pack('CC')))
372
+ raw = plain([0, 0xca, 0xbf, 0x21], reference)
373
+ @card_certs = @crypto.certificates(Protocol.untlv(data: raw).map do |row|
374
+ raise ChannelError, 'Invalid certificate bundle tag' unless row[:tag] == 0x30
375
+
376
+ Protocol.tlv(tag: row[:tag], value: row[:value])
377
+ end)
378
+ validate_card_certificates
379
+ end
380
+ raise ArgumentError, 'OCE and card EC curves must match' if @oce_key && @oce_key.group.curve_name != @card_key.group.curve_name
381
+
382
+ @oce_certs&.each_with_index do |cert, index|
383
+ more = index < @oce_certs.length - 1 ? 0x80 : 0
384
+ plain([0x80, 0x2a, @oce_kvn, @oce_kid | more], cert.to_der)
385
+ end
386
+ ephemeral = OpenSSL::PKey::EC.generate(@card_key.group.curve_name)
387
+ parameters = { scp11a: 1, scp11b: 0, scp11c: 3 }.fetch(@scp)
388
+ template = Protocol.tlv(tag: 0x90, value: [0x11, parameters].pack('CC')) + Protocol.tlv(tag: 0x95, value: "\x3c".b) + Protocol.tlv(tag: 0x80, value: "\x88".b) + Protocol.tlv(tag: 0x81, value: "\x10".b)
389
+ data = Protocol.tlv(tag: 0xa6, value: template) + Protocol.tlv(tag: 0x5f49, value: ephemeral.public_key.to_octet_string(:uncompressed))
390
+ ins = @scp == :scp11b ? 0x88 : 0x82
391
+ raw_response = plain([0x80, ins, @kvn, @kid], data)
392
+ response = Protocol.untlv(data: raw_response)
393
+ raise ChannelError, 'Invalid SCP11 key agreement response' unless response.map { |row| row[:tag] } == [0x5f49, 0x86] && response[1][:value].bytesize == 16
394
+
395
+ canonical = response.map { |row| Protocol.tlv(tag: row[:tag], value: row[:value]) }.join.b
396
+ raise ChannelError, 'Noncanonical SCP11 receipt transcript' unless @crypto.equal?(raw_response, canonical)
397
+
398
+ peer = @crypto.public_point(response[0][:value], @card_key.group.curve_name)
399
+ secret = ephemeral.derive(peer) + (@oce_key || ephemeral).derive(@card_key)
400
+ # ANSI X9.63 KDF: SHA256(Z || counter32be || sharedInfo).
401
+ material = (1..3).map { |counter| OpenSSL::Digest::SHA256.digest(secret + [counter].pack('N') + "\x3c\x88\x10".b) }.join.byteslice(0, 80)
402
+ receipt_key, @enc, @mac, @rmac, @dek = 5.times.map { |index| material.byteslice(index * 16, 16) }
403
+ expected = @crypto.cmac(receipt_key, data + Protocol.tlv(tag: 0x5f49, value: response[0][:value]))
404
+ raise ChannelError, 'SCP11 receipt verification failed' unless @crypto.equal?(expected, response[1][:value])
405
+
406
+ @chain = expected
407
+ rescue OpenSSL::OpenSSLError
408
+ raise ChannelError, 'SCP11 key agreement failed', cause: nil
409
+ ensure
410
+ secret&.replace("\0".b * secret.bytesize)
411
+ material&.replace("\0".b * material.bytesize)
412
+ receipt_key&.replace("\0".b * receipt_key.bytesize)
413
+ end
414
+
415
+ def verify_response(response, counter)
416
+ raise ChannelError, 'Missing or oversized secure response MAC' unless response.is_a?(String) && response.bytesize.between?(10, 1_048_586)
417
+
418
+ ciphertext = response.byteslice(0, response.bytesize - 10)
419
+ received = response.byteslice(-10, 8)
420
+ status = response.byteslice(-2, 2)
421
+ expected = @crypto.cmac(@rmac, @chain + ciphertext + status).byteslice(0, 8)
422
+ raise ChannelError, 'Secure response MAC verification failed' unless @crypto.equal?(expected, received)
423
+ raise ChannelError, 'Truncated secure ciphertext' unless (ciphertext.bytesize % 16).zero?
424
+
425
+ data = ciphertext.empty? ? ''.b : @crypto.unpad(@crypto.aes(@enc, ciphertext, initialization_vector: @crypto.cipher_iv(@enc, counter, response: true), decrypt: true))
426
+ data + status
427
+ end
428
+
429
+ def invalidate
430
+ return if @closed
431
+
432
+ @closed = true
433
+ @ready = false
434
+ # Best effort only: Ruby/OpenSSL/GC may retain copies. No locked
435
+ # memory or guaranteed zeroization is claimed.
436
+ [@enc, @mac, @rmac, @dek, @chain].compact.each { |value| value.replace("\0".b * value.bytesize) unless value.frozen? }
437
+ @connection.close
438
+ end
439
+ end
440
+
441
+ # Authenticate a native APDU connection with explicit in-memory keys.
442
+ public_class_method def self.open(opts = {})
443
+ raise ArgumentError, 'connection is required' unless opts[:connection]
444
+
445
+ Connection.new(opts)
446
+ end
447
+
448
+ # Return module attribution without device access.
449
+ public_class_method def self.authors
450
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
451
+ end
452
+
453
+ # Describe the private implementation module without accessing a device.
454
+ public_class_method def self.help
455
+ puts <<~HELP
456
+ Authenticate a native APDU connection; prefer the parent secure_connection API.
457
+ #{self}.open(
458
+ connection: 'required - plain native APDU transport owned by the resulting wrapper',
459
+ scp: 'required - Symbol scp03, scp11a, scp11b or scp11c',
460
+ kvn: 'required - exact integer key version 0..255',
461
+ confirm: 'required - literal true permits authentication attempts',
462
+ key_enc: 'optional - required 16-byte binary K-ENC for SCP03',
463
+ key_mac: 'optional - required 16-byte binary K-MAC for SCP03',
464
+ key_dek: 'optional - 16-byte binary K-DEK for SCP03 key import',
465
+ card_public_key: 'optional - SCP11 pinned EC public key object/PEM/DER or supply CA anchors',
466
+ ca_certificates: 'optional - trusted X509 object/PEM/DER Array for SCP11',
467
+ card_certificates: 'optional - leaf-last X509 card bundle; otherwise fetched for CA validation',
468
+ oce_private_key: 'optional - required EC private key object/PEM/DER for SCP11a/c',
469
+ oce_certificates: 'optional - required leaf-last X509 OCE chain for SCP11a/c',
470
+ oce_kid: 'optional - OCE CA KID; 0, 16 or 32..47',
471
+ oce_kvn: 'optional - OCE CA version 0..255; default zero',
472
+ timeout: 'optional - finite positive handshake timeout in seconds; default 30'
473
+ )
474
+ Return module attribution without device access.
475
+ #{self}.authors
476
+ HELP
477
+ end
478
+ end
479
+ end
480
+ end
481
+ end
482
+ end