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,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
require 'pwn/plugins/yubikey'
|
|
6
|
+
|
|
7
|
+
RSpec.describe PWN::Plugins::YubiKey, 'native credential boundaries' do
|
|
8
|
+
before { expect(described_class::Transport).not_to receive(:open) }
|
|
9
|
+
|
|
10
|
+
it 'rejects legacy process settings and unknown parameters without exposing values' do
|
|
11
|
+
%i[ykman stdin input log_file max_output scp scp_ca scp_sd scp_oce arbitrary].each do |key|
|
|
12
|
+
expect do
|
|
13
|
+
described_class.command({ args: %w[otp info], key => 'synthetic-sensitive-value' })
|
|
14
|
+
end.to raise_error(ArgumentError, /[Uu]nknown|[Uu]nsupported/) { |error|
|
|
15
|
+
expect(error.full_message).not_to include('synthetic-sensitive-value')
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'validates native selectors before opening anything' do
|
|
21
|
+
{ serial: ['', 123, ' 123', '123x', "123\n", false],
|
|
22
|
+
reader: ['', false, "reader\n"], device_path: ['', false, "path\0"],
|
|
23
|
+
timeout: [0, -1, Float::NAN, Float::INFINITY, '30', nil, false], connection: [false, Object.new] }.each do |key, values|
|
|
24
|
+
values.each do |value|
|
|
25
|
+
expect { described_class.command({ args: %w[otp info], key => value }) }.to raise_error(ArgumentError)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'requires literal consent for mutations and authentication regardless of force flags' do
|
|
31
|
+
[%w[otp delete 2 --force], %w[fido reset], %w[piv access change-pin], %w[oath accounts code account],
|
|
32
|
+
%w[sd reset], %w[config usb --list --disable OTP], %w[hsmauth credentials generate name]].each do |args|
|
|
33
|
+
[nil, false, 'true', 1].each do |confirm|
|
|
34
|
+
expect { described_class.command(args: args, confirm: confirm) }.to raise_error(described_class::ConfirmationRequired)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'rejects secret argv including inline flags and surplus positional credentials' do
|
|
40
|
+
[%w[piv info --pin fixture-secret], %w[piv info --pin=fixture-secret],
|
|
41
|
+
%w[fido access change-pin -nfixture-secret], %w[otp --access-code fixture-secret info],
|
|
42
|
+
%w[otp chalresp 2 fixture-secret], %w[otp static 2 fixture-secret],
|
|
43
|
+
%w[oath accounts add account fixture-secret], %w[oath accounts uri otpauth://fixture-secret],
|
|
44
|
+
%w[sd keys import 1 1 fixture-secret], %w[info --log-file fixture-secret],
|
|
45
|
+
%w[future reset fixture-secret]].each do |args|
|
|
46
|
+
expect do
|
|
47
|
+
described_class.command(args: args, confirm: true)
|
|
48
|
+
end.to raise_error(ArgumentError) { |error| expect(error.full_message).not_to include('fixture-secret') }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'rejects malformed argv without coercion or control characters' do
|
|
53
|
+
['info', [], [nil], [false], ["otp\0", 'info'], ['otp', 'info', 'x' * 65_537]].each do |args|
|
|
54
|
+
expect { described_class.command(args: args) }.to raise_error(ArgumentError)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
RSpec.describe PWN::Plugins::YubiKey, 'native connection lifetime' do
|
|
60
|
+
let(:connection) { double('native connection', descriptor: { protocol: :apdu }, exchange: ''.b) }
|
|
61
|
+
|
|
62
|
+
[IOError, Interrupt, PWN::Plugins::YubiKey::TimeoutError].each do |error_class|
|
|
63
|
+
it "closes its owned connection on #{error_class}" do
|
|
64
|
+
expect(described_class::Transport).to receive(:open).and_return(connection)
|
|
65
|
+
expect(connection).to receive(:close).once
|
|
66
|
+
expect(described_class::OTP).to receive(:execute) do |params|
|
|
67
|
+
params[:connection].descriptor
|
|
68
|
+
raise error_class, 'fixture failure'
|
|
69
|
+
end
|
|
70
|
+
expected = error_class == IOError ? described_class::CommandError : error_class
|
|
71
|
+
expect { described_class.otp(args: ['info']) }.to raise_error(expected)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'never closes a borrowed connection even on an APDU failure' do
|
|
76
|
+
expect(connection).not_to receive(:close)
|
|
77
|
+
expect(connection).to receive(:exchange).and_return(['6982'].pack('H*'))
|
|
78
|
+
expect do
|
|
79
|
+
described_class.oath(args: ['info'], connection: connection)
|
|
80
|
+
end.to raise_error(described_class::Protocol::APDUError) { |error| expect(error.status).to eq(0x6982) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'suppresses native IO diagnostic secrets and exception causes' do
|
|
84
|
+
expect(described_class::Transport).to receive(:open).and_raise(IOError, 'synthetic-sensitive-path')
|
|
85
|
+
expect do
|
|
86
|
+
described_class.otp(args: ['info'])
|
|
87
|
+
end.to raise_error(described_class::CommandError) { |error|
|
|
88
|
+
expect(error.full_message).not_to include('synthetic-sensitive-path')
|
|
89
|
+
expect(error.cause).to be_nil
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
[false, true].each do |owned|
|
|
94
|
+
it "wraps an explicit secure channel and preserves #{owned ? 'owned cleanup' : 'caller ownership'}" do
|
|
95
|
+
options = { scp: :scp03, kvn: 7, key_enc: 'e' * 16, key_mac: 'm' * 16 }.freeze
|
|
96
|
+
wrapper = double('secure connection', exchange: ''.b)
|
|
97
|
+
expect(described_class::Transport).to receive(:open).and_return(connection) if owned
|
|
98
|
+
if owned
|
|
99
|
+
expect(connection).to receive(:close).once
|
|
100
|
+
else
|
|
101
|
+
expect(connection).not_to receive(:close)
|
|
102
|
+
end
|
|
103
|
+
expect(described_class::SecurityDomain).to receive(:secure_connection) do |params|
|
|
104
|
+
expect(params).to include(options)
|
|
105
|
+
expect(params[:confirm]).to be(true)
|
|
106
|
+
expect(params[:connection].descriptor).to eq(protocol: :apdu)
|
|
107
|
+
params[:connection].close # SCP invalidation must not close the borrowed handle.
|
|
108
|
+
wrapper
|
|
109
|
+
end
|
|
110
|
+
expect(wrapper).to receive(:close).once
|
|
111
|
+
expect(described_class::OATH).to receive(:execute).with(hash_including(connection: wrapper, args: ['info'], confirm: true)).and_return(:secure_result)
|
|
112
|
+
selected = owned ? {} : { connection: connection }
|
|
113
|
+
expect(described_class.oath(selected.merge(args: ['info'], confirm: true, secure_channel: options))).to eq(:secure_result)
|
|
114
|
+
expect(options).not_to have_key(:connection)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'never falls back to plaintext after secure-channel authentication failure' do
|
|
119
|
+
expect(described_class::Transport).to receive(:open).and_return(connection)
|
|
120
|
+
expect(connection).to receive(:close).once
|
|
121
|
+
expect(described_class::SecurityDomain).to receive(:secure_connection).and_raise(described_class::ResponseError, 'SCP authentication failed')
|
|
122
|
+
expect(described_class::OATH).not_to receive(:execute)
|
|
123
|
+
expect do
|
|
124
|
+
described_class.oath(args: ['info'], confirm: true, secure_channel: { scp: :scp03, kvn: 1, key_enc: 'e' * 16, key_mac: 'm' * 16 })
|
|
125
|
+
end.to raise_error(described_class::ResponseError, /SCP/)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'validates secure-channel options and explicit consent before opening hardware' do
|
|
129
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
130
|
+
[false, 'scp03', {}, { scp: :scp03, confirm: true }, { connection: connection }].each do |value|
|
|
131
|
+
expect { described_class.oath(args: ['info'], confirm: true, secure_channel: value) }.to raise_error(ArgumentError)
|
|
132
|
+
end
|
|
133
|
+
expect do
|
|
134
|
+
described_class.oath(args: ['info'], secure_channel: { scp: :scp03, kvn: 1, key_enc: 'e' * 16, key_mac: 'm' * 16 })
|
|
135
|
+
end.to raise_error(described_class::ConfirmationRequired)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'loads the facade in a fresh Ruby without process libraries or device discovery' do
|
|
139
|
+
source = <<~RUBY
|
|
140
|
+
before = $LOADED_FEATURES.dup
|
|
141
|
+
require 'pwn/plugins/yubikey'
|
|
142
|
+
added = $LOADED_FEATURES - before
|
|
143
|
+
abort 'process library loaded' if added.any? { |path| path.match?(%r{/(?:open3|pty)(?:\\.|/)}) }
|
|
144
|
+
abort 'native transport eagerly loaded' unless PWN::Plugins::YubiKey.autoload?(:Transport)
|
|
145
|
+
puts 'native-only'
|
|
146
|
+
RUBY
|
|
147
|
+
output = IO.popen([RbConfig.ruby, '-Ilib', '-e', source], &:read)
|
|
148
|
+
expect($CHILD_STATUS).to be_success
|
|
149
|
+
expect(output).to eq("native-only\n")
|
|
150
|
+
text = File.read(File.expand_path('../../../../lib/pwn/plugins/yubikey.rb', __dir__))
|
|
151
|
+
expect(text).not_to match(/Open3|PTY|\b(?:system|spawn|popen|exec)\s*\(/)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
RSpec.describe PWN::Plugins::YubiKey, 'total native operation deadline' do
|
|
156
|
+
before do
|
|
157
|
+
@now = 100.0
|
|
158
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { @now }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'shares one budget across acquisition, OATH SELECT, LIST and calculation' do
|
|
162
|
+
connection = double('APDU transport', descriptor: { protocol: :apdu })
|
|
163
|
+
expect(described_class::Transport).to receive(:open).with(hash_including(application: :oath, timeout: 3)) do
|
|
164
|
+
@now += 1
|
|
165
|
+
connection
|
|
166
|
+
end
|
|
167
|
+
expect(connection).to receive(:close).once
|
|
168
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a4040007a0000005272101'].pack('H*'), timeout: 2)).ordered do
|
|
169
|
+
@now += 1
|
|
170
|
+
['79030507017101739000'].pack('H*')
|
|
171
|
+
end
|
|
172
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'), timeout: 1)).ordered do
|
|
173
|
+
@now += 2
|
|
174
|
+
['720221619000'].pack('H*')
|
|
175
|
+
end
|
|
176
|
+
expect do
|
|
177
|
+
described_class.oath_code(account: 'a', confirm: true, timeout: 3, timestamp: 59)
|
|
178
|
+
end.to raise_error(described_class::TimeoutError)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'charges secure-channel authentication to the same budget on a borrowed connection' do
|
|
182
|
+
connection = double('APDU transport', descriptor: { protocol: :apdu })
|
|
183
|
+
expect(connection).not_to receive(:close)
|
|
184
|
+
expect(described_class::SecurityDomain).to receive(:secure_connection) do |params|
|
|
185
|
+
expect(params[:timeout]).to eq(3)
|
|
186
|
+
@now += 2
|
|
187
|
+
params[:connection]
|
|
188
|
+
end
|
|
189
|
+
expect(connection).to receive(:exchange).with(hash_including(timeout: 1)).once do
|
|
190
|
+
@now += 2
|
|
191
|
+
['79030507017101739000'].pack('H*')
|
|
192
|
+
end
|
|
193
|
+
expect do
|
|
194
|
+
described_class.oath(args: ['info'], connection: connection, confirm: true, timeout: 3,
|
|
195
|
+
secure_channel: { scp: :scp03, kvn: 1, key_enc: 'e' * 16, key_mac: 'm' * 16 })
|
|
196
|
+
end.to raise_error(described_class::TimeoutError)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'refuses exchanges when acquisition exhausts the budget and still closes the owned handle' do
|
|
200
|
+
connection = double('late native transport', descriptor: { protocol: :otp })
|
|
201
|
+
expect(described_class::Transport).to receive(:open) do
|
|
202
|
+
@now += 4
|
|
203
|
+
connection
|
|
204
|
+
end
|
|
205
|
+
expect(connection).not_to receive(:exchange)
|
|
206
|
+
expect(connection).to receive(:close).once
|
|
207
|
+
expect { described_class.otp(args: ['info'], timeout: 3) }.to raise_error(described_class::TimeoutError)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
RSpec.describe PWN::Plugins::YubiKey, 'validation before native acquisition' do
|
|
212
|
+
it 'does not open a device for missing PIV credentials or an invalid native slot' do
|
|
213
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
214
|
+
expect { described_class.piv(args: %w[access change-pin], confirm: true) }.to raise_error(ArgumentError)
|
|
215
|
+
expect { described_class.otp(args: %w[delete invalid-slot], confirm: true) }.to raise_error(ArgumentError)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'runs explicit cache-only forget without discovering or opening a YubiKey' do
|
|
219
|
+
cache = double('caller encrypted cache', encrypted?: true, clear: nil, empty?: true)
|
|
220
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
221
|
+
expect(described_class::Transport).not_to receive(:devices)
|
|
222
|
+
expect(cache).to receive(:clear).once
|
|
223
|
+
expect(described_class.oath(args: %w[access forget --all], confirm: true, encrypted_cache: cache)).to eq(forgotten: :all, verified: true)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'allows native read-only inventory without authorizing authentication or changes' do
|
|
227
|
+
connection = double('caller connection', exchange: nil)
|
|
228
|
+
expect(described_class::Management).to receive(:execute).with(hash_including(args: %w[usb --list])).and_return(:inventory)
|
|
229
|
+
expect(described_class.config(args: %w[usb --list], connection: connection)).to eq(:inventory)
|
|
230
|
+
expect(described_class::HSMAuth).to receive(:execute).with(hash_including(args: %w[credentials list])).and_return([])
|
|
231
|
+
expect(described_class.hsmauth(args: %w[credentials list], connection: connection)).to eq([])
|
|
232
|
+
expect do
|
|
233
|
+
described_class.config(args: %w[usb --list], disable: 'OTP', connection: connection)
|
|
234
|
+
end.to raise_error(described_class::ConfirmationRequired)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'keeps OATH list read-only only when no authentication or cache state is requested' do
|
|
238
|
+
connection = double('caller connection', descriptor: { protocol: :apdu })
|
|
239
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a4040007a0000005272101'].pack('H*'))).ordered.and_return(['79030507017101739000'].pack('H*'))
|
|
240
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'))).ordered.and_return(['9000'].pack('H*'))
|
|
241
|
+
expect(described_class.oath(args: %w[accounts list], connection: connection)).to eq([])
|
|
242
|
+
expect do
|
|
243
|
+
described_class.oath(args: %w[accounts list], password: 'synthetic', connection: connection)
|
|
244
|
+
end.to raise_error(described_class::ConfirmationRequired)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it 'documents every public root API with native behavior and no executable backend' do
|
|
248
|
+
output = StringIO.new
|
|
249
|
+
prior = $stdout
|
|
250
|
+
$stdout = output
|
|
251
|
+
described_class.help
|
|
252
|
+
(described_class.singleton_methods(false) - [:help]).each do |method|
|
|
253
|
+
expect(output.string).to include("PWN::Plugins::YubiKey.#{method}")
|
|
254
|
+
end
|
|
255
|
+
expect(output.string).to include('native', 'secret_provider', 'operator:', 'total')
|
|
256
|
+
expect(output.string).not_to include('wrapper for official', 'executable path')
|
|
257
|
+
ensure
|
|
258
|
+
$stdout = prior
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pwn/plugins/yubikey'
|
|
5
|
+
|
|
6
|
+
RSpec.describe PWN::Plugins::YubiKey do
|
|
7
|
+
it 'has no executable dependencies' do
|
|
8
|
+
expect(described_class.required_bins).to eq([])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'autoloads native children without probing a device' do
|
|
12
|
+
%i[Protocol Transport OTP Management OATH HSMAuth PIV FIDO OpenPGP SecurityDomain].each do |name|
|
|
13
|
+
expect(described_class.const_defined?(name, false)).to be(true)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'checks host availability without discovery' do
|
|
18
|
+
expect(described_class::Transport).to receive(:available?).with({}).and_return(true)
|
|
19
|
+
expect(described_class::Transport).not_to receive(:devices)
|
|
20
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
21
|
+
expect(described_class.available?).to be(true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'returns native descriptors from explicit discovery' do
|
|
25
|
+
records = [{ serial: '001234', protocol: :apdu, reader: 'Fixture NFC' }]
|
|
26
|
+
expect(described_class::Transport).to receive(:devices).with({ serial: '001234', timeout: 3 }).and_return(records)
|
|
27
|
+
expect(described_class.devices(serial: '001234', timeout: 3)).to equal(records)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'routes OTP info through the actual application and Protocol with owned cleanup' do
|
|
31
|
+
connection = double('native transport', descriptor: { protocol: :apdu }, close: :closed)
|
|
32
|
+
expect(described_class::Transport).to receive(:open) do |opened|
|
|
33
|
+
expect(opened).to include(application: :otp, serial: '001234', reader: 'Fixture NFC')
|
|
34
|
+
expect(opened[:timeout]).to be_positive.and be <= 3
|
|
35
|
+
connection
|
|
36
|
+
end
|
|
37
|
+
expect(connection).to receive(:exchange).with(protocol: :apdu, data: ['00a4040007a0000005272001'].pack('H*'), timeout: kind_of(Numeric)).and_return(['0507010302009000'].pack('H*'))
|
|
38
|
+
expect(connection).to receive(:close).once
|
|
39
|
+
result = described_class.command(args: %w[otp info], serial: '001234', reader: 'Fixture NFC', timeout: 3)
|
|
40
|
+
expect(result).to include(version: [5, 7, 1], programming_sequence: 3)
|
|
41
|
+
expect(result[:slots][2][:configured]).to be(true)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
RSpec.describe PWN::Plugins::YubiKey, 'native family routing' do
|
|
46
|
+
{ otp: ['otp', :OTP, :otp], config: ['config', :Management, :management], oath: ['oath', :OATH, :oath],
|
|
47
|
+
hsmauth: ['hsmauth', :HSMAuth, :hsmauth], piv: ['piv', :PIV, :piv], fido: ['fido', :FIDO, :fido],
|
|
48
|
+
openpgp: ['openpgp', :OpenPGP, :openpgp], security_domain: ['sd', :SecurityDomain, :security_domain] }.each do |method, (prefix, name, application)|
|
|
49
|
+
child = PWN::Plugins::YubiKey.const_get(name)
|
|
50
|
+
it "provides local #{prefix} help without a transport" do
|
|
51
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
52
|
+
expect(described_class::Transport).not_to receive(:devices)
|
|
53
|
+
expect(described_class.public_send(method)).to include(application: application, operations: child.operations)
|
|
54
|
+
child.operations.each do |operation|
|
|
55
|
+
expect(described_class.cli_help(path: [prefix] + operation.split)).to include(operations: [operation])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
child.operations.each do |operation|
|
|
60
|
+
it "routes #{prefix} #{operation} to the native child without rewriting options" do
|
|
61
|
+
connection = double('caller connection', exchange: nil)
|
|
62
|
+
expect(connection).not_to receive(:close)
|
|
63
|
+
opts = { args: operation.split.freeze, confirm: true, connection: connection, timeout: 4 }.freeze
|
|
64
|
+
expect(child).to receive(:execute).with(hash_including(opts.except(:connection))) do |received|
|
|
65
|
+
expect(received[:connection]).to respond_to(:exchange, :descriptor)
|
|
66
|
+
expect(received.keys).to match_array(opts.keys)
|
|
67
|
+
:native_result
|
|
68
|
+
end
|
|
69
|
+
expect(described_class.public_send(method, opts)).to eq(:native_result)
|
|
70
|
+
expect(opts[:args]).to eq(operation.split)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'forwards typed credentials and providers by identity, never as argv' do
|
|
76
|
+
connection = double('caller connection', exchange: nil)
|
|
77
|
+
pin = 'synthetic-pin'
|
|
78
|
+
provider = ->(_name) { raise 'unexpected prompt' }
|
|
79
|
+
expect(described_class::PIV).to receive(:execute).with(hash_including(args: %w[access change-pin], confirm: true, pin: pin, new_pin: 'synthetic-new-pin', secret_provider: provider)).and_return(:changed)
|
|
80
|
+
expect(described_class.piv(args: %w[access change-pin], confirm: true, connection: connection, pin: pin, new_pin: 'synthetic-new-pin', secret_provider: provider)).to eq(:changed)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'calls Management.info for root info without inventing a config subcommand' do
|
|
84
|
+
connection = double('caller connection', exchange: nil)
|
|
85
|
+
expect(described_class::Management).to receive(:info).with(hash_including(connection: respond_to(:exchange))).and_return(version: [5, 7, 1])
|
|
86
|
+
expect(described_class.info(connection: connection)).to eq(version: [5, 7, 1])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'dispatches root list and info aliases in Ruby' do
|
|
90
|
+
expect(described_class::Transport).to receive(:devices).with({}).and_return([{ serial: '001234' }])
|
|
91
|
+
expect(described_class.command(args: ['list'])).to eq([{ serial: '001234' }])
|
|
92
|
+
connection = double('caller connection', exchange: nil)
|
|
93
|
+
expect(described_class::Management).to receive(:info).with(hash_including(connection: respond_to(:exchange))).and_return(:information)
|
|
94
|
+
expect(described_class.command(args: ['info'], connection: connection)).to eq(:information)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'rejects arbitrary help paths without opening a device' do
|
|
98
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
99
|
+
[%w[otp delete 2 --force], ['--help'], ['script'], ['future']].each do |path|
|
|
100
|
+
expect { described_class.cli_help(path: path) }.to raise_error(ArgumentError)
|
|
101
|
+
end
|
|
102
|
+
expect(described_class.command(args: ['--help'])).to include(backend: :native)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
RSpec.describe PWN::Plugins::YubiKey, 'native Vault and MFA helpers' do
|
|
107
|
+
let(:connection) { double('native connection', descriptor: { protocol: :otp }, close: :closed) }
|
|
108
|
+
|
|
109
|
+
it 'returns the raw RFC 2202 HMAC from the native OTP exchange' do
|
|
110
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
111
|
+
expect(connection).not_to receive(:close)
|
|
112
|
+
expect(connection).to receive(:exchange).with(protocol: :otp, command: 0, data: ''.b, response_length: 6, timeout: a_value_between(0, 3)).and_return(['050701030200'].pack('H*'))
|
|
113
|
+
expect(connection).to receive(:exchange).with(protocol: :otp, command: 0x38, data: 'Hi There'.b.ljust(64, "\0"), response_length: 20, timeout: a_value_between(0, 3)).and_return(['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*'))
|
|
114
|
+
opts = { challenge: 'Hi There', connection: connection, timeout: 3, confirm: true }.freeze
|
|
115
|
+
result = described_class.challenge_response(opts)
|
|
116
|
+
expect(result.unpack1('H*')).to eq('b617318655057264e28bc0b6fb378c8ef146be00')
|
|
117
|
+
expect(result.encoding).to eq(Encoding::BINARY)
|
|
118
|
+
expect(opts.keys).not_to include(:slot)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'programs and verifies a slot through the real OTP application without subprocesses' do
|
|
122
|
+
key = ['0b' * 20].pack('H*').freeze
|
|
123
|
+
before = ['050701030200'].pack('H*')
|
|
124
|
+
after = ['050701040200'].pack('H*')
|
|
125
|
+
expect(connection).to receive(:exchange).with(hash_including(command: 0)).ordered.and_return(before)
|
|
126
|
+
expect(connection).to receive(:exchange).with(hash_including(command: 3)).ordered do |request|
|
|
127
|
+
expect(request[:data].bytesize).to eq(58)
|
|
128
|
+
expect(request[:data].byteslice(22, 16)).to eq(key.byteslice(0, 16))
|
|
129
|
+
''.b
|
|
130
|
+
end
|
|
131
|
+
expect(connection).to receive(:exchange).with(hash_including(command: 0)).ordered.and_return(after)
|
|
132
|
+
expect(connection).to receive(:exchange).with(hash_including(command: 0)).ordered.and_return(after)
|
|
133
|
+
expect(connection).to receive(:exchange).with(hash_including(command: 0x38)).ordered.and_return(['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*'))
|
|
134
|
+
expect(described_class.provision_challenge_response(key: key, connection: connection, serial: '001234', confirm: true, verification_challenge: 'Hi There')).to eq(:verified)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'validates helper arguments before opening a transport' do
|
|
138
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
139
|
+
[nil, '', 'x' * 65, [], 42].each do |challenge|
|
|
140
|
+
expect { described_class.challenge_response(challenge: challenge, confirm: true) }.to raise_error(ArgumentError)
|
|
141
|
+
end
|
|
142
|
+
[0, 3, '2', 2.0, false].each do |slot|
|
|
143
|
+
expect { described_class.challenge_response(challenge: 'x', slot: slot, confirm: true) }.to raise_error(ArgumentError)
|
|
144
|
+
end
|
|
145
|
+
[nil, '', "bad\n", 'x' * 65].each do |account|
|
|
146
|
+
expect { described_class.oath_code(account: account, confirm: true) }.to raise_error(ArgumentError)
|
|
147
|
+
end
|
|
148
|
+
expect { described_class.oath_code(account: 'fixture', timestamp: -1, confirm: true) }.to raise_error(ArgumentError)
|
|
149
|
+
expect { described_class.provision_challenge_response(key: 'k' * 19, serial: '001234', confirm: true) }.to raise_error(ArgumentError)
|
|
150
|
+
expect { described_class.provision_challenge_response(key: 'k' * 20, confirm: true) }.to raise_error(ArgumentError)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'requires consent for provisioning and OATH calculation before opening a device' do
|
|
154
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
155
|
+
[false, nil, 'true'].each do |confirm|
|
|
156
|
+
expect { described_class.oath_code(account: 'fixture', confirm: confirm) }.to raise_error(described_class::ConfirmationRequired)
|
|
157
|
+
expect { described_class.provision_challenge_response(key: 'k' * 20, serial: '001234', confirm: confirm) }.to raise_error(described_class::ConfirmationRequired)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'calculates an existing-slot HMAC without a mutation consent flag' do
|
|
162
|
+
connection = double('offline OTP', descriptor: { protocol: :otp }, close: :closed)
|
|
163
|
+
allow(described_class::Transport).to receive(:open).and_return(connection)
|
|
164
|
+
allow(described_class::OTP).to receive(:challenge_response).and_return('r'.b * 20)
|
|
165
|
+
expect(described_class.challenge_response(challenge: 'fixture')).to eq('r'.b * 20)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
RSpec.describe PWN::Plugins::YubiKey, 'explicit native APDUs' do
|
|
170
|
+
let(:connection) { double('smartcard connection', descriptor: { protocol: :apdu }, close: :closed) }
|
|
171
|
+
|
|
172
|
+
it 'encodes typed APDUs through Protocol without putting payloads into argv' do
|
|
173
|
+
expect(connection).not_to receive(:close)
|
|
174
|
+
expect(connection).to receive(:exchange).with(protocol: :apdu, data: ['002000800431323334'].pack('H*'), timeout: kind_of(Numeric)).and_return(['9000'].pack('H*'))
|
|
175
|
+
expect(described_class.apdu(ins: 0x20, p2: 0x80, data: '1234'.b, connection: connection, confirm: true)).to eq(''.b)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'selects an explicit application and sends legacy nonsecret compact APDUs in Ruby' do
|
|
179
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a4040007a0000005272101'].pack('H*'))).ordered.and_return(['9000'].pack('H*'))
|
|
180
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'))).ordered.and_return(['720221619000'].pack('H*'))
|
|
181
|
+
result = described_class.command(args: %w[apdu --app oath a1=], connection: connection, confirm: true)
|
|
182
|
+
expect(result).to eq([{ data: ['72022161'].pack('H*'), status: 0x9000 }])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'supports explicit full APDU frames with status records and no automatic retries' do
|
|
186
|
+
expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'))).once.and_return(['6982'].pack('H*'))
|
|
187
|
+
expect(described_class.apdu(args: %w[--send-apdu 00a1000000], connection: connection, confirm: true)).to eq([{ data: ''.b, status: 0x6982 }])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'checks expected status words without including command payloads in errors' do
|
|
191
|
+
expect(connection).to receive(:exchange).once.and_return(['6982'].pack('H*'))
|
|
192
|
+
expect do
|
|
193
|
+
described_class.apdu(args: ['20:31323334='], connection: connection, confirm: true)
|
|
194
|
+
end.to raise_error(described_class::Protocol::APDUError) { |error|
|
|
195
|
+
expect(error.status).to eq(0x6982)
|
|
196
|
+
expect(error.full_message).not_to include('31323334')
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'rejects consent bypass, malformed frames and mixed inputs before transport acquisition' do
|
|
201
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
202
|
+
expect { described_class.apdu(ins: 0xa1) }.to raise_error(described_class::ConfirmationRequired)
|
|
203
|
+
[%w[--send-apdu xyz], %w[--app oath --send-apdu 00a1000000], %w[--send-apdu 00a1], ['invalid']].each do |args|
|
|
204
|
+
expect { described_class.apdu(args: args, confirm: true) }.to raise_error(ArgumentError)
|
|
205
|
+
end
|
|
206
|
+
expect { described_class.apdu(ins: 256, confirm: true) }.to raise_error(ArgumentError)
|
|
207
|
+
expect(described_class.apdu).to include(operations: ['apdu'])
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pty'
|
|
5
|
+
require 'io/console'
|
|
6
|
+
require 'io/wait'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
require 'fcntl'
|
|
9
|
+
|
|
10
|
+
RSpec.describe PWN::Plugins::YubiKey, 'private native operator input' do
|
|
11
|
+
let(:connection) { double('caller native connection', exchange: nil) }
|
|
12
|
+
|
|
13
|
+
it 'reads a masked credential directly from the console and returns no secrets or device results' do
|
|
14
|
+
master, terminal = PTY.open
|
|
15
|
+
flags = terminal.fcntl(Fcntl::F_GETFL)
|
|
16
|
+
screen = ''.b
|
|
17
|
+
captured = nil
|
|
18
|
+
opts = { args: %w[oath accounts list], confirm: true, operator: true, terminal: terminal, connection: connection, timeout: 5 }.freeze
|
|
19
|
+
expect(described_class::OATH).to receive(:execute) do |received|
|
|
20
|
+
captured = received[:secret_provider].call(:password)
|
|
21
|
+
expect(captured).to eq('synthetic-password')
|
|
22
|
+
'sensitive-device-output'
|
|
23
|
+
end
|
|
24
|
+
operator = Thread.new do
|
|
25
|
+
Timeout.timeout(5) do
|
|
26
|
+
screen << master.readpartial(1024) until screen.include?('YubiKey password: ')
|
|
27
|
+
expect(terminal.echo?).to be(false)
|
|
28
|
+
master.write("synthetic-password\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
expect do
|
|
32
|
+
expect(described_class.interact(opts)).to eq(:completed)
|
|
33
|
+
end.not_to output.to_stdout
|
|
34
|
+
operator.value
|
|
35
|
+
screen << master.readpartial(1024) while master.wait_readable(0)
|
|
36
|
+
expect(screen).not_to include('synthetic-password', 'sensitive-device-output')
|
|
37
|
+
expect(captured).to eq("\0" * 'synthetic-password'.bytesize)
|
|
38
|
+
expect(terminal).not_to be_closed
|
|
39
|
+
expect(terminal.echo?).to be(true)
|
|
40
|
+
expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
|
|
41
|
+
expect(opts).not_to have_key(:secret_provider)
|
|
42
|
+
ensure
|
|
43
|
+
operator&.kill&.join
|
|
44
|
+
master&.close
|
|
45
|
+
terminal&.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'requires literal operator opt-in and refuses agent request contexts before looking up a console' do
|
|
49
|
+
expect(IO).not_to receive(:console)
|
|
50
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
51
|
+
expect { described_class.interact(args: %w[oath accounts list]) }.to raise_error(described_class::ConfirmationRequired)
|
|
52
|
+
expect { described_class.interact(args: %w[oath accounts list], confirm: true) }.to raise_error(ArgumentError, /operator/)
|
|
53
|
+
prior = Thread.current[:pwn_request_runtime]
|
|
54
|
+
Thread.current[:pwn_request_runtime] = Object.new
|
|
55
|
+
expect do
|
|
56
|
+
described_class.interact(args: %w[oath accounts list], confirm: true, operator: true)
|
|
57
|
+
end.to raise_error(ArgumentError, /private.*operator|agent/)
|
|
58
|
+
ensure
|
|
59
|
+
Thread.current[:pwn_request_runtime] = prior
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'rejects virtual terminals, credential argv and an overriding provider before device acquisition' do
|
|
63
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
64
|
+
expect do
|
|
65
|
+
described_class.interact(args: %w[oath accounts list], confirm: true, operator: true, terminal: StringIO.new)
|
|
66
|
+
end.to raise_error(ArgumentError, /terminal/)
|
|
67
|
+
expect do
|
|
68
|
+
described_class.interact(args: %w[piv info --pin synthetic-secret], confirm: true, operator: true, terminal: StringIO.new)
|
|
69
|
+
end.to raise_error(ArgumentError)
|
|
70
|
+
expect do
|
|
71
|
+
described_class.interact(args: %w[oath accounts list], confirm: true, operator: true, secret_provider: ->(_name) { 'synthetic-secret' })
|
|
72
|
+
end.to raise_error(ArgumentError)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
[false, true].each do |interrupt|
|
|
76
|
+
it "restores echo and descriptor flags on #{interrupt ? 'interruption' : 'input timeout'} without closing caller IO" do
|
|
77
|
+
master, terminal = PTY.open
|
|
78
|
+
flags = terminal.fcntl(Fcntl::F_GETFL) | Fcntl::O_NONBLOCK
|
|
79
|
+
terminal.fcntl(Fcntl::F_SETFL, flags)
|
|
80
|
+
expect(described_class::OATH).to receive(:execute) { |opts| opts[:secret_provider].call(:password) }
|
|
81
|
+
worker = Thread.new do
|
|
82
|
+
Thread.current.report_on_exception = false
|
|
83
|
+
described_class.interact(args: %w[oath accounts list], confirm: true, operator: true, terminal: terminal, connection: connection, timeout: interrupt ? 5 : 0.2)
|
|
84
|
+
end
|
|
85
|
+
screen = ''.b
|
|
86
|
+
Timeout.timeout(5) { screen << master.readpartial(1024) until screen.include?('YubiKey password: ') }
|
|
87
|
+
worker.raise(Interrupt) if interrupt
|
|
88
|
+
expect { Timeout.timeout(5) { worker.value } }.to raise_error(interrupt ? Interrupt : described_class::TimeoutError)
|
|
89
|
+
expect(terminal).not_to be_closed
|
|
90
|
+
expect(terminal.echo?).to be(true)
|
|
91
|
+
expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
|
|
92
|
+
ensure
|
|
93
|
+
worker.kill.join if worker&.alive?
|
|
94
|
+
master&.close
|
|
95
|
+
terminal&.close
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
RSpec.describe PWN::Plugins::YubiKey, 'terminal access and descriptor ownership' do
|
|
101
|
+
%i[file wrapper].each do |kind|
|
|
102
|
+
%w[r w].each do |mode|
|
|
103
|
+
it "rejects #{kind} terminal mode #{mode} before consuming input or opening a device" do
|
|
104
|
+
master, slave = PTY.open
|
|
105
|
+
terminal = kind == :file ? File.open(slave.path, mode) : IO.for_fd(slave.fileno, mode, autoclose: false)
|
|
106
|
+
slave.echo = false
|
|
107
|
+
master.write("untouched-input\n")
|
|
108
|
+
expect(slave.wait_readable(1)).to eq(slave)
|
|
109
|
+
flags = terminal.fcntl(Fcntl::F_GETFL)
|
|
110
|
+
expect(flags & Fcntl::O_ACCMODE).to eq(Fcntl::O_RDWR) if kind == :wrapper
|
|
111
|
+
expect(described_class::Transport).not_to receive(:open)
|
|
112
|
+
expect(terminal).not_to receive(:getpass)
|
|
113
|
+
expect do
|
|
114
|
+
described_class.interact(args: %w[piv reset --force], confirm: true, operator: true, terminal: terminal)
|
|
115
|
+
end.to raise_error(ArgumentError, /readable and writable/)
|
|
116
|
+
expect(terminal).not_to be_closed
|
|
117
|
+
expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
|
|
118
|
+
expect(slave.echo?).to be(false)
|
|
119
|
+
expect(Timeout.timeout(1) { slave.gets }).to eq("untouched-input\n")
|
|
120
|
+
expect(master.wait_readable(0)).to be_nil
|
|
121
|
+
ensure
|
|
122
|
+
terminal&.close
|
|
123
|
+
master&.close
|
|
124
|
+
slave&.close
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'uses IO.console by default without consuming pending input for a read-only operation' do
|
|
130
|
+
master, terminal = PTY.open
|
|
131
|
+
terminal.echo = false
|
|
132
|
+
master.write("queued-fixture\n")
|
|
133
|
+
expect(terminal.wait_readable(1)).to eq(terminal)
|
|
134
|
+
flags = terminal.fcntl(Fcntl::F_GETFL)
|
|
135
|
+
connection = double('native OTP connection', descriptor: { protocol: :otp })
|
|
136
|
+
expect(connection).to receive(:exchange).with(hash_including(command: 0)).and_return(['050701030200'].pack('H*'))
|
|
137
|
+
expect(connection).not_to receive(:close)
|
|
138
|
+
expect(IO).to receive(:console).and_return(terminal)
|
|
139
|
+
expect(described_class.interact(args: %w[otp info], confirm: true, operator: true, connection: connection)).to eq(:completed)
|
|
140
|
+
expect(terminal.gets).to eq("queued-fixture\n")
|
|
141
|
+
expect(terminal.echo?).to be(false)
|
|
142
|
+
expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
|
|
143
|
+
ensure
|
|
144
|
+
master&.close
|
|
145
|
+
terminal&.close
|
|
146
|
+
end
|
|
147
|
+
end
|