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,844 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'fcntl'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
module Plugins
|
|
9
|
+
# Native Ruby YubiKey applications over bounded HID and smartcard transports.
|
|
10
|
+
module YubiKey
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class DependencyError < Error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class CommandError < Error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class TimeoutError < Error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class OutputLimitError < Error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ConfirmationRequired < Error
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ResponseError < Error
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
autoload :Protocol, 'pwn/plugins/yubikey/protocol'
|
|
33
|
+
autoload :Transport, 'pwn/plugins/yubikey/transport'
|
|
34
|
+
autoload :OTP, 'pwn/plugins/yubikey/otp'
|
|
35
|
+
autoload :Management, 'pwn/plugins/yubikey/management'
|
|
36
|
+
autoload :OATH, 'pwn/plugins/yubikey/oath'
|
|
37
|
+
autoload :HSMAuth, 'pwn/plugins/yubikey/hsmauth'
|
|
38
|
+
autoload :PIV, 'pwn/plugins/yubikey/piv'
|
|
39
|
+
autoload :FIDO, 'pwn/plugins/yubikey/fido'
|
|
40
|
+
autoload :OpenPGP, 'pwn/plugins/yubikey/openpgp'
|
|
41
|
+
autoload :SecurityDomain, 'pwn/plugins/yubikey/security_domain'
|
|
42
|
+
|
|
43
|
+
# ykman 5.9.1 grammar: maximum nonsecret positionals, switches, value options.
|
|
44
|
+
# Long options only: short aliases collide across application families.
|
|
45
|
+
# Credential values are deliberately absent; use the operator terminal workflow.
|
|
46
|
+
COMMANDS = {
|
|
47
|
+
'list' => [0, %w[--serials --readers], %w[]],
|
|
48
|
+
'info' => [0, %w[--check-fips], %w[]],
|
|
49
|
+
'otp' => [0, %w[], %w[]],
|
|
50
|
+
'otp info' => [0, %w[], %w[]],
|
|
51
|
+
'otp swap' => [0, %w[--force], %w[]],
|
|
52
|
+
'otp ndef' => [1, %w[], %w[--prefix --ndef-type]],
|
|
53
|
+
'otp delete' => [1, %w[--force], %w[]],
|
|
54
|
+
'otp yubiotp' => [1, %w[--enter --no-enter --serial-public-id --generate-private-id --generate-key --upload --force], %w[--public-id --config-output]],
|
|
55
|
+
'otp static' => [1, %w[--generate --enter --no-enter --force], %w[--length --keyboard-layout]],
|
|
56
|
+
'otp chalresp' => [1, %w[--touch --totp --generate --force], %w[]],
|
|
57
|
+
'otp calculate' => [2, %w[--totp], %w[--digits]],
|
|
58
|
+
'otp hotp' => [1, %w[--enter --no-enter --force], %w[--digits --counter --identifier]],
|
|
59
|
+
'otp settings' => [1, %w[--force --delete-access-code --enter --no-enter --use-numeric-keypad --serial-usb-visible], %w[--pacing]],
|
|
60
|
+
'openpgp' => [0, %w[], %w[]],
|
|
61
|
+
'openpgp info' => [0, %w[], %w[]],
|
|
62
|
+
'openpgp reset' => [0, %w[--force], %w[]],
|
|
63
|
+
'openpgp access' => [0, %w[], %w[]],
|
|
64
|
+
'openpgp access set-retries' => [3, %w[--force], %w[]],
|
|
65
|
+
'openpgp access change-pin' => [0, %w[], %w[]],
|
|
66
|
+
'openpgp access change-reset-code' => [0, %w[], %w[]],
|
|
67
|
+
'openpgp access change-admin-pin' => [0, %w[], %w[]],
|
|
68
|
+
'openpgp access unblock-pin' => [0, %w[], %w[]],
|
|
69
|
+
'openpgp access set-signature-policy' => [1, %w[], %w[]],
|
|
70
|
+
'openpgp keys' => [0, %w[], %w[]],
|
|
71
|
+
'openpgp keys info' => [1, %w[], %w[]],
|
|
72
|
+
'openpgp keys set-touch' => [2, %w[--force], %w[]],
|
|
73
|
+
'openpgp keys import' => [2, %w[], %w[]],
|
|
74
|
+
'openpgp keys attest' => [2, %w[], %w[--format]],
|
|
75
|
+
'openpgp certificates' => [0, %w[], %w[]],
|
|
76
|
+
'openpgp certificates export' => [2, %w[], %w[--format]],
|
|
77
|
+
'openpgp certificates delete' => [1, %w[], %w[]],
|
|
78
|
+
'openpgp certificates import' => [2, %w[], %w[]],
|
|
79
|
+
'oath' => [0, %w[], %w[]],
|
|
80
|
+
'oath info' => [0, %w[], %w[]],
|
|
81
|
+
'oath reset' => [0, %w[--force], %w[]],
|
|
82
|
+
'oath access' => [0, %w[], %w[]],
|
|
83
|
+
'oath access change' => [0, %w[--clear --remember], %w[]],
|
|
84
|
+
'oath access remember' => [0, %w[], %w[]],
|
|
85
|
+
'oath access forget' => [0, %w[--all], %w[]],
|
|
86
|
+
'oath accounts' => [0, %w[], %w[]],
|
|
87
|
+
'oath accounts add' => [1, %w[--generate --touch --force --remember], %w[--oath-type --digits --algorithm --counter --issuer --period --output]],
|
|
88
|
+
'oath accounts uri' => [0, %w[--touch --force --remember], %w[]],
|
|
89
|
+
'oath accounts import' => [1, %w[--touch --force --remember], %w[]],
|
|
90
|
+
'oath accounts list' => [0, %w[--show-hidden --oath-type --period --remember], %w[]],
|
|
91
|
+
'oath accounts code' => [1, %w[--show-hidden --single --remember], %w[]],
|
|
92
|
+
'oath accounts rename' => [2, %w[--force --remember], %w[]],
|
|
93
|
+
'oath accounts delete' => [1, %w[--force --remember], %w[]],
|
|
94
|
+
'piv' => [0, %w[], %w[]],
|
|
95
|
+
'piv info' => [0, %w[], %w[]],
|
|
96
|
+
'piv reset' => [0, %w[--force], %w[]],
|
|
97
|
+
'piv access' => [0, %w[], %w[]],
|
|
98
|
+
'piv access set-retries' => [2, %w[--force], %w[]],
|
|
99
|
+
'piv access change-pin' => [0, %w[], %w[]],
|
|
100
|
+
'piv access change-puk' => [0, %w[], %w[]],
|
|
101
|
+
'piv access change-management-key' => [0, %w[--touch --protect --generate --force], %w[--algorithm]],
|
|
102
|
+
'piv access unblock-pin' => [0, %w[], %w[]],
|
|
103
|
+
'piv keys' => [0, %w[], %w[]],
|
|
104
|
+
'piv keys generate' => [2, %w[], %w[--algorithm --format --pin-policy --touch-policy]],
|
|
105
|
+
'piv keys import' => [2, %w[], %w[--pin-policy --touch-policy]],
|
|
106
|
+
'piv keys attest' => [2, %w[], %w[--format]],
|
|
107
|
+
'piv keys info' => [1, %w[], %w[]],
|
|
108
|
+
'piv keys export' => [2, %w[--verify], %w[--format]],
|
|
109
|
+
'piv keys move' => [2, %w[], %w[]],
|
|
110
|
+
'piv keys delete' => [1, %w[], %w[]],
|
|
111
|
+
'piv certificates' => [0, %w[], %w[]],
|
|
112
|
+
'piv certificates import' => [2, %w[--verify --compress --update-chuid --no-update-chuid], %w[]],
|
|
113
|
+
'piv certificates export' => [2, %w[], %w[--format]],
|
|
114
|
+
'piv certificates generate' => [2, %w[--update-chuid --no-update-chuid], %w[--subject --valid-days --hash-algorithm]],
|
|
115
|
+
'piv certificates request' => [3, %w[], %w[--subject --hash-algorithm]],
|
|
116
|
+
'piv certificates delete' => [1, %w[--update-chuid --no-update-chuid], %w[]],
|
|
117
|
+
'piv objects' => [0, %w[], %w[]],
|
|
118
|
+
'piv objects export' => [2, %w[], %w[]],
|
|
119
|
+
'piv objects import' => [2, %w[], %w[]],
|
|
120
|
+
'piv objects generate' => [1, %w[], %w[]],
|
|
121
|
+
'fido' => [0, %w[], %w[]],
|
|
122
|
+
'fido info' => [0, %w[], %w[]],
|
|
123
|
+
'fido reset' => [0, %w[--force], %w[]],
|
|
124
|
+
'fido access' => [0, %w[], %w[]],
|
|
125
|
+
'fido access change-pin' => [0, %w[--u2f], %w[]],
|
|
126
|
+
'fido access verify-pin' => [0, %w[], %w[]],
|
|
127
|
+
'fido access force-change' => [0, %w[], %w[]],
|
|
128
|
+
'fido access set-min-length' => [1, %w[], %w[--rp-id]],
|
|
129
|
+
'fido credentials' => [0, %w[], %w[]],
|
|
130
|
+
'fido credentials list' => [0, %w[--csv], %w[]],
|
|
131
|
+
'fido credentials delete' => [1, %w[--force], %w[]],
|
|
132
|
+
'fido fingerprints' => [0, %w[], %w[]],
|
|
133
|
+
'fido fingerprints list' => [0, %w[], %w[]],
|
|
134
|
+
'fido fingerprints add' => [1, %w[], %w[]],
|
|
135
|
+
'fido fingerprints rename' => [2, %w[], %w[]],
|
|
136
|
+
'fido fingerprints delete' => [1, %w[--force], %w[]],
|
|
137
|
+
'fido config' => [0, %w[], %w[]],
|
|
138
|
+
'fido config toggle-always-uv' => [0, %w[], %w[]],
|
|
139
|
+
'fido config enable-ep-attestation' => [0, %w[], %w[]],
|
|
140
|
+
'config' => [0, %w[], %w[]],
|
|
141
|
+
'config reset' => [0, %w[--force], %w[]],
|
|
142
|
+
'config set-lock-code' => [0, %w[--force --clear --generate], %w[]],
|
|
143
|
+
'config usb' => [0, %w[--force --list --enable-all --touch-eject --no-touch-eject], %w[--enable --disable --autoeject-timeout --chalresp-timeout]],
|
|
144
|
+
'config nfc' => [0, %w[--force --enable-all --disable-all --list --restrict], %w[--enable --disable]],
|
|
145
|
+
'config mode' => [1, %w[--touch-eject --force], %w[--autoeject-timeout --chalresp-timeout]],
|
|
146
|
+
'apdu' => [256, %w[--no-pretty --short], %w[--app --send-apdu]],
|
|
147
|
+
'hsmauth' => [0, %w[], %w[]],
|
|
148
|
+
'hsmauth info' => [0, %w[], %w[]],
|
|
149
|
+
'hsmauth reset' => [0, %w[--force], %w[]],
|
|
150
|
+
'hsmauth credentials' => [0, %w[], %w[]],
|
|
151
|
+
'hsmauth credentials list' => [0, %w[], %w[]],
|
|
152
|
+
'hsmauth credentials generate' => [1, %w[--touch], %w[]],
|
|
153
|
+
'hsmauth credentials import' => [2, %w[--touch], %w[]],
|
|
154
|
+
'hsmauth credentials export' => [2, %w[], %w[--format]],
|
|
155
|
+
'hsmauth credentials symmetric' => [1, %w[--generate --touch], %w[]],
|
|
156
|
+
'hsmauth credentials derive' => [1, %w[--touch], %w[]],
|
|
157
|
+
'hsmauth credentials delete' => [1, %w[--force], %w[]],
|
|
158
|
+
'hsmauth credentials change-password' => [1, %w[], %w[]],
|
|
159
|
+
'hsmauth access' => [0, %w[], %w[]],
|
|
160
|
+
'hsmauth access change-management-key' => [0, %w[--generate], %w[]],
|
|
161
|
+
'hsmauth access change-management-password' => [0, %w[], %w[]],
|
|
162
|
+
'sd' => [0, %w[], %w[]],
|
|
163
|
+
'sd info' => [0, %w[], %w[]],
|
|
164
|
+
'sd reset' => [0, %w[--force], %w[]],
|
|
165
|
+
'sd keys' => [0, %w[], %w[]],
|
|
166
|
+
'sd keys generate' => [3, %w[], %w[--replace-kvn]],
|
|
167
|
+
'sd keys import' => [3, %w[], %w[--replace-kvn]],
|
|
168
|
+
'sd keys export' => [3, %w[], %w[]],
|
|
169
|
+
'sd keys delete' => [2, %w[--force], %w[]],
|
|
170
|
+
'sd keys set-allowlist' => [256, %w[], %w[]]
|
|
171
|
+
}.transform_values { |entry| entry.each(&:freeze).freeze }.freeze
|
|
172
|
+
|
|
173
|
+
SELECTOR_OPTIONS = %i[serial reader device_path timeout].freeze
|
|
174
|
+
COMMON_OPTIONS = (SELECTOR_OPTIONS + %i[connection confirm secure_channel secret_provider]).freeze
|
|
175
|
+
NATIVE_OPTIONS = {
|
|
176
|
+
'otp' => %i[key password access_code new_access_code private_id public_id generate generate_key generate_private_id secret_sink touch totp digits counter identifier key_encoding keyboard_layout length prefix ndef_type config_output enter no_enter serial_public_id delete_access_code use_numeric_keypad serial_usb_visible pacing verification_challenge challenge timestamp],
|
|
177
|
+
'config' => %i[lock_code new_lock_code secret_sink reconnect enable disable enable_all disable_all touch_eject no_touch_eject autoeject_timeout chalresp_timeout restrict clear generate reboot list mode],
|
|
178
|
+
'oath' => %i[password new_password access_key new_access_key timestamp encrypted_cache remember use_cache secret secret_base32 uri credentials pskc pskc_password pskc_key oath_type algorithm digits period counter issuer touch generate overwrite],
|
|
179
|
+
'hsmauth' => %i[management_key new_management_key new_management_password credential_password new_credential_password password derivation_password key_enc key_mac private_key key_password public_key touch generate],
|
|
180
|
+
'piv' => %i[pin new_pin puk new_puk management_key new_management_key management_algorithm algorithm input password certificate_serial extensions fips_restrictions secret_sink generate protect touch pin_policy touch_policy use_protected_key temporary_pin request_temporary_pin block_retries subject valid_days hash_algorithm not_before not_after compress update_chuid no_update_chuid verify format public_key_source],
|
|
181
|
+
'fido' => %i[pin new_pin u2f uv rp_ids min_length on_keepalive cancelled on_sample max_records max_samples capture_timeout_ms],
|
|
182
|
+
'openpgp' => %i[pin new_pin admin_pin new_admin_pin reset_code new_reset_code use_admin key key_password certificate],
|
|
183
|
+
'sd' => %i[key key_enc key_mac key_dek certificate certificates ski curve replace_kvn serials]
|
|
184
|
+
}.transform_values(&:freeze).freeze
|
|
185
|
+
FAMILIES = {
|
|
186
|
+
'otp' => %i[OTP otp], 'config' => %i[Management management],
|
|
187
|
+
'oath' => %i[OATH oath], 'hsmauth' => %i[HSMAuth hsmauth],
|
|
188
|
+
'piv' => %i[PIV piv], 'fido' => %i[FIDO fido],
|
|
189
|
+
'openpgp' => %i[OpenPGP openpgp], 'sd' => %i[SecurityDomain security_domain]
|
|
190
|
+
}.freeze
|
|
191
|
+
|
|
192
|
+
public_class_method def self.required_bins
|
|
193
|
+
[]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
public_class_method def self.available?(opts = {})
|
|
197
|
+
validate_options(options: opts, allowed: %i[backend application])
|
|
198
|
+
Transport.available?(opts.slice(:backend, :application))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
public_class_method def self.devices(opts = {})
|
|
202
|
+
validate_options(options: opts, allowed: SELECTOR_OPTIONS + [:backend])
|
|
203
|
+
validate_selection(opts.merge(serial: opts[:serial]))
|
|
204
|
+
Transport.devices(opts)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
public_class_method def self.challenge_response(opts = {})
|
|
208
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS + %i[challenge slot])
|
|
209
|
+
validate_selection(opts)
|
|
210
|
+
validate_slot(slot: opts.fetch(:slot, 2))
|
|
211
|
+
challenge = opts[:challenge]
|
|
212
|
+
raise ArgumentError, 'challenge must contain 1 to 64 raw bytes, not pre-encoded hex' unless challenge.is_a?(String) && challenge.bytesize.between?(1, 64)
|
|
213
|
+
|
|
214
|
+
# Omitted confirm is the vault unlock path. An explicit flag must still be literal true.
|
|
215
|
+
Protocol.require_confirm(opts) if opts.key?(:confirm)
|
|
216
|
+
result = with_connection(opts.merge(application: :otp)) { |connection| OTP.challenge_response(opts.merge(connection: connection)) }
|
|
217
|
+
raise ResponseError, 'OTP must return exactly 20 raw HMAC bytes' unless result.is_a?(String) && result.bytesize == 20
|
|
218
|
+
|
|
219
|
+
result.b
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
public_class_method def self.provision_challenge_response(opts = {})
|
|
223
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS + %i[key slot access_code new_access_code touch verification_challenge])
|
|
224
|
+
validate_selection(opts)
|
|
225
|
+
Protocol.require_confirm(opts)
|
|
226
|
+
validate_slot(slot: opts.fetch(:slot, 2))
|
|
227
|
+
key = opts[:key]
|
|
228
|
+
raise ArgumentError, 'key must contain exactly 20 raw bytes' unless key.is_a?(String) && key.bytesize == 20
|
|
229
|
+
raise ArgumentError, 'Provisioning requires an explicit serial, reader, device_path or connection' unless opts[:serial] || opts[:reader] || opts[:device_path] || opts[:connection]
|
|
230
|
+
raise ArgumentError, 'touch must be a boolean' unless [true, false].include?(opts.fetch(:touch, true))
|
|
231
|
+
|
|
232
|
+
%i[access_code new_access_code].each do |name|
|
|
233
|
+
value = opts[name]
|
|
234
|
+
raise ArgumentError, 'OTP access codes must contain six raw bytes' unless value.nil? || (value.is_a?(String) && value.bytesize == 6)
|
|
235
|
+
end
|
|
236
|
+
challenge = opts[:verification_challenge]
|
|
237
|
+
raise ArgumentError, 'verification_challenge must contain 1 to 64 raw bytes' unless challenge.nil? || (challenge.is_a?(String) && challenge.bytesize.between?(1, 64))
|
|
238
|
+
|
|
239
|
+
result = with_connection(opts.merge(application: :otp)) { |connection| OTP.provision_challenge_response(opts.merge(connection: connection)) }
|
|
240
|
+
raise ResponseError, 'OTP provisioning verification failed; inspect the device before retrying' unless result == :verified || (result.is_a?(Hash) && result[:verified] == true && result[:verification] == :hmac_sha1)
|
|
241
|
+
|
|
242
|
+
:verified
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
public_class_method def self.oath_code(opts = {})
|
|
246
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS + %i[account timestamp password access_key encrypted_cache remember use_cache])
|
|
247
|
+
validate_selection(opts)
|
|
248
|
+
account = opts[:account]
|
|
249
|
+
raise ArgumentError, 'account must contain 1 to 64 exact control-free bytes' unless account.is_a?(String) && account.bytesize.between?(1, 64) && account.valid_encoding? && !account.b.match?(/[\x00-\x1f\x7f]/)
|
|
250
|
+
|
|
251
|
+
timestamp = opts[:timestamp]
|
|
252
|
+
raise ArgumentError, 'timestamp must be a nonnegative Integer' unless timestamp.nil? || (timestamp.is_a?(Integer) && timestamp.between?(0, 0x7fffffffffffffff))
|
|
253
|
+
|
|
254
|
+
validate_oath_options(opts)
|
|
255
|
+
Protocol.require_confirm(opts)
|
|
256
|
+
result = with_connection(opts.merge(application: :oath)) { |connection| OATH.code(opts.merge(connection: connection)) }
|
|
257
|
+
raise ResponseError, 'OATH must return exactly one numeric code' unless result.is_a?(String) && result.match?(/\A[0-9]{6,8}\z/)
|
|
258
|
+
|
|
259
|
+
result
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
public_class_method def self.info(opts = {})
|
|
263
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS)
|
|
264
|
+
validate_selection(opts.merge(connection: opts[:connection]))
|
|
265
|
+
with_connection(opts.merge(application: :management)) { |connection| Management.info(opts.merge(connection: connection)) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
public_class_method def self.cli_help(opts = {})
|
|
269
|
+
validate_options(options: opts, allowed: SELECTOR_OPTIONS + [:path])
|
|
270
|
+
validate_selection(opts)
|
|
271
|
+
path = opts[:path] || []
|
|
272
|
+
raise ArgumentError, 'path must contain only supported native command names' unless path.is_a?(Array) && path.all? { |part| part.is_a?(String) && part.match?(/\A[a-z][a-z0-9-]*\z/) }
|
|
273
|
+
return { backend: :native, families: FAMILIES.keys, operations: %w[list info apdu] } if path.empty?
|
|
274
|
+
return { backend: :native, operations: [path.first] } if path.length == 1 && %w[list info apdu].include?(path.first)
|
|
275
|
+
|
|
276
|
+
route = FAMILIES[path.first]
|
|
277
|
+
raise ArgumentError, 'Unknown native help path' unless route
|
|
278
|
+
|
|
279
|
+
prefix = path.drop(1).join(' ')
|
|
280
|
+
operations = const_get(route[0], false).operations.select { |entry| prefix.empty? || entry == prefix || entry.start_with?("#{prefix} ") }
|
|
281
|
+
raise ArgumentError, 'Unknown native help path' if operations.empty?
|
|
282
|
+
|
|
283
|
+
{ backend: :native, application: route[1], path: path.dup, operations: operations }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
public_class_method def self.config(opts = {})
|
|
287
|
+
family(opts.merge(family: 'config', args: opts[:args]))
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
public_class_method def self.otp(opts = {})
|
|
291
|
+
family(opts.merge(family: 'otp', args: opts[:args]))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
public_class_method def self.oath(opts = {})
|
|
295
|
+
family(opts.merge(family: 'oath', args: opts[:args]))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
public_class_method def self.fido(opts = {})
|
|
299
|
+
family(opts.merge(family: 'fido', args: opts[:args]))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
public_class_method def self.piv(opts = {})
|
|
303
|
+
family(opts.merge(family: 'piv', args: opts[:args]))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
public_class_method def self.openpgp(opts = {})
|
|
307
|
+
family(opts.merge(family: 'openpgp', args: opts[:args]))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
public_class_method def self.hsmauth(opts = {})
|
|
311
|
+
family(opts.merge(family: 'hsmauth', args: opts[:args]))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
public_class_method def self.security_domain(opts = {})
|
|
315
|
+
family(opts.merge(family: 'sd', args: opts[:args]))
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
public_class_method def self.apdu(opts = {})
|
|
319
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS + %i[args application ins cla p1 p2 data le extended max_response])
|
|
320
|
+
validate_selection(opts)
|
|
321
|
+
args = opts[:args]
|
|
322
|
+
return cli_help(path: ['apdu']) if !opts.key?(:ins) && (args.nil? || args == [] || args == ['--help'])
|
|
323
|
+
|
|
324
|
+
Protocol.require_confirm(opts)
|
|
325
|
+
if opts.key?(:ins)
|
|
326
|
+
raise ArgumentError, 'Do not combine typed APDU fields and argv' unless args.nil?
|
|
327
|
+
|
|
328
|
+
validate_apdu_fields(opts)
|
|
329
|
+
application = opts.fetch(:application, :security_domain)
|
|
330
|
+
raise ArgumentError, 'Unknown APDU application selector' unless FAMILIES.values.map(&:last).include?(application)
|
|
331
|
+
|
|
332
|
+
return with_connection(opts.merge(application: application)) do |connection|
|
|
333
|
+
Protocol.apdu(opts.slice(:ins, :cla, :p1, :p2, :data, :le, :extended, :max_response, :timeout).merge(connection: connection))
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
raise ArgumentError, 'Typed APDU fields require ins' if opts.keys.intersect?(%i[data cla p1 p2 le extended max_response application])
|
|
337
|
+
|
|
338
|
+
prepared = prepare_apdus(args: validate_args(args: args))
|
|
339
|
+
with_connection(opts.merge(application: prepared[:application])) do |connection|
|
|
340
|
+
raise ArgumentError, 'Raw APDUs require smartcard transport' unless connection.descriptor[:protocol] == :apdu
|
|
341
|
+
|
|
342
|
+
Protocol.select(connection: connection, aid: prepared[:aid], timeout: opts.fetch(:timeout, 30)) if prepared[:aid]
|
|
343
|
+
prepared[:frames].map do |frame|
|
|
344
|
+
response = connection.exchange(protocol: :apdu, data: frame[:data], timeout: opts.fetch(:timeout, 30))
|
|
345
|
+
raise ResponseError, 'Malformed raw APDU response' unless response.is_a?(String) && response.bytesize.between?(2, Protocol::MAX_BYTES)
|
|
346
|
+
|
|
347
|
+
status = response.byteslice(-2, 2).unpack1('n')
|
|
348
|
+
raise Protocol::APDUError.new(status: status) if frame[:expected] && status != frame[:expected]
|
|
349
|
+
|
|
350
|
+
{ data: response.byteslice(0, response.bytesize - 2).b, status: status }
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
public_class_method def self.command(opts = {})
|
|
356
|
+
args = validate_args(args: opts[:args])
|
|
357
|
+
return cli_help(opts.except(:args).merge(path: [])) if [%w[--help], %w[--full-help]].include?(args)
|
|
358
|
+
return cli_help(opts.except(:args, :confirm).merge(path: args[0...-1])) if args.last == '--help'
|
|
359
|
+
return info(opts.except(:args)) if args == ['info']
|
|
360
|
+
return apdu(opts.merge(args: args.drop(1))) if args.first == 'apdu'
|
|
361
|
+
return devices(opts.except(:args)) if args == ['list']
|
|
362
|
+
|
|
363
|
+
if [%w[list --serials], %w[list --readers]].include?(args)
|
|
364
|
+
key = args.last == '--serials' ? :serial : :reader
|
|
365
|
+
return devices(opts.except(:args)).filter_map { |entry| entry[key] }.uniq
|
|
366
|
+
end
|
|
367
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS + [:args] + NATIVE_OPTIONS.fetch(args.first, []))
|
|
368
|
+
validate_selection(opts)
|
|
369
|
+
family = args.first
|
|
370
|
+
route = FAMILIES[family]
|
|
371
|
+
raise ArgumentError, 'Unknown native YubiKey command' unless route
|
|
372
|
+
|
|
373
|
+
child = const_get(route[0], false)
|
|
374
|
+
path = child.operations.sort_by { |entry| -entry.split.length }.find { |entry| args.drop(1).take(entry.split.length) == entry.split }
|
|
375
|
+
return cli_help(opts.slice(*SELECTOR_OPTIONS).merge(path: args)) unless path
|
|
376
|
+
|
|
377
|
+
validate_command(args: args, path: "#{family} #{path}")
|
|
378
|
+
Protocol.require_confirm(opts) unless read_only?(opts.merge(path: "#{family} #{path}", args: args))
|
|
379
|
+
with_connection(opts.merge(application: route[1])) do |connection|
|
|
380
|
+
child.execute(opts.merge(args: args.drop(1), connection: connection))
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
public_class_method def self.interact(opts = {})
|
|
385
|
+
Protocol.require_confirm(opts)
|
|
386
|
+
raise ArgumentError, 'Private terminal use requires operator: true outside agent requests' unless opts[:operator] == true && Thread.current[:pwn_request_runtime].nil?
|
|
387
|
+
raise ArgumentError, 'interact owns its private credential provider' if opts.key?(:secret_provider)
|
|
388
|
+
|
|
389
|
+
args = validate_args(args: opts[:args])
|
|
390
|
+
validate_options(options: opts, allowed: COMMON_OPTIONS + %i[args terminal operator] + NATIVE_OPTIONS.fetch(args.first, []))
|
|
391
|
+
validate_selection(opts)
|
|
392
|
+
terminal = opts[:terminal].nil? ? IO.console : opts[:terminal]
|
|
393
|
+
validate_terminal(terminal: terminal)
|
|
394
|
+
flags = terminal.fcntl(Fcntl::F_GETFL)
|
|
395
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + opts.fetch(:timeout, 30)
|
|
396
|
+
secrets = []
|
|
397
|
+
active = true
|
|
398
|
+
provider = lambda do |name|
|
|
399
|
+
raise ArgumentError, 'Private input callback is no longer active' unless active
|
|
400
|
+
raise ArgumentError, 'Invalid private credential field name' unless name.is_a?(Symbol) && name.to_s.match?(/\A[a-z_]{1,64}\z/)
|
|
401
|
+
|
|
402
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
403
|
+
raise TimeoutError, 'Private YubiKey input timed out' unless remaining.positive?
|
|
404
|
+
|
|
405
|
+
value = ::Timeout.timeout(remaining, TimeoutError, 'Private YubiKey input timed out') do
|
|
406
|
+
terminal.noecho { terminal.getpass("YubiKey #{name.to_s.tr('_', ' ')}: ") }
|
|
407
|
+
end
|
|
408
|
+
raise CommandError, 'Private YubiKey input ended without a credential' unless value.is_a?(String) && !value.empty?
|
|
409
|
+
|
|
410
|
+
secrets << value
|
|
411
|
+
value
|
|
412
|
+
end
|
|
413
|
+
command(opts.except(:terminal, :operator).merge(args: args, secret_provider: provider,
|
|
414
|
+
timeout: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)))
|
|
415
|
+
raise TimeoutError, 'Private YubiKey operation timed out' unless Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
416
|
+
|
|
417
|
+
:completed
|
|
418
|
+
rescue IOError, SystemCallError
|
|
419
|
+
raise CommandError, 'Private YubiKey terminal failed; device state may need inspection', cause: nil
|
|
420
|
+
ensure
|
|
421
|
+
active = false
|
|
422
|
+
secrets&.each { |value| value.replace("\0".b * value.bytesize) unless value.frozen? }
|
|
423
|
+
secrets&.clear
|
|
424
|
+
terminal.fcntl(Fcntl::F_SETFL, flags) if flags && terminal && !terminal.closed?
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
public_class_method def self.authors
|
|
428
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
public_class_method def self.help
|
|
432
|
+
puts <<~HELP
|
|
433
|
+
USAGE:
|
|
434
|
+
Native Ruby YubiKey applications over HID and smartcard transports.
|
|
435
|
+
No ykman process is started. Secrets stay in memory. One total deadline
|
|
436
|
+
covers open, authentication and commands. Existing-slot HMAC does not
|
|
437
|
+
require confirm; mutations, OATH and provisioning do.
|
|
438
|
+
|
|
439
|
+
# Return the empty executable dependency list.
|
|
440
|
+
#{self}.required_bins
|
|
441
|
+
|
|
442
|
+
# Probe host libraries without discovering or opening a device.
|
|
443
|
+
#{self}.available?(
|
|
444
|
+
backend: 'optional - hid or pcsc',
|
|
445
|
+
application: 'optional - otp, oath, piv, openpgp, fido, management, hsmauth or security_domain'
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Explicitly list native descriptors.
|
|
449
|
+
#{self}.devices(
|
|
450
|
+
serial: 'optional - exact decimal serial String',
|
|
451
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
452
|
+
device_path: 'optional - nonempty device path String',
|
|
453
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
454
|
+
backend: 'optional - hid or pcsc'
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Return 20 raw HMAC-SHA1 bytes from an already provisioned slot.
|
|
458
|
+
#{self}.challenge_response(
|
|
459
|
+
challenge: 'required - raw String of 1 to 64 bytes',
|
|
460
|
+
slot: 'optional - integer 1 or 2, defaults to 2',
|
|
461
|
+
serial: 'optional - exact decimal serial String',
|
|
462
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
463
|
+
device_path: 'optional - nonempty device path String',
|
|
464
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
465
|
+
connection: 'optional - caller-owned native exchange connection'
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Overwrite an OTP slot and verify a fresh HMAC before returning.
|
|
469
|
+
#{self}.provision_challenge_response(
|
|
470
|
+
key: 'required - exactly 20 raw key bytes',
|
|
471
|
+
confirm: 'required - literal true',
|
|
472
|
+
slot: 'optional - integer 1 or 2, defaults to 2',
|
|
473
|
+
serial: 'optional - exact decimal serial String; one selector is required',
|
|
474
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
475
|
+
device_path: 'optional - nonempty device path String',
|
|
476
|
+
connection: 'optional - caller-owned native exchange connection',
|
|
477
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
478
|
+
touch: 'optional - boolean, defaults to true',
|
|
479
|
+
access_code: 'optional - six raw access-code bytes',
|
|
480
|
+
new_access_code: 'optional - six raw replacement access-code bytes',
|
|
481
|
+
verification_challenge: 'optional - raw challenge of 1 to 64 bytes'
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Return one exact-account OATH code, preserving leading zeros.
|
|
485
|
+
#{self}.oath_code(
|
|
486
|
+
account: 'required - exact account String of 1 to 64 bytes',
|
|
487
|
+
confirm: 'required - literal true',
|
|
488
|
+
timestamp: 'optional - nonnegative Integer epoch seconds',
|
|
489
|
+
password: 'optional - nonempty in-memory OATH password',
|
|
490
|
+
access_key: 'optional - 16 raw access-key bytes',
|
|
491
|
+
serial: 'optional - exact decimal serial String',
|
|
492
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
493
|
+
device_path: 'optional - nonempty device path String',
|
|
494
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
495
|
+
connection: 'optional - caller-owned native exchange connection',
|
|
496
|
+
remember: 'optional - boolean cache control; requires encrypted_cache',
|
|
497
|
+
use_cache: 'optional - boolean cache control; requires encrypted_cache',
|
|
498
|
+
encrypted_cache: 'optional - caller adapter with encrypted?, fetch, store and delete'
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Read structured management information.
|
|
502
|
+
#{self}.info(
|
|
503
|
+
serial: 'optional - exact decimal serial String',
|
|
504
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
505
|
+
device_path: 'optional - nonempty device path String',
|
|
506
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
507
|
+
connection: 'optional - caller-owned native exchange connection'
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Return local native help metadata without opening a device.
|
|
511
|
+
#{self}.cli_help(
|
|
512
|
+
path: 'optional - Array of supported command name Strings'
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Dispatch a config leaf through the native management application.
|
|
516
|
+
#{self}.config(args: 'optional - argv after the family name')
|
|
517
|
+
|
|
518
|
+
# Dispatch an otp leaf through the native OTP application.
|
|
519
|
+
#{self}.otp(args: 'optional - argv after the family name')
|
|
520
|
+
|
|
521
|
+
# Dispatch an oath leaf through the native OATH application.
|
|
522
|
+
#{self}.oath(args: 'optional - argv after the family name')
|
|
523
|
+
|
|
524
|
+
# Dispatch a fido leaf through the native FIDO application.
|
|
525
|
+
#{self}.fido(args: 'optional - argv after the family name')
|
|
526
|
+
|
|
527
|
+
# Dispatch a piv leaf through the native PIV application.
|
|
528
|
+
#{self}.piv(args: 'optional - argv after the family name')
|
|
529
|
+
|
|
530
|
+
# Dispatch an openpgp leaf through the native OpenPGP application.
|
|
531
|
+
#{self}.openpgp(args: 'optional - argv after the family name')
|
|
532
|
+
|
|
533
|
+
# Dispatch an hsmauth leaf through the native HSM Auth application.
|
|
534
|
+
#{self}.hsmauth(args: 'optional - argv after the family name')
|
|
535
|
+
|
|
536
|
+
# Dispatch a security_domain leaf through the native Security Domain application.
|
|
537
|
+
#{self}.security_domain(args: 'optional - argv after the family name')
|
|
538
|
+
|
|
539
|
+
# Send an explicit smartcard command. Mutations require confirm: true.
|
|
540
|
+
#{self}.apdu(
|
|
541
|
+
args: 'optional - compact or full APDU argv; omit with ins for typed fields',
|
|
542
|
+
ins: 'optional - integer instruction byte for a typed APDU',
|
|
543
|
+
cla: 'optional - integer class byte, defaults to zero',
|
|
544
|
+
p1: 'optional - integer parameter byte, defaults to zero',
|
|
545
|
+
p2: 'optional - integer parameter byte, defaults to zero',
|
|
546
|
+
data: 'optional - raw command bytes',
|
|
547
|
+
le: 'optional - expected response length',
|
|
548
|
+
extended: 'optional - boolean extended framing',
|
|
549
|
+
application: 'optional - native application Symbol for a typed APDU',
|
|
550
|
+
max_response: 'optional - positive response byte limit',
|
|
551
|
+
confirm: 'required - literal true before a device APDU',
|
|
552
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
553
|
+
connection: 'optional - caller-owned native exchange connection'
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Dispatch a supported leaf to its native Ruby application.
|
|
557
|
+
#{self}.command(
|
|
558
|
+
args: 'required - nonsecret command Array including the family name',
|
|
559
|
+
confirm: 'required - literal true for mutations and OATH calculation',
|
|
560
|
+
serial: 'optional - exact decimal serial String',
|
|
561
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
562
|
+
device_path: 'optional - nonempty device path String',
|
|
563
|
+
timeout: 'optional - finite positive seconds, defaults to 30',
|
|
564
|
+
connection: 'optional - caller-owned native exchange connection',
|
|
565
|
+
secret_provider: 'optional - callable receiving a field Symbol',
|
|
566
|
+
secure_channel: 'optional - explicit SCP selector Hash'
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Prompt on a private operator terminal and run one native command.
|
|
570
|
+
#{self}.interact(
|
|
571
|
+
args: 'required - nonsecret command Array',
|
|
572
|
+
confirm: 'required - literal true',
|
|
573
|
+
operator: 'required - literal true outside an agent request',
|
|
574
|
+
terminal: 'optional - readable and writable IO terminal',
|
|
575
|
+
timeout: 'optional - finite positive seconds including human input, defaults to 30',
|
|
576
|
+
serial: 'optional - exact decimal serial String',
|
|
577
|
+
reader: 'optional - nonempty smartcard reader String',
|
|
578
|
+
device_path: 'optional - nonempty device path String',
|
|
579
|
+
connection: 'optional - caller-owned native exchange connection'
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Return module author information without device access.
|
|
583
|
+
#{self}.authors
|
|
584
|
+
HELP
|
|
585
|
+
nil
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
private_class_method def self.validate_terminal(opts = {})
|
|
589
|
+
terminal = opts[:terminal]
|
|
590
|
+
raise ArgumentError, 'A private readable and writable IO terminal is required' unless terminal.is_a?(IO) && !terminal.closed? && terminal.tty?
|
|
591
|
+
|
|
592
|
+
# Ruby's wrapper modes can be narrower than the descriptor's O_RDWR.
|
|
593
|
+
# These probes consume no bytes and do not alter descriptor ownership.
|
|
594
|
+
terminal.read(0)
|
|
595
|
+
terminal.syswrite('')
|
|
596
|
+
rescue IOError, SystemCallError
|
|
597
|
+
raise ArgumentError, 'A private readable and writable IO terminal is required', cause: nil
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
private_class_method def self.validate_apdu_fields(opts = {})
|
|
601
|
+
%i[ins cla p1 p2].each do |key|
|
|
602
|
+
value = opts.fetch(key, 0)
|
|
603
|
+
raise ArgumentError, 'APDU header fields must be integer bytes' unless value.is_a?(Integer) && value.between?(0, 255)
|
|
604
|
+
end
|
|
605
|
+
data = opts.fetch(:data, ''.b)
|
|
606
|
+
raise ArgumentError, 'APDU data must be a bounded raw String' unless data.is_a?(String) && data.bytesize <= Protocol::MAX_BYTES
|
|
607
|
+
raise ArgumentError, 'extended must be a boolean' unless [true, false].include?(opts.fetch(:extended, false))
|
|
608
|
+
|
|
609
|
+
le = opts[:le]
|
|
610
|
+
raise ArgumentError, 'APDU Le must fit its frame encoding' unless le.nil? || (le.is_a?(Integer) && le.between?(0, opts[:extended] ? 65_536 : 256))
|
|
611
|
+
|
|
612
|
+
limit = opts.fetch(:max_response, Protocol::MAX_BYTES)
|
|
613
|
+
raise ArgumentError, 'max_response must be a bounded positive Integer' unless limit.is_a?(Integer) && limit.between?(1, Protocol::MAX_BYTES)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
private_class_method def self.prepare_apdus(opts = {})
|
|
617
|
+
parsed = Protocol.parse_args(args: opts[:args], flags: %w[--no-pretty --short], values: %w[--app --send-apdu])
|
|
618
|
+
values = parsed[:options]
|
|
619
|
+
application = :security_domain
|
|
620
|
+
if values[:app]
|
|
621
|
+
route = FAMILIES[values[:app]]
|
|
622
|
+
raise ArgumentError, 'Unknown APDU application name' unless route
|
|
623
|
+
|
|
624
|
+
application = route[1]
|
|
625
|
+
child = const_get(route[0], false)
|
|
626
|
+
raise ArgumentError, 'Application has no smartcard AID' unless child.const_defined?(:AID, false)
|
|
627
|
+
|
|
628
|
+
aid = child.const_get(:AID, false)
|
|
629
|
+
end
|
|
630
|
+
if values[:send_apdu]
|
|
631
|
+
raise ArgumentError, 'Full APDUs cannot be combined with application or compact commands' if aid || !parsed[:positionals].empty?
|
|
632
|
+
|
|
633
|
+
frames = Array(values[:send_apdu]).map do |value|
|
|
634
|
+
raise ArgumentError, 'A full APDU must be even hex containing 4 to 65544 bytes' unless value.match?(/\A(?:[0-9a-fA-F]{2}){4,65544}\z/)
|
|
635
|
+
|
|
636
|
+
{ data: [value].pack('H*'), expected: nil }
|
|
637
|
+
end
|
|
638
|
+
else
|
|
639
|
+
frames = parsed[:positionals].map { |value| compact_apdu(value: value, short: values[:short]) }
|
|
640
|
+
end
|
|
641
|
+
raise ArgumentError, 'At least one APDU or explicit application is required' if frames.empty? && aid.nil?
|
|
642
|
+
|
|
643
|
+
{ application: application, aid: aid, frames: frames }
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
private_class_method def self.compact_apdu(opts = {})
|
|
647
|
+
# Compatibility syntax is a local parser, never executable input.
|
|
648
|
+
match = opts[:value].match(%r{\A(?<cla>[0-9a-f]{2})?(?<ins>[0-9a-f]{2})(?<params>[0-9a-f]{4})?(?::(?<body>(?:[0-9a-f]{2})+))?(?:/(?<le>[0-9a-f]{2}))?(?<check>=(?<sw>[0-9a-f]{4})?)?\z}i)
|
|
649
|
+
raise ArgumentError, 'Malformed compact APDU syntax' unless match
|
|
650
|
+
|
|
651
|
+
header = [match[:cla] ? match[:cla].to_i(16) : 0, match[:ins].to_i(16), *(match[:params] || '0000').scan(/../).map { |value| value.to_i(16) }].pack('C*')
|
|
652
|
+
data = [match[:body] || ''].pack('H*')
|
|
653
|
+
le = match[:le] ? match[:le].to_i(16) : 0
|
|
654
|
+
if data.bytesize > 255
|
|
655
|
+
raise ArgumentError, 'Compact APDU data exceeds selected encoding' if opts[:short] || data.bytesize > 65_535
|
|
656
|
+
|
|
657
|
+
encoded = header + "\0".b + [data.bytesize].pack('n') + data + [le].pack('n')
|
|
658
|
+
else
|
|
659
|
+
encoded = header + (data.empty? ? ''.b : [data.bytesize].pack('C') + data) + [le].pack('C')
|
|
660
|
+
end
|
|
661
|
+
expected = if match[:check]
|
|
662
|
+
match[:sw].to_s.empty? ? 0x9000 : match[:sw].to_i(16)
|
|
663
|
+
end
|
|
664
|
+
{ data: encoded, expected: expected }
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
private_class_method def self.validate_slot(opts = {})
|
|
668
|
+
slot = opts[:slot]
|
|
669
|
+
raise ArgumentError, 'slot must be integer 1 or 2' unless slot.is_a?(Integer) && [1, 2].include?(slot)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
private_class_method def self.validate_oath_options(opts = {})
|
|
673
|
+
password = opts[:password]
|
|
674
|
+
key = opts[:access_key]
|
|
675
|
+
raise ArgumentError, 'password must be a nonempty in-memory String' unless password.nil? || (password.is_a?(String) && !password.empty?)
|
|
676
|
+
raise ArgumentError, 'access_key must contain 16 raw bytes' unless key.nil? || (key.is_a?(String) && key.bytesize == 16)
|
|
677
|
+
raise ArgumentError, 'Choose password or access_key, not both' if password && key
|
|
678
|
+
raise ArgumentError, 'use_cache conflicts with an explicit credential' if opts[:use_cache] && (password || key)
|
|
679
|
+
|
|
680
|
+
%i[remember use_cache].each do |name|
|
|
681
|
+
raise ArgumentError, 'OATH cache controls must be booleans' if opts.key?(name) && ![true, false].include?(opts[name])
|
|
682
|
+
end
|
|
683
|
+
return unless opts[:remember] || opts[:use_cache]
|
|
684
|
+
|
|
685
|
+
cache = opts[:encrypted_cache]
|
|
686
|
+
raise ArgumentError, 'Supply an explicit encrypted_cache adapter; no host cache is opened' unless cache.respond_to?(:encrypted?) && cache.encrypted? == true && %i[fetch store delete].all? { |name| cache.respond_to?(name) }
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
private_class_method def self.family(opts = {})
|
|
690
|
+
args = opts[:args]
|
|
691
|
+
raise ArgumentError, 'args must be an Array' unless args.nil? || args.is_a?(Array)
|
|
692
|
+
|
|
693
|
+
command(opts.except(:family).merge(args: [opts[:family]] + (args || [])))
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# One deadline spans acquisition, secure-channel setup and all application I/O.
|
|
697
|
+
# Closing this lease never closes a caller-owned underlying descriptor.
|
|
698
|
+
class DeadlineConnection
|
|
699
|
+
def initialize(opts, deadline)
|
|
700
|
+
@connection = opts[:connection]
|
|
701
|
+
@owned = @connection.nil?
|
|
702
|
+
@selection = opts.slice(*SELECTOR_OPTIONS).merge(application: opts[:application])
|
|
703
|
+
@deadline = deadline
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def acquire
|
|
707
|
+
@connection ||= Transport.open(@selection.merge(timeout: remaining))
|
|
708
|
+
remaining
|
|
709
|
+
@connection
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def release
|
|
713
|
+
handle = @connection
|
|
714
|
+
@connection = nil
|
|
715
|
+
handle.close if @owned && handle
|
|
716
|
+
:released
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def remaining
|
|
720
|
+
seconds = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
721
|
+
raise TimeoutError, 'Native YubiKey operation timed out; inspect device state before retrying' unless seconds.positive?
|
|
722
|
+
|
|
723
|
+
seconds
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def exchange(opts = {})
|
|
727
|
+
timeout = opts.fetch(:timeout, remaining)
|
|
728
|
+
raise ArgumentError, 'exchange timeout must be finite positive seconds' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
729
|
+
|
|
730
|
+
handle = acquire
|
|
731
|
+
result = handle.exchange(**opts, timeout: [timeout, remaining].min)
|
|
732
|
+
remaining
|
|
733
|
+
result
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def descriptor
|
|
737
|
+
remaining
|
|
738
|
+
value = acquire.descriptor
|
|
739
|
+
remaining
|
|
740
|
+
value
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def close
|
|
744
|
+
:released
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def inspect
|
|
748
|
+
'#<YubiKey deadline connection>'
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
private_constant :DeadlineConnection
|
|
752
|
+
|
|
753
|
+
private_class_method def self.with_connection(opts = {})
|
|
754
|
+
timeout = opts.fetch(:timeout, 30)
|
|
755
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
756
|
+
lease = DeadlineConnection.new(opts, deadline)
|
|
757
|
+
wrapped = nil
|
|
758
|
+
lease.remaining
|
|
759
|
+
if opts[:secure_channel]
|
|
760
|
+
lease.acquire
|
|
761
|
+
wrapped = SecurityDomain.secure_connection(opts[:secure_channel].merge(connection: lease, confirm: opts[:confirm], timeout: lease.remaining))
|
|
762
|
+
raise ResponseError, 'Secure-channel handshake did not return a native connection' unless wrapped.respond_to?(:exchange)
|
|
763
|
+
end
|
|
764
|
+
result = yield(wrapped || lease)
|
|
765
|
+
lease.remaining
|
|
766
|
+
result
|
|
767
|
+
rescue IOError, SystemCallError
|
|
768
|
+
raise CommandError, 'Native YubiKey I/O failed; inspect device state before retrying', cause: nil
|
|
769
|
+
ensure
|
|
770
|
+
begin
|
|
771
|
+
wrapped.close if wrapped
|
|
772
|
+
ensure
|
|
773
|
+
lease&.release
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
private_class_method def self.read_only?(opts = {})
|
|
778
|
+
path = opts[:path]
|
|
779
|
+
args = opts[:args]
|
|
780
|
+
return true if path.end_with?(' info') || path == 'hsmauth credentials list'
|
|
781
|
+
|
|
782
|
+
if path == 'oath accounts list'
|
|
783
|
+
return false if args.include?('--remember') || %i[password access_key secret_provider remember use_cache].any? { |name| opts[name] }
|
|
784
|
+
|
|
785
|
+
return true # A protected application still checks consent before authenticating.
|
|
786
|
+
end
|
|
787
|
+
return false unless %w[config].include?(args.first) && %w[usb nfc].include?(args[1])
|
|
788
|
+
return false unless args.drop(2) == ['--list'] || (args.length == 2 && opts[:list] == true)
|
|
789
|
+
|
|
790
|
+
!opts.keys.intersect?(NATIVE_OPTIONS.fetch('config') - [:list])
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
private_class_method def self.validate_options(opts = {})
|
|
794
|
+
raise ArgumentError, 'Unknown YubiKey option; use native operation options' unless opts[:options].is_a?(Hash) && (opts[:options].keys - opts[:allowed]).empty?
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
private_class_method def self.validate_selection(opts = {})
|
|
798
|
+
serial = opts[:serial]
|
|
799
|
+
raise ArgumentError, 'serial must be an exact decimal String' unless serial.nil? || (serial.is_a?(String) && serial.match?(/\A[0-9]+\z/))
|
|
800
|
+
|
|
801
|
+
%i[reader device_path].each do |key|
|
|
802
|
+
value = opts[key]
|
|
803
|
+
raise ArgumentError, 'reader and device_path must be nonempty control-free Strings' unless value.nil? || (value.is_a?(String) && !value.empty? && value.valid_encoding? && !value.b.match?(/[\x00-\x1f\x7f]/))
|
|
804
|
+
end
|
|
805
|
+
timeout = opts.fetch(:timeout, 30)
|
|
806
|
+
raise ArgumentError, 'timeout must be finite positive seconds' unless (timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?
|
|
807
|
+
raise ArgumentError, 'connection must implement native exchange' if opts.key?(:connection) && !opts[:connection].nil? && !opts[:connection].respond_to?(:exchange)
|
|
808
|
+
raise ArgumentError, 'secret_provider must be callable' unless opts[:secret_provider].nil? || opts[:secret_provider].respond_to?(:call)
|
|
809
|
+
|
|
810
|
+
validate_secure_channel(opts) if opts.key?(:secure_channel)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
private_class_method def self.validate_secure_channel(opts = {})
|
|
814
|
+
channel = opts[:secure_channel]
|
|
815
|
+
allowed = %i[scp kid kvn key_enc key_mac key_dek card_public_key ca_certificates card_certificates oce_private_key oce_certificates oce_kid oce_kvn]
|
|
816
|
+
raise ArgumentError, 'secure_channel must be an explicit nonempty Hash of native key options' unless channel.is_a?(Hash) && !channel.empty? && (channel.keys - allowed).empty?
|
|
817
|
+
raise ArgumentError, 'secure_channel requires an explicit scp protocol and integer kvn' unless %i[scp03 scp11a scp11b scp11c].include?(channel[:scp]) && channel[:kvn].is_a?(Integer) && channel[:kvn].between?(0, 255)
|
|
818
|
+
|
|
819
|
+
Protocol.require_confirm(opts)
|
|
820
|
+
return unless channel[:scp] == :scp03
|
|
821
|
+
|
|
822
|
+
%i[key_enc key_mac].each do |key|
|
|
823
|
+
raise ArgumentError, 'SCP03 requires explicit 16-byte in-memory keys' unless channel[key].is_a?(String) && channel[key].bytesize == 16
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
private_class_method def self.validate_args(opts = {})
|
|
828
|
+
args = opts[:args]
|
|
829
|
+
raise ArgumentError, 'args must be a bounded nonempty Array of control-free Strings' unless args.is_a?(Array) && args.length.between?(1, 256) && args.all? { |value| value.is_a?(String) && !value.empty? && value.valid_encoding? && !value.b.match?(/[\x00-\x1f\x7f]/) } && args.sum(&:bytesize) <= 65_536
|
|
830
|
+
|
|
831
|
+
args.map(&:dup)
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
private_class_method def self.validate_command(opts = {})
|
|
835
|
+
grammar = COMMANDS[opts[:path]]
|
|
836
|
+
return unless grammar
|
|
837
|
+
|
|
838
|
+
parsed = Protocol.parse_args(args: opts[:args].drop(opts[:path].split.length), flags: grammar[1], values: grammar[2])
|
|
839
|
+
maximum = { 'sd keys import' => 2, 'oath accounts import' => 0 }.fetch(opts[:path], grammar[0])
|
|
840
|
+
raise ArgumentError, 'Unexpected or secret positional argument; supply credentials in memory' if parsed[:positionals].length > maximum
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
end
|