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