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,686 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'pwn/plugins/yubikey/protocol'
|
|
5
|
+
|
|
6
|
+
module PWN
|
|
7
|
+
module Plugins
|
|
8
|
+
module YubiKey
|
|
9
|
+
# Native device configuration, capabilities and legacy USB mode protocol.
|
|
10
|
+
# Reference: Yubico yubikit.management 5.9.1 (wire-format specification).
|
|
11
|
+
module Management
|
|
12
|
+
AID = ['a000000527471117'].pack('H*').freeze
|
|
13
|
+
OTP_AID = ['a0000005272001'].pack('H*').freeze
|
|
14
|
+
CAPABILITIES = { 'OTP' => 0x01, 'U2F' => 0x02, 'OPENPGP' => 0x08, 'PIV' => 0x10, 'OATH' => 0x20, 'HSMAUTH' => 0x100, 'FIDO2' => 0x200 }.freeze
|
|
15
|
+
MODES = [1, 4, 5, 2, 3, 6, 7].freeze
|
|
16
|
+
|
|
17
|
+
public_class_method def self.info(opts = {})
|
|
18
|
+
session = begin_session(connection: opts[:connection], timeout: opts.fetch(:timeout, 30))
|
|
19
|
+
read_information(session: session)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
GRAMMAR = {
|
|
23
|
+
'usb' => [0, %w[--force --list --enable-all --touch-eject --no-touch-eject], %w[--enable --disable --autoeject-timeout --chalresp-timeout]],
|
|
24
|
+
'nfc' => [0, %w[--force --list --enable-all --disable-all --restrict], %w[--enable --disable]],
|
|
25
|
+
'mode' => [1, %w[--force --touch-eject], %w[--autoeject-timeout --chalresp-timeout]],
|
|
26
|
+
'set-lock-code' => [0, %w[--force --clear --generate], []],
|
|
27
|
+
'reset' => [0, %w[--force], []]
|
|
28
|
+
}.freeze
|
|
29
|
+
ROUTES = { 'usb' => :configure_usb, 'nfc' => :configure_nfc, 'mode' => :set_mode, 'set-lock-code' => :set_lock_code, 'reset' => :reset }.freeze
|
|
30
|
+
|
|
31
|
+
public_class_method def self.operations(opts = {})
|
|
32
|
+
opts[:format]
|
|
33
|
+
GRAMMAR.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
public_class_method def self.execute(opts = {})
|
|
37
|
+
args = opts[:args] || []
|
|
38
|
+
raise ArgumentError, 'args must be an Array of nonsecret Strings' unless args.is_a?(Array) && args.all?(String)
|
|
39
|
+
return { application: :management, operations: operations, secrets: :in_memory_only } if args.empty? || args == ['--help']
|
|
40
|
+
|
|
41
|
+
grammar = GRAMMAR[args.first]
|
|
42
|
+
raise ArgumentError, 'Unknown device configuration operation' unless grammar
|
|
43
|
+
|
|
44
|
+
parsed = Protocol.parse_args(args: args.drop(1), flags: grammar[1], values: grammar[2])
|
|
45
|
+
raise ArgumentError, 'Invalid management positional argument count' unless parsed[:positionals].length == grammar[0]
|
|
46
|
+
raise ArgumentError, 'Only enable and disable options can be repeated' if parsed[:options].any? { |key, value| value.is_a?(Array) && !%i[enable disable].include?(key) }
|
|
47
|
+
|
|
48
|
+
selected = opts.merge(parsed[:options])
|
|
49
|
+
selected[:mode] = parsed[:positionals].first if args.first == 'mode'
|
|
50
|
+
public_send(ROUTES.fetch(args.first), selected)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
public_class_method def self.configure_nfc(opts = {})
|
|
54
|
+
configure_applications(opts.merge(transport: :nfc, connection: opts[:connection]))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
public_class_method def self.set_lock_code(opts = {})
|
|
58
|
+
consent(opts.merge(confirm: opts[:confirm]))
|
|
59
|
+
choices = [opts[:clear], opts[:generate], !opts[:new_lock_code].nil?].count { |value| value }
|
|
60
|
+
raise ArgumentError, 'Specify only one of clear, generate or new_lock_code' if choices > 1
|
|
61
|
+
|
|
62
|
+
code = if opts[:clear]
|
|
63
|
+
"\0".b * 16
|
|
64
|
+
elsif opts[:generate]
|
|
65
|
+
raise ArgumentError, 'Generated lock code requires an explicit secret_sink callable' unless opts[:secret_sink].respond_to?(:call)
|
|
66
|
+
|
|
67
|
+
value = SecureRandom.random_bytes(16)
|
|
68
|
+
opts[:secret_sink].call(:new_lock_code, value.dup)
|
|
69
|
+
value
|
|
70
|
+
else
|
|
71
|
+
secret_value(opts.merge(value: opts[:new_lock_code], name: :new_lock_code))
|
|
72
|
+
end
|
|
73
|
+
raise ArgumentError, 'new_lock_code must contain exactly 16 raw bytes' unless code.bytesize == 16
|
|
74
|
+
|
|
75
|
+
session = begin_session(opts)
|
|
76
|
+
before = read_information(session: session)
|
|
77
|
+
require_version(version: before[:version], minimum: [5, 0, 0])
|
|
78
|
+
write_settings(opts.merge(session: session, before: before, changes: { 0x0a => code }, reboot: false))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
public_class_method def self.configure_usb(opts = {})
|
|
82
|
+
configure_applications(opts.merge(transport: :usb, connection: opts[:connection]))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
public_class_method def self.set_mode(opts = {})
|
|
86
|
+
consent(opts.merge(confirm: opts[:confirm]))
|
|
87
|
+
chalresp = numeric(value: opts.fetch(:chalresp_timeout, 0), maximum: 255)
|
|
88
|
+
autoeject = numeric(value: opts.fetch(:autoeject_timeout, 0), maximum: 65_535)
|
|
89
|
+
session = begin_session(opts)
|
|
90
|
+
before = read_information(session: session)
|
|
91
|
+
require_version(version: before[:version], minimum: [3, 0, 0])
|
|
92
|
+
current = before[:enabled_capabilities][:usb] ? interfaces(mask: before[:enabled_capabilities][:usb]) : before[:usb_interfaces]
|
|
93
|
+
requested = parse_mode(value: opts[:mode], current: current)
|
|
94
|
+
eject = opts[:touch_eject] || autoeject.positive?
|
|
95
|
+
raise ArgumentError, 'Touch eject requires CCID-only mode' if eject && requested != 4
|
|
96
|
+
|
|
97
|
+
if (before[:version] <=> [5, 0, 0]) >= 0
|
|
98
|
+
supported = before[:supported_capabilities][:usb]
|
|
99
|
+
raise CommandError, 'Requested USB interface is not supported' unless requested.nobits?(~interfaces(mask: supported))
|
|
100
|
+
raise CommandError, 'Applications can only be toggled on this device after reset' if before[:reset_blocked].positive?
|
|
101
|
+
|
|
102
|
+
desired = 0
|
|
103
|
+
desired |= 1 if requested.anybits?(1)
|
|
104
|
+
desired |= 0x202 if requested.anybits?(2)
|
|
105
|
+
desired |= 0x538 if requested.anybits?(4)
|
|
106
|
+
changes = { 3 => [desired & supported].pack('n') }
|
|
107
|
+
changes[7] = [chalresp].pack('C') if opts.key?(:chalresp_timeout)
|
|
108
|
+
changes[6] = [autoeject].pack('n') if opts.key?(:autoeject_timeout)
|
|
109
|
+
changes[8] = [before[:device_flags].to_i | 0x80].pack('C') if eject
|
|
110
|
+
write_settings(opts.merge(session: session, before: before, changes: changes, reboot: opts.fetch(:reboot, current != requested)))
|
|
111
|
+
else
|
|
112
|
+
raise ArgumentError, 'Legacy mode changes require reconnect after physical reinsertion for verification' unless opts[:reconnect].respond_to?(:call)
|
|
113
|
+
raise CommandError, 'Requested USB interface is not supported' if before[:supported_capabilities][:usb] && requested.anybits?(~interfaces(mask: before[:supported_capabilities][:usb]))
|
|
114
|
+
|
|
115
|
+
code = MODES.index(requested) | (eject ? 0x80 : 0)
|
|
116
|
+
begin
|
|
117
|
+
request(session: session, operation: :mode, data: [code, chalresp, autoeject].pack('CCv'))
|
|
118
|
+
fresh = opts[:reconnect].call(before.except(:raw_fields))
|
|
119
|
+
raise ResponseError, 'Legacy mode readback requires a fresh connection' if fresh.equal?(opts[:connection])
|
|
120
|
+
|
|
121
|
+
after = info(connection: fresh, timeout: opts.fetch(:timeout, 30))
|
|
122
|
+
verify_identity(before: before, after: after, reconnected: true)
|
|
123
|
+
raise ResponseError, 'Legacy USB mode readback mismatch' unless after[:usb_interfaces] == requested
|
|
124
|
+
|
|
125
|
+
{ verified: true, verification: :usb_descriptor, usb_interfaces: requested, info: after, timeouts_readback: false }
|
|
126
|
+
rescue StandardError
|
|
127
|
+
raise ResponseError, 'Legacy mode write state uncertain: reinsert and inspect the exact device; do not replay', cause: nil
|
|
128
|
+
ensure
|
|
129
|
+
fresh.close if fresh && !fresh.equal?(opts[:connection]) && fresh.respond_to?(:close)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
public_class_method def self.reset(opts = {})
|
|
135
|
+
consent(opts.merge(confirm: opts[:confirm]))
|
|
136
|
+
session = begin_session(opts)
|
|
137
|
+
raise CommandError, 'Full device reset is supported only over smartcard transport' unless session[:protocol] == :apdu
|
|
138
|
+
|
|
139
|
+
before = read_information(session: session)
|
|
140
|
+
bio_mpe = [6, 7].include?(before[:form_factor]) && before[:supported_capabilities].values.reduce(0, :|).anybits?(0x10)
|
|
141
|
+
raise CommandError, 'Full device reset is not supported by this device; use application-specific resets' unless bio_mpe || before[:reset_blocked].positive?
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
request(session: session, operation: :reset)
|
|
145
|
+
after = read_information(session: session)
|
|
146
|
+
verify_identity(before: before, after: after)
|
|
147
|
+
raise ResponseError, 'Reset-state metadata is absent or not cleared' unless after[:raw_fields].key?(0x18) && after[:reset_blocked].zero? && after[:is_locked] == false
|
|
148
|
+
|
|
149
|
+
{ verified: true, verification: :reset_state_metadata, data_erasure_verified: false, info: after }
|
|
150
|
+
rescue StandardError
|
|
151
|
+
raise ResponseError, 'Device reset state uncertain: reset-state readback failed; do not automatically reset again', cause: nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
public_class_method def self.reboot(opts = {})
|
|
156
|
+
consent(opts.merge(confirm: opts[:confirm]))
|
|
157
|
+
session = begin_session(opts)
|
|
158
|
+
before = read_information(session: session)
|
|
159
|
+
require_version(version: before[:version], minimum: [5, 0, 0])
|
|
160
|
+
write_settings(opts.merge(session: session, before: before, changes: {}, reboot: true))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private_class_method def self.parse_mode(opts = {})
|
|
164
|
+
value = opts[:value].to_s.upcase
|
|
165
|
+
return MODES[value.to_i] if value.match?(/\A[0-6]\z/)
|
|
166
|
+
|
|
167
|
+
names = { 'O' => 1, 'OTP' => 1, 'F' => 2, 'FIDO' => 2, 'C' => 4, 'CCID' => 4 }
|
|
168
|
+
if value.start_with?('+', '-')
|
|
169
|
+
raise ArgumentError, 'Current interfaces unavailable for relative mode changes' unless opts[:current]
|
|
170
|
+
raise ArgumentError, 'Invalid relative USB mode syntax' unless value.match?(/\A(?:[+-](?:OTP|FIDO|CCID|O|F|C))+\z/)
|
|
171
|
+
|
|
172
|
+
mask = opts[:current]
|
|
173
|
+
value.scan(/([+-])([A-Z]+)/).each { |sign, name| mask = sign == '+' ? mask | names.fetch(name) : mask & ~names.fetch(name) }
|
|
174
|
+
else
|
|
175
|
+
raise ArgumentError, 'Invalid USB mode syntax' unless value.match?(/\A(?:OTP|FIDO|CCID|O|F|C)(?:\+(?:OTP|FIDO|CCID|O|F|C))*\z/)
|
|
176
|
+
|
|
177
|
+
mask = value.split('+').reduce(0) { |bits, name| bits | names.fetch(name) }
|
|
178
|
+
end
|
|
179
|
+
raise ArgumentError, 'At least one USB interface must remain enabled' unless MODES.include?(mask)
|
|
180
|
+
|
|
181
|
+
mask
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private_class_method def self.observed_interfaces(opts = {})
|
|
185
|
+
pid = opts[:descriptor][:product_id]
|
|
186
|
+
return pid & 7 if pid.is_a?(Integer) && pid.between?(0x0401, 0x0407)
|
|
187
|
+
|
|
188
|
+
{ 0x0010 => 1, 0x0110 => 1, 0x0111 => 5, 0x0112 => 4, 0x0113 => 2, 0x0114 => 3, 0x0115 => 6, 0x0116 => 7, 0x0120 => 2, 0x0410 => 3 }[pid]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private_class_method def self.configure_applications(opts = {})
|
|
192
|
+
transport = opts[:transport]
|
|
193
|
+
modifiers = %i[enable disable enable_all disable_all touch_eject no_touch_eject autoeject_timeout chalresp_timeout restrict]
|
|
194
|
+
changes_requested = modifiers.any? { |key| opts.key?(key) && opts[key] != false && opts[key] != [] && !opts[key].nil? }
|
|
195
|
+
raise ArgumentError, 'List cannot be combined with configuration changes' if opts[:list] && changes_requested
|
|
196
|
+
raise ArgumentError, 'No configuration options chosen' unless opts[:list] || changes_requested
|
|
197
|
+
|
|
198
|
+
if opts[:list]
|
|
199
|
+
result = info(opts)
|
|
200
|
+
mask = result[:enabled_capabilities][transport]
|
|
201
|
+
raise CommandError, 'Enabled capabilities unavailable for this transport' unless mask
|
|
202
|
+
|
|
203
|
+
return { transport: transport, enabled: CAPABILITIES.filter_map { |name, bit| name if mask.anybits?(bit) }, mask: mask }
|
|
204
|
+
end
|
|
205
|
+
consent(opts.merge(confirm: opts[:confirm]))
|
|
206
|
+
enable = capability_mask(value: opts.fetch(:enable, []))
|
|
207
|
+
disable = capability_mask(value: opts.fetch(:disable, []))
|
|
208
|
+
raise ArgumentError, 'Conflicting enable and disable options' if enable.anybits?(disable) || (opts[:enable_all] && opts[:disable_all])
|
|
209
|
+
raise ArgumentError, 'Conflicting touch-eject options' if opts[:touch_eject] && opts[:no_touch_eject]
|
|
210
|
+
|
|
211
|
+
changes = {}
|
|
212
|
+
changes[6] = [numeric(value: opts[:autoeject_timeout], maximum: 65_535)].pack('n') if opts.key?(:autoeject_timeout)
|
|
213
|
+
changes[7] = [numeric(value: opts[:chalresp_timeout], maximum: 255)].pack('C') if opts.key?(:chalresp_timeout)
|
|
214
|
+
session = begin_session(opts)
|
|
215
|
+
before = read_information(session: session)
|
|
216
|
+
require_version(version: before[:version], minimum: [5, 0, 0])
|
|
217
|
+
supported = before[:supported_capabilities][transport]
|
|
218
|
+
enabled = before[:enabled_capabilities][transport]
|
|
219
|
+
raise CommandError, 'This transport is not supported or lacks capability metadata' unless supported && enabled
|
|
220
|
+
|
|
221
|
+
if opts[:restrict]
|
|
222
|
+
raise ArgumentError, 'restrict is valid only for NFC and conflicts with capability changes' unless transport == :nfc && enable.zero? && disable.zero? && !opts[:enable_all] && !opts[:disable_all]
|
|
223
|
+
|
|
224
|
+
require_version(version: before[:version], minimum: [5, 7, 0])
|
|
225
|
+
reconnect = opts[:connection].descriptor[:transport] == :nfc
|
|
226
|
+
return write_settings(opts.merge(session: session, before: before, changes: { 0x17 => "\1".b }, reconnect_required: reconnect, reboot: false))
|
|
227
|
+
end
|
|
228
|
+
raise CommandError, 'Applications can only be toggled on this device after reset' if before[:reset_blocked].positive?
|
|
229
|
+
|
|
230
|
+
enable = supported if opts[:enable_all]
|
|
231
|
+
disable = supported if opts[:disable_all]
|
|
232
|
+
raise ArgumentError, 'Conflicting enable and disable options' if enable.anybits?(disable)
|
|
233
|
+
raise CommandError, 'Requested application is not supported on this transport' if (enable | disable).anybits?(~supported)
|
|
234
|
+
|
|
235
|
+
desired = (enabled | enable) & ~disable
|
|
236
|
+
raise ArgumentError, 'Cannot disable all USB applications' if transport == :usb && desired.nobits?(CAPABILITIES.values.reduce(:|))
|
|
237
|
+
|
|
238
|
+
changes[transport == :usb ? 3 : 0x0e] = [desired].pack('n')
|
|
239
|
+
if opts[:touch_eject] || opts[:no_touch_eject]
|
|
240
|
+
raise ResponseError, 'Missing device flag metadata' if before[:device_flags].nil?
|
|
241
|
+
|
|
242
|
+
flags = opts[:touch_eject] ? before[:device_flags] | 0x80 : before[:device_flags] & ~0x80
|
|
243
|
+
changes[8] = [flags].pack('C')
|
|
244
|
+
end
|
|
245
|
+
reboot = opts.fetch(:reboot, transport == :usb && interfaces(mask: enabled) != interfaces(mask: desired))
|
|
246
|
+
write_settings(opts.merge(session: session, before: before, changes: changes, reboot: reboot))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private_class_method def self.write_settings(opts = {})
|
|
250
|
+
before = opts[:before]
|
|
251
|
+
changes = opts[:changes]
|
|
252
|
+
reboot = opts.fetch(:reboot, false)
|
|
253
|
+
raise ArgumentError, 'reboot must be true or false' unless [true, false].include?(reboot)
|
|
254
|
+
|
|
255
|
+
reconnect = reboot || opts[:reconnect_required]
|
|
256
|
+
raise ArgumentError, 'Disconnecting configuration changes require an explicit reconnect callable for readback' if reconnect && !opts[:reconnect].respond_to?(:call)
|
|
257
|
+
|
|
258
|
+
raise ResponseError, 'Configuration lock metadata is unavailable; no write attempted' if before[:is_locked].nil?
|
|
259
|
+
|
|
260
|
+
unlock = opts[:lock_code]
|
|
261
|
+
if before[:is_locked]
|
|
262
|
+
unlock = secret_value(opts.merge(value: unlock, name: :lock_code))
|
|
263
|
+
elsif unlock
|
|
264
|
+
raise ArgumentError, 'Device is not locked; do not supply lock_code'
|
|
265
|
+
end
|
|
266
|
+
raise ArgumentError, 'lock_code must contain exactly 16 raw bytes' if unlock && (!unlock.is_a?(String) || unlock.bytesize != 16)
|
|
267
|
+
|
|
268
|
+
body = ''.b
|
|
269
|
+
body << Protocol.tlv(tag: 0x0c, value: ''.b) if reboot
|
|
270
|
+
body << Protocol.tlv(tag: 0x0b, value: unlock.b) if unlock
|
|
271
|
+
changes.each { |tag, value| body << Protocol.tlv(tag: tag, value: value) }
|
|
272
|
+
raise ArgumentError, 'Management configuration exceeds protocol bounds' if body.bytesize > 255 || (opts[:session][:protocol] == :otp && body.bytesize > 63)
|
|
273
|
+
|
|
274
|
+
begin
|
|
275
|
+
request(session: opts[:session], operation: :write, data: [body.bytesize].pack('C') + body)
|
|
276
|
+
if reconnect
|
|
277
|
+
fresh = opts[:reconnect].call(before.except(:raw_fields))
|
|
278
|
+
raise ResponseError, 'Reconnect must return a fresh native connection' if fresh.equal?(opts[:session][:connection])
|
|
279
|
+
|
|
280
|
+
after = info(connection: fresh, timeout: opts[:session][:timeout])
|
|
281
|
+
else
|
|
282
|
+
after = read_information(session: opts[:session])
|
|
283
|
+
end
|
|
284
|
+
verify_identity(before: before, after: after, reconnected: reconnect)
|
|
285
|
+
changes.each do |tag, value|
|
|
286
|
+
matches = if tag == 0x0a
|
|
287
|
+
after[:is_locked] == value.bytes.any?(&:positive?)
|
|
288
|
+
else
|
|
289
|
+
after[:raw_fields][tag] == value
|
|
290
|
+
end
|
|
291
|
+
raise ResponseError, 'Device configuration readback mismatch' unless matches
|
|
292
|
+
end
|
|
293
|
+
{ verified: true, verification: :device_information, info: after, reconnected: !!reconnect, lock_secret_readback: false }
|
|
294
|
+
rescue StandardError
|
|
295
|
+
raise ResponseError, 'Management write state uncertain: acknowledgment or readback failed; inspect the exact device, do not replay', cause: nil
|
|
296
|
+
ensure
|
|
297
|
+
fresh.close if fresh && !fresh.equal?(opts[:session][:connection]) && fresh.respond_to?(:close)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private_class_method def self.verify_identity(opts = {})
|
|
302
|
+
before = opts[:before]
|
|
303
|
+
after = opts[:after]
|
|
304
|
+
raise ResponseError, 'Readback device identity changed' unless before[:serial] == after[:serial] && before[:version] == after[:version]
|
|
305
|
+
raise ResponseError, 'Serial-less readback requires the same observed explicit selector' if opts[:reconnected] && before[:serial].nil? && (before[:identity].nil? || before[:identity].empty? || before[:identity] != after[:identity])
|
|
306
|
+
|
|
307
|
+
:matched
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
private_class_method def self.capability_mask(opts = {})
|
|
311
|
+
values = opts[:value]
|
|
312
|
+
values = [values] if values.is_a?(String) || values.is_a?(Symbol)
|
|
313
|
+
raise ArgumentError, 'Capabilities must be names in an Array' unless values.is_a?(Array)
|
|
314
|
+
|
|
315
|
+
values.reduce(0) do |mask, name|
|
|
316
|
+
bit = CAPABILITIES[name.to_s.upcase]
|
|
317
|
+
raise ArgumentError, 'Unknown application capability' unless bit
|
|
318
|
+
|
|
319
|
+
mask | bit
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private_class_method def self.interfaces(opts = {})
|
|
324
|
+
mask = opts[:mask]
|
|
325
|
+
value = mask.anybits?(1) ? 1 : 0
|
|
326
|
+
value |= 2 if mask.anybits?(0x202)
|
|
327
|
+
value |= 4 if mask.anybits?(0x53c)
|
|
328
|
+
value
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private_class_method def self.numeric(opts = {})
|
|
332
|
+
value = opts[:value]
|
|
333
|
+
raise ArgumentError, 'Numeric option must be an unsigned decimal integer' unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
|
|
334
|
+
raise ArgumentError, 'Numeric option is outside the supported range' unless value.to_i.between?(0, opts[:maximum])
|
|
335
|
+
|
|
336
|
+
value.to_i
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
private_class_method def self.secret_value(opts = {})
|
|
340
|
+
value = opts[:value]
|
|
341
|
+
value = opts[:secret_provider].call(opts[:name]) if value.nil? && opts[:secret_provider].respond_to?(:call)
|
|
342
|
+
raise ArgumentError, "#{opts[:name]} must be supplied in memory" unless value.is_a?(String) && !value.empty?
|
|
343
|
+
|
|
344
|
+
value.b
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private_class_method def self.consent(opts = {})
|
|
348
|
+
raise ConfirmationRequired, 'Device configuration requires confirm: true' unless opts[:confirm] == true
|
|
349
|
+
|
|
350
|
+
%i[enable_all disable_all touch_eject no_touch_eject restrict clear generate reboot force list].each do |key|
|
|
351
|
+
raise ArgumentError, "#{key} must be true or false" if opts.key?(key) && ![true, false].include?(opts[key])
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
:confirmed
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private_class_method def self.begin_session(opts = {})
|
|
358
|
+
connection = opts[:connection]
|
|
359
|
+
timeout = opts.fetch(:timeout, 30)
|
|
360
|
+
raise ArgumentError, 'connection must implement descriptor and exchange' unless connection.respond_to?(:descriptor) && connection.respond_to?(:exchange)
|
|
361
|
+
raise ArgumentError, 'timeout must be finite positive seconds' unless (timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?
|
|
362
|
+
|
|
363
|
+
session = { connection: connection, protocol: connection.descriptor[:protocol], timeout: timeout }
|
|
364
|
+
case session[:protocol]
|
|
365
|
+
when :otp
|
|
366
|
+
raw = connection.exchange(protocol: :otp, command: 0, data: ''.b, response_length: 6, timeout: timeout)
|
|
367
|
+
raise ResponseError, 'Malformed OTP status for management' unless raw.is_a?(String) && raw.bytesize == 6
|
|
368
|
+
|
|
369
|
+
session[:version] = raw.bytes.take(3)
|
|
370
|
+
when :apdu
|
|
371
|
+
begin
|
|
372
|
+
selected = Protocol.select(connection: connection, aid: AID, timeout: timeout)
|
|
373
|
+
rescue Protocol::APDUError => e
|
|
374
|
+
raise unless e.status == 0x6a82 && connection.descriptor[:transport] == :nfc
|
|
375
|
+
|
|
376
|
+
raw = Protocol.select(connection: connection, aid: OTP_AID, timeout: timeout)
|
|
377
|
+
raise ResponseError, 'NFC management fallback is only defined for legacy NEO' unless raw.bytesize == 6 && raw.getbyte(0) == 3
|
|
378
|
+
|
|
379
|
+
return session.merge(version: raw.bytes.take(3), neo: true)
|
|
380
|
+
end
|
|
381
|
+
selected = selected.delete_suffix("\x90\0".b) # Edge duplicate status-word quirk.
|
|
382
|
+
match = selected.match(/\A(?:YubiKey )?(\d+)\.(\d+)\.(\d+)\z/)
|
|
383
|
+
raise ResponseError, 'Malformed management firmware version' unless match
|
|
384
|
+
|
|
385
|
+
session[:version] = match.captures.map(&:to_i)
|
|
386
|
+
if session[:version].first == 3
|
|
387
|
+
# NEO arbitrator needs the documented deselect before selecting OTP.
|
|
388
|
+
connection.exchange(protocol: :apdu, data: [0xa4, 4, 0, 8].pack('C*'), timeout: timeout)
|
|
389
|
+
raw = Protocol.select(connection: connection, aid: OTP_AID, timeout: timeout)
|
|
390
|
+
raise ResponseError, 'Malformed NEO OTP status' unless raw.bytesize == 6
|
|
391
|
+
|
|
392
|
+
session[:neo] = true
|
|
393
|
+
end
|
|
394
|
+
when :ctap
|
|
395
|
+
session[:version] = connection.descriptor[:version]
|
|
396
|
+
else
|
|
397
|
+
raise ArgumentError, 'Management requires OTP HID, smartcard or CTAP HID'
|
|
398
|
+
end
|
|
399
|
+
session
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
private_class_method def self.read_information(opts = {})
|
|
403
|
+
session = opts[:session]
|
|
404
|
+
version = session[:version]
|
|
405
|
+
if version && (version <=> [1, 0, 0]) >= 0 && (version <=> [4, 1, 0]).negative?
|
|
406
|
+
descriptor = session[:connection].descriptor
|
|
407
|
+
return { version: version, serial: descriptor[:serial], identity: descriptor.slice(:device_path, :reader), usb_interfaces: observed_interfaces(descriptor: descriptor), supported_capabilities: {}, enabled_capabilities: {}, capabilities_source: :unavailable_on_legacy_firmware, raw_fields: {}, reset_blocked: 0 }
|
|
408
|
+
end
|
|
409
|
+
fields = {}
|
|
410
|
+
finished = false
|
|
411
|
+
256.times do |page|
|
|
412
|
+
raw = request(session: session, operation: :read, page: page)
|
|
413
|
+
raise ResponseError, 'Malformed management information length' unless raw.is_a?(String) && !raw.empty? && raw.getbyte(0) == raw.bytesize - 1
|
|
414
|
+
|
|
415
|
+
more = false
|
|
416
|
+
seen = {}
|
|
417
|
+
Protocol.untlv(data: raw.byteslice(1..)).each do |record|
|
|
418
|
+
tag = record[:tag]
|
|
419
|
+
raise ResponseError, 'Duplicate management information field' if seen[tag]
|
|
420
|
+
|
|
421
|
+
seen[tag] = true
|
|
422
|
+
if tag == 0x10
|
|
423
|
+
raise ResponseError, 'Invalid management pagination' unless ["\0".b, "\1".b].include?(record[:value])
|
|
424
|
+
|
|
425
|
+
more = record[:value] == "\1".b
|
|
426
|
+
else
|
|
427
|
+
raise ResponseError, 'Duplicate management information field' if fields.key?(tag)
|
|
428
|
+
|
|
429
|
+
fields[tag] = record[:value]
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
unless more
|
|
433
|
+
finished = true
|
|
434
|
+
break
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
raise ResponseError, 'Too many management information pages' unless finished
|
|
438
|
+
|
|
439
|
+
descriptor = session[:connection].descriptor
|
|
440
|
+
decode_information(fields: fields, version: version).merge(usb_interfaces: observed_interfaces(descriptor: descriptor), identity: descriptor.slice(:device_path, :reader))
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
private_class_method def self.decode_information(opts = {})
|
|
444
|
+
fields = opts[:fields]
|
|
445
|
+
version = fields[5]&.bytes || opts[:version]
|
|
446
|
+
raise ResponseError, 'Missing or invalid management firmware version' unless version.is_a?(Array) && version.length == 3 && version.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) }
|
|
447
|
+
|
|
448
|
+
reported_version = version
|
|
449
|
+
qualifier = { version: version, type: :final, iteration: 0 }
|
|
450
|
+
if fields[0x19]
|
|
451
|
+
qualified = Protocol.tlv_map(data: fields[0x19])
|
|
452
|
+
qversion = version_field(value: qualified[1])
|
|
453
|
+
qtype = integer_field(fields: qualified, tag: 2, sizes: [1])
|
|
454
|
+
iteration = integer_field(fields: qualified, tag: 3, sizes: [1, 2, 3, 4])
|
|
455
|
+
raise ResponseError, 'Invalid qualified firmware metadata' unless qversion && qtype.between?(0, 2)
|
|
456
|
+
|
|
457
|
+
qualifier = { version: qversion, type: %i[alpha beta final][qtype], iteration: iteration }
|
|
458
|
+
version = qversion unless qtype == 2
|
|
459
|
+
end
|
|
460
|
+
raise ResponseError, 'Missing USB supported capabilities' unless fields[1]
|
|
461
|
+
|
|
462
|
+
supported = { usb: integer_field(fields: fields, tag: 1, sizes: [1, 2]) }
|
|
463
|
+
enabled = {}
|
|
464
|
+
supported[:usb] = 0x3f if version == [4, 2, 4] # Published firmware-reporting defect.
|
|
465
|
+
enabled[:usb] = integer_field(fields: fields, tag: 3, sizes: [1, 2]) if fields[3] && version.first != 4
|
|
466
|
+
if fields[0x0d]
|
|
467
|
+
supported[:nfc] = integer_field(fields: fields, tag: 0x0d, sizes: [1, 2])
|
|
468
|
+
enabled[:nfc] = integer_field(fields: fields, tag: 0x0e, sizes: [1, 2]) if fields[0x0e]
|
|
469
|
+
end
|
|
470
|
+
form = integer_field(fields: fields, tag: 4, sizes: [1], default: 0)
|
|
471
|
+
serial = integer_field(fields: fields, tag: 2, sizes: [4], default: 0)
|
|
472
|
+
{
|
|
473
|
+
version: version, reported_version: reported_version, version_qualifier: qualifier, serial: serial.zero? ? nil : serial.to_s,
|
|
474
|
+
form_factor: form & 15, is_fips: form.anybits?(0x80), is_sky: form.anybits?(0x40),
|
|
475
|
+
supported_capabilities: supported, enabled_capabilities: enabled,
|
|
476
|
+
is_locked: boolean_field(fields: fields, tag: 0x0a),
|
|
477
|
+
auto_eject_timeout: integer_field(fields: fields, tag: 6, sizes: [2], default: nil),
|
|
478
|
+
challenge_response_timeout: integer_field(fields: fields, tag: 7, sizes: [1], default: nil),
|
|
479
|
+
device_flags: integer_field(fields: fields, tag: 8, sizes: [1], default: nil),
|
|
480
|
+
nfc_restricted: boolean_field(fields: fields, tag: 0x17),
|
|
481
|
+
reset_blocked: integer_field(fields: fields, tag: 0x18, sizes: [1, 2], default: 0),
|
|
482
|
+
pin_complexity: boolean_field(fields: fields, tag: 0x16),
|
|
483
|
+
fips_capable: fips_capabilities(value: integer_field(fields: fields, tag: 0x14, sizes: [1, 2], default: 0)),
|
|
484
|
+
fips_approved: fips_capabilities(value: integer_field(fields: fields, tag: 0x15, sizes: [1, 2], default: 0)),
|
|
485
|
+
part_number: text_field(value: fields[0x13]),
|
|
486
|
+
fps_version: version_field(value: fields[0x20]), stm_version: version_field(value: fields[0x21]),
|
|
487
|
+
raw_fields: fields, capabilities_source: :device_information
|
|
488
|
+
}
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
private_class_method def self.text_field(opts = {})
|
|
492
|
+
value = opts[:value]
|
|
493
|
+
return nil if value.nil?
|
|
494
|
+
|
|
495
|
+
text = value.dup.force_encoding(Encoding::UTF_8)
|
|
496
|
+
raise ResponseError, 'Invalid management UTF8 metadata' unless text.valid_encoding?
|
|
497
|
+
|
|
498
|
+
text
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
private_class_method def self.version_field(opts = {})
|
|
502
|
+
value = opts[:value]
|
|
503
|
+
return nil if value.nil?
|
|
504
|
+
raise ResponseError, 'Invalid component firmware version' unless value.bytesize == 3
|
|
505
|
+
|
|
506
|
+
value.bytes
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
private_class_method def self.integer_field(opts = {})
|
|
510
|
+
raw = opts[:fields][opts[:tag]]
|
|
511
|
+
return opts[:default] if raw.nil? && opts.key?(:default)
|
|
512
|
+
raise ResponseError, 'Invalid management field length' unless raw && opts[:sizes].include?(raw.bytesize)
|
|
513
|
+
|
|
514
|
+
raw.bytes.reduce(0) { |number, byte| (number << 8) | byte }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private_class_method def self.boolean_field(opts = {})
|
|
518
|
+
raw = opts[:fields][opts[:tag]]
|
|
519
|
+
return nil if raw.nil?
|
|
520
|
+
raise ResponseError, 'Invalid management boolean field' unless ["\0".b, "\1".b].include?(raw)
|
|
521
|
+
|
|
522
|
+
raw == "\1".b
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
private_class_method def self.fips_capabilities(opts = {})
|
|
526
|
+
[0x200, 0x10, 0x08, 0x20, 0x100].each_with_index.sum { |mask, index| opts[:value].anybits?(1 << index) ? mask : 0 }
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
private_class_method def self.request(opts = {})
|
|
530
|
+
session = opts[:session]
|
|
531
|
+
operation = opts[:operation]
|
|
532
|
+
data = opts.fetch(:data, ''.b)
|
|
533
|
+
connection = session[:connection]
|
|
534
|
+
case session[:protocol]
|
|
535
|
+
when :otp
|
|
536
|
+
command = { read: 0x13, write: 0x15, mode: 0x11 }.fetch(operation)
|
|
537
|
+
data = [opts.fetch(:page, 0)].pack('C') if operation == :read
|
|
538
|
+
connection.exchange(protocol: :otp, command: command, data: data, timeout: session[:timeout])
|
|
539
|
+
when :apdu
|
|
540
|
+
ins = { read: 0x1d, write: 0x1c, mode: 0x16, reset: 0x1f }.fetch(operation)
|
|
541
|
+
ins = 1 if operation == :mode && session[:neo]
|
|
542
|
+
p1 = if operation == :read
|
|
543
|
+
opts.fetch(:page, 0)
|
|
544
|
+
else
|
|
545
|
+
operation == :mode ? 0x11 : 0
|
|
546
|
+
end
|
|
547
|
+
Protocol.apdu(connection: connection, ins: ins, p1: p1, data: data, timeout: session[:timeout])
|
|
548
|
+
when :ctap
|
|
549
|
+
command = { read: 0x42, write: 0x43, mode: 0x40 }.fetch(operation)
|
|
550
|
+
data = [opts.fetch(:page, 0)].pack('C') if operation == :read
|
|
551
|
+
connection.exchange(protocol: :ctap, command: command, data: data, timeout: session[:timeout])
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
private_class_method def self.require_version(opts = {})
|
|
556
|
+
raise CommandError, 'Operation is not supported by this device firmware' unless opts[:version].is_a?(Array) && (opts[:version] <=> opts[:minimum]) >= 0
|
|
557
|
+
|
|
558
|
+
:supported
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
public_class_method def self.authors(opts = {})
|
|
562
|
+
opts[:format]
|
|
563
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
public_class_method def self.help(opts = {})
|
|
567
|
+
opts[:format]
|
|
568
|
+
puts <<~HELP
|
|
569
|
+
Native Ruby only. No subprocess backend. Never log binary responses or secrets.
|
|
570
|
+
Writes are sent once. Uncertain readback raises; inspect rather than replay.
|
|
571
|
+
OTP keyboard/NDEF settings cannot export stored secrets; verification scope is explicit.
|
|
572
|
+
# List prior device-configuration leaf operations without device traffic.
|
|
573
|
+
#{self}.operations(
|
|
574
|
+
format: 'optional - reserved output format'
|
|
575
|
+
)
|
|
576
|
+
# Dispatch a native device-configuration operation with nonsecret arguments.
|
|
577
|
+
#{self}.execute(
|
|
578
|
+
args: 'optional - Array after config, empty returns local metadata; force never grants consent',
|
|
579
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
580
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
581
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
582
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
583
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
584
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
585
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed'
|
|
586
|
+
)
|
|
587
|
+
# Read exact firmware, supported applications and configuration metadata.
|
|
588
|
+
#{self}.info(
|
|
589
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
590
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds'
|
|
591
|
+
)
|
|
592
|
+
# Enable or disable USB applications with explicit field readback.
|
|
593
|
+
#{self}.configure_usb(
|
|
594
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
595
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
596
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
597
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
598
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
599
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
600
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
|
|
601
|
+
enable: 'optional - Array of exact application names to enable on this transport',
|
|
602
|
+
disable: 'optional - Array of exact application names to disable on this transport',
|
|
603
|
+
enable_all: 'optional - true enables only capabilities reported by this device',
|
|
604
|
+
list: 'optional - true lists enabled applications without mutation and cannot mix with changes',
|
|
605
|
+
reboot: 'optional - explicit boolean; USB interface changes default to reboot with reconnect verification',
|
|
606
|
+
touch_eject: 'optional - true enables smartcard touch-eject',
|
|
607
|
+
no_touch_eject: 'optional - true disables smartcard touch-eject',
|
|
608
|
+
autoeject_timeout: 'optional - uint16 seconds before smartcard auto-eject',
|
|
609
|
+
chalresp_timeout: 'optional - uint8 seconds allowed for an OTP touch'
|
|
610
|
+
)
|
|
611
|
+
# Enable or disable NFC applications and verify configuration fields.
|
|
612
|
+
#{self}.configure_nfc(
|
|
613
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
614
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
615
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
616
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
617
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
618
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
619
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
|
|
620
|
+
enable: 'optional - Array of exact application names to enable on this transport',
|
|
621
|
+
disable: 'optional - Array of exact application names to disable on this transport',
|
|
622
|
+
enable_all: 'optional - true enables only capabilities reported by this device',
|
|
623
|
+
list: 'optional - true lists enabled applications without mutation and cannot mix with changes',
|
|
624
|
+
reboot: 'optional - explicit boolean; USB interface changes default to reboot with reconnect verification',
|
|
625
|
+
disable_all: 'optional - true disables all reported NFC applications, not USB',
|
|
626
|
+
restrict: 'optional - true restricts NFC until USB power on firmware 5.7 or later'
|
|
627
|
+
)
|
|
628
|
+
# Set or clear configuration protection and read its lock flag.
|
|
629
|
+
#{self}.set_lock_code(
|
|
630
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
631
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
632
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
633
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
634
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
635
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
636
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
|
|
637
|
+
new_lock_code: 'optional - required sixteen raw replacement bytes unless clearing, generating or supplied by provider',
|
|
638
|
+
clear: 'optional - true clears configuration protection',
|
|
639
|
+
generate: 'optional - true generates sixteen bytes and requires secret_sink'
|
|
640
|
+
)
|
|
641
|
+
# Configure USB interfaces using modern capabilities or legacy mode bytes.
|
|
642
|
+
#{self}.set_mode(
|
|
643
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
644
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
645
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
646
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
647
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
648
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
649
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed',
|
|
650
|
+
mode: 'required - mode 0 through 6 or OTP+FIDO+CCID style string; relative forms require observed interfaces',
|
|
651
|
+
touch_eject: 'optional - true requests CCID-only touch-eject',
|
|
652
|
+
autoeject_timeout: 'optional - uint16 seconds; positive values imply touch-eject',
|
|
653
|
+
chalresp_timeout: 'optional - uint8 touch timeout in seconds',
|
|
654
|
+
reboot: 'optional - modern modes default to reboot if interfaces change; legacy needs physical reinsertion'
|
|
655
|
+
)
|
|
656
|
+
# Reset a reported supported Bio multi-protocol device and read reset metadata.
|
|
657
|
+
#{self}.reset(
|
|
658
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
659
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
660
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
661
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
662
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
663
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
664
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed'
|
|
665
|
+
)
|
|
666
|
+
# Reboot and verify exact-device information through a fresh connection.
|
|
667
|
+
#{self}.reboot(
|
|
668
|
+
connection: 'required - exact selected native transport handle; caller owns its lifetime',
|
|
669
|
+
confirm: 'required - literal true for a mutation or cryptographic operation; force does not grant consent',
|
|
670
|
+
timeout: 'optional - finite positive timeout per device exchange, defaults to 30 seconds',
|
|
671
|
+
secret_provider: 'optional - callable receiving a missing secret field Symbol; returns raw secret bytes in memory',
|
|
672
|
+
secret_sink: 'optional - required callable for generation, receives field Symbol and secret bytes; never log them',
|
|
673
|
+
lock_code: 'optional - current sixteen raw configuration-lock bytes; otherwise an explicit provider is consulted only if locked',
|
|
674
|
+
reconnect: 'optional - callable returning a fresh native handle to the exact device after disconnect; required for reboot and legacy mode verification, returned handle is closed'
|
|
675
|
+
)
|
|
676
|
+
# Return module author information.
|
|
677
|
+
#{self}.authors(
|
|
678
|
+
format: 'optional - reserved output format'
|
|
679
|
+
)
|
|
680
|
+
HELP
|
|
681
|
+
nil
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
end
|