pwn 0.5.783 → 0.5.785
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +35 -16
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/scripts/build_gate.rb +58 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +6 -9
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +599 -15
- metadata +140 -17
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Lint/RescueException -- Native resource ownership must survive Interrupt/SystemExit; errors are re-raised or forwarded, never logged.
|
|
4
|
+
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
module Plugins
|
|
9
|
+
module YubiKey
|
|
10
|
+
# Native HIDAPI and PC/SC only. Requiring this file does not load a shared
|
|
11
|
+
# library, initialize a library, enumerate devices or open a reader.
|
|
12
|
+
module Transport
|
|
13
|
+
# Each binding object owns its platform ABI. No PWN::FFI name resolution.
|
|
14
|
+
class NativeLibrary
|
|
15
|
+
attr_reader :library, :platform, :kind, :dword, :handle_type, :pci
|
|
16
|
+
|
|
17
|
+
def initialize(kind, library: nil, platform: nil)
|
|
18
|
+
require 'ffi'
|
|
19
|
+
@kind = kind
|
|
20
|
+
@users = 0
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@platform = platform || (if ::FFI::Platform.windows?
|
|
23
|
+
:windows
|
|
24
|
+
else
|
|
25
|
+
(::FFI::Platform.mac? ? :macos : :linux)
|
|
26
|
+
end)
|
|
27
|
+
@library = library || Module.new
|
|
28
|
+
@library.extend(::FFI::Library)
|
|
29
|
+
case kind
|
|
30
|
+
when :hid then bind_hid
|
|
31
|
+
when :pcsc then bind_pcsc
|
|
32
|
+
else raise ArgumentError, 'backend must be :hid or :pcsc'
|
|
33
|
+
end
|
|
34
|
+
rescue LoadError, ::FFI::NotFoundError => e
|
|
35
|
+
raise DependencyError, "Native #{kind} library unavailable; install HIDAPI for USB HID or PC/SC for smartcard/NFC (#{e.class})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def acquire
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
raise CommandError, 'HIDAPI initialization failed' if @users.zero? && library.hid_init.negative?
|
|
41
|
+
|
|
42
|
+
@users += 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def release
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@users -= 1
|
|
49
|
+
library.hid_exit if @users.zero?
|
|
50
|
+
end
|
|
51
|
+
:released
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def devices
|
|
55
|
+
kind == :hid ? HIDInventory.new(self).devices : PCSCInventory.new(self).devices
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def open(descriptor)
|
|
59
|
+
kind == :hid ? HIDIO.new(self, descriptor[:device_path]) : PCSCIO.new(self, descriptor[:reader])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def bind_pcsc
|
|
65
|
+
@dword = platform == :linux ? :ulong : :uint32
|
|
66
|
+
result = platform == :linux ? :long : :int32
|
|
67
|
+
@handle_type = platform == :windows ? :uintptr_t : result
|
|
68
|
+
library.ffi_convention(:stdcall) if platform == :windows
|
|
69
|
+
names = case platform
|
|
70
|
+
when :windows then ['winscard.dll']
|
|
71
|
+
when :macos then ['/System/Library/Frameworks/PCSC.framework/PCSC']
|
|
72
|
+
else ['libpcsclite.so.1', 'libpcsclite.so']
|
|
73
|
+
end
|
|
74
|
+
library.ffi_lib(names)
|
|
75
|
+
{
|
|
76
|
+
SCardEstablishContext: [[dword, :pointer, :pointer, :pointer], result],
|
|
77
|
+
SCardReleaseContext: [[handle_type], result], SCardCancel: [[handle_type], result],
|
|
78
|
+
SCardDisconnect: [[handle_type, dword], result],
|
|
79
|
+
SCardGetAttrib: [[handle_type, dword, :pointer, :pointer], result],
|
|
80
|
+
SCardTransmit: [[handle_type, :pointer, :pointer, dword, :pointer, :pointer, :pointer], result]
|
|
81
|
+
}.each { |name, (args, output)| library.attach_function(name, args, output, blocking: true) }
|
|
82
|
+
{ SCardListReaders: [handle_type, :pointer, :pointer, :pointer],
|
|
83
|
+
SCardConnect: [handle_type, :pointer, dword, dword, :pointer, :pointer] }.each do |name, args|
|
|
84
|
+
symbol = platform == :windows ? :"#{name}W" : name
|
|
85
|
+
library.attach_function(name, symbol, args, result, blocking: true)
|
|
86
|
+
end
|
|
87
|
+
# Use exported PCI structs, not a guessed macOS struct packing.
|
|
88
|
+
@pci = { 1 => 'g_rgSCardT0Pci', 2 => 'g_rgSCardT1Pci' }.transform_values do |symbol|
|
|
89
|
+
pointer = library.ffi_libraries.filter_map { |lib| lib.find_symbol(symbol) }.first
|
|
90
|
+
raise DependencyError, 'PC/SC library has no protocol control information symbols' unless pointer
|
|
91
|
+
|
|
92
|
+
pointer
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def bind_hid
|
|
97
|
+
names = case platform
|
|
98
|
+
when :windows then ['hidapi.dll']
|
|
99
|
+
when :macos then ['libhidapi.dylib', '/opt/homebrew/lib/libhidapi.dylib', '/usr/local/lib/libhidapi.dylib']
|
|
100
|
+
else ['libhidapi-hidraw.so.0', 'libhidapi-hidraw.so', 'libhidapi-libusb.so.0', 'libhidapi-libusb.so', 'libhidapi.so']
|
|
101
|
+
end
|
|
102
|
+
library.ffi_lib(names)
|
|
103
|
+
{
|
|
104
|
+
hid_init: [[], :int], hid_exit: [[], :int],
|
|
105
|
+
hid_enumerate: [%i[ushort ushort], :pointer], hid_free_enumeration: [[:pointer], :void],
|
|
106
|
+
hid_open_path: [[:string], :pointer], hid_close: [[:pointer], :void],
|
|
107
|
+
hid_write: [%i[pointer pointer size_t], :int],
|
|
108
|
+
hid_read_timeout: [%i[pointer pointer size_t int], :int],
|
|
109
|
+
hid_send_feature_report: [%i[pointer pointer size_t], :int],
|
|
110
|
+
hid_get_feature_report: [%i[pointer pointer size_t], :int]
|
|
111
|
+
}.each { |name, (args, result)| library.attach_function(name, args, result, blocking: true) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# C structures are created only after the lazy FFI dependency is loaded.
|
|
116
|
+
class HIDInventory
|
|
117
|
+
def initialize(native)
|
|
118
|
+
@native = native
|
|
119
|
+
@info = Class.new(::FFI::Struct) do
|
|
120
|
+
layout :path, :pointer, :vendor_id, :ushort, :product_id, :ushort,
|
|
121
|
+
:serial_number, :pointer, :release_number, :ushort,
|
|
122
|
+
:manufacturer_string, :pointer, :product_string, :pointer,
|
|
123
|
+
:usage_page, :ushort, :usage, :ushort, :interface_number, :int,
|
|
124
|
+
:next, :pointer
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def devices
|
|
129
|
+
@native.acquire
|
|
130
|
+
owned = true
|
|
131
|
+
pointer = @native.library.hid_enumerate(0x1050, 0)
|
|
132
|
+
current = pointer
|
|
133
|
+
found = []
|
|
134
|
+
seen = {}
|
|
135
|
+
until current.null?
|
|
136
|
+
raise ResponseError, 'Invalid or oversized HID enumeration' if seen[current.address] || seen.length >= 1024
|
|
137
|
+
|
|
138
|
+
seen[current.address] = true
|
|
139
|
+
entry = @info.new(current)
|
|
140
|
+
protocol = case [entry[:usage_page], entry[:usage]]
|
|
141
|
+
when [1, 6] then :otp
|
|
142
|
+
when [0xf1d0, 1] then :ctap
|
|
143
|
+
end
|
|
144
|
+
if entry[:vendor_id] == 0x1050 && protocol
|
|
145
|
+
found << {
|
|
146
|
+
backend: :hid, transport: :usb, protocol: protocol, device_path: string(entry[:path]),
|
|
147
|
+
serial: wide_string(entry[:serial_number]), vendor_id: entry[:vendor_id],
|
|
148
|
+
product_id: entry[:product_id], interface: entry[:interface_number],
|
|
149
|
+
product: wide_string(entry[:product_string])
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
current = entry[:next]
|
|
153
|
+
end
|
|
154
|
+
found
|
|
155
|
+
ensure
|
|
156
|
+
begin
|
|
157
|
+
@native.library.hid_free_enumeration(pointer) if pointer && !pointer.null?
|
|
158
|
+
ensure
|
|
159
|
+
@native.release if owned
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def string(pointer)
|
|
166
|
+
raise ResponseError, 'HID device has no path' if pointer.null?
|
|
167
|
+
|
|
168
|
+
bytes = ''.b
|
|
169
|
+
4096.times do |index|
|
|
170
|
+
byte = pointer.get_uint8(index)
|
|
171
|
+
return bytes if byte.zero?
|
|
172
|
+
|
|
173
|
+
bytes << byte
|
|
174
|
+
end
|
|
175
|
+
raise ResponseError, 'Unterminated native device path'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def wide_string(pointer)
|
|
179
|
+
return nil if pointer.null?
|
|
180
|
+
|
|
181
|
+
windows = @native.platform == :windows
|
|
182
|
+
units = []
|
|
183
|
+
4096.times do |index|
|
|
184
|
+
unit = windows ? pointer.get_uint16(index * 2) : pointer.get_uint32(index * 4)
|
|
185
|
+
if unit.zero?
|
|
186
|
+
return windows ? units.pack('v*').force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8) : units.pack('U*')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
units << unit
|
|
190
|
+
end
|
|
191
|
+
raise ResponseError, 'Unterminated native wide string'
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# One native handle; FFI buffers remain alive for the entire C call.
|
|
196
|
+
class HIDIO
|
|
197
|
+
def initialize(native, path)
|
|
198
|
+
@native = native
|
|
199
|
+
@native.acquire
|
|
200
|
+
@owned = true
|
|
201
|
+
@handle = @native.library.hid_open_path(path)
|
|
202
|
+
raise CommandError, 'Cannot open selected HID device; check permissions and competing clients' if @handle.null?
|
|
203
|
+
rescue Exception
|
|
204
|
+
close
|
|
205
|
+
raise
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def write_packet(bytes)
|
|
209
|
+
write(:hid_write, bytes, 64)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def write_feature(bytes)
|
|
213
|
+
write(:hid_send_feature_report, bytes, 8)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def read_packet(milliseconds)
|
|
217
|
+
buffer = ::FFI::MemoryPointer.new(:uchar, 64)
|
|
218
|
+
count = @native.library.hid_read_timeout(@handle, buffer, 64, milliseconds)
|
|
219
|
+
raise CommandError, 'HIDAPI read failed; device may be disconnected' if count.negative?
|
|
220
|
+
raise ResponseError, 'Invalid HIDAPI input length' unless [0, 64].include?(count)
|
|
221
|
+
|
|
222
|
+
buffer.read_bytes(count)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def read_feature
|
|
226
|
+
buffer = ::FFI::MemoryPointer.new(:uchar, 9)
|
|
227
|
+
count = @native.library.hid_get_feature_report(@handle, buffer, 9)
|
|
228
|
+
raise CommandError, 'HIDAPI feature read failed' if count.negative?
|
|
229
|
+
raise ResponseError, 'Invalid HIDAPI feature length or report ID' unless count == 9 && buffer.get_uint8(0).zero?
|
|
230
|
+
|
|
231
|
+
buffer.get_bytes(1, 8)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def close
|
|
235
|
+
return :closed unless @owned
|
|
236
|
+
|
|
237
|
+
@owned = false
|
|
238
|
+
begin
|
|
239
|
+
@native.library.hid_close(@handle) if @handle && !@handle.null?
|
|
240
|
+
ensure
|
|
241
|
+
@native.release
|
|
242
|
+
end
|
|
243
|
+
:closed
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def write(function, bytes, size)
|
|
249
|
+
raise ArgumentError, 'Invalid outgoing HID report size' unless bytes.bytesize == size
|
|
250
|
+
|
|
251
|
+
wire = "\0".b + bytes
|
|
252
|
+
buffer = ::FFI::MemoryPointer.new(:uchar, wire.bytesize)
|
|
253
|
+
buffer.put_bytes(0, wire)
|
|
254
|
+
count = @native.library.public_send(function, @handle, buffer, wire.bytesize)
|
|
255
|
+
raise CommandError, 'HIDAPI write failed or was incomplete; command not retried' unless count == wire.bytesize
|
|
256
|
+
|
|
257
|
+
:sent
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Reader enumeration does not connect to cards or execute application APDUs.
|
|
262
|
+
class PCSCInventory
|
|
263
|
+
def initialize(native)
|
|
264
|
+
@native = native
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def devices
|
|
268
|
+
context_ptr = ::FFI::MemoryPointer.new(@native.handle_type)
|
|
269
|
+
check(@native.library.SCardEstablishContext(2, nil, nil, context_ptr))
|
|
270
|
+
context = context_ptr.read(@native.handle_type)
|
|
271
|
+
length = ::FFI::MemoryPointer.new(@native.dword)
|
|
272
|
+
code = @native.library.SCardListReaders(context, nil, nil, length)
|
|
273
|
+
return [] if (code & 0xffffffff) == 0x8010002e
|
|
274
|
+
|
|
275
|
+
check(code)
|
|
276
|
+
count = length.read(@native.dword)
|
|
277
|
+
raise ResponseError, 'Invalid PC/SC reader list size' unless count.between?(2, 65_536)
|
|
278
|
+
|
|
279
|
+
unit = @native.platform == :windows ? 2 : 1
|
|
280
|
+
buffer = ::FFI::MemoryPointer.new(:uchar, count * unit)
|
|
281
|
+
check(@native.library.SCardListReaders(context, nil, buffer, length))
|
|
282
|
+
actual = length.read(@native.dword)
|
|
283
|
+
raise ResponseError, 'PC/SC reader list grew; rerun discovery explicitly' unless actual.between?(2, count)
|
|
284
|
+
|
|
285
|
+
bytes = buffer.read_bytes(actual * unit)
|
|
286
|
+
text = unit == 2 ? bytes.force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8) : bytes.force_encoding(Encoding::UTF_8)
|
|
287
|
+
raise ResponseError, 'Invalid PC/SC reader list encoding or terminator' unless text.valid_encoding? && text.end_with?("\0\0")
|
|
288
|
+
|
|
289
|
+
text.split("\0").map { |reader| { backend: :pcsc, protocol: :apdu, reader: reader, serial: nil } }
|
|
290
|
+
ensure
|
|
291
|
+
check(@native.library.SCardReleaseContext(context)) if context
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
def check(code)
|
|
297
|
+
raise CommandError, format('PC/SC reader discovery failed (0x%08x); check the PC/SC service', code & 0xffffffff) unless code.zero?
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# An exclusive PC/SC session never falls back to sharing with another client.
|
|
302
|
+
class PCSCIO
|
|
303
|
+
def initialize(native, reader)
|
|
304
|
+
@native = native
|
|
305
|
+
@context = establish
|
|
306
|
+
handle = ::FFI::MemoryPointer.new(native.handle_type)
|
|
307
|
+
active = ::FFI::MemoryPointer.new(native.dword)
|
|
308
|
+
bytes = native.platform == :windows ? "#{reader.encode(Encoding::UTF_16LE).b}\0\0" : "#{reader.b}\0"
|
|
309
|
+
name = ::FFI::MemoryPointer.new(:uchar, bytes.bytesize)
|
|
310
|
+
name.put_bytes(0, bytes)
|
|
311
|
+
check(native.library.SCardConnect(@context, name, 1, 3, handle, active), 'connect (exclusive; close competing clients)')
|
|
312
|
+
@handle = handle.read(native.handle_type)
|
|
313
|
+
@protocol = active.read(native.dword)
|
|
314
|
+
raise ResponseError, 'PC/SC negotiated neither T=0 nor T=1' unless native.pci.key?(@protocol)
|
|
315
|
+
rescue Exception
|
|
316
|
+
close
|
|
317
|
+
raise
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def metadata
|
|
321
|
+
atr = ::FFI::MemoryPointer.new(:uchar, 33)
|
|
322
|
+
length = ::FFI::MemoryPointer.new(@native.dword)
|
|
323
|
+
length.write(@native.dword, 33)
|
|
324
|
+
check(@native.library.SCardGetAttrib(@handle, 0x00090303, atr, length), 'read ATR')
|
|
325
|
+
count = length.read(@native.dword)
|
|
326
|
+
raise ResponseError, 'Invalid smartcard ATR size' unless count.between?(2, 33)
|
|
327
|
+
|
|
328
|
+
# Yubico's documented USB/NFC ATR distinction; not a vendor attestation.
|
|
329
|
+
{ transport: atr.get_uint8(1).allbits?(0xf0) ? :usb : :nfc }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def transmit(data)
|
|
333
|
+
input = ::FFI::MemoryPointer.new(:uchar, data.bytesize)
|
|
334
|
+
input.put_bytes(0, data)
|
|
335
|
+
output = ::FFI::MemoryPointer.new(:uchar, 65_538)
|
|
336
|
+
length = ::FFI::MemoryPointer.new(@native.dword)
|
|
337
|
+
length.write(@native.dword, 65_538)
|
|
338
|
+
check(@native.library.SCardTransmit(@handle, @native.pci.fetch(@protocol), input, data.bytesize, nil, output, length), 'transmit; command not retried')
|
|
339
|
+
count = length.read(@native.dword)
|
|
340
|
+
raise ResponseError, 'PC/SC returned a truncated or oversized R-APDU' unless count.between?(2, 65_538)
|
|
341
|
+
|
|
342
|
+
output.read_bytes(count)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def close
|
|
346
|
+
handle = @handle
|
|
347
|
+
context = @context
|
|
348
|
+
@handle = @context = nil
|
|
349
|
+
begin
|
|
350
|
+
check(@native.library.SCardDisconnect(handle, 0), 'disconnect') if handle
|
|
351
|
+
ensure
|
|
352
|
+
check(@native.library.SCardReleaseContext(context), 'release context') if context
|
|
353
|
+
end
|
|
354
|
+
:closed
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
|
|
359
|
+
def establish
|
|
360
|
+
context = ::FFI::MemoryPointer.new(@native.handle_type)
|
|
361
|
+
check(@native.library.SCardEstablishContext(2, nil, nil, context), 'establish context; ensure the PC/SC service is running')
|
|
362
|
+
context.read(@native.handle_type)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def check(code, operation)
|
|
366
|
+
return :ok if code.zero?
|
|
367
|
+
|
|
368
|
+
raise CommandError, format('PC/SC %<operation>s failed (0x%<code>08x)', operation: operation, code: code & 0xffffffff)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# One monotonic budget covers INIT, packet writes, reads and touch waits.
|
|
373
|
+
class Deadline
|
|
374
|
+
def initialize(seconds, cancelled = nil)
|
|
375
|
+
raise ArgumentError, 'timeout must be finite positive seconds' unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && seconds.positive?
|
|
376
|
+
|
|
377
|
+
@expires = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
378
|
+
@cancelled = cancelled
|
|
379
|
+
@stopped = false
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def stop
|
|
383
|
+
@stopped = true
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def check!
|
|
387
|
+
raise CancelledError, 'Device operation cancelled; do not retry uncertain writes' if @stopped || @cancelled&.call
|
|
388
|
+
raise TimeoutError, 'Device operation timed out; do not retry uncertain writes' unless remaining.positive?
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def remaining
|
|
392
|
+
@expires - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def milliseconds
|
|
396
|
+
check!
|
|
397
|
+
(remaining * 1000).ceil.clamp(1, 50)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def pause(seconds)
|
|
401
|
+
check!
|
|
402
|
+
sleep(remaining.clamp(0, seconds))
|
|
403
|
+
check!
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
class CancelledError < Error
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Fixed-size YubiKey CTAPHID reports. Application status stays opaque.
|
|
411
|
+
class CTAP
|
|
412
|
+
MAX_PAYLOAD = 57 + (128 * 59)
|
|
413
|
+
|
|
414
|
+
def initialize(io, descriptor)
|
|
415
|
+
@io = io
|
|
416
|
+
@descriptor = descriptor
|
|
417
|
+
@channel = 0xffffffff
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def prepare(deadline)
|
|
421
|
+
initialize_channel(deadline) if @channel == 0xffffffff
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def exchange(command, data, deadline, keepalive)
|
|
425
|
+
prepare(deadline)
|
|
426
|
+
send_message(command, data, deadline)
|
|
427
|
+
receive_message(command, deadline, keepalive)
|
|
428
|
+
rescue Exception
|
|
429
|
+
# Cancellation is serialized behind the current native call; never
|
|
430
|
+
# race hid_write/close against an in-flight C read on the handle.
|
|
431
|
+
begin
|
|
432
|
+
@io.write_packet([@channel, 0x91, 0].pack('NCn').ljust(64, "\0")) unless @channel == 0xffffffff
|
|
433
|
+
rescue StandardError
|
|
434
|
+
nil
|
|
435
|
+
end
|
|
436
|
+
raise
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
private
|
|
440
|
+
|
|
441
|
+
def initialize_channel(deadline)
|
|
442
|
+
nonce = SecureRandom.random_bytes(8)
|
|
443
|
+
send_message(0x06, nonce, deadline)
|
|
444
|
+
reply = receive_message(0x06, deadline, nil)
|
|
445
|
+
raise ResponseError, 'Invalid CTAPHID INIT length or nonce' unless reply.bytesize == 17 && reply.byteslice(0, 8) == nonce
|
|
446
|
+
|
|
447
|
+
channel, protocol, major, minor, patch, caps = reply.byteslice(8, 9).unpack('NCCCCC')
|
|
448
|
+
raise ResponseError, 'Invalid CTAPHID channel or protocol' if [0, 0xffffffff].include?(channel) || protocol != 2
|
|
449
|
+
|
|
450
|
+
@channel = channel
|
|
451
|
+
@descriptor.merge!(channel: channel, version: [major, minor, patch], capabilities: caps)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def send_message(command, data, deadline)
|
|
455
|
+
offset = 57
|
|
456
|
+
deadline.check!
|
|
457
|
+
@io.write_packet(([@channel, command | 0x80, data.bytesize].pack('NCn') + data.byteslice(0, 57)).ljust(64, "\0"))
|
|
458
|
+
sequence = 0
|
|
459
|
+
while offset < data.bytesize
|
|
460
|
+
deadline.check!
|
|
461
|
+
@io.write_packet(([@channel, sequence].pack('NC') + data.byteslice(offset, 59)).ljust(64, "\0"))
|
|
462
|
+
offset += 59
|
|
463
|
+
sequence += 1
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def receive_message(command, deadline, keepalive)
|
|
468
|
+
response = ''.b
|
|
469
|
+
length = nil
|
|
470
|
+
sequence = 0
|
|
471
|
+
loop do
|
|
472
|
+
deadline.check!
|
|
473
|
+
packet = @io.read_packet(deadline.milliseconds)
|
|
474
|
+
next if packet.empty?
|
|
475
|
+
raise ResponseError, 'Invalid CTAPHID report size' unless packet.bytesize == 64
|
|
476
|
+
next unless packet.unpack1('N') == @channel
|
|
477
|
+
|
|
478
|
+
marker = packet.getbyte(4)
|
|
479
|
+
if length.nil?
|
|
480
|
+
length = packet.byteslice(5, 2).unpack1('n')
|
|
481
|
+
if marker == 0xbf
|
|
482
|
+
raise ResponseError, 'Invalid CTAPHID error length' unless length == 1
|
|
483
|
+
|
|
484
|
+
raise CommandError, format('CTAPHID error 0x%02x; command not retried', packet.getbyte(7))
|
|
485
|
+
elsif marker == 0xbb
|
|
486
|
+
raise ResponseError, 'Invalid CTAPHID keepalive' unless length == 1 && [1, 2].include?(packet.getbyte(7))
|
|
487
|
+
|
|
488
|
+
keepalive&.call(packet.getbyte(7))
|
|
489
|
+
length = nil
|
|
490
|
+
next
|
|
491
|
+
end
|
|
492
|
+
raise ResponseError, 'Unexpected CTAPHID command or oversized message' unless marker == (command | 0x80) && length <= MAX_PAYLOAD
|
|
493
|
+
|
|
494
|
+
response << packet.byteslice(7, [length, 57].min)
|
|
495
|
+
else
|
|
496
|
+
raise ResponseError, 'Out-of-order CTAPHID continuation' unless marker == sequence && sequence <= 127
|
|
497
|
+
|
|
498
|
+
response << packet.byteslice(5, [length - response.bytesize, 59].min)
|
|
499
|
+
sequence += 1
|
|
500
|
+
end
|
|
501
|
+
return response if response.bytesize == length
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# OTP feature-report framing, distinct from the keyboard input endpoint.
|
|
507
|
+
class OTP
|
|
508
|
+
def initialize(io, descriptor)
|
|
509
|
+
@io = io
|
|
510
|
+
@descriptor = descriptor
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def exchange(command, data, deadline, keepalive, expected = nil)
|
|
514
|
+
status = report(deadline)
|
|
515
|
+
@descriptor[:version] = status.byteslice(1, 3).bytes
|
|
516
|
+
return status.byteslice(1, 6) if command.zero?
|
|
517
|
+
|
|
518
|
+
previous = status.getbyte(4)
|
|
519
|
+
payload = data.ljust(64, "\0")
|
|
520
|
+
frame = payload + [command, crc(payload)].pack('Cv') + ("\0" * 3)
|
|
521
|
+
10.times do |sequence|
|
|
522
|
+
part = frame.byteslice(sequence * 7, 7)
|
|
523
|
+
next if sequence != 0 && sequence != 9 && part.bytes.all?(&:zero?)
|
|
524
|
+
|
|
525
|
+
deadline.pause(0.02) until report(deadline).getbyte(7).nobits?(0x80)
|
|
526
|
+
deadline.check!
|
|
527
|
+
@io.write_feature(part + [0x80 | sequence].pack('C'))
|
|
528
|
+
end
|
|
529
|
+
receive(previous, deadline, keepalive, expected)
|
|
530
|
+
rescue Exception
|
|
531
|
+
begin
|
|
532
|
+
@io.write_feature(("\0" * 7) + "\xff".b) unless command.zero?
|
|
533
|
+
rescue StandardError
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
raise
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
private
|
|
540
|
+
|
|
541
|
+
def report(deadline)
|
|
542
|
+
deadline.check!
|
|
543
|
+
bytes = @io.read_feature
|
|
544
|
+
raise ResponseError, 'Invalid OTP feature report size' unless bytes.bytesize == 8
|
|
545
|
+
|
|
546
|
+
bytes
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def crc(bytes)
|
|
550
|
+
result = 0xffff
|
|
551
|
+
bytes.each_byte do |byte|
|
|
552
|
+
result ^= byte
|
|
553
|
+
8.times { result = (result >> 1) ^ (result.odd? ? 0x8408 : 0) }
|
|
554
|
+
end
|
|
555
|
+
result
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def receive(previous, deadline, keepalive, expected)
|
|
559
|
+
sequence = 0
|
|
560
|
+
payload = ''.b
|
|
561
|
+
touched = false
|
|
562
|
+
loop do
|
|
563
|
+
bytes = report(deadline)
|
|
564
|
+
flag = bytes.getbyte(7)
|
|
565
|
+
if flag.anybits?(0x40)
|
|
566
|
+
actual = flag & 0x1f
|
|
567
|
+
if actual.zero? && sequence.positive?
|
|
568
|
+
@io.write_feature(("\0" * 7) + "\xff".b)
|
|
569
|
+
return checked_payload(payload, expected)
|
|
570
|
+
end
|
|
571
|
+
raise ResponseError, 'Out-of-order OTP response' unless actual == sequence && sequence < 32
|
|
572
|
+
|
|
573
|
+
payload << bytes.byteslice(0, 7)
|
|
574
|
+
sequence += 1
|
|
575
|
+
elsif flag.zero?
|
|
576
|
+
raise ResponseError, 'Truncated OTP response' unless payload.empty?
|
|
577
|
+
|
|
578
|
+
next_sequence = bytes.getbyte(4)
|
|
579
|
+
updated = next_sequence == previous + 1 || (next_sequence.zero? && previous.positive? && bytes.getbyte(5).nobits?(3))
|
|
580
|
+
raise ResponseError, 'OTP returned status instead of the requested data' if updated && expected
|
|
581
|
+
return bytes.byteslice(1, 6) if updated
|
|
582
|
+
raise TimeoutError, 'OTP touch wait ended without confirmation' if touched
|
|
583
|
+
|
|
584
|
+
raise CommandError, 'OTP command rejected or no response; command not retried'
|
|
585
|
+
else
|
|
586
|
+
touched ||= flag.anybits?(0x20)
|
|
587
|
+
keepalive&.call(flag.anybits?(0x20) ? 2 : 1)
|
|
588
|
+
deadline.pause(0.02)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def checked_payload(bytes, expected)
|
|
594
|
+
if expected
|
|
595
|
+
valid = bytes.bytesize >= expected + 2 && bytes.bytesize < expected + 9 && crc(bytes.byteslice(0, expected + 2)) == 0xf0b8
|
|
596
|
+
raise ResponseError, 'Invalid OTP response length or CRC' unless valid
|
|
597
|
+
|
|
598
|
+
return bytes.byteslice(0, expected)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Without an expected length, only strip an unambiguous CRC followed
|
|
602
|
+
# by at most six zero fill bytes in the final seven-byte packet.
|
|
603
|
+
sizes = ([2, bytes.bytesize - 6].max..bytes.bytesize).select do |size|
|
|
604
|
+
crc(bytes.byteslice(0, size)) == 0xf0b8 && bytes.byteslice(size..).bytes.all?(&:zero?)
|
|
605
|
+
end
|
|
606
|
+
raise ResponseError, 'Ambiguous or invalid OTP CRC; specify response_length' unless sizes.length == 1
|
|
607
|
+
|
|
608
|
+
bytes.byteslice(0, sizes.first - 2)
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# A bounded Ruby waiter never kills a thread inside C. Drivers lacking a
|
|
613
|
+
# native timeout may outlive the waiter; their owner retains buffers and
|
|
614
|
+
# closes the handle only after the call returns (no use-after-free).
|
|
615
|
+
class NativeTask
|
|
616
|
+
attr_reader :thread
|
|
617
|
+
|
|
618
|
+
def initialize(deadline)
|
|
619
|
+
@deadline = deadline
|
|
620
|
+
@result = Queue.new
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def start(&work)
|
|
624
|
+
@thread = Thread.new do
|
|
625
|
+
@deadline.check!
|
|
626
|
+
value = work.call
|
|
627
|
+
@deadline.check!
|
|
628
|
+
@result << [:ok, value]
|
|
629
|
+
rescue Exception => e
|
|
630
|
+
@result << [:error, e]
|
|
631
|
+
end
|
|
632
|
+
self
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def await
|
|
636
|
+
loop do
|
|
637
|
+
@deadline.check!
|
|
638
|
+
break if thread.join([@deadline.remaining, 0.01].min)
|
|
639
|
+
end
|
|
640
|
+
@deadline.check!
|
|
641
|
+
kind, result = @result.pop
|
|
642
|
+
raise result if kind == :error
|
|
643
|
+
|
|
644
|
+
result
|
|
645
|
+
rescue Exception
|
|
646
|
+
@deadline.stop
|
|
647
|
+
raise
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# The protocol-facing handle. Callers always supply a raw binary String.
|
|
652
|
+
class Connection
|
|
653
|
+
def initialize(descriptor:, io:, timeout: 30, prepare: nil)
|
|
654
|
+
Deadline.new(timeout)
|
|
655
|
+
@descriptor = descriptor.dup
|
|
656
|
+
@descriptor.merge!(io.metadata) if io.respond_to?(:metadata)
|
|
657
|
+
raise ArgumentError, 'Unknown transport protocol' unless %i[otp ctap apdu].include?(@descriptor[:protocol])
|
|
658
|
+
|
|
659
|
+
@io = io
|
|
660
|
+
@timeout = timeout
|
|
661
|
+
@closed = false
|
|
662
|
+
@state = Mutex.new
|
|
663
|
+
@codec = case @descriptor[:protocol]
|
|
664
|
+
when :ctap then CTAP.new(io, @descriptor)
|
|
665
|
+
when :otp then OTP.new(io, @descriptor)
|
|
666
|
+
end
|
|
667
|
+
@codec.prepare(prepare) if prepare && @descriptor[:protocol] == :ctap
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def descriptor
|
|
671
|
+
@descriptor.transform_values { |value| value.is_a?(String) || value.is_a?(Array) ? value.dup : value }
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def exchange(opts = {})
|
|
675
|
+
data = validate(opts)
|
|
676
|
+
deadline = Deadline.new(opts.fetch(:timeout, @timeout), opts[:cancelled])
|
|
677
|
+
deadline.check!
|
|
678
|
+
task = @state.synchronize do
|
|
679
|
+
raise Error, 'Connection is closed' if @closed
|
|
680
|
+
raise CommandError, 'Another exchange is active on this connection' if @active
|
|
681
|
+
|
|
682
|
+
@active = true
|
|
683
|
+
@deadline = deadline
|
|
684
|
+
@task = NativeTask.new(deadline).start do
|
|
685
|
+
case @descriptor[:protocol]
|
|
686
|
+
when :apdu then @io.transmit(data)
|
|
687
|
+
when :ctap then @codec.exchange(opts[:command], data, deadline, opts[:on_keepalive])
|
|
688
|
+
when :otp then @codec.exchange(opts[:command], data, deadline, opts[:on_keepalive], opts[:response_length])
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
begin
|
|
693
|
+
task.await
|
|
694
|
+
rescue Exception
|
|
695
|
+
close
|
|
696
|
+
raise
|
|
697
|
+
ensure
|
|
698
|
+
@state.synchronize { @active = false }
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Read-only identity probe, never authentication or a guessed serial.
|
|
703
|
+
def identify_serial(deadline)
|
|
704
|
+
if @descriptor[:protocol] == :apdu
|
|
705
|
+
selected = exchange(protocol: :apdu, data: ['00a4040007a0000005272001'].pack('H*'), timeout: deadline.remaining)
|
|
706
|
+
if selected.end_with?("\x90\x00".b)
|
|
707
|
+
response = exchange(protocol: :apdu, data: ['0001100000'].pack('H*'), timeout: deadline.remaining)
|
|
708
|
+
if response.end_with?("\x90\x00".b)
|
|
709
|
+
raise ResponseError, 'Invalid OTP serial response' unless response.bytesize == 6
|
|
710
|
+
|
|
711
|
+
serial = response.byteslice(0, 4).unpack1('N')
|
|
712
|
+
return @descriptor[:serial] = serial.to_s if serial.positive?
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
@descriptor[:serial] = management_serial(deadline)
|
|
716
|
+
elsif @descriptor[:protocol] == :otp
|
|
717
|
+
response = exchange(protocol: :otp, command: 0x10, data: ''.b, response_length: 4, timeout: deadline.remaining)
|
|
718
|
+
@descriptor[:serial] = response.unpack1('N').to_s
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def close
|
|
723
|
+
cleanup = @state.synchronize do
|
|
724
|
+
@closed = true
|
|
725
|
+
@deadline&.stop
|
|
726
|
+
@cleanup ||= Thread.new do
|
|
727
|
+
@task&.thread&.join
|
|
728
|
+
@io.close
|
|
729
|
+
:closed
|
|
730
|
+
rescue Exception => e
|
|
731
|
+
e
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
return :closing unless cleanup.join(0.05)
|
|
735
|
+
|
|
736
|
+
result = cleanup.value
|
|
737
|
+
raise result if result.is_a?(Exception)
|
|
738
|
+
|
|
739
|
+
:closed
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
private
|
|
743
|
+
|
|
744
|
+
def management_serial(deadline)
|
|
745
|
+
selected = exchange(protocol: :apdu, data: ['00a4040008a000000527471117'].pack('H*'), timeout: deadline.remaining)
|
|
746
|
+
return nil unless selected.end_with?("\x90\x00".b)
|
|
747
|
+
|
|
748
|
+
require 'pwn/plugins/yubikey/protocol'
|
|
749
|
+
data = Protocol.apdu(connection: self, ins: 0x1d, timeout: deadline.remaining, max_response: 256)
|
|
750
|
+
raise ResponseError, 'Invalid management identity response length' if data.empty? || data.getbyte(0) != data.bytesize - 1
|
|
751
|
+
|
|
752
|
+
serial = Protocol.tlv_map(data: data.byteslice(1..))[2]
|
|
753
|
+
return nil unless serial
|
|
754
|
+
raise ResponseError, 'Invalid management serial length' unless serial.bytesize == 4
|
|
755
|
+
|
|
756
|
+
number = serial.unpack1('N')
|
|
757
|
+
number.positive? ? number.to_s : nil
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def validate(opts)
|
|
761
|
+
raise Error, 'Connection is closed' if @closed
|
|
762
|
+
|
|
763
|
+
data = opts.fetch(:data, ''.b)
|
|
764
|
+
command = opts[:command]
|
|
765
|
+
raise ArgumentError, 'data must be a String' unless data.is_a?(String)
|
|
766
|
+
raise ArgumentError, 'protocol does not match this connection' unless opts[:protocol] == @descriptor[:protocol]
|
|
767
|
+
|
|
768
|
+
%i[cancelled on_keepalive].each do |key|
|
|
769
|
+
raise ArgumentError, "#{key} must be callable" unless opts[key].nil? || opts[key].respond_to?(:call)
|
|
770
|
+
end
|
|
771
|
+
case @descriptor[:protocol]
|
|
772
|
+
when :apdu
|
|
773
|
+
raise ArgumentError, 'C-APDU length must be in 4..65544' unless data.bytesize.between?(4, 65_544)
|
|
774
|
+
when :ctap
|
|
775
|
+
raise ArgumentError, 'CTAPHID command must be an integer in 1..127' unless command.is_a?(Integer) && command.between?(1, 127)
|
|
776
|
+
raise ArgumentError, 'CTAPHID control command is reserved for the transport' if [0x06, 0x11, 0x3b, 0x3f].include?(command)
|
|
777
|
+
raise ArgumentError, 'CTAPHID payload exceeds 7609 bytes' if data.bytesize > CTAP::MAX_PAYLOAD
|
|
778
|
+
when :otp
|
|
779
|
+
raise ArgumentError, 'OTP command must be a byte' unless command.is_a?(Integer) && command.between?(0, 255)
|
|
780
|
+
raise ArgumentError, 'OTP payload exceeds 64 bytes' if data.bytesize > 64
|
|
781
|
+
raise ArgumentError, 'OTP status command takes no data' if command.zero? && !data.empty?
|
|
782
|
+
|
|
783
|
+
expected = opts[:response_length]
|
|
784
|
+
raise ArgumentError, 'OTP response_length must be in 0..222' unless expected.nil? || (expected.is_a?(Integer) && expected.between?(0, 222))
|
|
785
|
+
end
|
|
786
|
+
data.b
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
APPLICATIONS = %i[otp oath piv openpgp fido management hsmauth security_domain].freeze
|
|
791
|
+
BINDING_LOCK = Mutex.new
|
|
792
|
+
|
|
793
|
+
public_class_method def self.available?(opts = {})
|
|
794
|
+
application = opts[:application]
|
|
795
|
+
raise ArgumentError, 'Unknown YubiKey application' unless application.nil? || APPLICATIONS.include?(application)
|
|
796
|
+
raise ArgumentError, 'backend must be :hid or :pcsc' unless opts[:backend].nil? || %i[hid pcsc].include?(opts[:backend])
|
|
797
|
+
|
|
798
|
+
kinds = if opts[:backend]
|
|
799
|
+
[opts[:backend]]
|
|
800
|
+
elsif application && !%i[otp fido management].include?(application)
|
|
801
|
+
[:pcsc]
|
|
802
|
+
else
|
|
803
|
+
%i[hid pcsc]
|
|
804
|
+
end
|
|
805
|
+
kinds.any? do |kind|
|
|
806
|
+
native(kind: kind)
|
|
807
|
+
true
|
|
808
|
+
rescue DependencyError
|
|
809
|
+
false
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
public_class_method def self.devices(opts = {})
|
|
814
|
+
validate_selection(opts)
|
|
815
|
+
deadline = Deadline.new(opts.fetch(:timeout, 30))
|
|
816
|
+
kinds = if opts[:backend]
|
|
817
|
+
[opts[:backend]]
|
|
818
|
+
else
|
|
819
|
+
(opts[:reader] ? [:pcsc] : %i[hid pcsc])
|
|
820
|
+
end
|
|
821
|
+
missing = []
|
|
822
|
+
records = kinds.flat_map do |kind|
|
|
823
|
+
binding = native(kind: kind)
|
|
824
|
+
entries = NativeTask.new(deadline).start { binding.devices }.await
|
|
825
|
+
entries.select do |entry|
|
|
826
|
+
(!opts[:serial] || entry[:serial] == opts[:serial]) &&
|
|
827
|
+
(!opts[:reader] || entry[:reader] == opts[:reader]) &&
|
|
828
|
+
(!opts[:device_path] || entry[:device_path] == opts[:device_path])
|
|
829
|
+
end
|
|
830
|
+
rescue DependencyError => e
|
|
831
|
+
missing << e
|
|
832
|
+
[]
|
|
833
|
+
end
|
|
834
|
+
raise missing.first if missing.length == kinds.length
|
|
835
|
+
|
|
836
|
+
records.uniq { |entry| [entry[:backend], entry[:protocol], entry[:device_path], entry[:reader]] }
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
public_class_method def self.open(opts = {})
|
|
840
|
+
validate_selection(opts)
|
|
841
|
+
application = opts[:application]
|
|
842
|
+
raise ArgumentError, 'application must name a supported YubiKey application' unless APPLICATIONS.include?(application)
|
|
843
|
+
|
|
844
|
+
deadline = Deadline.new(opts.fetch(:timeout, 30))
|
|
845
|
+
order = if opts[:reader]
|
|
846
|
+
[:pcsc]
|
|
847
|
+
elsif opts[:device_path]
|
|
848
|
+
[:hid]
|
|
849
|
+
elsif %i[otp fido].include?(application)
|
|
850
|
+
%i[hid pcsc]
|
|
851
|
+
else
|
|
852
|
+
%i[pcsc hid]
|
|
853
|
+
end
|
|
854
|
+
protocols = case application
|
|
855
|
+
when :otp then %i[otp apdu]
|
|
856
|
+
when :fido then %i[ctap apdu]
|
|
857
|
+
when :management then %i[apdu otp ctap]
|
|
858
|
+
else [:apdu]
|
|
859
|
+
end
|
|
860
|
+
missing = []
|
|
861
|
+
order.each do |kind|
|
|
862
|
+
binding = native(kind: kind)
|
|
863
|
+
entries = NativeTask.new(deadline).start { binding.devices }.await
|
|
864
|
+
entries = entries.select do |entry|
|
|
865
|
+
entry[:backend] == kind && protocols.include?(entry[:protocol]) &&
|
|
866
|
+
(!opts[:serial] || entry[:serial].nil? || entry[:serial].empty? || entry[:serial] == opts[:serial]) &&
|
|
867
|
+
(!opts[:device_path] || entry[:device_path] == opts[:device_path]) &&
|
|
868
|
+
(opts[:reader] ? entry[:reader] == opts[:reader] : (kind != :pcsc || entry[:reader].match?(/yubikey/i)))
|
|
869
|
+
end
|
|
870
|
+
next if entries.empty?
|
|
871
|
+
|
|
872
|
+
# Several application interfaces on one key are still not an excuse
|
|
873
|
+
# to choose an arbitrary endpoint; prefer the documented protocol.
|
|
874
|
+
preferred = protocols.find { |protocol| entries.any? { |entry| entry[:protocol] == protocol } }
|
|
875
|
+
entries = entries.select { |entry| entry[:protocol] == preferred }
|
|
876
|
+
if opts[:serial] && entries.any? { |entry| entry[:serial].nil? || entry[:serial].empty? }
|
|
877
|
+
connection = select_serial(binding: binding, entries: entries, serial: opts[:serial], deadline: deadline, timeout: opts.fetch(:timeout, 30))
|
|
878
|
+
return connection if connection
|
|
879
|
+
|
|
880
|
+
next
|
|
881
|
+
end
|
|
882
|
+
raise Error, 'Multiple matching YubiKeys; specify exact serial, device_path or reader' unless entries.length == 1
|
|
883
|
+
|
|
884
|
+
return acquire_connection(binding: binding, descriptor: entries.first, deadline: deadline, timeout: opts.fetch(:timeout, 30))
|
|
885
|
+
rescue DependencyError => e
|
|
886
|
+
missing << e
|
|
887
|
+
end
|
|
888
|
+
raise missing.first if missing.length == order.length
|
|
889
|
+
|
|
890
|
+
raise Error, 'No matching YubiKey; check application mode, exact selectors, permissions, or specify the NFC reader'
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
public_class_method def self.authors(opts = {})
|
|
894
|
+
opts[:format]
|
|
895
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
public_class_method def self.help(opts = {})
|
|
899
|
+
opts[:format]
|
|
900
|
+
puts <<~HELP
|
|
901
|
+
# Probe native shared libraries without device initialization or discovery.
|
|
902
|
+
#{self}.available?(
|
|
903
|
+
backend: 'optional - :hid or :pcsc; omitted accepts either suitable native library',
|
|
904
|
+
application: 'optional - application Symbol; APDU-only applications require the PC/SC library'
|
|
905
|
+
)
|
|
906
|
+
# Enumerate interface descriptors without connecting to smartcards.
|
|
907
|
+
#{self}.devices(
|
|
908
|
+
backend: 'optional - :hid or :pcsc; defaults to both available libraries',
|
|
909
|
+
serial: 'optional - exact observed HID serial String; reader serials are unknown until opened',
|
|
910
|
+
reader: 'optional - exact PC/SC reader String for USB smartcard or NFC',
|
|
911
|
+
device_path: 'optional - exact HIDAPI path String, including serial-less keys',
|
|
912
|
+
timeout: 'optional - finite positive seconds, defaults to 30'
|
|
913
|
+
)
|
|
914
|
+
# Open one unambiguously selected native YubiKey application connection.
|
|
915
|
+
#{self}.open(
|
|
916
|
+
application: 'required - :otp, :oath, :piv, :openpgp, :fido, :management, :hsmauth or :security_domain',
|
|
917
|
+
serial: 'optional - exact serial String; never coerced, trimmed or normalized',
|
|
918
|
+
reader: 'optional - exact PC/SC reader String; forces smartcard transport',
|
|
919
|
+
device_path: 'optional - exact HIDAPI path String; mutually exclusive with reader',
|
|
920
|
+
timeout: 'optional - finite positive discovery and opening deadline in seconds, defaults to 30'
|
|
921
|
+
)
|
|
922
|
+
Connection#descriptor returns protocol :otp, :ctap or :apdu.
|
|
923
|
+
Connection#exchange accepts protocol:, binary String data:, timeout: and optional cancelled: callable.
|
|
924
|
+
OTP adds command: byte, response_length: optional payload byte count, on_keepalive: callback.
|
|
925
|
+
OTP command 0 reads six status bytes without issuing a command. Other replies have CRC removed/checked.
|
|
926
|
+
CTAP adds command: 1..127 (transport control commands reserved), on_keepalive: callback.
|
|
927
|
+
CTAPHID INIT is completed by open; CBOR response retains its CTAP status byte.
|
|
928
|
+
APDU returns the entire R-APDU including SW1/SW2. No automatic authentication, retry or logging occurs.
|
|
929
|
+
Close in ensure. Connection#close is idempotent; :closing means deferred native cleanup.
|
|
930
|
+
A timed-out blocking OS driver keeps its owned thread, buffers and handle until C returns.
|
|
931
|
+
PC/SC SCardCancel does not portably abort SCardTransmit; no thread is killed inside C.
|
|
932
|
+
# Return module author information.
|
|
933
|
+
#{self}.authors(format: 'optional - reserved output format')
|
|
934
|
+
HELP
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
private_class_method def self.validate_selection(opts = {})
|
|
938
|
+
Deadline.new(opts.fetch(:timeout, 30))
|
|
939
|
+
%i[serial reader device_path].each do |key|
|
|
940
|
+
value = opts[key]
|
|
941
|
+
next if value.nil?
|
|
942
|
+
raise ArgumentError, "#{key} must be a nonempty exact String without NUL" unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
943
|
+
end
|
|
944
|
+
raise ArgumentError, 'reader and device_path cannot be combined' if opts[:reader] && opts[:device_path]
|
|
945
|
+
raise ArgumentError, 'backend must be :hid or :pcsc' unless opts[:backend].nil? || %i[hid pcsc].include?(opts[:backend])
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
private_class_method def self.select_serial(opts = {})
|
|
949
|
+
opened = []
|
|
950
|
+
matches = opts[:entries].filter_map do |entry|
|
|
951
|
+
conn = acquire_connection(binding: opts[:binding], descriptor: entry, deadline: opts[:deadline], timeout: opts[:timeout])
|
|
952
|
+
opened << conn
|
|
953
|
+
observed = entry[:serial]
|
|
954
|
+
observed = conn.identify_serial(opts[:deadline]) if observed.nil? || observed.empty?
|
|
955
|
+
observed == opts[:serial] ? conn : nil
|
|
956
|
+
end
|
|
957
|
+
raise Error, 'Multiple matching YubiKey serials; specify reader or device_path' if matches.length > 1
|
|
958
|
+
|
|
959
|
+
chosen = matches.first
|
|
960
|
+
chosen
|
|
961
|
+
rescue Exception => e
|
|
962
|
+
primary = e
|
|
963
|
+
raise
|
|
964
|
+
ensure
|
|
965
|
+
cleanup_error = nil
|
|
966
|
+
opened&.each do |conn|
|
|
967
|
+
next if conn.equal?(chosen)
|
|
968
|
+
|
|
969
|
+
begin
|
|
970
|
+
conn.close
|
|
971
|
+
rescue Exception => e
|
|
972
|
+
cleanup_error ||= e
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
if cleanup_error && !primary
|
|
976
|
+
begin
|
|
977
|
+
chosen&.close
|
|
978
|
+
rescue Exception
|
|
979
|
+
nil
|
|
980
|
+
end
|
|
981
|
+
raise cleanup_error
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
private_class_method def self.acquire_connection(opts = {})
|
|
986
|
+
io = nil
|
|
987
|
+
deadline = opts[:deadline]
|
|
988
|
+
task = NativeTask.new(deadline).start do
|
|
989
|
+
io = opts[:binding].open(opts[:descriptor])
|
|
990
|
+
Connection.new(descriptor: opts[:descriptor], io: io, timeout: opts[:timeout], prepare: deadline)
|
|
991
|
+
end
|
|
992
|
+
task.await
|
|
993
|
+
rescue Exception
|
|
994
|
+
deadline&.stop
|
|
995
|
+
Thread.new do
|
|
996
|
+
task&.thread&.join
|
|
997
|
+
io&.close
|
|
998
|
+
rescue StandardError
|
|
999
|
+
nil
|
|
1000
|
+
end
|
|
1001
|
+
raise
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
private_class_method def self.native(opts = {})
|
|
1005
|
+
kind = opts[:kind]
|
|
1006
|
+
raise ArgumentError, 'backend must be :hid or :pcsc' unless %i[hid pcsc].include?(kind)
|
|
1007
|
+
|
|
1008
|
+
BINDING_LOCK.synchronize do
|
|
1009
|
+
@bindings ||= {}
|
|
1010
|
+
@bindings[kind] ||= NativeLibrary.new(kind)
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
# rubocop:enable Lint/RescueException
|