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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +6 -6
- data/README.md +37 -0
- data/bin/pwn +2 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +69 -15
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +2 -2
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Harness-Evaluation.md +1 -1
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +6 -5
- data/documentation/Installation.md +12 -9
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +18 -0
- data/documentation/Persistence.md +7 -5
- data/documentation/Plugin-Degradation.md +14 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Policy-Benchmark.md +32 -33
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +185 -0
- data/documentation/YubiKey.md +218 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -130
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
- data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
- data/documentation/diagrams/reinforcement-learning.svg +89 -89
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +43 -27
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/learning.rb +14 -18
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +4 -0
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +8 -1
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +349 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/migrate.rb +85 -280
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +3 -1
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +293 -26
- data/lib/pwn/plugins/repl/ai_console.rb +268 -88
- data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
- data/lib/pwn/plugins/repl/mesh.rb +14 -19
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +9 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/setup.rb +114 -24
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +8 -16
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +1 -1
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +129 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/migrate_spec.rb +29 -81
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +605 -19
- metadata +129 -16
- data/scripts/benchmark_policy.rb +0 -376
- data/scripts/build_gate.rb +0 -58
- 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
|