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
@@ -7,12 +7,16 @@ require 'json'
7
7
  require 'fileutils'
8
8
  require 'securerandom'
9
9
  require 'time'
10
+ require 'tempfile'
11
+ require 'tmpdir'
10
12
 
11
13
  module PWN
12
14
  module Plugins
13
15
  # Used to encrypt/decrypt configuration files leveraging AES256
14
16
  module Vault
15
17
  CREDENTIAL_KDF_ITERATIONS = 600_000
18
+ YUBIKEY_KDF = 'yubikey-hmac-sha1-hkdf-sha256'
19
+ YUBIKEY_HEADER = "PWN-YUBIKEY-VAULT:1\n"
16
20
 
17
21
  # Returns an authenticated envelope; the caller persists only this value.
18
22
  # keyring is an injected OS-keyring reader responding to call(key_id).
@@ -22,6 +26,13 @@ module PWN
22
26
  'key_id' => opts[:key_id] || 'pwn-ai-credentials',
23
27
  'iterations' => CREDENTIAL_KDF_ITERATIONS,
24
28
  'salt' => Base64.strict_encode64(OpenSSL::Random.random_bytes(16)) }
29
+ unless opts[:yubikey].nil?
30
+ selector = yubikey_options(value: opts[:yubikey])
31
+ raise ArgumentError, 'Choose YubiKey, passphrase, or keyring, not a combination' if opts[:passphrase] || opts[:keyring]
32
+
33
+ envelope['kdf'] = YUBIKEY_KDF
34
+ envelope['yubikey'] = selector.slice(:slot, :serial).transform_keys(&:to_s).merge('challenge' => Base64.strict_encode64(OpenSSL::Random.random_bytes(32)))
35
+ end
25
36
  cipher = OpenSSL::Cipher.new('aes-256-gcm')
26
37
  cipher.encrypt
27
38
  cipher.key = credential_key(opts.merge(envelope: envelope))
@@ -54,6 +65,10 @@ module PWN
54
65
 
55
66
  private_class_method def self.credential_key(opts = {})
56
67
  envelope = opts.fetch(:envelope)
68
+ return yubikey_key(opts) if envelope['kdf'] == YUBIKEY_KDF
69
+
70
+ raise ArgumentError, 'Envelope is not protected by YubiKey' unless opts[:yubikey].nil?
71
+
57
72
  if envelope['kdf'] == 'keyring'
58
73
  reader = opts[:keyring]
59
74
  raise ArgumentError, 'OS keyring reader required' unless reader.respond_to?(:call)
@@ -75,11 +90,215 @@ module PWN
75
90
  OpenSSL::PKCS5.pbkdf2_hmac(passphrase, salt, CREDENTIAL_KDF_ITERATIONS, 32, 'sha256')
76
91
  end
77
92
 
93
+ private_class_method def self.yubikey_options(opts = {})
94
+ value = opts[:value]
95
+ raise ArgumentError, 'yubikey must be a selector Hash' unless value.is_a?(Hash)
96
+ raise ArgumentError, 'YubiKey selector keys must be Strings or Symbols' unless value.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
97
+
98
+ selector = value.transform_keys(&:to_sym)
99
+ raise ArgumentError, 'Unknown YubiKey selector option' unless (selector.keys - %i[slot serial timeout reader device_path connection]).empty?
100
+
101
+ selector[:slot] = selector.fetch(:slot, 2)
102
+ raise ArgumentError, 'YubiKey HMAC slot must be 1 or 2' unless selector[:slot].is_a?(Integer) && [1, 2].include?(selector[:slot])
103
+
104
+ serial = selector[:serial]
105
+ raise ArgumentError, 'YubiKey serial must be a decimal String' unless serial.nil? || (serial.is_a?(String) && serial.match?(/\A[0-9]+\z/))
106
+
107
+ timeout = selector[:timeout]
108
+ raise ArgumentError, 'YubiKey timeout must be finite positive seconds' unless timeout.nil? || ((timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?)
109
+
110
+ %i[reader device_path].each do |field|
111
+ name = selector[field]
112
+ raise ArgumentError, "YubiKey #{field} must be a nonempty String without control characters" unless name.nil? || (name.is_a?(String) && name.valid_encoding? && !name.empty? && !name.match?(/[[:cntrl:]]/))
113
+ end
114
+ raise ArgumentError, 'YubiKey serial and reader selectors are mutually exclusive' if !serial.nil? && !selector[:reader].nil?
115
+ raise ArgumentError, 'YubiKey reader and device_path selectors are mutually exclusive' if !selector[:reader].nil? && !selector[:device_path].nil?
116
+
117
+ connection = selector[:connection]
118
+ raise ArgumentError, 'YubiKey connection must respond to exchange' unless connection.nil? || connection.respond_to?(:exchange)
119
+
120
+ selector
121
+ end
122
+
123
+ private_class_method def self.yubikey_key(opts = {})
124
+ raise ArgumentError, 'Hardware envelopes do not accept software credentials' if opts[:passphrase] || opts[:keyring]
125
+
126
+ envelope = opts.fetch(:envelope)
127
+ metadata = envelope.fetch('yubikey')
128
+ raise ArgumentError, 'Invalid YubiKey envelope metadata' unless metadata.is_a?(Hash) && (metadata.keys - %w[slot serial challenge]).empty?
129
+
130
+ selector = yubikey_options(value: metadata.slice('slot', 'serial'))
131
+ override = opts[:yubikey]
132
+ unless override.nil?
133
+ raise ArgumentError, 'yubikey must be a selector Hash' unless override.is_a?(Hash)
134
+
135
+ override = yubikey_options(value: selector.merge(override.transform_keys(&:to_sym)))
136
+ raise ArgumentError, 'YubiKey slot does not match the envelope' unless override[:slot] == selector[:slot]
137
+
138
+ selector = selector.merge(override)
139
+ end
140
+ challenge = Base64.strict_decode64(metadata.fetch('challenge'))
141
+ salt = Base64.strict_decode64(envelope.fetch('salt'))
142
+ raise ArgumentError, 'Invalid YubiKey envelope challenge or salt' unless challenge.bytesize == 32 && salt.bytesize == 16
143
+
144
+ response = PWN::Plugins::YubiKey.challenge_response(selector.merge(challenge: challenge))
145
+ raise IOError, 'Invalid YubiKey HMAC response' unless response.is_a?(String) && response.bytesize == 20
146
+
147
+ OpenSSL::KDF.hkdf(response, salt: salt, info: 'pwn-vault-credentials-v1', length: 32, hash: 'SHA256')
148
+ end
149
+
78
150
  private_class_method def self.credential_aad(opts = {})
79
151
  envelope = opts.fetch(:envelope)
80
152
  JSON.generate(envelope.except('ct', 'tag').sort.to_h)
81
153
  end
82
154
 
155
+ # Ciphertext-only callers use these primitives without a plaintext file stage.
156
+ public_class_method def self.encrypt_data(opts = {})
157
+ data = opts.fetch(:data)
158
+ raise ArgumentError, 'data must be a String' unless data.is_a?(String)
159
+
160
+ unless opts[:yubikey].nil?
161
+ raise ArgumentError, 'YubiKey encryption does not accept a software key or IV' if opts[:key] || opts[:iv]
162
+
163
+ envelope = seal_credentials(credentials: { 'data' => Base64.strict_encode64(data) }, yubikey: opts[:yubikey])
164
+ return "#{YUBIKEY_HEADER}#{JSON.generate(envelope)}\n"
165
+ end
166
+ cipher = OpenSSL::Cipher.new('aes-256-cbc')
167
+ cipher.encrypt
168
+ cipher.key = Base64.strict_decode64(opts[:key] || PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Key'))
169
+ cipher.iv = Base64.strict_decode64(opts[:iv] || PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'IV'))
170
+ "#{Base64.strict_encode64((data.empty? ? ''.b : cipher.update(data)) + cipher.final)}\n"
171
+ end
172
+
173
+ public_class_method def self.decrypt_data(opts = {})
174
+ data = opts.fetch(:data)
175
+ raise ArgumentError, 'data must be a String' unless data.is_a?(String)
176
+
177
+ if data.start_with?('PWN-YUBIKEY-VAULT:')
178
+ raise ArgumentError, 'Hardware vaults do not accept a software key or IV' if opts[:key] || opts[:iv]
179
+
180
+ envelope = hardware_envelope(data: data)
181
+ return Base64.strict_decode64(open_credentials(envelope: envelope, yubikey: opts[:yubikey]).fetch('data'))
182
+ end
183
+ raise ArgumentError, 'Vault is not protected by YubiKey' unless opts[:yubikey].nil?
184
+
185
+ cipher = OpenSSL::Cipher.new('aes-256-cbc')
186
+ cipher.decrypt
187
+ cipher.key = Base64.strict_decode64(opts[:key] || PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Key'))
188
+ cipher.iv = Base64.strict_decode64(opts[:iv] || PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'IV'))
189
+ cipher.update(Base64.strict_decode64(data.chomp)) + cipher.final
190
+ end
191
+
192
+ public_class_method def self.hardware_encrypted?(opts = {})
193
+ data = opts[:data] || File.binread(opts.fetch(:file), YUBIKEY_HEADER.bytesize)
194
+ data.start_with?('PWN-YUBIKEY-VAULT:')
195
+ end
196
+
197
+ # Resolve a self-describing hardware vault or a legacy key/IV sidecar.
198
+ # Never prompt or read a software decryptor for a hardware envelope.
199
+ public_class_method def self.access_options(opts = {})
200
+ file = opts.fetch(:file)
201
+ if hardware_encrypted?(file: file)
202
+ raise ArgumentError, 'Hardware vaults do not accept a software key or IV' if opts[:key] || opts[:iv]
203
+
204
+ metadata = hardware_envelope(data: File.binread(file)).fetch('yubikey')
205
+ selector = metadata.slice('slot', 'serial').transform_keys(&:to_sym)
206
+ unless opts[:yubikey].nil?
207
+ raise ArgumentError, 'yubikey must be a selector Hash' unless opts[:yubikey].is_a?(Hash)
208
+
209
+ selector = selector.merge(opts[:yubikey].transform_keys(&:to_sym))
210
+ end
211
+ return { yubikey: yubikey_options(value: selector) }
212
+ end
213
+ raise ArgumentError, 'Vault is not protected by YubiKey' unless opts[:yubikey].nil?
214
+
215
+ key = opts[:key]
216
+ iv = opts[:iv]
217
+ path = opts[:decryptor_file] || "#{file}.decryptor"
218
+ if (!key || !iv) && File.file?(path)
219
+ sidecar = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false, symbolize_names: true)
220
+ raise ArgumentError, 'Invalid vault decryptor' unless sidecar.is_a?(Hash)
221
+
222
+ key ||= sidecar[:key]
223
+ iv ||= sidecar[:iv]
224
+ end
225
+ key ||= ENV.fetch('PWN_DECRYPTOR_KEY', nil)
226
+ iv ||= ENV.fetch('PWN_DECRYPTOR_IV', nil)
227
+ raise ArgumentError, 'Vault key and IV required' unless key.is_a?(String) && iv.is_a?(String) && !key.empty? && !iv.empty?
228
+
229
+ { key: key, iv: iv }
230
+ end
231
+
232
+ # Mutate a YAML document in memory and atomically publish only ciphertext.
233
+ public_class_method def self.update(opts = {})
234
+ file = File.expand_path(opts.fetch(:file))
235
+ with_vault_lock(file: file) do
236
+ bytes = File.binread(file)
237
+ access = access_options(opts)
238
+ plaintext = decrypt_data(access.merge(data: bytes)).force_encoding(Encoding::UTF_8)
239
+ document = YAML.safe_load(plaintext, permitted_classes: [Symbol], aliases: true, symbolize_names: true)
240
+ raise ArgumentError, 'Vault configuration must be a mapping' unless document.is_a?(Hash)
241
+
242
+ yield document
243
+ ciphertext = encrypt_data(access.merge(data: YAML.dump(document)))
244
+ publish_data(file: file, data: ciphertext, expected: bytes)
245
+ end
246
+ :saved
247
+ end
248
+
249
+ private_class_method def self.hardware_envelope(opts = {})
250
+ data = opts.fetch(:data)
251
+ raise ArgumentError, 'Unsupported YubiKey vault format' unless data.start_with?(YUBIKEY_HEADER)
252
+
253
+ envelope = JSON.parse(data.delete_prefix(YUBIKEY_HEADER))
254
+ raise ArgumentError, 'Invalid YubiKey vault envelope' unless envelope.is_a?(Hash) && envelope['kdf'] == YUBIKEY_KDF
255
+
256
+ envelope
257
+ end
258
+
259
+ # Share the editor's stable directory lock. Reentrancy is internal, never an
260
+ # option callers can supply to bypass locking; inode identity covers aliases.
261
+ private_class_method def self.with_vault_lock(opts = {})
262
+ path = File.expand_path(opts.fetch(:file))
263
+ File.open(File.dirname(path), File::RDONLY) do |lock|
264
+ stat = lock.stat
265
+ identity = [Process.pid, stat.dev, stat.ino]
266
+ held = Thread.current[:pwn_vault_locks] ||= {}
267
+ return yield if held.key?(identity)
268
+
269
+ lock.flock(File::LOCK_EX)
270
+ begin
271
+ held[identity] = lock
272
+ yield
273
+ ensure
274
+ held.delete(identity)
275
+ Thread.current[:pwn_vault_locks] = nil if held.empty?
276
+ lock.flock(File::LOCK_UN)
277
+ end
278
+ end
279
+ end
280
+
281
+ private_class_method def self.publish_data(opts = {})
282
+ path = File.expand_path(opts.fetch(:file))
283
+ with_vault_lock(file: path) do
284
+ stat = File.lstat(path)
285
+ raise IOError, 'Vault must be an owned regular file without links' unless stat.file? && stat.uid == Process.uid && stat.nlink == 1
286
+
287
+ Tempfile.create(['.pwn-vault-', '.tmp'], File.dirname(path)) do |temp|
288
+ temp.binmode
289
+ temp.chmod(0o600)
290
+ temp.write(opts.fetch(:data))
291
+ temp.flush
292
+ temp.fsync
293
+ raise IOError, 'Vault changed before replacement' unless File.lstat(path).ino == stat.ino && File.binread(path) == opts.fetch(:expected)
294
+
295
+ File.rename(temp.path, path)
296
+ File.open(File.dirname(path), File::RDONLY, &:fsync)
297
+ end
298
+ end
299
+ opts.fetch(:data).bytesize
300
+ end
301
+
83
302
  # Supported Method Parameters::
84
303
  # PWN::Plugins::Vault.refresh_encryption_secrets(
85
304
  # file: 'required - file to encrypt with new key and iv',
@@ -88,7 +307,18 @@ module PWN
88
307
  # )
89
308
 
90
309
  public_class_method def self.refresh_encryption_secrets(opts = {})
310
+ destination = yubikey_options(value: opts[:new_yubikey]) unless opts[:new_yubikey].nil?
91
311
  file = opts[:file].to_s.scrub if File.exist?(opts[:file].to_s.scrub)
312
+ raise ArgumentError, 'Legacy vault conversion to YubiKey requires new_yubikey, not yubikey' if !opts[:yubikey].nil? && !hardware_encrypted?(file: file)
313
+
314
+ if !destination.nil? || hardware_encrypted?(file: file)
315
+ bytes = File.binread(file)
316
+ access = access_options(opts.merge(file: file))
317
+ plaintext = decrypt_data(access.merge(data: bytes))
318
+ selector = destination || access.fetch(:yubikey)
319
+ ciphertext = encrypt_data(data: plaintext, yubikey: selector)
320
+ return publish_data(file: file, data: ciphertext, expected: bytes)
321
+ end
92
322
  key = opts[:key]
93
323
  iv = opts[:iv]
94
324
 
@@ -101,8 +331,6 @@ module PWN
101
331
  create(
102
332
  file: file
103
333
  )
104
- rescue ArgumentError
105
- raise 'ERROR: Incorrect Key or IV.'
106
334
  rescue StandardError => e
107
335
  raise e
108
336
  end
@@ -115,6 +343,12 @@ module PWN
115
343
 
116
344
  public_class_method def self.create(opts = {})
117
345
  file = opts[:file].to_s.scrub if File.exist?(opts[:file].to_s.scrub)
346
+ unless opts[:yubikey].nil?
347
+ raise ArgumentError, 'A YubiKey vault does not use a decryptor file' if opts[:decryptor_file]
348
+ raise ArgumentError, 'YubiKey encryption does not accept a software key or IV' if opts[:key] || opts[:iv]
349
+
350
+ return encrypt(file: file, yubikey: opts[:yubikey])
351
+ end
118
352
  decryptor_file = opts[:decryptor_file]
119
353
 
120
354
  cipher = OpenSSL::Cipher.new('aes-256-cbc')
@@ -150,31 +384,10 @@ module PWN
150
384
  # )
151
385
 
152
386
  public_class_method def self.decrypt(opts = {})
153
- file = opts[:file].to_s.scrub if File.exist?(opts[:file].to_s.scrub)
154
- key = opts[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(
155
- prompt: 'Key'
156
- )
157
-
158
- iv = opts[:iv] ||= PWN::Plugins::AuthenticationHelper.mask_password(
159
- prompt: 'IV'
160
- )
161
-
162
- is_encrypted = file_encrypted?(file: file)
163
- raise 'ERROR: File is not encrypted.' unless is_encrypted
164
-
165
- cipher = OpenSSL::Cipher.new('aes-256-cbc')
166
- cipher.decrypt
167
- cipher.key = Base64.strict_decode64(key)
168
- cipher.iv = Base64.strict_decode64(iv)
169
-
170
- b64_decoded_file_contents = Base64.strict_decode64(File.read(file).chomp)
171
- plain_text = cipher.update(b64_decoded_file_contents) + cipher.final
172
-
173
- File.write(file, plain_text)
174
- rescue ArgumentError
175
- raise 'ERROR: Incorrect Key or IV.'
176
- rescue StandardError => e
177
- raise e
387
+ file = opts.fetch(:file)
388
+ bytes = File.binread(file)
389
+ plaintext = decrypt_data(opts.merge(data: bytes))
390
+ publish_data(file: file, data: plaintext, expected: bytes)
178
391
  end
179
392
 
180
393
  # Supported Method Parameters::
@@ -186,26 +399,8 @@ module PWN
186
399
  # )
187
400
 
188
401
  public_class_method def self.dump(opts = {})
189
- file = opts[:file].to_s.scrub if File.exist?(opts[:file].to_s.scrub)
190
- key = opts[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(
191
- prompt: 'Key'
192
- )
193
-
194
- iv = opts[:iv] ||= PWN::Plugins::AuthenticationHelper.mask_password(
195
- prompt: 'IV'
196
- )
197
-
198
- cipher = OpenSSL::Cipher.new('aes-256-cbc')
199
- cipher.decrypt
200
- cipher.key = Base64.strict_decode64(key)
201
- cipher.iv = Base64.strict_decode64(iv)
202
- bytes = Base64.strict_decode64(File.read(file).chomp)
203
- plaintext = cipher.update(bytes) + cipher.final
204
- opts[:yaml] == false ? plaintext : YAML.safe_load(plaintext, permitted_classes: [Symbol], aliases: true, symbolize_names: true)
205
- rescue ArgumentError
206
- raise 'ERROR: Incorrect Key or IV.'
207
- rescue StandardError => e
208
- raise e
402
+ plaintext = decrypt_data(opts.merge(data: File.binread(opts.fetch(:file))))
403
+ opts[:yaml] == false ? plaintext : YAML.safe_load(plaintext.force_encoding(Encoding::UTF_8), permitted_classes: [Symbol], aliases: true, symbolize_names: true)
209
404
  end
210
405
 
211
406
  # Supported Method Parameters::
@@ -217,41 +412,32 @@ module PWN
217
412
  # )
218
413
 
219
414
  public_class_method def self.edit(opts = {})
220
- file = opts[:file].to_s.scrub if File.exist?(opts[:file].to_s.scrub)
221
- key = opts[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(
222
- prompt: 'Key'
223
- )
224
-
225
- iv = opts[:iv] ||= PWN::Plugins::AuthenticationHelper.mask_password(
226
- prompt: 'IV'
227
- )
228
-
229
- editor = opts[:editor] ||= '/usr/bin/vim'
230
-
231
- raise 'ERROR: Editor not found.' unless File.exist?(editor)
232
-
233
- decrypt(
234
- file: file,
235
- key: key,
236
- iv: iv
237
- )
238
-
239
- # Get realtive editor in case aliases are used
240
- relative_editor = File.basename(editor)
241
- system(relative_editor, file)
242
-
243
- # If the Pry object exists, set refresh_config to true
415
+ file = File.expand_path(opts.fetch(:file))
416
+ raise ArgumentError, 'Vault is not protected by YubiKey' if !opts[:yubikey].nil? && !hardware_encrypted?(file: file)
417
+
418
+ editor = opts[:editor] || '/usr/bin/vim'
419
+ raise 'ERROR: Editor not found.' unless File.executable?(editor)
420
+
421
+ bytes = File.binread(file)
422
+ access = if hardware_encrypted?(data: bytes)
423
+ access_options(opts)
424
+ else
425
+ { key: opts[:key] || PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Key'),
426
+ iv: opts[:iv] || PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'IV') }
427
+ end
428
+ plaintext = decrypt_data(access.merge(data: bytes))
429
+ runtime = File.join(Dir.home, '.pwn', 'workspaces', 'runtime')
430
+ FileUtils.mkdir_p(runtime, mode: 0o700)
431
+ Dir.mktmpdir('pwn-vault-edit-', runtime) do |directory|
432
+ temporary = File.join(directory, 'vault.yaml')
433
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |io| io.write(plaintext) }
434
+ raise IOError, 'Vault editor failed; original ciphertext retained' unless system(editor, temporary)
435
+
436
+ ciphertext = encrypt_data(access.merge(data: File.binread(temporary)))
437
+ publish_data(file: file, data: ciphertext, expected: bytes)
438
+ end
244
439
  Pry.config.refresh_pwn_env = true if defined?(Pry)
245
-
246
- encrypt(
247
- file: file,
248
- key: key,
249
- iv: iv
250
- )
251
- rescue ArgumentError
252
- raise 'ERROR: Incorrect Key or IV.'
253
- rescue StandardError => e
254
- raise e
440
+ :saved
255
441
  end
256
442
 
257
443
  # Supported Method Parameters::
@@ -262,27 +448,10 @@ module PWN
262
448
  # )
263
449
 
264
450
  public_class_method def self.encrypt(opts = {})
265
- file = opts[:file].to_s.scrub if File.exist?(opts[:file].to_s.scrub)
266
- key = opts[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(
267
- prompt: 'Key'
268
- )
269
-
270
- iv = opts[:iv] ||= PWN::Plugins::AuthenticationHelper.mask_password(
271
- prompt: 'IV'
272
- )
273
-
274
- cipher = OpenSSL::Cipher.new('aes-256-cbc')
275
- cipher.encrypt
276
- cipher.key = Base64.strict_decode64(key)
277
- cipher.iv = Base64.strict_decode64(iv)
278
-
279
- data = File.read(file)
280
- encrypted = cipher.update(data) + cipher.final
281
- encrypted_string = Base64.strict_encode64(encrypted)
282
-
283
- File.write(file, "#{encrypted_string}\n")
284
- rescue StandardError => e
285
- raise e
451
+ file = opts.fetch(:file)
452
+ bytes = File.binread(file)
453
+ ciphertext = encrypt_data(opts.merge(data: bytes))
454
+ publish_data(file: file, data: ciphertext, expected: bytes)
286
455
  end
287
456
 
288
457
  # Supported Method Parameters::
@@ -295,6 +464,8 @@ module PWN
295
464
  raise 'ERROR: File does not exist.' unless File.exist?(file)
296
465
 
297
466
  file_contents = File.read(file).chomp
467
+ return true if hardware_encrypted?(data: file_contents)
468
+
298
469
  file_contents.is_a?(String) && Base64.strict_encode64(Base64.strict_decode64(file_contents)) == file_contents
299
470
  rescue ArgumentError
300
471
  false
@@ -311,6 +482,13 @@ module PWN
311
482
  label = opts[:label].to_s
312
483
  label = "#{opts[:host] || 'loot'}:#{opts[:username] || 'user'}:#{SecureRandom.hex(4)}" if label.empty?
313
484
  records = load_records(engagement: eng)
485
+ selector = opts[:yubikey]
486
+ prior = records.find { |row| row[:label] == label }
487
+ envelope = prior && prior[:secret]
488
+ if selector.nil? && envelope.is_a?(Hash) && (envelope[:kdf] || envelope['kdf']) == YUBIKEY_KDF
489
+ metadata = envelope[:yubikey] || envelope['yubikey']
490
+ selector = metadata.transform_keys(&:to_sym).slice(:slot, :serial)
491
+ end
314
492
  record = {
315
493
  id: SecureRandom.hex(6),
316
494
  label: label,
@@ -319,7 +497,7 @@ module PWN
319
497
  host: opts[:host].to_s,
320
498
  service: opts[:service].to_s,
321
499
  username: opts[:username].to_s,
322
- secret: encrypt_secret(secret: secret),
500
+ secret: encrypt_secret(secret: secret, yubikey: selector),
323
501
  source: (opts[:source] || 'manual').to_s,
324
502
  where: (opts[:where] || opts[:path]).to_s,
325
503
  finding_id: (opts[:finding_id] || opts[:finding]).to_s,
@@ -351,7 +529,7 @@ module PWN
351
529
  row = rows.find { |item| item[:label] == label }
352
530
  return nil unless row
353
531
 
354
- decrypt_secret(row: row[:secret] || row)
532
+ decrypt_secret(row: row[:secret] || row, yubikey: opts[:yubikey])
355
533
  end
356
534
 
357
535
  # List loot metadata for lateral-movement planning (no plaintext secrets).
@@ -379,7 +557,7 @@ module PWN
379
557
  finding_id: opts[:finding_id] || opts[:finding],
380
558
  engagement: opts[:engagement] || opts[:engagement_id]
381
559
  ).map do |row|
382
- row.merge(secret: fetch(label: row[:label], engagement: row[:engagement]))
560
+ row.merge(secret: fetch(label: row[:label], engagement: row[:engagement], yubikey: opts[:yubikey]))
383
561
  end
384
562
  end
385
563
 
@@ -395,13 +573,13 @@ module PWN
395
573
 
396
574
  public_class_method def self.expand(opts = {})
397
575
  text = opts[:text].to_s
398
- text.gsub(/\{\{vault:([^}]+)\}\}/) { fetch(label: Regexp.last_match(1).to_s.strip).to_s }
576
+ text.gsub(/\{\{vault:([^}]+)\}\}/) { fetch(label: Regexp.last_match(1).to_s.strip, yubikey: opts[:yubikey]).to_s }
399
577
  end
400
578
 
401
579
  public_class_method def self.redact(opts = {})
402
580
  text = opts[:text].to_s
403
581
  load_all_records.each do |row|
404
- val = decrypt_secret(row: row[:secret] || row).to_s
582
+ val = decrypt_secret(row: row[:secret] || row, yubikey: opts[:yubikey]).to_s
405
583
  next if val.empty?
406
584
 
407
585
  text = text.gsub(val, "{{vault:#{row[:label]}}}")
@@ -449,6 +627,8 @@ module PWN
449
627
  end
450
628
 
451
629
  private_class_method def self.encrypt_secret(opts = {})
630
+ return seal_credentials(credentials: { 'secret' => opts[:secret].to_s }, yubikey: opts[:yubikey]) unless opts[:yubikey].nil?
631
+
452
632
  cipher = OpenSSL::Cipher.new('aes-256-gcm')
453
633
  cipher.encrypt
454
634
  cipher.key = master_key
@@ -462,6 +642,14 @@ module PWN
462
642
  row = opts[:row]
463
643
  return '' unless row.is_a?(Hash)
464
644
 
645
+ hardware = row.key?(:kdf) || row.key?('kdf')
646
+ if hardware
647
+ envelope = row.transform_keys(&:to_s)
648
+ envelope['yubikey'] = envelope['yubikey'].transform_keys(&:to_s) if envelope['yubikey'].is_a?(Hash)
649
+ return open_credentials(envelope: envelope, yubikey: opts[:yubikey]).fetch('secret')
650
+ end
651
+ raise ArgumentError, 'Loot is not protected by YubiKey' unless opts[:yubikey].nil?
652
+
465
653
  cipher = OpenSSL::Cipher.new('aes-256-gcm')
466
654
  cipher.decrypt
467
655
  cipher.key = master_key
@@ -470,6 +658,8 @@ module PWN
470
658
  cipher.auth_data = 'pwn-vault'
471
659
  cipher.update(Base64.strict_decode64(row[:ct] || row['ct'].to_s)) + cipher.final
472
660
  rescue StandardError
661
+ raise if hardware || opts[:yubikey]
662
+
473
663
  ''
474
664
  end
475
665
 
@@ -489,22 +679,59 @@ module PWN
489
679
 
490
680
  private_class_method def self.parse_box(opts = {})
491
681
  path = opts[:path].to_s
492
- return [] unless File.file?(path)
682
+ stat = begin
683
+ File.lstat(path)
684
+ rescue Errno::ENOENT
685
+ nil
686
+ end
687
+ return [] unless stat
688
+
689
+ invalid = "Invalid loot store #{path}"
690
+ raise IOError, "#{invalid}: expected a regular file" unless stat.file?
493
691
 
494
692
  data = JSON.parse(File.read(path), symbolize_names: true)
495
- if data.is_a?(Hash) && data[:records].is_a?(Array)
496
- data[:records].map { |row| row.transform_keys(&:to_sym) }
497
- elsif data.is_a?(Hash)
498
- data.map do |label, row|
499
- next unless row.is_a?(Hash)
693
+ raise IOError, "#{invalid}: expected a mapping" unless data.is_a?(Hash)
694
+
695
+ if data[:records].is_a?(Array)
696
+ raise IOError, "#{invalid}: unsupported version" if data.key?(:version) && data[:version] != 2
500
697
 
501
- { label: label.to_s, secret: row.transform_keys(&:to_sym), engagement: 'default', host: '', source: 'legacy' }
502
- end.compact
698
+ rows = data[:records].map do |row|
699
+ next row unless row.is_a?(Hash) && !row.key?(:secret)
700
+
701
+ # Strip only record metadata so unknown KDFs and hardware fields still
702
+ # reach envelope validation rather than becoming software ciphertext.
703
+ secret = row.except(:id, :label, :kind, :engagement, :host, :service, :username, :source, :where, :finding_id, :at)
704
+ row.except(*secret.keys).merge(secret: secret)
705
+ end
706
+ raise IOError, "#{invalid}: invalid record or secret envelope" unless rows.all? { |row| row.is_a?(Hash) && row[:label].is_a?(String) && !row[:label].empty? && valid_loot_secret?(secret: row[:secret]) }
707
+
708
+ rows
503
709
  else
504
- []
710
+ raise IOError, "#{invalid}: invalid legacy secret envelope" unless data.all? { |label, row| !label.to_s.empty? && valid_loot_secret?(secret: row) }
711
+
712
+ data.map do |label, row|
713
+ { label: label.to_s, secret: row, engagement: 'default', host: '', source: 'legacy' }
714
+ end
505
715
  end
506
- rescue StandardError
507
- []
716
+ rescue JSON::ParserError
717
+ raise IOError, "Invalid loot store #{path}: malformed JSON", cause: nil
718
+ rescue SystemCallError => e
719
+ raise IOError, "Cannot read loot store #{path} (#{e.class})", cause: nil
720
+ end
721
+
722
+ # Validate storage shape without unlocking secrets or accepting an unknown backend.
723
+ private_class_method def self.valid_loot_secret?(opts = {})
724
+ secret = opts[:secret]
725
+ return false unless secret.is_a?(Hash) && %i[iv ct tag].all? { |key| secret[key].is_a?(String) && !secret[key].empty? }
726
+ return true if (secret.keys - %i[iv ct tag]).empty?
727
+ return false unless secret[:kdf] == YUBIKEY_KDF && secret[:version] == 1 && secret[:cipher] == 'aes-256-gcm'
728
+ return false unless %i[salt key_id].all? { |key| secret[key].is_a?(String) && !secret[key].empty? }
729
+
730
+ metadata = secret[:yubikey]
731
+ return false unless metadata.is_a?(Hash) && (metadata.keys - %i[slot serial challenge]).empty? && metadata[:slot].is_a?(Integer) && [1, 2].include?(metadata[:slot])
732
+ return false unless metadata[:challenge].is_a?(String) && !metadata[:challenge].empty?
733
+
734
+ metadata[:serial].nil? || (metadata[:serial].is_a?(String) && metadata[:serial].match?(/\A[0-9]+\z/))
508
735
  end
509
736
 
510
737
  private_class_method def self.save_records(opts = {})
@@ -529,57 +756,101 @@ module PWN
529
756
  # Seal credentials in an authenticated AES256GCM envelope, without filesystem access.
530
757
  #{self}.seal_credentials(
531
758
  credentials: 'required - JSON-compatible credentials hash',
532
- passphrase: 'optional - required unless keyring is supplied',
759
+ passphrase: 'optional - required unless keyring or yubikey is supplied',
533
760
  keyring: 'optional - callable OS keyring reader returning a raw 32-byte key',
761
+ yubikey: 'optional - native hardware selector Hash: Integer slot (1 or 2), exact decimal String serial, nonempty control-free reader/device_path, exchange-capable connection and finite positive timeout; serial/reader and reader/device_path are mutually exclusive; only slot/serial persist; requires a provisioned HMAC-SHA1 slot',
534
762
  key_id: 'optional - keyring identifier, default pwn-ai-credentials'
535
763
  )
536
764
  # Open an authenticated envelope in memory; never writes plaintext.
537
765
  #{self}.open_credentials(
538
766
  envelope: 'required - sealed credentials hash',
539
767
  passphrase: 'optional - required for passphrase envelopes',
540
- keyring: 'optional - callable OS keyring reader for keyring envelopes'
768
+ keyring: 'optional - callable OS keyring reader for keyring envelopes',
769
+ yubikey: 'optional - runtime hardware selector; defaults to the stored slot and serial'
770
+ )
771
+ # Encrypt bytes in memory using a legacy key and IV or a YubiKey-authenticated envelope.
772
+ #{self}.encrypt_data(
773
+ data: 'required - plaintext String, including binary data',
774
+ key: 'optional - Base64 AES key for legacy CBC encryption only',
775
+ iv: 'optional - Base64 IV for legacy CBC encryption only',
776
+ yubikey: 'optional - hardware selector Hash; excludes software key and IV'
777
+ )
778
+ # Authenticate and decrypt bytes in memory without a software fallback for hardware envelopes.
779
+ #{self}.decrypt_data(
780
+ data: 'required - encrypted String returned by encrypt_data',
781
+ key: 'optional - legacy Base64 AES key',
782
+ iv: 'optional - legacy Base64 IV',
783
+ yubikey: 'optional - runtime hardware selector Hash for the stored slot'
784
+ )
785
+ # Identify hardware envelopes without using a device or decryptor.
786
+ #{self}.hardware_encrypted?(
787
+ file: 'optional - existing file path when data is not supplied',
788
+ data: 'optional - encrypted String to inspect instead of a file'
789
+ )
790
+ # Resolve hardware selectors or legacy credentials without prompting.
791
+ #{self}.access_options(
792
+ file: 'required - existing encrypted vault path',
793
+ decryptor_file: 'optional - legacy sidecar path, default file.decryptor',
794
+ key: 'optional - explicit legacy key overrides the sidecar',
795
+ iv: 'optional - explicit legacy IV overrides the sidecar',
796
+ yubikey: 'optional - runtime selector Hash for a hardware envelope'
541
797
  )
542
- # Run refresh encryption secrets and return its result
798
+ # Yield a YAML mapping for in-memory edits and atomically save ciphertext only.
799
+ #{self}.update(
800
+ file: 'required - encrypted YAML file; mutate the mapping yielded to the block',
801
+ decryptor_file: 'optional - legacy key and IV sidecar path',
802
+ key: 'optional - explicit legacy Base64 key',
803
+ iv: 'optional - explicit legacy Base64 IV',
804
+ yubikey: 'optional - hardware selector override; file format is preserved'
805
+ ) { |document| ... }
806
+ # Rewrap hardware ciphertext with fresh randomness or explicitly convert legacy encryption to hardware.
543
807
  #{self}.refresh_encryption_secrets(
544
808
  file: 'required - file to encrypt with new key and iv',
545
- key: 'required - key to decrypt',
546
- iv: 'required - iv to decrypt'
809
+ key: 'optional - legacy key to decrypt',
810
+ iv: 'optional - legacy IV to decrypt',
811
+ yubikey: 'optional - current hardware selector; defaults to the envelope metadata',
812
+ new_yubikey: 'optional - destination hardware selector; never provisions or rotates a device slot'
547
813
  )
548
814
 
549
815
  # Run create and return its result
550
816
  #{self}.create(
551
817
  file: 'required - encrypted file to create',
552
- decryptor_file: 'optional - file to save the key && iv values'
818
+ decryptor_file: 'optional - file to save legacy key and IV; omit for hardware vaults',
819
+ yubikey: 'optional - hardware selector Hash instead of generating software secrets'
553
820
  )
554
821
 
555
822
  # Run decrypt and return its result
556
823
  #{self}.decrypt(
557
824
  file: 'required - file to decrypt',
558
- key: 'required - key to decrypt',
559
- iv: 'required - iv to decrypt'
825
+ key: 'optional - legacy key to decrypt',
826
+ iv: 'optional - legacy IV to decrypt',
827
+ yubikey: 'optional - runtime hardware selector; hardware format is auto-detected'
560
828
  )
561
829
 
562
830
  # Run dump and return its result
563
831
  #{self}.dump(
564
832
  file: 'required - file to dump',
565
- key: 'required - key to decrypt',
566
- iv: 'required - iv to decrypt',
833
+ key: 'optional - legacy key to decrypt',
834
+ iv: 'optional - legacy IV to decrypt',
835
+ yubikey: 'optional - hardware selector Hash; no plaintext file is created',
567
836
  yaml: 'optional - dump as parsed yaml hash (default: true)'
568
837
  )
569
838
 
570
839
  # Run edit and return its result
571
840
  #{self}.edit(
572
841
  file: 'required - file to edit',
573
- key: 'required - key to decrypt',
574
- iv: 'required - iv to decrypt',
842
+ key: 'optional - legacy key to decrypt',
843
+ iv: 'optional - legacy IV to decrypt',
844
+ yubikey: 'optional - hardware selector; original ciphertext stays intact until the temporary edited copy is re-encrypted',
575
845
  editor: 'optional - editor to use (default: /usr/bin/vim)'
576
846
  )
577
847
 
578
848
  # Run encrypt and return its result
579
849
  #{self}.encrypt(
580
850
  file: 'required - file to encrypt',
581
- key: 'required - key to decrypt',
582
- iv: 'required - iv to decrypt'
851
+ key: 'optional - legacy key for encryption',
852
+ iv: 'optional - legacy IV for encryption',
853
+ yubikey: 'optional - hardware selector Hash selecting authenticated encryption instead of legacy CBC'
583
854
  )
584
855
 
585
856
  # Run file encrypted and return its result
@@ -587,9 +858,10 @@ module PWN
587
858
  file: 'required - file to check if encrypted'
588
859
  )
589
860
 
590
- # Store a secret outside the transcript (AES-GCM; key in ~/.pwn-vault.key).
861
+ # Store a secret outside the transcript using AES-GCM with hardware or a local master key.
591
862
  #{self}.store(
592
863
  secret: 'required - secret value (password, token, or key material)',
864
+ yubikey: 'optional - hardware selector Hash; no disk master key; replacing a hardware label preserves protection',
593
865
  label: 'optional - vault label; defaults to host:user:hex',
594
866
  engagement: 'optional - engagement id scoping the loot file (defaults to default)',
595
867
  engagement_id: 'optional - alias for engagement',
@@ -607,6 +879,7 @@ module PWN
607
879
  # Fetch a stored secret by label.
608
880
  #{self}.fetch(
609
881
  label: 'required - vault label',
882
+ yubikey: 'optional - runtime hardware selector; hardware failures raise without software fallback',
610
883
  engagement: 'optional - engagement id to search first',
611
884
  engagement_id: 'optional - alias for engagement'
612
885
  )
@@ -625,6 +898,7 @@ module PWN
625
898
  # Return decrypted creds that match a service auth prompt.
626
899
  #{self}.offer(
627
900
  host: 'optional - hostname or IP to match',
901
+ yubikey: 'optional - hardware selector forwarded when decrypting matching loot',
628
902
  service: 'optional - service name such as http or ssh',
629
903
  kind: 'optional - password, token, or key',
630
904
  finding_id: 'optional - finding id this secret is linked to',
@@ -636,6 +910,7 @@ module PWN
636
910
  # Parse username/password strings from recon evidence into the loot store.
637
911
  #{self}.ingest(
638
912
  text: 'required - recon banner, .env, or HTML containing creds',
913
+ yubikey: 'optional - hardware selector to encrypt extracted loot',
639
914
  host: 'optional - hostname or IP this secret authenticates',
640
915
  service: 'optional - service name such as http or ssh',
641
916
  source: 'optional - provenance source such as recon',
@@ -649,12 +924,14 @@ module PWN
649
924
 
650
925
  # Replace {{vault:label}} tokens with stored secrets.
651
926
  #{self}.expand(
652
- text: 'required - string possibly containing {{vault:label}} tokens'
927
+ text: 'required - string possibly containing {{vault:label}} tokens',
928
+ yubikey: 'optional - hardware selector for referenced loot'
653
929
  )
654
930
 
655
931
  # Replace stored secret values with {{vault:label}} placeholders.
656
932
  #{self}.redact(
657
- text: 'required - string that may contain stored secrets'
933
+ text: 'required - string that may contain stored secrets',
934
+ yubikey: 'optional - hardware selector for stored hardware loot; may require physical touch'
658
935
  )
659
936
 
660
937
  # Print the AUTHOR(S) string for this module.