pwn 0.5.784 → 0.5.787

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'timeout'
6
+
7
+ RSpec.describe PWN::Plugins::Vault, 'serialized publication safety' do
8
+ before do
9
+ @root = Dir.mktmpdir('vault-publication-safety-')
10
+ allow(Dir).to receive(:home).and_return(@root)
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, 'vault')
15
+ @legacy = { key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16) }
16
+ @document = { token: 'fixture', retained: 'original' }
17
+ @threads = []
18
+ end
19
+
20
+ after do
21
+ @threads.each do |thread|
22
+ thread.kill if thread.alive?
23
+ thread.join(1)
24
+ end
25
+ FileUtils.remove_entry(@root)
26
+ end
27
+
28
+ def writer_thread(role, &block)
29
+ thread = Thread.new do
30
+ Thread.current.report_on_exception = false
31
+ Thread.current[:vault_publication_role] = role
32
+ block.call
33
+ end
34
+ @threads << thread
35
+ thread
36
+ end
37
+
38
+ def prepare_vault(backend = :hardware)
39
+ access = backend == :hardware ? { yubikey: {} } : @legacy
40
+ File.binwrite(@path, described_class.encrypt_data(access.merge(data: YAML.dump(@document))))
41
+ end
42
+
43
+ def fake_editor
44
+ allow(File).to receive(:executable?).and_call_original
45
+ allow(File).to receive(:executable?).with('/offline-editor').and_return(true)
46
+ allow(described_class).to receive(:system).with('/offline-editor', anything) do |_editor, path|
47
+ File.write(path, YAML.dump(@document.merge(edited: true)))
48
+ true
49
+ end
50
+ end
51
+
52
+ %i[refresh_encryption_secrets edit].each do |writer|
53
+ it "serializes #{writer} against update even when paused after its baseline check", :aggregate_failures do
54
+ prepare_vault
55
+ fake_editor if writer == :edit
56
+ ready = Queue.new
57
+ release = Queue.new
58
+ attempts = Queue.new
59
+ allow(File).to receive(:rename).and_wrap_original do |rename, from, to|
60
+ if to == @path && Thread.current[:vault_publication_role] == :first
61
+ ready << true
62
+ release.pop
63
+ end
64
+ rename.call(from, to)
65
+ end
66
+ # Probe the updater's actual flock before blocking. This makes the old
67
+ # unchecked-rename interleaving deterministic, without sleeps or polling.
68
+ allow(File).to receive(:open).and_wrap_original do |open, *args, **kwargs, &block|
69
+ if args == [@root, File::RDONLY] && Thread.current[:vault_publication_role] == :updater
70
+ open.call(*args) do |lock|
71
+ allow(lock).to receive(:flock).and_wrap_original do |flock, operation|
72
+ if operation == File::LOCK_EX
73
+ acquired = flock.call(operation | File::LOCK_NB)
74
+ attempts << (acquired ? :acquired : :blocked)
75
+ acquired || flock.call(operation)
76
+ else
77
+ flock.call(operation)
78
+ end
79
+ end
80
+ block.call(lock)
81
+ end
82
+ else
83
+ open.call(*args, **kwargs, &block)
84
+ end
85
+ end
86
+ attempt = nil
87
+ Timeout.timeout(5) do
88
+ first = writer_thread(:first) { described_class.public_send(writer, file: @path, editor: '/offline-editor') }
89
+ ready.pop
90
+ updater = writer_thread(:updater) { described_class.update(file: @path) { |cfg| cfg[:updated] = true } }
91
+ attempt = attempts.pop
92
+ updater.value if attempt == :acquired
93
+ release << true
94
+ first.value
95
+ expect(updater.value).to eq(:saved)
96
+ end
97
+ expect(attempt).to eq(:blocked)
98
+ expected = @document.merge(updated: true)
99
+ expected[:edited] = true if writer == :edit
100
+ expect(described_class.dump(file: @path)).to eq(expected)
101
+ expect(Dir.glob(File.join(@root, '.pwn-vault-*'))).to be_empty
102
+ end
103
+ end
104
+
105
+ %i[update refresh_encryption_secrets edit encrypt decrypt create legacy_refresh legacy_create].each do |writer|
106
+ it "holds the shared directory lock through every #{writer} rename" do
107
+ if %i[encrypt create legacy_create].include?(writer)
108
+ File.write(@path, YAML.dump(@document))
109
+ else
110
+ prepare_vault(writer == :legacy_refresh ? :legacy : :hardware)
111
+ end
112
+ fake_editor if writer == :edit
113
+ publications = 0
114
+ allow(File).to receive(:rename).and_wrap_original do |rename, from, to|
115
+ if to == @path
116
+ File.open(@root, File::RDONLY) do |probe|
117
+ expect(probe.flock(File::LOCK_EX | File::LOCK_NB)).to be(false)
118
+ end
119
+ publications += 1
120
+ end
121
+ rename.call(from, to)
122
+ end
123
+ Timeout.timeout(5) do
124
+ case writer
125
+ when :update
126
+ described_class.update(file: @path) { |cfg| cfg[:updated] = true }
127
+ when :legacy_refresh
128
+ described_class.refresh_encryption_secrets(@legacy.merge(file: @path))
129
+ when :legacy_create
130
+ described_class.create(file: @path, decryptor_file: "#{@path}.decryptor")
131
+ when :create, :encrypt
132
+ described_class.public_send(writer, file: @path, yubikey: {})
133
+ else
134
+ described_class.public_send(writer, file: @path, editor: '/offline-editor')
135
+ end
136
+ end
137
+ expect(publications).to be_positive
138
+ end
139
+ end
140
+
141
+ it 'allows nested updates in one directory without reacquiring its own lock' do
142
+ prepare_vault
143
+ sibling = File.join(@root, 'sibling')
144
+ File.binwrite(sibling, File.binread(@path))
145
+ Timeout.timeout(5) do
146
+ described_class.update(file: @path) do |cfg|
147
+ described_class.update(file: sibling) { |other| other[:nested] = true }
148
+ cfg[:outer] = true
149
+ end
150
+ end
151
+ expect(described_class.dump(file: @path)).to eq(@document.merge(outer: true))
152
+ expect(described_class.dump(file: sibling)).to eq(@document.merge(nested: true))
153
+ end
154
+
155
+ it 'retains expected-byte conflict detection for nested writes to the same vault' do
156
+ prepare_vault
157
+ Timeout.timeout(5) do
158
+ expect do
159
+ described_class.update(file: @path) do |cfg|
160
+ described_class.refresh_encryption_secrets(file: @path)
161
+ cfg[:stale] = true
162
+ end
163
+ end.to raise_error(IOError, /changed before replacement/)
164
+ end
165
+ expect(described_class.dump(file: @path)).to eq(@document)
166
+ end
167
+
168
+ it 'does not overwrite changes from a writer that bypasses the advisory lock' do
169
+ prepare_vault
170
+ replacement = described_class.encrypt_data(data: YAML.dump(@document.merge(external: true)), yubikey: {})
171
+ expect do
172
+ described_class.update(file: @path) do |cfg|
173
+ File.binwrite(@path, replacement)
174
+ cfg[:stale] = true
175
+ end
176
+ end.to raise_error(IOError, /changed before replacement/)
177
+ expect(File.binread(@path)).to eq(replacement)
178
+ expect(Dir.children(@root)).to eq(['vault'])
179
+ end
180
+
181
+ it 'recognizes directory aliases when reentering its own publication lock' do
182
+ prepare_vault
183
+ sibling = File.join(@root, 'sibling')
184
+ File.binwrite(sibling, File.binread(@path))
185
+ File.symlink(@root, File.join(@root, 'alias'))
186
+ Timeout.timeout(5) do
187
+ described_class.update(file: @path) do |cfg|
188
+ described_class.update(file: File.join(@root, 'alias', 'sibling')) { |other| other[:aliased] = true }
189
+ cfg[:outer] = true
190
+ end
191
+ end
192
+ expect(described_class.dump(file: sibling)).to eq(@document.merge(aliased: true))
193
+ expect(described_class.dump(file: @path)).to eq(@document.merge(outer: true))
194
+ end
195
+
196
+ it 'does not treat a lock inherited across fork as owned by the child' do
197
+ prepare_vault
198
+ reader, writer = IO.pipe
199
+ pid = nil
200
+ Timeout.timeout(8) do
201
+ described_class.update(file: @path) do |cfg|
202
+ pid = fork do
203
+ reader.close
204
+ allow(File).to receive(:open).and_wrap_original do |open, *args, **kwargs, &block|
205
+ if args == [@root, File::RDONLY]
206
+ open.call(*args) do |lock|
207
+ allow(lock).to receive(:flock).and_wrap_original do |flock, operation|
208
+ if operation == File::LOCK_EX
209
+ acquired = flock.call(operation | File::LOCK_NB)
210
+ writer.puts(acquired ? 'acquired' : 'blocked')
211
+ acquired || flock.call(operation)
212
+ else
213
+ flock.call(operation)
214
+ end
215
+ end
216
+ block.call(lock)
217
+ end
218
+ else
219
+ open.call(*args, **kwargs, &block)
220
+ end
221
+ end
222
+ begin
223
+ described_class.update(file: @path) { |child| child[:child] = true }
224
+ writer.puts('saved')
225
+ exit! 0
226
+ rescue StandardError, RSpec::Mocks::MockExpectationError
227
+ writer.puts('failed')
228
+ exit! 1
229
+ end
230
+ end
231
+ writer.close
232
+ expect(reader.gets).to eq("blocked\n")
233
+ cfg[:parent] = true
234
+ end
235
+ expect(reader.gets).to eq("saved\n")
236
+ _, status = Process.waitpid2(pid)
237
+ pid = nil
238
+ expect(status.success?).to be(true)
239
+ end
240
+ expect(described_class.dump(file: @path)).to eq(@document.merge(parent: true, child: true))
241
+ ensure
242
+ if pid
243
+ Process.kill('KILL', pid)
244
+ Process.waitpid(pid)
245
+ end
246
+ reader&.close unless reader&.closed?
247
+ writer&.close unless writer&.closed?
248
+ end
249
+
250
+ it 'preserves original ciphertext and removes staging files when publication fails' do
251
+ prepare_vault
252
+ original = File.binread(@path)
253
+ allow(File).to receive(:rename).and_raise(IOError, 'fixture rename failure')
254
+ expect { described_class.update(file: @path) { |cfg| cfg[:changed] = true } }.to raise_error(IOError, 'fixture rename failure')
255
+ expect(File.binread(@path)).to eq(original)
256
+ expect(Dir.children(@root)).to eq(['vault'])
257
+ end
258
+ end
@@ -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