pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Vault, 'loot corruption safety' do
7
+ before do
8
+ @root = Dir.mktmpdir('vault-loot-safety-')
9
+ allow(Dir).to receive(:home).and_return(@root)
10
+ allow(described_class).to receive(:note_mission_loot)
11
+ @device = double('offline YubiKey')
12
+ stub_const('PWN::Plugins::YubiKey', @device)
13
+ allow(@device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge]) }
14
+ @path = File.join(@root, '.pwn', 'engagements', 'default', 'loot.json')
15
+ @legacy_path = File.join(@root, '.pwn', 'vault-secrets.json')
16
+ FileUtils.mkdir_p(File.dirname(@path))
17
+ end
18
+
19
+ after do
20
+ FileUtils.remove_entry(@root)
21
+ end
22
+
23
+ {
24
+ 'malformed JSON' => '{"PRIVATE_PARSE_MARKER":',
25
+ 'nonmapping root' => '[]',
26
+ 'nonarray records' => '{"records": {}}',
27
+ 'null records' => '{"records": null}',
28
+ 'nonmapping row' => '{"version": 2, "records": [false]}',
29
+ 'missing secret' => '{"version": 2, "records": [{"label": "protected"}]}',
30
+ 'nonmapping secret' => '{"version": 2, "records": [{"label": "protected", "secret": false}]}',
31
+ 'nonstring label' => '{"version": 2, "records": [{"label": 123, "secret": {}}]}',
32
+ 'invalid legacy row' => '{"protected": false}'
33
+ }.each do |description, damaged|
34
+ it "refuses #{description} before choosing a store backend", :aggregate_failures do
35
+ described_class.store(label: 'protected', secret: 'original-fixture', yubikey: {})
36
+ File.binwrite(@path, damaged)
37
+ expect do
38
+ described_class.store(label: 'protected', secret: 'replacement-fixture')
39
+ end.to raise_error(IOError, /loot store/)
40
+ expect(File.binread(@path)).to eq(damaged)
41
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
42
+ end
43
+ end
44
+
45
+ it 'does not overwrite corrupt records even with an explicit hardware destination' do
46
+ damaged = '{"records": [null]}'
47
+ File.write(@path, damaged)
48
+ expect(@device).not_to receive(:challenge_response)
49
+ expect { described_class.store(label: 'protected', secret: 'fixture', yubikey: {}) }.to raise_error(IOError, /loot store/)
50
+ expect(File.binread(@path)).to eq(damaged)
51
+ end
52
+
53
+ it 'rejects incomplete or unknown hardware envelopes rather than replacing them with software encryption', :aggregate_failures do
54
+ described_class.store(label: 'protected', secret: 'original-fixture', yubikey: {})
55
+ original = JSON.parse(File.read(@path))
56
+ [nil, 'unknown-kdf'].each do |invalid|
57
+ damaged = Marshal.load(Marshal.dump(original))
58
+ damaged['records'].first['secret']['kdf'] = invalid
59
+ File.write(@path, JSON.generate(damaged))
60
+ expect { described_class.store(label: 'protected', secret: 'replacement-fixture') }.to raise_error(IOError, /loot store/)
61
+ expect(JSON.parse(File.read(@path))).to eq(damaged)
62
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
63
+ end
64
+ original['records'].first['secret']['yubikey'] = nil
65
+ File.write(@path, JSON.generate(original))
66
+ expect { described_class.store(label: 'protected', secret: 'replacement-fixture') }.to raise_error(IOError, /loot store/)
67
+ expect(JSON.parse(File.read(@path))).to eq(original)
68
+ end
69
+
70
+ it 'reports unreadable existing stores without treating them as absent', :aggregate_failures do
71
+ File.write(@path, '{"records": []}')
72
+ allow(File).to receive(:read).and_call_original
73
+ allow(File).to receive(:read).with(@path).and_raise(Errno::EACCES, 'PRIVATE_PARSE_MARKER')
74
+ expect { described_class.store(label: 'protected', secret: 'fixture') }.to raise_error(IOError, /loot store/)
75
+ expect(File.binread(@path)).to eq('{"records": []}')
76
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
77
+ end
78
+
79
+ it 'does not treat a nonregular existing store as absent' do
80
+ Dir.mkdir(@path)
81
+ expect { described_class.query }.to raise_error(IOError, /loot store/)
82
+ expect { described_class.store(label: 'protected', secret: 'fixture') }.to raise_error(IOError, /loot store/)
83
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
84
+ end
85
+
86
+ it 'fails closed on corrupt legacy stores before creating engagement records' do
87
+ File.write(@legacy_path, '{"PRIVATE_PARSE_MARKER":')
88
+ expect { described_class.store(label: 'protected', secret: 'fixture') }.to raise_error(IOError, /loot store/)
89
+ expect(File).not_to exist(@path)
90
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
91
+ end
92
+
93
+ it 'surfaces safe diagnostics on every read-only parser path instead of silently hiding records' do
94
+ File.write(@path, '{"PRIVATE_PARSE_MARKER":')
95
+ queries = [
96
+ -> { described_class.query },
97
+ -> { described_class.fetch(label: 'protected', engagement: 'default') },
98
+ -> { described_class.fetch(label: 'protected') },
99
+ -> { described_class.offer },
100
+ -> { described_class.expand(text: '{{vault:protected}}') },
101
+ -> { described_class.redact(text: 'fixture') }
102
+ ]
103
+ queries.each do |query|
104
+ expect(&query).to raise_error(IOError, /loot store/) do |error|
105
+ expect(error.message).to include(@path)
106
+ expect(error.message).not_to include('PRIVATE_PARSE_MARKER')
107
+ expect(error.cause).to be_nil
108
+ end
109
+ end
110
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
111
+ end
112
+
113
+ it 'preserves absent and empty stores as empty collections' do
114
+ expect(described_class.query).to eq([])
115
+ expect(described_class.fetch(label: 'absent')).to be_nil
116
+ File.write(@path, JSON.generate(version: 2, records: []))
117
+ File.write(@legacy_path, '{}')
118
+ expect(described_class.query).to eq([])
119
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
120
+ end
121
+
122
+ it 'reads a legacy label named records without mistaking its ciphertext for a modern collection' do
123
+ secret = described_class.send(:encrypt_secret, secret: 'records-fixture')
124
+ original = JSON.generate(records: secret)
125
+ File.write(@legacy_path, original)
126
+ expect(described_class.fetch(label: 'records')).to eq('records-fixture')
127
+ expect(described_class.query).to contain_exactly(label: 'records', engagement: 'default', host: '', source: 'legacy', has_secret: true)
128
+ described_class.store(label: 'hardware', secret: 'hardware-fixture', yubikey: {})
129
+ expect(described_class.fetch(label: 'records', engagement: 'default')).to eq('records-fixture')
130
+ expect(File.binread(@legacy_path)).to eq(original)
131
+ end
132
+
133
+ it 'rejects a versioned collection with a ciphertext Hash instead of a records array' do
134
+ secret = described_class.send(:encrypt_secret, secret: 'fixture', yubikey: {})
135
+ damaged = JSON.generate(version: 2, records: secret)
136
+ File.write(@path, damaged)
137
+ expect(@device).not_to receive(:challenge_response)
138
+ expect { described_class.store(label: 'protected', secret: 'replacement-fixture') }.to raise_error(IOError, /loot store/)
139
+ expect(File.binread(@path)).to eq(damaged)
140
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
141
+ end
142
+
143
+ %i[legacy hardware].each do |backend|
144
+ it "normalizes inline #{backend} ciphertext while preserving loot metadata and protection" do
145
+ access = backend == :hardware ? { yubikey: {} } : {}
146
+ secret = described_class.send(:encrypt_secret, access.merge(secret: 'flat-fixture'))
147
+ metadata = {
148
+ id: 'fixture-id', label: 'flat', kind: 'password', engagement: 'default', host: 'login.example.test',
149
+ service: 'ssh', username: 'fixture-user', source: 'legacy', where: '/fixture/path',
150
+ finding_id: 'fixture-finding', at: '2026-01-01T00:00:00Z'
151
+ }
152
+ original = JSON.generate(version: 2, records: [secret.merge(metadata)])
153
+ File.write(@path, original)
154
+ expect(described_class.fetch(label: 'flat')).to eq('flat-fixture')
155
+ expect(described_class.query).to contain_exactly(metadata.merge(has_secret: true))
156
+ expect(described_class.offer(host: metadata[:host]).first[:secret]).to eq('flat-fixture')
157
+ expect(described_class.expand(text: '{{vault:flat}}')).to eq('flat-fixture')
158
+ expect(described_class.redact(text: 'flat-fixture')).to eq('{{vault:flat}}')
159
+ expect(File.binread(@path)).to eq(original)
160
+
161
+ described_class.store(label: 'other', secret: 'other-fixture', yubikey: {})
162
+ saved = JSON.parse(File.read(@path))['records'].find { |row| row['label'] == 'flat' }
163
+ expect(saved).to eq(JSON.parse(JSON.generate(metadata.merge(secret: secret))))
164
+ described_class.store(label: 'flat', secret: 'replacement-fixture')
165
+ expect(described_class.fetch(label: 'flat')).to eq('replacement-fixture')
166
+ saved_secret = JSON.parse(File.read(@path))['records'].find { |row| row['label'] == 'flat' }['secret']
167
+ if backend == :hardware
168
+ expect(saved_secret['kdf']).to eq(described_class::YUBIKEY_KDF)
169
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
170
+ else
171
+ expect(saved_secret.keys).to contain_exactly('iv', 'ct', 'tag')
172
+ end
173
+ end
174
+ end
175
+
176
+ {
177
+ 'unknown KDF' => { 'kdf' => 'unknown-kdf' },
178
+ 'null KDF' => { 'kdf' => nil },
179
+ 'invalid hardware metadata' => { 'yubikey' => false },
180
+ 'runtime hardware options' => { 'yubikey' => { 'slot' => 2, 'challenge' => 'fixture', 'ykman' => '/untrusted/backend' } },
181
+ 'explicit null secret' => { 'secret' => nil },
182
+ 'explicit false secret' => { 'secret' => false }
183
+ }.each do |description, changes|
184
+ it "rejects inline ciphertext with #{description} instead of selecting software encryption" do
185
+ secret = described_class.send(:encrypt_secret, secret: 'original-fixture', yubikey: {})
186
+ damaged = JSON.generate(version: 2, records: [secret.merge('label' => 'protected').merge(changes)])
187
+ File.write(@path, damaged)
188
+ expect(@device).not_to receive(:challenge_response)
189
+ expect { described_class.fetch(label: 'protected') }.to raise_error(IOError, /loot store/)
190
+ expect { described_class.store(label: 'protected', secret: 'replacement-fixture') }.to raise_error(IOError, /loot store/)
191
+ expect(File.binread(@path)).to eq(damaged)
192
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
193
+ end
194
+ end
195
+
196
+ [1.0, 2.0].each do |slot|
197
+ %i[inline nested].each do |shape|
198
+ it "rejects float hardware slot #{slot} in #{shape} loot envelopes before selecting a backend" do
199
+ secret = described_class.send(:encrypt_secret, secret: 'original-fixture', yubikey: {})
200
+ secret['yubikey']['slot'] = slot
201
+ row = shape == :inline ? secret.merge(label: 'protected') : { label: 'protected', secret: secret }
202
+ damaged = JSON.generate(version: 2, records: [row])
203
+ File.write(@path, damaged)
204
+ expect(@device).not_to receive(:challenge_response)
205
+ expect { described_class.query }.to raise_error(IOError, /loot store/)
206
+ expect { described_class.store(label: 'protected', secret: 'replacement-fixture') }.to raise_error(IOError, /loot store/)
207
+ expect(File.binread(@path)).to eq(damaged)
208
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
209
+ end
210
+ end
211
+ end
212
+
213
+ it 'retains inline hardware ciphertext when the device is unavailable during replacement' do
214
+ secret = described_class.send(:encrypt_secret, secret: 'original-fixture', yubikey: {})
215
+ original = JSON.generate(version: 2, records: [secret.merge(label: 'protected')])
216
+ File.write(@path, original)
217
+ allow(@device).to receive(:challenge_response).and_raise(IOError, 'Device unavailable')
218
+ expect { described_class.store(label: 'protected', secret: 'replacement-fixture') }.to raise_error(IOError, 'Device unavailable')
219
+ expect(File.binread(@path)).to eq(original)
220
+ expect(File).not_to exist(File.join(@root, '.pwn-vault.key'))
221
+ end
222
+
223
+ it 'reads valid legacy label maps and preserves them when adding modern records' do
224
+ key = 'k' * 32
225
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
226
+ cipher.encrypt
227
+ cipher.key = key
228
+ iv = cipher.random_iv
229
+ cipher.auth_data = 'pwn-vault'
230
+ ct = cipher.update('legacy-fixture') + cipher.final
231
+ secret = { iv: Base64.strict_encode64(iv), ct: Base64.strict_encode64(ct), tag: Base64.strict_encode64(cipher.auth_tag) }
232
+ File.binwrite(File.join(@root, '.pwn-vault.key'), key)
233
+ File.write(@legacy_path, JSON.generate(legacy: secret))
234
+ expect(described_class.fetch(label: 'legacy')).to eq('legacy-fixture')
235
+ described_class.store(label: 'hardware', secret: 'hardware-fixture', yubikey: {})
236
+ expect(described_class.query.map { |row| row[:label] }).to include('legacy', 'hardware')
237
+ expect(described_class.fetch(label: 'legacy', engagement: 'default')).to eq('legacy-fixture')
238
+ expect(described_class.fetch(label: 'hardware', engagement: 'default')).to eq('hardware-fixture')
239
+ expect(JSON.parse(File.read(@legacy_path), symbolize_names: true)).to eq(legacy: secret)
240
+ end
241
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Vault, 'YubiKey failure safety' do
7
+ before do
8
+ @device = double('offline YubiKey')
9
+ stub_const('PWN::Plugins::YubiKey', @device)
10
+ allow(@device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge]) }
11
+ @legacy = { key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16) }
12
+ allow(described_class).to receive(:note_mission_loot)
13
+ end
14
+
15
+ it 'never ignores explicit hardware selection when editing or rotating a legacy vault' do
16
+ Dir.mktmpdir('vault-no-fallback-') do |root|
17
+ allow(Dir).to receive(:home).and_return(root)
18
+ path = File.join(root, 'vault')
19
+ original = described_class.encrypt_data(@legacy.merge(data: "token: fixture\n"))
20
+ File.binwrite(path, original)
21
+ expect(described_class).not_to receive(:system)
22
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
23
+ %i[edit refresh_encryption_secrets].each do |method|
24
+ expect { described_class.public_send(method, @legacy.merge(file: path, yubikey: {})) }.to raise_error(ArgumentError, /YubiKey/)
25
+ expect(File.binread(path)).to eq(original)
26
+ end
27
+ end
28
+ end
29
+
30
+ it 'rejects software credentials combined with hardware creation rather than discarding them' do
31
+ Dir.mktmpdir('vault-conflict-') do |root|
32
+ path = File.join(root, 'vault')
33
+ File.write(path, 'original')
34
+ expect { described_class.create(@legacy.merge(file: path, yubikey: {})) }.to raise_error(ArgumentError, /software/)
35
+ expect(File.read(path)).to eq('original')
36
+ end
37
+ end
38
+
39
+ it 'does not accept false as a hardware selector during unsealing' do
40
+ envelope = described_class.seal_credentials(credentials: { token: 'fixture' }, yubikey: {})
41
+ expect { described_class.open_credentials(envelope: envelope, yubikey: false) }.to raise_error(ArgumentError, /selector/)
42
+ end
43
+
44
+ [false, { slot: 3 }, { slot: 1.0 }, { slot: 2.0 }, { unexpected: true }, { serial: false }, { serial: 123 },
45
+ { timeout: false }, { timeout: 0 }, { timeout: Float::INFINITY }, { ykman: '' }, { ykman: false },
46
+ { ykman: '/fixture/unsupported-executable' }, { device_path: false }, { device_path: '' }, { device_path: "bad\npath" },
47
+ { reader: false }, { reader: '' }, { reader: "bad\0reader" }, { reader: 'Fixture reader', serial: '123' },
48
+ { connection: false }, { connection: Object.new }, { reader: 'Fixture reader', device_path: '/fixture/native-device' }, { 123 => 'invalid key' }].each do |selector|
49
+ %i[legacy hardware].each do |backend|
50
+ it "rejects destination selector #{selector.inspect} before opening a #{backend} vault" do
51
+ Dir.mktmpdir('vault-invalid-destination-') do |root|
52
+ allow(Dir).to receive(:home).and_return(root)
53
+ path = File.join(root, 'vault')
54
+ access = backend == :legacy ? @legacy : { yubikey: {} }
55
+ original = described_class.encrypt_data(access.merge(data: "token: fixture\n"))
56
+ File.binwrite(path, original)
57
+ expect(described_class).not_to receive(:decrypt_data)
58
+ expect(@device).not_to receive(:challenge_response)
59
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
60
+ expect do
61
+ expect { described_class.refresh_encryption_secrets(access.merge(file: path, new_yubikey: selector)) }.to raise_error(ArgumentError, /yubikey/i)
62
+ end.not_to output.to_stdout
63
+ expect(File.binread(path)).to eq(original)
64
+ expect(Dir.children(root)).to eq(['vault'])
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ it 'keeps entropy redacted when the hardware loot write fails' do
71
+ secret = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
72
+ allow(@device).to receive(:challenge_response).and_raise(IOError, 'Device unavailable')
73
+ Dir.mktmpdir('capture-no-device-') do |root|
74
+ allow(Dir).to receive(:home).and_return(root)
75
+ result = PWN::Redaction.capture(value: secret, entropy: true, yubikey: {})
76
+ expect(result[:redacted]).not_to include(secret)
77
+ expect(result[:redacted]).to start_with('[REDACTED:entropy:')
78
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
79
+ end
80
+ end
81
+
82
+ it 'authenticates ciphertext and public metadata and rejects a wrong device response' do
83
+ envelope = described_class.seal_credentials(credentials: { value: 'fixture' }, yubikey: { slot: 1, serial: '12345678' })
84
+ %w[ct tag salt iv key_id].each do |field|
85
+ changed = Marshal.load(Marshal.dump(envelope))
86
+ changed[field] = field == 'key_id' ? 'different-context' : Base64.strict_encode64('x' * Base64.strict_decode64(changed[field]).bytesize)
87
+ expect { described_class.open_credentials(envelope: changed) }.to raise_error(ArgumentError)
88
+ end
89
+ expect(described_class.open_credentials(envelope: envelope, yubikey: { serial: '87654321' })).to eq('value' => 'fixture')
90
+ expect { described_class.open_credentials(envelope: envelope, yubikey: { slot: 2 }) }.to raise_error(ArgumentError, /slot/)
91
+ allow(@device).to receive(:challenge_response) { 'w' * 20 }
92
+ expect { described_class.open_credentials(envelope: envelope) }.to raise_error(ArgumentError, /authentication/)
93
+ end
94
+
95
+ it 'rejects caller-only native controls injected into envelope metadata before contacting hardware' do
96
+ original = described_class.seal_credentials(credentials: {}, yubikey: {})
97
+ expect(@device).not_to receive(:challenge_response)
98
+ %w[ykman reader device_path connection timeout].each do |field|
99
+ envelope = Marshal.load(Marshal.dump(original))
100
+ envelope['yubikey'][field] = '/untrusted/backend'
101
+ expect { described_class.open_credentials(envelope: envelope) }.to raise_error(ArgumentError, /metadata/)
102
+ end
103
+ end
104
+
105
+ it 'preserves ciphertext when the device disappears on the encryption half of an update' do
106
+ Dir.mktmpdir('vault-save-failure-') do |root|
107
+ path = File.join(root, 'vault')
108
+ File.write(path, "token: original\n")
109
+ described_class.create(file: path, yubikey: {})
110
+ before = File.binread(path)
111
+ calls = 0
112
+ allow(@device).to receive(:challenge_response) do |opts|
113
+ calls += 1
114
+ raise IOError, 'Device removed' if calls > 1
115
+
116
+ OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge])
117
+ end
118
+ expect do
119
+ described_class.update(file: path) do |cfg|
120
+ cfg[:token] = 'changed'
121
+ expect(File.binread(path)).to eq(before)
122
+ end
123
+ end.to raise_error(IOError, 'Device removed')
124
+ expect(File.binread(path)).to eq(before)
125
+ expect(Dir.children(root)).to eq(['vault'])
126
+ end
127
+ end
128
+
129
+ it 'converts a legacy vault in memory and never downgrades a hardware loot label on replacement' do
130
+ Dir.mktmpdir('vault-conversion-') do |root|
131
+ allow(Dir).to receive(:home).and_return(root)
132
+ path = File.join(root, 'vault')
133
+ File.binwrite(path, described_class.encrypt_data(@legacy.merge(data: "token: original\n")))
134
+ described_class.refresh_encryption_secrets(@legacy.merge(file: path, new_yubikey: {}))
135
+ expect(described_class.dump(file: path)).to eq(token: 'original')
136
+ expect(File.stat(path).mode & 0o777).to eq(0o600)
137
+ described_class.store(label: 'fixed', secret: 'first', yubikey: {})
138
+ described_class.store(label: 'fixed', secret: 'second')
139
+ expect(described_class.fetch(label: 'fixed')).to eq('second')
140
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
141
+ end
142
+ end
143
+
144
+ %i[legacy hardware].each do |backend|
145
+ it "preserves untouched multiline YAML scalars during a #{backend} update" do
146
+ Dir.mktmpdir('vault-yaml-scalars-') do |root|
147
+ path = File.join(root, 'vault')
148
+ original = {
149
+ banner: ":first\n:second\n\n :indented\nlast:\n",
150
+ nested: { values: [":array-line\n:next\n", :symbol, false, nil, 123], unicode: "é\n:行\n" }
151
+ }
152
+ access = backend == :legacy ? @legacy : { yubikey: {} }
153
+ File.binwrite(path, described_class.encrypt_data(access.merge(data: YAML.dump(original))))
154
+ expect(described_class.update(access.merge(file: path)) { |cfg| cfg[:changed] = true }).to eq(:saved)
155
+ expect(described_class.dump(access.merge(file: path))).to eq(original.merge(changed: true))
156
+ end
157
+ end
158
+ end
159
+
160
+ it 'supports empty binary data with independent challenge, salt and nonce on every encryption' do
161
+ first = described_class.encrypt_data(data: ''.b, yubikey: {})
162
+ second = described_class.encrypt_data(data: ''.b, yubikey: {})
163
+ expect(described_class.decrypt_data(data: first)).to eq(''.b)
164
+ a, b = [first, second].map { |ciphertext| JSON.parse(ciphertext.delete_prefix(described_class::YUBIKEY_HEADER)) }
165
+ %w[salt iv].each { |field| expect(a[field]).not_to eq(b[field]) }
166
+ expect(a['yubikey']['challenge']).not_to eq(b['yubikey']['challenge'])
167
+ end
168
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Vault, 'YubiKey credential envelopes' do
7
+ before do
8
+ @device = double('YubiKey HMAC fixture')
9
+ stub_const('PWN::Plugins::YubiKey', @device)
10
+ allow(@device).to receive(:challenge_response) do |opts|
11
+ expect(opts[:slot]).to eq(2)
12
+ expect(opts[:challenge].bytesize).to eq(32)
13
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
14
+ end
15
+ end
16
+
17
+ it 'round trips credentials with a hardware-derived key and no software key material in the envelope' do
18
+ envelope = described_class.seal_credentials(credentials: { 'token' => 'fixture-token' }, yubikey: { slot: 2 })
19
+ expect(envelope['kdf']).to eq('yubikey-hmac-sha1-hkdf-sha256')
20
+ expect(envelope.fetch('yubikey')).to include('slot' => 2)
21
+ expect(JSON.generate(envelope)).not_to include('fixture-token', 'synthetic-slot-secret')
22
+ expect(described_class.open_credentials(envelope: envelope)).to eq('token' => 'fixture-token')
23
+ expect(@device).to have_received(:challenge_response).twice
24
+ end
25
+
26
+ it 'encrypts a binary file and dumps or decrypts it without a software decryptor' do
27
+ Dir.mktmpdir('vault-yubikey-') do |root|
28
+ path = File.join(root, 'fixture')
29
+ bytes = "binary\x00\xff\n".b
30
+ File.binwrite(path, bytes)
31
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
32
+ described_class.encrypt(file: path, yubikey: { slot: 2 })
33
+ ciphertext = File.binread(path)
34
+ expect(ciphertext).to start_with(described_class::YUBIKEY_HEADER)
35
+ expect(described_class.file_encrypted?(file: path)).to be(true)
36
+ expect(described_class.dump(file: path, yaml: false)).to eq(bytes)
37
+ expect(File.binread(path)).to eq(ciphertext)
38
+ described_class.decrypt(file: path)
39
+ expect(File.binread(path)).to eq(bytes)
40
+ end
41
+ end
42
+
43
+ it 'updates a hardware configuration without requiring a sidecar or ever writing plaintext' do
44
+ Dir.mktmpdir('vault-update-') do |root|
45
+ path = File.join(root, 'pwn.yaml')
46
+ File.write(path, YAML.dump(ai: { active: 'ollama', ollama: { model: 'before' } }, retained: 'fixture'))
47
+ described_class.encrypt(file: path, yubikey: { slot: 2 })
48
+ before = File.binread(path)
49
+ expect(described_class.access_options(file: path)).to eq(yubikey: { slot: 2 })
50
+ expect(described_class.update(file: path) { |cfg| cfg[:ai][:ollama][:model] = 'after' }).to eq(:saved)
51
+ expect(File.binread(path)).not_to eq(before)
52
+ expect(described_class.dump(file: path)).to include(ai: { active: 'ollama', ollama: { model: 'after' } }, retained: 'fixture')
53
+ before = File.binread(path)
54
+ allow(@device).to receive(:challenge_response).and_raise(IOError, 'Device unavailable')
55
+ expect { described_class.update(file: path, &:clear) }.to raise_error(IOError, 'Device unavailable')
56
+ expect(File.binread(path)).to eq(before)
57
+ expect(Dir.children(root)).to eq(['pwn.yaml'])
58
+ end
59
+ end
60
+
61
+ it 'creates and rotates a hardware vault without printing or persisting a software decryptor' do
62
+ Dir.mktmpdir('vault-create-') do |root|
63
+ path = File.join(root, 'secret.yaml')
64
+ File.write(path, "token: fixture-only\n")
65
+ expect { described_class.create(file: path, yubikey: { slot: 2 }) }.not_to output.to_stdout
66
+ before = File.binread(path)
67
+ described_class.refresh_encryption_secrets(file: path)
68
+ expect(File.binread(path)).not_to eq(before)
69
+ expect(described_class.dump(file: path)).to eq(token: 'fixture-only')
70
+ expect(Dir.children(root)).to eq(['secret.yaml'])
71
+ end
72
+ end
73
+
74
+ it 'edits only a private temporary copy and retains ciphertext if the key disappears before save' do
75
+ Dir.mktmpdir('vault-edit-') do |root|
76
+ allow(Dir).to receive(:home).and_return(root)
77
+ path = File.join(root, 'vault')
78
+ File.write(path, "token: original\n")
79
+ described_class.create(file: path, yubikey: { slot: 2 })
80
+ before = File.binread(path)
81
+ edited = nil
82
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
83
+ allow(described_class).to receive(:system) do |editor, temporary|
84
+ expect(editor).to eq('/usr/bin/true')
85
+ expect(temporary).not_to eq(path)
86
+ expect(File.stat(temporary).mode & 0o777).to eq(0o600)
87
+ expect(File.binread(path)).to eq(before)
88
+ edited = temporary
89
+ File.write(temporary, "token: changed\n")
90
+ allow(@device).to receive(:challenge_response).and_raise(IOError, 'Device removed')
91
+ true
92
+ end
93
+ expect { described_class.edit(file: path, editor: '/usr/bin/true') }.to raise_error(IOError, 'Device removed')
94
+ expect(File.binread(path)).to eq(before)
95
+ expect(File).not_to exist(edited)
96
+ end
97
+ end
98
+
99
+ it 'protects engagement loot and its offer, expansion and redaction consumers without a disk master key' do
100
+ Dir.mktmpdir('vault-loot-hardware-') do |root|
101
+ allow(Dir).to receive(:home).and_return(root)
102
+ selector = { slot: 2 }
103
+ described_class.store(label: 'hardware', secret: 'fixture-password', host: 'login.example.test', username: 'fixture-user', yubikey: selector)
104
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
105
+ expect(described_class.fetch(label: 'hardware')).to eq('fixture-password')
106
+ expect(described_class.offer(host: 'login.example.test').first[:secret]).to eq('fixture-password')
107
+ expect(described_class.expand(text: '{{vault:hardware}}', yubikey: selector)).to eq('fixture-password')
108
+ expect(described_class.redact(text: 'fixture-password', yubikey: selector)).to eq('{{vault:hardware}}')
109
+ expect(PWN::Plugins::BasicAuth.encode(host: 'login.example.test', yubikey: selector)).to eq(Base64.strict_encode64('fixture-user:fixture-password'))
110
+ before = File.binread(File.join(root, '.pwn', 'engagements', 'default', 'loot.json'))
111
+ allow(@device).to receive(:challenge_response).and_raise(IOError, 'Device unavailable')
112
+ expect { described_class.fetch(label: 'hardware') }.to raise_error(IOError, 'Device unavailable')
113
+ expect { described_class.store(label: 'hardware', secret: 'replacement', yubikey: selector) }.to raise_error(IOError)
114
+ expect(File.binread(File.join(root, '.pwn', 'engagements', 'default', 'loot.json'))).to eq(before)
115
+ end
116
+ end
117
+
118
+ it 'allows redaction capture to explicitly use the hardware loot backend' do
119
+ Dir.mktmpdir('redaction-yubikey-') do |root|
120
+ allow(Dir).to receive(:home).and_return(root)
121
+ capture = PWN::Redaction.capture(value: 'password=fixture-password', yubikey: { slot: 2 })
122
+ label = capture[:refs].first.fetch(:token)
123
+ expect(capture[:redacted]).not_to include('fixture-password')
124
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
125
+ expect(described_class.fetch(label: label)).to eq('password=fixture-password')
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::Vault, 'independent hardware envelope vector' do
6
+ # Offline fixture: key bytes 00..13, challenge 00..1f, salt 00..0f,
7
+ # nonce 20..2b. Independently computed with Python hmac, manual RFC 5869
8
+ # extract/expand, and cryptography AESGCM, not the production Vault encoder.
9
+ let(:envelope) do
10
+ {
11
+ 'version' => 1, 'cipher' => 'aes-256-gcm', 'kdf' => 'yubikey-hmac-sha1-hkdf-sha256',
12
+ 'key_id' => 'pwn-ai-credentials', 'iterations' => 600_000,
13
+ 'salt' => 'AAECAwQFBgcICQoLDA0ODw==',
14
+ 'yubikey' => { 'slot' => 2, 'serial' => '12345678', 'challenge' => 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' },
15
+ 'iv' => 'ICEiIyQlJicoKSor', 'ct' => '3SNJ2cNhpHZ2I/o6/9fiAE9MeMop+YfsWrGP',
16
+ 'tag' => 'w4t/Vmz98ro9opHAmz472Q=='
17
+ }
18
+ end
19
+
20
+ before do
21
+ arguments = { slot: 2, serial: '12345678', challenge: (0...32).to_a.pack('C*') }
22
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response).with(arguments).and_return(['533bd067d1bbf825ed021f8f278adbb571c51627'].pack('H*'))
23
+ end
24
+
25
+ it 'opens independently produced authenticated ciphertext without re-encoding it' do
26
+ ciphertext = "#{described_class::YUBIKEY_HEADER}#{JSON.generate(envelope)}\n"
27
+ expect(described_class.decrypt_data(data: ciphertext)).to eq("offline:\x00\xff\n".b)
28
+ end
29
+
30
+ it 'matches the independent envelope with fixed public randomness' do
31
+ allow(OpenSSL::Random).to receive(:random_bytes).with(16).and_return((0...16).to_a.pack('C*'))
32
+ allow(OpenSSL::Random).to receive(:random_bytes).with(32).and_return((0...32).to_a.pack('C*'))
33
+ allow(OpenSSL::Cipher).to receive(:new).and_wrap_original do |build, *args|
34
+ cipher = build.call(*args)
35
+ allow(cipher).to receive(:random_iv) do
36
+ nonce = (32...44).to_a.pack('C*')
37
+ cipher.iv = nonce
38
+ nonce
39
+ end
40
+ cipher
41
+ end
42
+ ciphertext = described_class.encrypt_data(data: "offline:\x00\xff\n".b, yubikey: { slot: 2, serial: '12345678' })
43
+ expect(JSON.parse(ciphertext.delete_prefix(described_class::YUBIKEY_HEADER))).to eq(envelope)
44
+ end
45
+ end