pwn 0.5.784 → 0.5.787
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +6 -6
- data/README.md +37 -0
- data/bin/pwn +2 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +69 -15
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +2 -2
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Harness-Evaluation.md +1 -1
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +6 -5
- data/documentation/Installation.md +12 -9
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +18 -0
- data/documentation/Persistence.md +7 -5
- data/documentation/Plugin-Degradation.md +14 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Policy-Benchmark.md +32 -33
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +185 -0
- data/documentation/YubiKey.md +218 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -130
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
- data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
- data/documentation/diagrams/reinforcement-learning.svg +89 -89
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +43 -27
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/learning.rb +14 -18
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +4 -0
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +8 -1
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +349 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/migrate.rb +85 -280
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +3 -1
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +293 -26
- data/lib/pwn/plugins/repl/ai_console.rb +268 -88
- data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
- data/lib/pwn/plugins/repl/mesh.rb +14 -19
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +9 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/setup.rb +114 -24
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +8 -16
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +1 -1
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +129 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/migrate_spec.rb +29 -81
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +605 -19
- metadata +129 -16
- data/scripts/benchmark_policy.rb +0 -376
- data/scripts/build_gate.rb +0 -58
- data/spec/integration/build_gate_spec.rb +0 -73
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'pwn/plugins/yubikey'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
module Plugins
|
|
9
|
+
module YubiKey
|
|
10
|
+
# Native CTAP/U2F application logic. Transport owns CTAPHID framing.
|
|
11
|
+
# References: FIDO CTAP 2.1 PS (2021-06-15), U2F raw message formats 1.2,
|
|
12
|
+
# RFC 8949; cross-checked against python-fido2 and YubiKey Manager 5.9.1.
|
|
13
|
+
# No client/origin policy is fabricated: callers supply RP and client hashes.
|
|
14
|
+
module FIDO
|
|
15
|
+
class UnsupportedError < Error; end
|
|
16
|
+
class Cancelled < Error; end
|
|
17
|
+
|
|
18
|
+
# Status-only exception: never include packet bodies or PIN values.
|
|
19
|
+
class CTAPError < CommandError
|
|
20
|
+
attr_reader :code
|
|
21
|
+
|
|
22
|
+
NAMES = { 0x01 => 'INVALID_COMMAND', 0x02 => 'INVALID_PARAMETER', 0x03 => 'INVALID_LENGTH', 0x05 => 'TIMEOUT', 0x11 => 'CBOR_UNEXPECTED_TYPE', 0x12 => 'INVALID_CBOR', 0x14 => 'MISSING_PARAMETER', 0x19 => 'CREDENTIAL_EXCLUDED', 0x27 => 'OPERATION_DENIED', 0x28 => 'KEY_STORE_FULL', 0x2b => 'UNSUPPORTED_OPTION', 0x2c => 'INVALID_OPTION', 0x2d => 'KEEPALIVE_CANCEL', 0x2e => 'NO_CREDENTIALS', 0x2f => 'USER_ACTION_TIMEOUT', 0x30 => 'NOT_ALLOWED', 0x31 => 'PIN_INVALID', 0x32 => 'PIN_BLOCKED', 0x33 => 'PIN_AUTH_INVALID', 0x34 => 'PIN_AUTH_BLOCKED', 0x35 => 'PIN_NOT_SET', 0x36 => 'PUAT_REQUIRED', 0x37 => 'PIN_POLICY_VIOLATION', 0x38 => 'PIN_TOKEN_EXPIRED', 0x3b => 'UP_REQUIRED', 0x3c => 'UV_BLOCKED', 0x3f => 'UV_INVALID', 0x40 => 'UNAUTHORIZED_PERMISSION' }.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(code)
|
|
25
|
+
@code = code
|
|
26
|
+
super(format('CTAP error 0x%<code>02x (%<name>s); operation was not retried', code: code, name: NAMES.fetch(code, 'UNKNOWN')))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# U2F/PCSC error retaining the numeric status word only.
|
|
31
|
+
class APDUError < CommandError
|
|
32
|
+
attr_reader :code
|
|
33
|
+
|
|
34
|
+
def initialize(code)
|
|
35
|
+
@code = code
|
|
36
|
+
super(format('FIDO APDU status 0x%04x; operation was not retried', code))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Enrollment is durable before naming. Expose recovery identity, not a retry.
|
|
41
|
+
class EnrollmentError < Error
|
|
42
|
+
attr_reader :template_id
|
|
43
|
+
|
|
44
|
+
def initialize(template_id)
|
|
45
|
+
@template_id = template_id
|
|
46
|
+
super('Fingerprint enrolled, but naming failed; use template_id to rename; enrollment was not repeated or deleted')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
COMMANDS = {
|
|
51
|
+
'info' => [:info, [], [], []],
|
|
52
|
+
'reset' => [:reset, [], %w[force], []],
|
|
53
|
+
'access change-pin' => [:change_pin, [], %w[u2f], []],
|
|
54
|
+
'access verify-pin' => [:verify_pin, [], [], []],
|
|
55
|
+
'access force-change' => [:force_change, [], [], []],
|
|
56
|
+
'access set-min-length' => [:set_min_length, [:min_length], [], %w[rp-id]],
|
|
57
|
+
'credentials list' => [:list_credentials, [], %w[csv], []],
|
|
58
|
+
'credentials delete' => [:delete_credential, [:credential_id_hex], %w[force], []],
|
|
59
|
+
'fingerprints list' => [:list_fingerprints, [], [], []],
|
|
60
|
+
'fingerprints add' => [:add_fingerprint, [:name], [], []],
|
|
61
|
+
'fingerprints rename' => [:rename_fingerprint, %i[template_id_hex name], [], []],
|
|
62
|
+
'fingerprints delete' => [:delete_fingerprint, [:template_id_hex], %w[force], []],
|
|
63
|
+
'config toggle-always-uv' => [:toggle_always_uv, [], [], []],
|
|
64
|
+
'config enable-ep-attestation' => [:enable_enterprise_attestation, [], [], []]
|
|
65
|
+
}.freeze
|
|
66
|
+
GROUPS = ['', 'access', 'credentials', 'fingerprints', 'config'].freeze
|
|
67
|
+
|
|
68
|
+
public_class_method def self.operations
|
|
69
|
+
COMMANDS.keys
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
public_class_method def self.execute(opts = {})
|
|
73
|
+
args = opts.key?(:args) ? opts[:args] : []
|
|
74
|
+
raise ArgumentError, 'FIDO args must be an Array of nonsecret Strings' unless args.is_a?(Array) && args.size <= 256 && args.all? { |arg| arg.is_a?(String) && arg.bytesize <= 4096 }
|
|
75
|
+
|
|
76
|
+
path = args.join(' ')
|
|
77
|
+
return { group: path, operations: operations.select { |entry| path.empty? || entry.start_with?("#{path} ") } } if GROUPS.include?(path)
|
|
78
|
+
|
|
79
|
+
path = COMMANDS.keys.sort_by { |entry| -entry.length }.find { |entry| args.first(entry.split.size) == entry.split }
|
|
80
|
+
raise ArgumentError, 'Unknown FIDO operation' unless path
|
|
81
|
+
|
|
82
|
+
method, positional_names, flags, values = COMMANDS.fetch(path)
|
|
83
|
+
parsed = {}
|
|
84
|
+
positional = []
|
|
85
|
+
rest = args.drop(path.split.size)
|
|
86
|
+
until rest.empty?
|
|
87
|
+
arg = rest.shift
|
|
88
|
+
if arg == '--'
|
|
89
|
+
positional.concat(rest)
|
|
90
|
+
break
|
|
91
|
+
elsif arg.start_with?('--')
|
|
92
|
+
name = arg.delete_prefix('--')
|
|
93
|
+
if flags.include?(name)
|
|
94
|
+
parsed[name.tr('-', '_').to_sym] = true
|
|
95
|
+
elsif values.include?(name)
|
|
96
|
+
value = rest.shift
|
|
97
|
+
raise ArgumentError, 'Missing FIDO option value' unless value && !value.start_with?('--')
|
|
98
|
+
|
|
99
|
+
(parsed[:rp_ids] ||= []) << value
|
|
100
|
+
else
|
|
101
|
+
raise ArgumentError, 'Unknown FIDO option; secrets belong in memory options, not args'
|
|
102
|
+
end
|
|
103
|
+
else
|
|
104
|
+
positional << arg
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
optional_name = method == :add_fingerprint && positional.empty?
|
|
108
|
+
raise ArgumentError, 'Incorrect FIDO positional argument count' unless positional.size == positional_names.size || optional_name
|
|
109
|
+
|
|
110
|
+
positional_names.zip(positional).each { |key, value| parsed[key] = value if value }
|
|
111
|
+
perform(opts.merge(parsed).merge(operation: method))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
public_class_method def self.perform(opts = {})
|
|
115
|
+
operation = opts[:operation]
|
|
116
|
+
allowed = COMMANDS.values.map(&:first) + %i[pin_retries update_credential bio_sensor_info cancel_enrollment make_credential get_assertion u2f_register u2f_authenticate]
|
|
117
|
+
raise ArgumentError, 'Unknown native FIDO operation' unless allowed.include?(operation)
|
|
118
|
+
|
|
119
|
+
session = Session.new(opts)
|
|
120
|
+
session.run(operation)
|
|
121
|
+
ensure
|
|
122
|
+
session&.forget
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
public_class_method def self.make_credential(opts = {})
|
|
126
|
+
perform(opts.merge(operation: :make_credential, connection: opts[:connection]))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
public_class_method def self.get_assertion(opts = {})
|
|
130
|
+
perform(opts.merge(operation: :get_assertion, connection: opts[:connection]))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
public_class_method def self.u2f_register(opts = {})
|
|
134
|
+
perform(opts.merge(operation: :u2f_register, connection: opts[:connection]))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
public_class_method def self.u2f_authenticate(opts = {})
|
|
138
|
+
perform(opts.merge(operation: :u2f_authenticate, connection: opts[:connection]))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
public_class_method def self.authors
|
|
142
|
+
"AUTHOR(S):\n0day Inc. <request.pentest@0dayinc.com>"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
public_class_method def self.help
|
|
146
|
+
puts <<~HELP
|
|
147
|
+
Purpose: Execute native FIDO management without subprocesses or PIN retries.
|
|
148
|
+
#{self}.execute(args: 'optional - Nonsecret argv after fido, or a local help group', connection: 'optional - Transport connection, required for device operations', confirm: 'optional - Literal true required for authentication and mutations', pin: 'optional - Current PIN in memory only', new_pin: 'optional - Replacement PIN in memory only', secret_provider: 'optional - Callable receiving pin or new_pin Symbol when required', timeout: 'optional - Positive finite total operation seconds, default 30', on_keepalive: 'optional - Callable receiving status; exceptions cancel', cancelled: 'optional - Callable returning true to cancel before a command or on keepalive')
|
|
149
|
+
Purpose: Invoke typed CTAP operations with caller-owned RP and origin policy.
|
|
150
|
+
#{self}.perform(operation: 'required - Symbol from operations or typed operational API', connection: 'required - Transport connection', confirm: 'optional - Literal true required for authentication and mutations')
|
|
151
|
+
Purpose: Create a credential from caller-supplied clientDataHash and RP/user entities.
|
|
152
|
+
#{self}.make_credential(connection: 'required - Transport connection', client_data_hash: 'required - Binary 32-byte SHA-256 digest of caller-created clientDataJSON', rp: 'required - Text-keyed relying-party entity with id', user: 'required - Text-keyed user entity with binary id', confirm: 'required - Literal true for credential creation')
|
|
153
|
+
Purpose: Obtain every assertion advertised by the authenticator for a caller-supplied RP.
|
|
154
|
+
#{self}.get_assertion(connection: 'required - Transport connection', rp_id: 'required - Exact relying-party ID', client_data_hash: 'required - Binary 32-byte caller-provided clientDataJSON digest', confirm: 'required - Literal true for signing and counters')
|
|
155
|
+
Purpose: Register a U2F credential without inventing an application or client identity.
|
|
156
|
+
#{self}.u2f_register(connection: 'required - Transport connection', client_data_hash: 'required - Binary 32-byte caller-provided clientData digest', app_id_hash: 'required - Binary 32-byte application parameter', confirm: 'required - Literal true for credential creation')
|
|
157
|
+
Purpose: Authenticate a U2F key handle or perform an explicit check-only request.
|
|
158
|
+
#{self}.u2f_authenticate(connection: 'required - Transport connection', client_data_hash: 'required - Binary 32-byte caller-provided clientData digest', app_id_hash: 'required - Binary 32-byte application parameter', key_handle: 'required - Binary U2F credential handle', check_only: 'optional - Literal boolean, default false', confirm: 'required - Literal true for authentication')
|
|
159
|
+
Purpose: List implemented management leaf paths, excluding local help groups.
|
|
160
|
+
#{self}.operations
|
|
161
|
+
Purpose: Encode bounded canonical CTAP CBOR using binary versus UTF-8 String tags.
|
|
162
|
+
#{self}::CBOR.encode(value: 'required - Supported CBOR value, binary Strings encode byte strings')
|
|
163
|
+
Purpose: Decode one complete canonical CTAP CBOR object with resource limits.
|
|
164
|
+
#{self}::CBOR.decode(data: 'required - Raw binary CBOR String')
|
|
165
|
+
Purpose: Identify the implementation authors.
|
|
166
|
+
#{self}.authors
|
|
167
|
+
PINs, tokens and key agreements are not persisted or returned by management calls.
|
|
168
|
+
See the paired skill for typed operation options and relying-party responsibilities.
|
|
169
|
+
HELP
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# One operation per session. The connection is caller-owned and remains open.
|
|
173
|
+
class Session # rubocop:disable Metrics/ClassLength
|
|
174
|
+
def initialize(opts)
|
|
175
|
+
@opts = opts.dup
|
|
176
|
+
@connection = opts[:connection]
|
|
177
|
+
raise ArgumentError, 'FIDO requires a connection' unless @connection.respond_to?(:exchange) && @connection.respond_to?(:descriptor)
|
|
178
|
+
|
|
179
|
+
timeout = opts.fetch(:timeout, 30)
|
|
180
|
+
raise ArgumentError, 'FIDO timeout must be positive and finite' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
|
|
181
|
+
|
|
182
|
+
@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
183
|
+
@secrets = []
|
|
184
|
+
@max_message = 1024
|
|
185
|
+
@descriptor = @connection.descriptor
|
|
186
|
+
raise ArgumentError, 'FIDO requires CTAP HID or APDU transport' unless %i[ctap apdu].include?(@descriptor[:protocol])
|
|
187
|
+
|
|
188
|
+
%i[cancelled on_keepalive secret_provider].each do |key|
|
|
189
|
+
raise ArgumentError, "#{key} must be callable" if !opts[key].nil? && !opts[key].respond_to?(:call)
|
|
190
|
+
end
|
|
191
|
+
%i[u2f uv csv check_only].each do |key|
|
|
192
|
+
raise ArgumentError, "#{key} must be boolean" if opts.key?(key) && ![true, false].include?(opts[key])
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def inspect
|
|
197
|
+
'#<FIDO::Session secrets=redacted>'
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def run(operation)
|
|
201
|
+
confirm! unless %i[info pin_retries bio_sensor_info].include?(operation)
|
|
202
|
+
record_limit if @opts.key?(:max_records)
|
|
203
|
+
public_send(operation)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def forget
|
|
207
|
+
@secrets.each { |secret| secret.replace("\x00".b * secret.bytesize) unless secret.frozen? }
|
|
208
|
+
@secrets.clear
|
|
209
|
+
@opts.clear
|
|
210
|
+
:forgotten
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def info
|
|
214
|
+
return @info if @info
|
|
215
|
+
|
|
216
|
+
caps = @descriptor[:capabilities]
|
|
217
|
+
return u2f_info if caps.is_a?(Integer) && caps.nobits?(4)
|
|
218
|
+
|
|
219
|
+
data = ctap(4)
|
|
220
|
+
raise ResponseError, 'Invalid authenticator getInfo versions' unless data[1].is_a?(Array) && !data[1].empty? && data[1].all?(String)
|
|
221
|
+
raise ResponseError, 'Invalid authenticator getInfo options' unless data.fetch(4, {}).is_a?(Hash)
|
|
222
|
+
raise ResponseError, 'Authenticator options must be text-keyed booleans' unless data.fetch(4, {}).all? { |key, value| key.is_a?(String) && key.encoding == Encoding::UTF_8 && [true, false].include?(value) }
|
|
223
|
+
|
|
224
|
+
{ 13 => 4..255, 16 => 0..1024, 29 => 4..255 }.each do |field, range|
|
|
225
|
+
raise ResponseError, 'Invalid authenticator PIN policy limit' if data.key?(field) && (!data[field].is_a?(Integer) || !range.cover?(data[field]))
|
|
226
|
+
end
|
|
227
|
+
raise ResponseError, 'Invalid authenticator AAGUID' unless data[3].is_a?(String) && data[3].encoding == Encoding::BINARY && data[3].bytesize == 16
|
|
228
|
+
|
|
229
|
+
@max_message = data.fetch(5, 1024)
|
|
230
|
+
raise ResponseError, 'Invalid authenticator message limit' unless @max_message.is_a?(Integer) && @max_message.between?(64, CBOR::MAX_BYTES)
|
|
231
|
+
|
|
232
|
+
@info = { versions: data[1], ctap2: true, options: data.fetch(4, {}), aaguid: data[3], pin_uv_protocols: data.fetch(6, []), max_message_size: @max_message, raw: data }
|
|
233
|
+
rescue CTAPError => e
|
|
234
|
+
raise unless e.code == 1
|
|
235
|
+
|
|
236
|
+
u2f_info
|
|
237
|
+
rescue APDUError => e
|
|
238
|
+
raise unless [0x6d00, 0x6a81].include?(e.code)
|
|
239
|
+
|
|
240
|
+
u2f_info
|
|
241
|
+
rescue CommandError
|
|
242
|
+
# CTAPHID INIT can populate capability bits only during the first exchange.
|
|
243
|
+
caps = @connection.descriptor[:capabilities]
|
|
244
|
+
raise unless caps.is_a?(Integer) && caps.nobits?(4)
|
|
245
|
+
|
|
246
|
+
u2f_info
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def reset
|
|
250
|
+
if info[:ctap2]
|
|
251
|
+
ctap(7)
|
|
252
|
+
else
|
|
253
|
+
fips_status
|
|
254
|
+
u2f_apdu(0x45)
|
|
255
|
+
end
|
|
256
|
+
{ reset: true }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def pin_retries
|
|
260
|
+
protocol = pin_protocol
|
|
261
|
+
pin = ctap(6, { 1 => protocol.version, 2 => 1 })
|
|
262
|
+
result = { pin_retries: pin[3], power_cycle_state: pin[4] }
|
|
263
|
+
result[:uv_retries] = ctap(6, { 1 => protocol.version, 2 => 7 })[5] if info[:options]['uv']
|
|
264
|
+
result
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def change_pin
|
|
268
|
+
return fips_change_pin if @opts[:u2f] == true || !info[:ctap2]
|
|
269
|
+
|
|
270
|
+
protocol = pin_protocol
|
|
271
|
+
minimum = info[:raw].fetch(13, 4)
|
|
272
|
+
maximum = info[:raw].fetch(29, 63)
|
|
273
|
+
new_pin = pin_text(:new_pin, minimum: minimum, maximum: maximum)
|
|
274
|
+
initialized = info[:options]['clientPin'] == false
|
|
275
|
+
raise ArgumentError, 'Authenticator has no current PIN; omit pin when initializing' if initialized && !@opts[:pin].nil?
|
|
276
|
+
|
|
277
|
+
old_pin = pin_text(:pin) unless initialized
|
|
278
|
+
length = [64, ((new_pin.bytesize + 15) / 16) * 16].max
|
|
279
|
+
raise ArgumentError, 'Padded PIN exceeds the CTAP 255-byte limit' if length > 255
|
|
280
|
+
|
|
281
|
+
local, shared = key_agreement(protocol)
|
|
282
|
+
padded = keep_secret(new_pin.b.ljust(length, "\x00".b))
|
|
283
|
+
new_enc = protocol.encrypt(shared, padded)
|
|
284
|
+
params = { 1 => protocol.version, 2 => initialized ? 3 : 4, 3 => local, 5 => new_enc }
|
|
285
|
+
params[6] = protocol.encrypt(shared, keep_secret(OpenSSL::Digest::SHA256.digest(old_pin).byteslice(0, 16))) unless initialized
|
|
286
|
+
params[4] = protocol.authenticate(shared, new_enc + params.fetch(6, ''.b))
|
|
287
|
+
ctap(6, params)
|
|
288
|
+
{ changed: true, initialized: initialized }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def verify_pin
|
|
292
|
+
raise ArgumentError, 'verify-pin requires a PIN, not built-in UV' if @opts[:uv] == true
|
|
293
|
+
|
|
294
|
+
rp_id = text(@opts[:rp_id], 'RP ID', max: 253) if @opts.key?(:rp_id)
|
|
295
|
+
if info[:ctap2]
|
|
296
|
+
permission = if rp_id
|
|
297
|
+
2
|
|
298
|
+
elsif info[:options]['credMgmt'] || info[:options]['credentialMgmtPreview']
|
|
299
|
+
4
|
|
300
|
+
elsif info[:options].key?('bioEnroll') || info[:options].key?('userVerificationMgmtPreview')
|
|
301
|
+
8
|
|
302
|
+
elsif info[:options]['authnrCfg']
|
|
303
|
+
32
|
|
304
|
+
end
|
|
305
|
+
authorize(permission, rp_id)
|
|
306
|
+
else
|
|
307
|
+
fips_status
|
|
308
|
+
u2f_apdu(0x43, pin_text(:pin, minimum: 6, maximum: 32).b)
|
|
309
|
+
end
|
|
310
|
+
{ verified: true }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def list_credentials
|
|
314
|
+
command = credential_command
|
|
315
|
+
auth = authorize(4)
|
|
316
|
+
metadata = credential_call(command, 1, nil, auth)
|
|
317
|
+
existing = bounded_count(metadata[1], zero: true)
|
|
318
|
+
rps = enumerate(command, [2, 3], nil, 5, auth)
|
|
319
|
+
credentials = []
|
|
320
|
+
seen_rps = {}
|
|
321
|
+
seen_credentials = {}
|
|
322
|
+
rps.each do |rp|
|
|
323
|
+
hash = binary(rp[4], 'RP ID hash', length: 32, error: ResponseError)
|
|
324
|
+
raise ResponseError, 'Invalid or duplicate RP enumeration' unless rp[3].is_a?(Hash) && rp[3]['id'].is_a?(String) && !seen_rps[hash]
|
|
325
|
+
|
|
326
|
+
seen_rps[hash] = true
|
|
327
|
+
enumerate(command, [4, 5], { 1 => hash }, 9, auth).each do |entry|
|
|
328
|
+
descriptor = credential_descriptor(entry[7], error: ResponseError)
|
|
329
|
+
raise ResponseError, 'Duplicate credential in enumeration' if seen_credentials[descriptor['id']]
|
|
330
|
+
raise ResponseError, 'Invalid credential metadata' unless entry[6].is_a?(Hash) && entry[8].is_a?(Hash)
|
|
331
|
+
|
|
332
|
+
seen_credentials[descriptor['id']] = true
|
|
333
|
+
credentials << { relying_party: rp[3], rp_id_hash: hash, user: entry[6], credential: descriptor, public_key: entry[8], credential_protection: entry[10], raw: entry }
|
|
334
|
+
raise ResponseError, 'Credential enumeration count limit exceeded' if credentials.size > record_limit
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
raise ResponseError, 'Credential enumeration count disagrees with metadata' unless credentials.size == existing
|
|
338
|
+
|
|
339
|
+
result = { metadata: { existing: existing, remaining: metadata[2] }, relying_parties: rps.map { |rp| { relying_party: rp[3], rp_id_hash: rp[4] } }, credentials: credentials }
|
|
340
|
+
return result unless @opts[:csv] == true
|
|
341
|
+
|
|
342
|
+
require 'csv'
|
|
343
|
+
CSV.generate do |csv|
|
|
344
|
+
csv << %w[credential_id rp_id user_id user_name display_name]
|
|
345
|
+
credentials.each { |entry| csv << [entry[:credential]['id'].unpack1('H*'), entry[:relying_party]['id'], entry[:user]['id']&.unpack1('H*'), entry[:user]['name'], entry[:user]['displayName']] }
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def delete_credential
|
|
350
|
+
descriptor = { 'id' => identifier(:credential_id), 'type' => 'public-key' }
|
|
351
|
+
command = credential_command
|
|
352
|
+
credential_call(command, 6, { 2 => descriptor }, authorize(4))
|
|
353
|
+
{ deleted: true }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def update_credential
|
|
357
|
+
descriptor = { 'id' => identifier(:credential_id), 'type' => 'public-key' }
|
|
358
|
+
user = user_entity(@opts[:user])
|
|
359
|
+
command = credential_command
|
|
360
|
+
raise UnsupportedError, 'Credential user update is not in the preview protocol' unless command == 10
|
|
361
|
+
|
|
362
|
+
credential_call(command, 7, { 2 => descriptor, 3 => user }, authorize(4))
|
|
363
|
+
{ updated: true }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def force_change
|
|
367
|
+
require_option!('setMinPINLength')
|
|
368
|
+
raise UnsupportedError, 'Force PIN change requires a configured PIN' unless info[:options]['clientPin'] == true
|
|
369
|
+
|
|
370
|
+
configure(3, { 3 => true })
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def set_min_length
|
|
374
|
+
length = integer_option(@opts[:min_length], 'min_length', 4..255)
|
|
375
|
+
rps = @opts[:rp_ids]
|
|
376
|
+
raise ArgumentError, 'rp_ids must be an Array' unless rps.nil? || rps.is_a?(Array)
|
|
377
|
+
|
|
378
|
+
rps = rps.map { |rp| text(rp, 'RP ID', max: 253) } if rps
|
|
379
|
+
require_option!('setMinPINLength')
|
|
380
|
+
maximum = info[:raw].fetch(29, 63)
|
|
381
|
+
minimum = info[:raw].fetch(13, 4)
|
|
382
|
+
raise ArgumentError, 'PIN minimum cannot decrease or exceed authenticator maximum' unless length.between?(minimum, maximum)
|
|
383
|
+
raise ArgumentError, 'Too many minimum-PIN-length RP IDs' if rps && rps.size > info[:raw].fetch(16, 0)
|
|
384
|
+
|
|
385
|
+
params = { 1 => length, 3 => false }
|
|
386
|
+
params[2] = rps if rps
|
|
387
|
+
configure(3, params)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def toggle_always_uv
|
|
391
|
+
require_option!('alwaysUv', present: true)
|
|
392
|
+
configure(2)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def enable_enterprise_attestation
|
|
396
|
+
require_option!('ep', present: true)
|
|
397
|
+
configure(1)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def bio_sensor_info
|
|
401
|
+
command = bio_command
|
|
402
|
+
modality = ctap(command, { 6 => true })[1]
|
|
403
|
+
raise UnsupportedError, 'Authenticator biometric modality is not fingerprint' unless modality == 1
|
|
404
|
+
|
|
405
|
+
result = bio_call(command, 7)
|
|
406
|
+
{ modality: modality, fingerprint_kind: result[2], max_samples: result[3], max_name_bytes: result.fetch(8, 64), raw: result }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def list_fingerprints
|
|
410
|
+
command = bio_command
|
|
411
|
+
bio_sensor_info
|
|
412
|
+
auth = authorize(8)
|
|
413
|
+
templates = begin
|
|
414
|
+
bio_call(command, 4, nil, auth)[7]
|
|
415
|
+
rescue CTAPError => e
|
|
416
|
+
raise unless e.code == 0x2c
|
|
417
|
+
|
|
418
|
+
[]
|
|
419
|
+
end
|
|
420
|
+
raise ResponseError, 'Invalid fingerprint template list' unless templates.is_a?(Array) && templates.size <= record_limit
|
|
421
|
+
|
|
422
|
+
seen = {}
|
|
423
|
+
templates.map do |entry|
|
|
424
|
+
raise ResponseError, 'Invalid fingerprint template' unless entry.is_a?(Hash)
|
|
425
|
+
|
|
426
|
+
id = binary(entry[1], 'fingerprint template ID', error: ResponseError)
|
|
427
|
+
raise ResponseError, 'Duplicate fingerprint template ID' if seen[id]
|
|
428
|
+
raise ResponseError, 'Invalid fingerprint name' unless entry[2].nil? || (entry[2].is_a?(String) && entry[2].encoding == Encoding::UTF_8)
|
|
429
|
+
|
|
430
|
+
seen[id] = true
|
|
431
|
+
{ template_id: id, name: entry[2] }
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def rename_fingerprint
|
|
436
|
+
id = identifier(:template_id)
|
|
437
|
+
name = text(@opts[:name], 'fingerprint name')
|
|
438
|
+
command = bio_command
|
|
439
|
+
max_name = bio_sensor_info[:max_name_bytes]
|
|
440
|
+
raise ArgumentError, 'Fingerprint name exceeds sensor limit' if name.bytesize > max_name
|
|
441
|
+
|
|
442
|
+
bio_call(command, 5, { 1 => id, 2 => name }, authorize(8))
|
|
443
|
+
{ renamed: true }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def delete_fingerprint
|
|
447
|
+
id = identifier(:template_id)
|
|
448
|
+
command = bio_command
|
|
449
|
+
bio_sensor_info
|
|
450
|
+
bio_call(command, 6, { 1 => id }, authorize(8))
|
|
451
|
+
{ deleted: true }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def cancel_enrollment
|
|
455
|
+
bio_call(bio_command, 3)
|
|
456
|
+
{ cancelled: true }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def add_fingerprint
|
|
460
|
+
name = text(@opts[:name], 'fingerprint name') if @opts[:name]
|
|
461
|
+
timeout_ms = integer_option(@opts.fetch(:capture_timeout_ms, 10_000), 'capture_timeout_ms', 1..120_000)
|
|
462
|
+
max_samples = integer_option(@opts.fetch(:max_samples, 128), 'max_samples', 1..1024)
|
|
463
|
+
callback = @opts[:on_sample]
|
|
464
|
+
raise ArgumentError, 'on_sample must be callable' if callback && !callback.respond_to?(:call)
|
|
465
|
+
|
|
466
|
+
command = bio_command
|
|
467
|
+
sensor = bio_sensor_info
|
|
468
|
+
raise ArgumentError, 'Fingerprint name exceeds sensor limit' if name && name.bytesize > sensor[:max_name_bytes]
|
|
469
|
+
|
|
470
|
+
auth = authorize(8)
|
|
471
|
+
pending = true
|
|
472
|
+
response = bio_call(command, 1, { 3 => timeout_ms }, auth)
|
|
473
|
+
id = binary(response[4], 'fingerprint template ID', error: ResponseError)
|
|
474
|
+
max_samples.times do |index|
|
|
475
|
+
status = response[5]
|
|
476
|
+
remaining_samples = response[6]
|
|
477
|
+
raise ResponseError, 'Invalid fingerprint capture status' unless status.is_a?(Integer) && status.between?(0, 14)
|
|
478
|
+
raise ResponseError, 'Invalid fingerprint remaining-sample count' unless remaining_samples.is_a?(Integer) && remaining_samples.between?(0, 1024)
|
|
479
|
+
|
|
480
|
+
complete = status.zero? && remaining_samples.zero?
|
|
481
|
+
pending = false if complete
|
|
482
|
+
callback&.call({ template_id: id, status: status, remaining_samples: remaining_samples })
|
|
483
|
+
if complete
|
|
484
|
+
if name
|
|
485
|
+
begin
|
|
486
|
+
bio_call(command, 5, { 1 => id, 2 => name }, auth)
|
|
487
|
+
rescue Error
|
|
488
|
+
raise EnrollmentError, id
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
return { enrolled: true, template_id: id, name: name }
|
|
492
|
+
end
|
|
493
|
+
raise ResponseError, 'Fingerprint already enrolled or database full' if [11, 12].include?(status)
|
|
494
|
+
raise TimeoutError, 'Fingerprint sample limit exceeded; enrollment cancelled' if index + 1 == max_samples
|
|
495
|
+
|
|
496
|
+
response = bio_call(command, 2, { 1 => id, 3 => timeout_ms }, auth)
|
|
497
|
+
end
|
|
498
|
+
ensure
|
|
499
|
+
if pending && command
|
|
500
|
+
begin
|
|
501
|
+
# Cleanup is bounded separately, and must run even if cancelled/deadline expired.
|
|
502
|
+
ctap(command, { 1 => 1, 2 => 3 }, cleanup: true)
|
|
503
|
+
rescue StandardError
|
|
504
|
+
# Preserve the original exception. No retry or credential deletion.
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def make_credential
|
|
511
|
+
client_hash = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
|
|
512
|
+
rp = @opts[:rp]
|
|
513
|
+
raise ArgumentError, 'RP must be a caller-supplied entity Hash' unless rp.is_a?(Hash)
|
|
514
|
+
|
|
515
|
+
rp_id = text(rp['id'], 'RP ID', max: 253)
|
|
516
|
+
text(rp['name'], 'RP name') if rp.key?('name')
|
|
517
|
+
user = user_entity(@opts[:user])
|
|
518
|
+
key_params = @opts.fetch(:key_params, [{ 'type' => 'public-key', 'alg' => -7 }])
|
|
519
|
+
raise ArgumentError, 'key_params must be public-key algorithm descriptors' unless key_params.is_a?(Array) && key_params.size.between?(1, 64) && key_params.all? { |param| param.is_a?(Hash) && param['type'] == 'public-key' && param['alg'].is_a?(Integer) }
|
|
520
|
+
|
|
521
|
+
params = { 1 => client_hash, 2 => rp, 3 => user, 4 => key_params }
|
|
522
|
+
params[5] = credential_list(@opts[:exclude_list]) if @opts.key?(:exclude_list)
|
|
523
|
+
add_request_options(params, 6, 7)
|
|
524
|
+
params[10] = integer_option(@opts[:enterprise_attestation], 'enterprise_attestation', 1..2) if @opts.key?(:enterprise_attestation)
|
|
525
|
+
preflight_request(params)
|
|
526
|
+
require_option!('rk') if params[7]&.fetch('rk', false)
|
|
527
|
+
require_option!('ep') if params[10]
|
|
528
|
+
needs_pin = info[:options]['clientPin'] && !(info[:options]['makeCredUvNotRqd'] && !params[7]&.fetch('rk', false))
|
|
529
|
+
if authentication_requested? || needs_pin
|
|
530
|
+
protocol, token = authorize(1, rp_id)
|
|
531
|
+
params[8] = protocol.authenticate(token, client_hash)
|
|
532
|
+
params[9] = protocol.version
|
|
533
|
+
# UV is performed through the token; CTAP forbids uv=true together with it.
|
|
534
|
+
params[7] = params[7].except('uv') if params[7]
|
|
535
|
+
end
|
|
536
|
+
result = ctap(1, params)
|
|
537
|
+
raise ResponseError, 'Invalid makeCredential response' unless result[1].is_a?(String) && result[3].is_a?(Hash)
|
|
538
|
+
|
|
539
|
+
{ format: result[1], authenticator_data: authenticator_data(result[2]), attestation_statement: result[3], enterprise_attestation: result[4], large_blob_key: result[5], raw: result }
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def get_assertion # rubocop:disable Naming/AccessorMethodName -- CTAP command name
|
|
543
|
+
rp_id = text(@opts[:rp_id], 'RP ID', max: 253)
|
|
544
|
+
client_hash = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
|
|
545
|
+
params = { 1 => rp_id, 2 => client_hash }
|
|
546
|
+
params[3] = credential_list(@opts[:allow_list]) if @opts.key?(:allow_list)
|
|
547
|
+
add_request_options(params, 4, 5)
|
|
548
|
+
preflight_request(params)
|
|
549
|
+
if authentication_requested?
|
|
550
|
+
protocol, token = authorize(2, rp_id)
|
|
551
|
+
params[6] = protocol.authenticate(token, client_hash)
|
|
552
|
+
params[7] = protocol.version
|
|
553
|
+
params[5] = params[5].except('uv') if params[5]
|
|
554
|
+
end
|
|
555
|
+
first = ctap(2, params)
|
|
556
|
+
count = bounded_count(first.fetch(5, 1))
|
|
557
|
+
results = [first] + Array.new(count - 1) { ctap(8) }
|
|
558
|
+
results.map do |result|
|
|
559
|
+
credential_descriptor(result[1], error: ResponseError) if result[1]
|
|
560
|
+
{ credential: result[1], authenticator_data: authenticator_data(result[2]), signature: binary(result[3], 'assertion signature', error: ResponseError), user: result[4], user_selected: result[6], large_blob_key: result[7], raw: result }
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def u2f_register
|
|
565
|
+
client = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
|
|
566
|
+
application = binary(@opts[:app_id_hash], 'application hash', length: 32)
|
|
567
|
+
response = u2f_apdu(1, client + application)
|
|
568
|
+
raise ResponseError, 'Invalid U2F registration response' unless response.bytesize > 67 && response.getbyte(0) == 5 && response.getbyte(1) == 4
|
|
569
|
+
|
|
570
|
+
key_length = response.getbyte(66)
|
|
571
|
+
raise ResponseError, 'Invalid U2F key handle length' if key_length.zero? || response.bytesize <= 67 + key_length
|
|
572
|
+
|
|
573
|
+
cert_offset = 67 + key_length
|
|
574
|
+
certificate_length = der_length(response.byteslice(cert_offset..))
|
|
575
|
+
certificate = response.byteslice(cert_offset, certificate_length)
|
|
576
|
+
OpenSSL::X509::Certificate.new(certificate)
|
|
577
|
+
signature = response.byteslice((cert_offset + certificate_length)..)
|
|
578
|
+
raise ResponseError, 'Missing U2F attestation signature' if signature.nil? || signature.empty?
|
|
579
|
+
|
|
580
|
+
{ public_key: response.byteslice(1, 65), key_handle: response.byteslice(67, key_length), certificate: certificate, signature: signature, raw: response }
|
|
581
|
+
rescue OpenSSL::OpenSSLError
|
|
582
|
+
raise ResponseError, 'Malformed U2F attestation certificate'
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def u2f_authenticate
|
|
586
|
+
client = binary(@opts[:client_data_hash], 'clientDataHash', length: 32)
|
|
587
|
+
application = binary(@opts[:app_id_hash], 'application hash', length: 32)
|
|
588
|
+
key_handle = binary(@opts[:key_handle], 'U2F key handle', max: 255)
|
|
589
|
+
check = @opts.fetch(:check_only, false)
|
|
590
|
+
raise ArgumentError, 'check_only must be boolean' unless [true, false].include?(check)
|
|
591
|
+
|
|
592
|
+
response = u2f_apdu(2, client + application + [key_handle.bytesize].pack('C') + key_handle, check ? 7 : 3)
|
|
593
|
+
raise ResponseError, 'Unexpected successful check-only response' if check
|
|
594
|
+
raise ResponseError, 'Invalid U2F authentication response' unless response.bytesize > 5 && response.getbyte(0).allbits?(1)
|
|
595
|
+
|
|
596
|
+
{ user_presence: response.getbyte(0), counter: response.byteslice(1, 4).unpack1('N'), signature: response.byteslice(5..), raw: response }
|
|
597
|
+
rescue APDUError => e
|
|
598
|
+
raise unless check && [0x6985, 0x6a80].include?(e.code)
|
|
599
|
+
|
|
600
|
+
{ recognized: e.code == 0x6985 }
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
private
|
|
604
|
+
|
|
605
|
+
def credential_list(list)
|
|
606
|
+
raise ArgumentError, 'Credential list must be an Array' unless list.is_a?(Array) && list.size <= record_limit
|
|
607
|
+
|
|
608
|
+
list.each { |descriptor| credential_descriptor(descriptor) }
|
|
609
|
+
list
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def add_request_options(params, extensions_key, options_key)
|
|
613
|
+
if @opts.key?(:extensions)
|
|
614
|
+
raise ArgumentError, 'Extensions must be a text-keyed map' unless @opts[:extensions].is_a?(Hash) && @opts[:extensions].keys.all? { |key| key.is_a?(String) && key.encoding == Encoding::UTF_8 }
|
|
615
|
+
|
|
616
|
+
params[extensions_key] = @opts[:extensions]
|
|
617
|
+
end
|
|
618
|
+
return unless @opts.key?(:authenticator_options)
|
|
619
|
+
|
|
620
|
+
options = @opts[:authenticator_options]
|
|
621
|
+
raise ArgumentError, 'Authenticator options must be a text/boolean map' unless options.is_a?(Hash) && options.all? { |key, value| key.is_a?(String) && key.encoding == Encoding::UTF_8 && [true, false].include?(value) }
|
|
622
|
+
|
|
623
|
+
params[options_key] = options
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def preflight_request(params)
|
|
627
|
+
encoded = CBOR.encode(value: params)
|
|
628
|
+
require_ctap2!
|
|
629
|
+
# Reserve space for protocol 2 PIN/UV authentication before requesting a token.
|
|
630
|
+
raise ArgumentError, 'CTAP request exceeds authenticator message limit' if encoded.bytesize + 40 > @max_message
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def authentication_requested?
|
|
634
|
+
!@opts[:pin].nil? || !@opts[:secret_provider].nil? || @opts[:uv] == true
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def authenticator_data(value)
|
|
638
|
+
binary(value, 'authenticator data', max: CBOR::MAX_BYTES, error: ResponseError)
|
|
639
|
+
raise ResponseError, 'Truncated authenticator data' if value.bytesize < 37
|
|
640
|
+
|
|
641
|
+
value
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def der_length(data)
|
|
645
|
+
raise ResponseError, 'Malformed DER certificate header' unless data.bytesize >= 2 && data.getbyte(0) == 0x30
|
|
646
|
+
|
|
647
|
+
first = data.getbyte(1)
|
|
648
|
+
return 2 + first if first < 128 && data.bytesize >= 2 + first
|
|
649
|
+
|
|
650
|
+
width = first & 127
|
|
651
|
+
raise ResponseError, 'Malformed DER certificate length' unless width.between?(1, 4) && data.bytesize >= 2 + width && data.getbyte(2).positive?
|
|
652
|
+
|
|
653
|
+
length = data.byteslice(2, width).bytes.reduce(0) { |total, byte| (total << 8) | byte }
|
|
654
|
+
raise ResponseError, 'Truncated or noncanonical DER certificate' unless length >= 128 && length < (1 << (8 * width)) && data.bytesize >= 2 + width + length
|
|
655
|
+
|
|
656
|
+
2 + width + length
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def bio_command
|
|
660
|
+
require_ctap2!
|
|
661
|
+
return 9 if info[:options].key?('bioEnroll')
|
|
662
|
+
return 0x40 if info[:versions].include?('FIDO_2_1_PRE') && info[:options].key?('userVerificationMgmtPreview')
|
|
663
|
+
|
|
664
|
+
raise UnsupportedError, 'Authenticator lacks biometric enrollment'
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def bio_call(command, subcommand, parameters = nil, auth = nil)
|
|
668
|
+
params = { 1 => 1, 2 => subcommand }
|
|
669
|
+
params[3] = parameters if parameters
|
|
670
|
+
if auth
|
|
671
|
+
protocol, token = auth
|
|
672
|
+
msg = [1, subcommand].pack('CC') + (parameters ? CBOR.encode(value: parameters) : ''.b)
|
|
673
|
+
params[4] = protocol.version
|
|
674
|
+
params[5] = protocol.authenticate(token, msg)
|
|
675
|
+
end
|
|
676
|
+
ctap(command, params)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def binary(value, label, length: nil, max: 4096, error: ArgumentError)
|
|
680
|
+
raise error, "Invalid #{label}: expected binary String of the required length" unless value.is_a?(String) && value.encoding == Encoding::BINARY && value.bytesize.positive? && value.bytesize <= max && (length.nil? || value.bytesize == length)
|
|
681
|
+
|
|
682
|
+
value
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def text(value, label, max: 256)
|
|
686
|
+
raise ArgumentError, "Invalid #{label}: expected nonempty UTF-8 text" unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? && value.bytesize.between?(1, max) && !value.include?("\x00")
|
|
687
|
+
|
|
688
|
+
value
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def integer_option(value, label, range)
|
|
692
|
+
value = Integer(value, 10) if value.is_a?(String) && value.match?(/\A[0-9]+\z/)
|
|
693
|
+
raise ArgumentError, "Invalid #{label}" unless value.is_a?(Integer) && range.cover?(value)
|
|
694
|
+
|
|
695
|
+
value
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def identifier(name)
|
|
699
|
+
hex = @opts[:"#{name}_hex"]
|
|
700
|
+
if hex
|
|
701
|
+
raise ArgumentError, 'Identifier must be complete, even-length hexadecimal' unless hex.is_a?(String) && hex.match?(/\A(?:[0-9a-fA-F]{2})+\z/) && hex.bytesize <= 8192
|
|
702
|
+
|
|
703
|
+
return [hex].pack('H*')
|
|
704
|
+
end
|
|
705
|
+
binary(@opts[name], name)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def user_entity(user)
|
|
709
|
+
raise ArgumentError, 'User entity must be a Hash' unless user.is_a?(Hash)
|
|
710
|
+
|
|
711
|
+
binary(user['id'], 'user ID', max: 64)
|
|
712
|
+
%w[name displayName].each { |field| text(user[field], field, max: 64) if user.key?(field) }
|
|
713
|
+
CBOR.encode(value: user)
|
|
714
|
+
user
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def credential_descriptor(descriptor, error: ArgumentError)
|
|
718
|
+
raise error, 'Invalid public-key credential descriptor' unless descriptor.is_a?(Hash) && descriptor['type'] == 'public-key'
|
|
719
|
+
|
|
720
|
+
binary(descriptor['id'], 'credential ID', error: error)
|
|
721
|
+
descriptor
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def record_limit
|
|
725
|
+
integer_option(@opts.fetch(:max_records, 1024), 'max_records', 1..16_384)
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def bounded_count(value, zero: false)
|
|
729
|
+
raise ResponseError, 'Invalid or excessive authenticator count' unless value.is_a?(Integer) && value.between?(zero ? 0 : 1, record_limit)
|
|
730
|
+
|
|
731
|
+
value
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def require_option!(name, present: false)
|
|
735
|
+
require_ctap2!
|
|
736
|
+
supported = present ? info[:options].key?(name) : info[:options][name] == true
|
|
737
|
+
raise UnsupportedError, "Authenticator does not support #{name}" unless supported
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def credential_command
|
|
741
|
+
require_ctap2!
|
|
742
|
+
return 10 if info[:options]['credMgmt'] == true
|
|
743
|
+
return 0x41 if info[:versions].include?('FIDO_2_1_PRE') && info[:options].key?('credentialMgmtPreview')
|
|
744
|
+
|
|
745
|
+
raise UnsupportedError, 'Authenticator lacks credential management'
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def credential_call(command, subcommand, parameters, auth = nil)
|
|
749
|
+
params = { 1 => subcommand }
|
|
750
|
+
params[2] = parameters unless parameters.nil?
|
|
751
|
+
if auth
|
|
752
|
+
protocol, token = auth
|
|
753
|
+
message = [subcommand].pack('C') + (parameters ? CBOR.encode(value: parameters) : ''.b)
|
|
754
|
+
params[3] = protocol.version
|
|
755
|
+
params[4] = protocol.authenticate(token, message)
|
|
756
|
+
end
|
|
757
|
+
ctap(command, params)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def enumerate(command, sequence, parameters, total_key, auth)
|
|
761
|
+
begin_command, next_command = sequence
|
|
762
|
+
first = begin
|
|
763
|
+
credential_call(command, begin_command, parameters, auth)
|
|
764
|
+
rescue CTAPError => e
|
|
765
|
+
raise unless e.code == 0x2e
|
|
766
|
+
|
|
767
|
+
nil
|
|
768
|
+
end
|
|
769
|
+
return [] unless first
|
|
770
|
+
|
|
771
|
+
count = bounded_count(first.fetch(total_key, 1), zero: true)
|
|
772
|
+
return [] if count.zero?
|
|
773
|
+
|
|
774
|
+
[first] + Array.new(count - 1) { credential_call(command, next_command, nil) }
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def configure(subcommand, parameters = nil)
|
|
778
|
+
require_option!('authnrCfg')
|
|
779
|
+
allowed = info[:raw][31]
|
|
780
|
+
raise UnsupportedError, 'Configuration subcommand not supported by authenticator' if allowed && !allowed.include?(subcommand)
|
|
781
|
+
|
|
782
|
+
params = { 1 => subcommand }
|
|
783
|
+
params[2] = parameters if parameters
|
|
784
|
+
if info[:options]['clientPin'] || @opts[:uv] == true
|
|
785
|
+
protocol, token = authorize(32)
|
|
786
|
+
message = ("\xff".b * 32) + [0x0d, subcommand].pack('CC') + (parameters ? CBOR.encode(value: parameters) : ''.b)
|
|
787
|
+
params[3] = protocol.version
|
|
788
|
+
params[4] = protocol.authenticate(token, message)
|
|
789
|
+
end
|
|
790
|
+
ctap(13, params)
|
|
791
|
+
{ configured: true, subcommand: subcommand }
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def confirm!
|
|
795
|
+
raise ConfirmationRequired, 'FIDO authentication or mutation requires literal confirm: true' unless @opts[:confirm] == true
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def remaining
|
|
799
|
+
raise Cancelled, 'FIDO operation cancelled' if @opts[:cancelled]&.call
|
|
800
|
+
|
|
801
|
+
seconds = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
802
|
+
raise TimeoutError, 'FIDO operation deadline exceeded; not retried' unless seconds.positive?
|
|
803
|
+
|
|
804
|
+
seconds
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def keepalive(status)
|
|
808
|
+
remaining
|
|
809
|
+
@opts[:on_keepalive]&.call(status)
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def ctap(command, params = nil, cleanup: false)
|
|
813
|
+
read_only = command == 4 || (command == 6 && [1, 2, 7].include?(params&.[](2))) || ([9, 0x40].include?(command) && (params&.[](6) == true || params&.[](2) == 7))
|
|
814
|
+
confirm! unless read_only
|
|
815
|
+
data = [command].pack('C') + (params ? CBOR.encode(value: params) : ''.b)
|
|
816
|
+
raise ArgumentError, 'CTAP request exceeds authenticator message limit' if data.bytesize > @max_message
|
|
817
|
+
|
|
818
|
+
response = if @descriptor[:protocol] == :apdu
|
|
819
|
+
nfc_cbor(data, cleanup: cleanup)
|
|
820
|
+
else
|
|
821
|
+
@connection.exchange(protocol: :ctap, command: 0x10, data: data, timeout: wire_timeout(cleanup), on_keepalive: cleanup ? nil : method(:keepalive), cancelled: cleanup ? nil : @opts[:cancelled])
|
|
822
|
+
end
|
|
823
|
+
raise ResponseError, 'Empty or oversized CTAP response' unless response.is_a?(String) && response.bytesize.between?(1, CBOR::MAX_BYTES)
|
|
824
|
+
raise CTAPError, response.getbyte(0) unless response.getbyte(0).zero?
|
|
825
|
+
return {} if response.bytesize == 1
|
|
826
|
+
|
|
827
|
+
decoded = CBOR.decode(data: response.byteslice(1..))
|
|
828
|
+
raise ResponseError, 'CTAP response must be a map' unless decoded.is_a?(Hash)
|
|
829
|
+
|
|
830
|
+
decoded
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def u2f_apdu(ins, data = ''.b, parameter1 = 0)
|
|
834
|
+
confirm! unless [3, 0x46].include?(ins)
|
|
835
|
+
if @descriptor[:protocol] == :apdu
|
|
836
|
+
select_fido
|
|
837
|
+
body, status = smartcard_message(0, ins, parameter1, data)
|
|
838
|
+
raise APDUError, status unless status == 0x9000
|
|
839
|
+
|
|
840
|
+
return body
|
|
841
|
+
end
|
|
842
|
+
caps = @descriptor[:capabilities]
|
|
843
|
+
raise UnsupportedError, 'Authenticator does not support CTAPHID MSG/U2F' if caps.is_a?(Integer) && caps.anybits?(8)
|
|
844
|
+
|
|
845
|
+
packet = [0, ins, parameter1, 0, 0, data.bytesize].pack('CCCCCn') + data + "\x00\x00".b
|
|
846
|
+
response = @connection.exchange(protocol: :ctap, command: 3, data: packet, timeout: remaining, on_keepalive: method(:keepalive), cancelled: @opts[:cancelled])
|
|
847
|
+
raise ResponseError, 'Truncated U2F response' unless response.is_a?(String) && response.bytesize.between?(2, CBOR::MAX_BYTES)
|
|
848
|
+
|
|
849
|
+
status = response.byteslice(-2, 2).unpack1('n')
|
|
850
|
+
raise APDUError, status unless status == 0x9000
|
|
851
|
+
|
|
852
|
+
response.byteslice(0, response.bytesize - 2)
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def u2f_info
|
|
856
|
+
version = u2f_apdu(3)
|
|
857
|
+
raise ResponseError, 'Unexpected U2F version' unless version == 'U2F_V2'
|
|
858
|
+
|
|
859
|
+
@info = { versions: ['U2F_V2'], ctap2: false, options: {}, pin_uv_protocols: [], raw: {} }
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def fips_status
|
|
863
|
+
u2f_apdu(0x46)
|
|
864
|
+
:configured
|
|
865
|
+
rescue APDUError => e
|
|
866
|
+
return :unconfigured if e.code == 0x6a81
|
|
867
|
+
raise unless [0x6d00, 0x6e00, 0x6a86].include?(e.code)
|
|
868
|
+
|
|
869
|
+
raise UnsupportedError, 'Authenticator does not implement YubiKey 4 FIPS U2F PIN/reset commands'
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def fips_change_pin
|
|
873
|
+
new_pin = pin_text(:new_pin, minimum: 6, maximum: 32)
|
|
874
|
+
state = fips_status
|
|
875
|
+
old_pin = if state == :configured || @opts[:pin]
|
|
876
|
+
pin_text(:pin, minimum: 6, maximum: 32)
|
|
877
|
+
else
|
|
878
|
+
''.b
|
|
879
|
+
end
|
|
880
|
+
data = keep_secret([new_pin.bytesize].pack('C') + old_pin.b + new_pin.b)
|
|
881
|
+
u2f_apdu(0x44, data)
|
|
882
|
+
{ changed: true, initialized: state == :unconfigured }
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def wire_timeout(cleanup)
|
|
886
|
+
return remaining unless cleanup
|
|
887
|
+
|
|
888
|
+
@cleanup_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
889
|
+
seconds = @cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
890
|
+
raise TimeoutError, 'FIDO cancellation cleanup timed out' unless seconds.positive?
|
|
891
|
+
|
|
892
|
+
seconds
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def select_fido
|
|
896
|
+
return if @selected
|
|
897
|
+
|
|
898
|
+
aid = [0xa0, 0, 0, 6, 0x47, 0x2f, 0, 1].pack('C*')
|
|
899
|
+
_body, status = smartcard_message(0, 0xa4, 4, aid)
|
|
900
|
+
raise APDUError, status unless status == 0x9000
|
|
901
|
+
|
|
902
|
+
@selected = true
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def smartcard_message(cla, ins, parameter1, data = ''.b, cleanup: false)
|
|
906
|
+
offset = 0
|
|
907
|
+
while data.bytesize - offset > 250
|
|
908
|
+
chunk = data.byteslice(offset, 250)
|
|
909
|
+
body, status = smartcard_exchange([cla | 0x10, ins, parameter1, 0, 250].pack('C*') + chunk, cleanup: cleanup)
|
|
910
|
+
raise APDUError, status unless status == 0x9000
|
|
911
|
+
raise ResponseError, 'Unexpected response during APDU command chaining' unless body.empty?
|
|
912
|
+
|
|
913
|
+
offset += 250
|
|
914
|
+
end
|
|
915
|
+
chunk = data.byteslice(offset..)
|
|
916
|
+
packet = [cla, ins, parameter1, 0].pack('C*')
|
|
917
|
+
packet += [chunk.bytesize].pack('C') + chunk unless chunk.empty?
|
|
918
|
+
body, status = smartcard_exchange(packet + "\x00".b, cleanup: cleanup)
|
|
919
|
+
128.times do
|
|
920
|
+
return [body, status] unless status >> 8 == 0x61
|
|
921
|
+
|
|
922
|
+
extra, status = smartcard_exchange([0, 0xc0, 0, 0, status & 255].pack('C*'), cleanup: cleanup)
|
|
923
|
+
body += extra
|
|
924
|
+
raise ResponseError, 'FIDO APDU response limit exceeded' if body.bytesize > CBOR::MAX_BYTES
|
|
925
|
+
end
|
|
926
|
+
raise ResponseError, 'FIDO GET RESPONSE iteration limit exceeded'
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def smartcard_exchange(packet, cleanup: false)
|
|
930
|
+
response = @connection.exchange(protocol: :apdu, data: packet, timeout: wire_timeout(cleanup), cancelled: cleanup ? nil : @opts[:cancelled])
|
|
931
|
+
raise ResponseError, 'Truncated or oversized FIDO APDU' unless response.is_a?(String) && response.bytesize.between?(2, CBOR::MAX_BYTES)
|
|
932
|
+
|
|
933
|
+
[response.byteslice(0, response.bytesize - 2), response.byteslice(-2, 2).unpack1('n')]
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def nfc_cbor(data, cleanup: false)
|
|
937
|
+
select_fido
|
|
938
|
+
body, status = smartcard_message(0x80, 0x10, 0x80, data, cleanup: cleanup)
|
|
939
|
+
while status == 0x9100
|
|
940
|
+
pending = true
|
|
941
|
+
raise ResponseError, 'Invalid NFC keepalive status' unless body.bytesize == 1 && [1, 2].include?(body.getbyte(0))
|
|
942
|
+
|
|
943
|
+
keepalive(body.getbyte(0)) unless cleanup
|
|
944
|
+
body, status = smartcard_message(0x80, 0x11, 0, cleanup: cleanup)
|
|
945
|
+
end
|
|
946
|
+
pending = false
|
|
947
|
+
raise APDUError, status unless status == 0x9000
|
|
948
|
+
|
|
949
|
+
body
|
|
950
|
+
ensure
|
|
951
|
+
if pending
|
|
952
|
+
begin
|
|
953
|
+
smartcard_message(0x80, 0x11, 0x11, cleanup: true)
|
|
954
|
+
rescue StandardError
|
|
955
|
+
nil
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def require_ctap2!
|
|
961
|
+
raise UnsupportedError, 'Authenticator supports U2F only; CTAP2 operation unavailable' unless info[:ctap2]
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
def pin_protocol
|
|
965
|
+
require_ctap2!
|
|
966
|
+
raise UnsupportedError, 'Authenticator does not support clientPin' unless info[:options].key?('clientPin') || @opts[:uv] == true
|
|
967
|
+
|
|
968
|
+
versions = info[:pin_uv_protocols]
|
|
969
|
+
raise ResponseError, 'Invalid PIN protocol list' unless versions.is_a?(Array)
|
|
970
|
+
|
|
971
|
+
version = [2, 1].find { |candidate| versions.include?(candidate) }
|
|
972
|
+
raise UnsupportedError, 'Authenticator has no supported PIN protocol (1 or 2)' unless version
|
|
973
|
+
|
|
974
|
+
PinProtocol.new(version)
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def keep_secret(bytes)
|
|
978
|
+
@secrets << bytes
|
|
979
|
+
bytes
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def pin_text(name, minimum: 4, maximum: 63)
|
|
983
|
+
confirm!
|
|
984
|
+
value = @opts[name]
|
|
985
|
+
value = @opts[:secret_provider].call(name) if value.nil? && @opts[:secret_provider]
|
|
986
|
+
raise ArgumentError, "#{name} must be supplied in memory" unless value.is_a?(String)
|
|
987
|
+
|
|
988
|
+
text = value.dup.force_encoding(Encoding::UTF_8)
|
|
989
|
+
raise ArgumentError, "#{name} has invalid UTF-8, length or NUL character" unless text.valid_encoding? && text.length >= minimum && text.bytesize <= maximum && !text.include?("\x00")
|
|
990
|
+
|
|
991
|
+
keep_secret(text)
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
def key_agreement(protocol)
|
|
995
|
+
peer = ctap(6, { 1 => protocol.version, 2 => 2 })[1]
|
|
996
|
+
local, shared = protocol.encapsulate(peer)
|
|
997
|
+
[local, keep_secret(shared)]
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def authorize(permissions, rp_id = nil)
|
|
1001
|
+
confirm!
|
|
1002
|
+
protocol = pin_protocol
|
|
1003
|
+
using_uv = @opts[:uv] == true
|
|
1004
|
+
raise UnsupportedError, 'UV tokens require configured built-in UV, permissions and pinUvAuthToken support' if using_uv && (!info[:options]['uv'] || !info[:options]['pinUvAuthToken'] || permissions.nil?)
|
|
1005
|
+
|
|
1006
|
+
pin = pin_text(:pin) unless using_uv
|
|
1007
|
+
raise UnsupportedError, 'Authenticator has no configured client PIN' if !using_uv && info[:options]['clientPin'] != true
|
|
1008
|
+
|
|
1009
|
+
local, shared = key_agreement(protocol)
|
|
1010
|
+
scoped = !permissions.nil? && info[:options]['pinUvAuthToken'] == true
|
|
1011
|
+
params = { 1 => protocol.version, 2 => if using_uv
|
|
1012
|
+
6
|
|
1013
|
+
else
|
|
1014
|
+
(scoped ? 9 : 5)
|
|
1015
|
+
end, 3 => local }
|
|
1016
|
+
params[6] = protocol.encrypt(shared, keep_secret(OpenSSL::Digest::SHA256.digest(pin).byteslice(0, 16))) unless using_uv
|
|
1017
|
+
if scoped
|
|
1018
|
+
params[9] = permissions
|
|
1019
|
+
params[10] = rp_id if rp_id
|
|
1020
|
+
end
|
|
1021
|
+
encrypted = ctap(6, params)[2]
|
|
1022
|
+
token = keep_secret(protocol.validate_token(protocol.decrypt(shared, encrypted)))
|
|
1023
|
+
[protocol, token]
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Ephemeral key agreement and CTAP PIN/UV protocols 1 and 2.
|
|
1028
|
+
# Keys are generated with OpenSSL's OS-seeded CSPRNG, never device OTP.
|
|
1029
|
+
class PinProtocol
|
|
1030
|
+
attr_reader :version
|
|
1031
|
+
|
|
1032
|
+
def initialize(version)
|
|
1033
|
+
raise ArgumentError, 'PIN protocol must be 1 or 2' unless [1, 2].include?(version)
|
|
1034
|
+
|
|
1035
|
+
@version = version
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
def derive(shared_x)
|
|
1039
|
+
raise ResponseError, 'Invalid ECDH secret length' unless shared_x.is_a?(String) && shared_x.bytesize == 32
|
|
1040
|
+
return OpenSSL::Digest::SHA256.digest(shared_x) if @version == 1
|
|
1041
|
+
|
|
1042
|
+
# HKDF-Extract followed by one HKDF-Expand block for each label.
|
|
1043
|
+
prk = OpenSSL::HMAC.digest('SHA256', "\x00".b * 32, shared_x)
|
|
1044
|
+
['CTAP2 HMAC key', 'CTAP2 AES key'].map { |info| OpenSSL::HMAC.digest('SHA256', prk, info.b + "\x01".b) }.join.b
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def encapsulate(peer)
|
|
1048
|
+
raise ResponseError, 'Invalid PIN key agreement COSE key' unless peer.is_a?(Hash) && peer[1] == 2 && peer[3] == -25 && peer[-1] == 1 && [-2, -3].all? { |label| peer[label].is_a?(String) && peer[label].encoding == Encoding::BINARY && peer[label].bytesize == 32 }
|
|
1049
|
+
|
|
1050
|
+
group = OpenSSL::PKey::EC::Group.new('prime256v1')
|
|
1051
|
+
point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("\x04".b + peer[-2] + peer[-3], 2))
|
|
1052
|
+
raise ResponseError, 'Invalid PIN key agreement point' if point.infinity? || !point.on_curve?
|
|
1053
|
+
|
|
1054
|
+
ephemeral = OpenSSL::PKey::EC.generate('prime256v1')
|
|
1055
|
+
public_bytes = ephemeral.public_key.to_octet_string(:uncompressed)
|
|
1056
|
+
local = { 1 => 2, 3 => -25, -1 => 1, -2 => public_bytes.byteslice(1, 32), -3 => public_bytes.byteslice(33, 32) }
|
|
1057
|
+
[local, derive(ephemeral.dh_compute_key(point))]
|
|
1058
|
+
rescue OpenSSL::OpenSSLError, ArgumentError
|
|
1059
|
+
raise ResponseError, 'Invalid PIN key agreement point'
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def authenticate(key, message)
|
|
1063
|
+
mac = OpenSSL::HMAC.digest('SHA256', @version == 1 ? key : key.byteslice(0, 32), message)
|
|
1064
|
+
@version == 1 ? mac.byteslice(0, 16) : mac
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def encrypt(key, plaintext)
|
|
1068
|
+
validate_cipher_input(key, plaintext, ArgumentError)
|
|
1069
|
+
iv = @version == 1 ? "\x00".b * 16 : SecureRandom.random_bytes(16)
|
|
1070
|
+
output = cipher(key, iv, plaintext, true)
|
|
1071
|
+
@version == 1 ? output : iv + output
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def decrypt(key, ciphertext)
|
|
1075
|
+
if @version == 2
|
|
1076
|
+
raise ResponseError, 'Truncated protocol 2 ciphertext' unless ciphertext.is_a?(String) && ciphertext.bytesize >= 32
|
|
1077
|
+
|
|
1078
|
+
iv = ciphertext.byteslice(0, 16)
|
|
1079
|
+
ciphertext = ciphertext.byteslice(16..)
|
|
1080
|
+
else
|
|
1081
|
+
iv = "\x00".b * 16
|
|
1082
|
+
end
|
|
1083
|
+
validate_cipher_input(key, ciphertext, ResponseError)
|
|
1084
|
+
cipher(key, iv, ciphertext, false)
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def validate_token(token)
|
|
1088
|
+
sizes = @version == 1 ? [16, 32] : [32]
|
|
1089
|
+
raise ResponseError, 'Invalid PIN/UV token length' unless token.is_a?(String) && sizes.include?(token.bytesize)
|
|
1090
|
+
|
|
1091
|
+
token
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
private
|
|
1095
|
+
|
|
1096
|
+
def validate_cipher_input(key, data, error)
|
|
1097
|
+
raise error, 'Invalid PIN cipher key length' unless key.is_a?(String) && key.bytesize == (@version == 1 ? 32 : 64)
|
|
1098
|
+
raise error, 'Invalid PIN cipher block length' unless data.is_a?(String) && data.bytesize.positive? && (data.bytesize % 16).zero?
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
def cipher(key, iv_bytes, data, encrypt)
|
|
1102
|
+
aes = OpenSSL::Cipher.new('aes-256-cbc')
|
|
1103
|
+
encrypt ? aes.encrypt : aes.decrypt
|
|
1104
|
+
aes.padding = 0
|
|
1105
|
+
aes.key = @version == 1 ? key : key.byteslice(32, 32)
|
|
1106
|
+
aes.iv = iv_bytes
|
|
1107
|
+
aes.update(data) + aes.final
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# CTAP canonical CBOR: binary Strings are bytes, UTF-8 Strings are text.
|
|
1112
|
+
# Reject indefinite lengths, tags/floats, duplicates and excessive work.
|
|
1113
|
+
module CBOR
|
|
1114
|
+
MAX_BYTES = 1_048_576
|
|
1115
|
+
MAX_ITEMS = 16_384
|
|
1116
|
+
MAX_DEPTH = 32
|
|
1117
|
+
|
|
1118
|
+
public_class_method def self.encode(opts = {})
|
|
1119
|
+
Codec.new.encode(opts[:value])
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
public_class_method def self.decode(opts = {})
|
|
1123
|
+
Codec.new.decode(opts[:data])
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
public_class_method def self.authors
|
|
1127
|
+
"AUTHOR(S):\n0day Inc. <request.pentest@0dayinc.com>"
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
public_class_method def self.help
|
|
1131
|
+
puts <<~HELP
|
|
1132
|
+
Purpose: Encode bounded CTAP canonical CBOR without a runtime dependency.
|
|
1133
|
+
#{self}.encode(value: 'required - Integer, binary or UTF-8 String, Array, Hash, boolean or nil')
|
|
1134
|
+
Purpose: Decode exactly one bounded canonical CBOR object.
|
|
1135
|
+
#{self}.decode(data: 'required - Binary CBOR String')
|
|
1136
|
+
Purpose: Identify the implementation authors.
|
|
1137
|
+
#{self}.authors
|
|
1138
|
+
HELP
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# Per-call budget prevents recursion and allocation attacks in either direction.
|
|
1142
|
+
class Codec
|
|
1143
|
+
def initialize
|
|
1144
|
+
@items = 0
|
|
1145
|
+
@encoded_size = 0
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
def encode(value, depth = 0)
|
|
1149
|
+
budget(depth, ArgumentError)
|
|
1150
|
+
out = case value
|
|
1151
|
+
when Integer then header(value.negative? ? 1 : 0, value.negative? ? -1 - value : value)
|
|
1152
|
+
when String
|
|
1153
|
+
binary = value.encoding == Encoding::BINARY
|
|
1154
|
+
text = binary ? value : value.encode(Encoding::UTF_8)
|
|
1155
|
+
raise ArgumentError, 'CBOR text must be valid UTF-8' unless text.valid_encoding?
|
|
1156
|
+
raise ArgumentError, 'CBOR byte limit exceeded' if text.bytesize > MAX_BYTES
|
|
1157
|
+
|
|
1158
|
+
header(binary ? 2 : 3, text.bytesize) + text.b
|
|
1159
|
+
when Array
|
|
1160
|
+
raise ArgumentError, 'CBOR item limit exceeded' if value.size > MAX_ITEMS
|
|
1161
|
+
|
|
1162
|
+
header(4, value.size) + value.map { |entry| encode(entry, depth + 1) }.join.b
|
|
1163
|
+
when Hash
|
|
1164
|
+
raise ArgumentError, 'CBOR item limit exceeded' if value.size > MAX_ITEMS
|
|
1165
|
+
|
|
1166
|
+
entries = value.map do |key, entry|
|
|
1167
|
+
validate_key(key, ArgumentError)
|
|
1168
|
+
[encode(key, depth + 1), encode(entry, depth + 1)]
|
|
1169
|
+
end
|
|
1170
|
+
header(5, entries.size) + entries.sort_by { |key, _| [key.getbyte(0), key.bytesize, key] }.join.b
|
|
1171
|
+
when true then "\xf5".b
|
|
1172
|
+
when false then "\xf4".b
|
|
1173
|
+
when nil then "\xf6".b
|
|
1174
|
+
else raise ArgumentError, 'Unsupported CBOR type'
|
|
1175
|
+
end
|
|
1176
|
+
# Count leaf data once; every assembled container is also bounded.
|
|
1177
|
+
@encoded_size += out.bytesize unless value.is_a?(Array) || value.is_a?(Hash)
|
|
1178
|
+
raise ArgumentError, 'CBOR byte limit exceeded' if out.bytesize > MAX_BYTES || @encoded_size > MAX_BYTES
|
|
1179
|
+
|
|
1180
|
+
out
|
|
1181
|
+
rescue EncodingError
|
|
1182
|
+
raise ArgumentError, 'CBOR text must be valid UTF-8'
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def decode(data)
|
|
1186
|
+
raise ResponseError, 'CBOR requires a bounded binary String' unless data.is_a?(String) && data.bytesize <= MAX_BYTES
|
|
1187
|
+
|
|
1188
|
+
@data = data.b
|
|
1189
|
+
@offset = 0
|
|
1190
|
+
value = read_value(0)
|
|
1191
|
+
raise ResponseError, 'Trailing CBOR bytes' unless @offset == @data.bytesize
|
|
1192
|
+
|
|
1193
|
+
value
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
private
|
|
1197
|
+
|
|
1198
|
+
def header(major, value)
|
|
1199
|
+
raise ArgumentError, 'CBOR integer out of range' unless value.between?(0, 0xffffffffffffffff)
|
|
1200
|
+
|
|
1201
|
+
case value
|
|
1202
|
+
when 0..23 then [(major << 5) | value].pack('C')
|
|
1203
|
+
when 24..255 then [(major << 5) | 24, value].pack('CC')
|
|
1204
|
+
when 256..65_535 then [(major << 5) | 25, value].pack('Cn')
|
|
1205
|
+
when 65_536..0xffffffff then [(major << 5) | 26, value].pack('CN')
|
|
1206
|
+
else [(major << 5) | 27, value].pack('CQ>')
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
def budget(depth, error)
|
|
1211
|
+
@items += 1
|
|
1212
|
+
raise error, 'CBOR depth limit exceeded (possibly cyclic input)' if depth > MAX_DEPTH
|
|
1213
|
+
raise error, 'CBOR item limit exceeded' if @items > MAX_ITEMS
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def validate_key(key, error)
|
|
1217
|
+
return if key.is_a?(Integer) || (key.is_a?(String) && key.encoding == Encoding::UTF_8)
|
|
1218
|
+
|
|
1219
|
+
raise error, 'CTAP CBOR map keys must be integers or text'
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
def take(length)
|
|
1223
|
+
raise ResponseError, 'Truncated CBOR' if length > @data.bytesize - @offset
|
|
1224
|
+
|
|
1225
|
+
result = @data.byteslice(@offset, length)
|
|
1226
|
+
@offset += length
|
|
1227
|
+
result
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def length_value(additional_info)
|
|
1231
|
+
return additional_info if additional_info < 24
|
|
1232
|
+
|
|
1233
|
+
width, fmt, minimum = { 24 => [1, 'C', 24], 25 => [2, 'n', 256], 26 => [4, 'N', 65_536], 27 => [8, 'Q>', 0x100000000] }[additional_info]
|
|
1234
|
+
raise ResponseError, 'Indefinite or invalid CBOR length' unless width
|
|
1235
|
+
|
|
1236
|
+
value = take(width).unpack1(fmt)
|
|
1237
|
+
raise ResponseError, 'Noncanonical CBOR integer or length' if value < minimum
|
|
1238
|
+
|
|
1239
|
+
value
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def read_value(depth)
|
|
1243
|
+
budget(depth, ResponseError)
|
|
1244
|
+
first = take(1).getbyte(0)
|
|
1245
|
+
major = first >> 5
|
|
1246
|
+
ai = first & 31
|
|
1247
|
+
return { 20 => false, 21 => true, 22 => nil }.fetch(ai) { raise ResponseError, 'Unsupported CBOR simple/float value' } if major == 7
|
|
1248
|
+
|
|
1249
|
+
length = length_value(ai)
|
|
1250
|
+
case major
|
|
1251
|
+
when 0 then length
|
|
1252
|
+
when 1 then -1 - length
|
|
1253
|
+
when 2, 3
|
|
1254
|
+
raise ResponseError, 'CBOR byte limit exceeded' if length > MAX_BYTES
|
|
1255
|
+
|
|
1256
|
+
value = take(length)
|
|
1257
|
+
value.force_encoding(Encoding::UTF_8) if major == 3
|
|
1258
|
+
raise ResponseError, 'Invalid CBOR UTF-8' unless value.valid_encoding?
|
|
1259
|
+
|
|
1260
|
+
value
|
|
1261
|
+
when 4, 5
|
|
1262
|
+
raise ResponseError, 'CBOR item limit exceeded' if length > MAX_ITEMS
|
|
1263
|
+
return Array.new(length) { read_value(depth + 1) } if major == 4
|
|
1264
|
+
|
|
1265
|
+
map = {}
|
|
1266
|
+
previous = nil
|
|
1267
|
+
length.times do
|
|
1268
|
+
start = @offset
|
|
1269
|
+
key = read_value(depth + 1)
|
|
1270
|
+
validate_key(key, ResponseError)
|
|
1271
|
+
encoded = @data.byteslice(start, @offset - start)
|
|
1272
|
+
order = [encoded.getbyte(0), encoded.bytesize, encoded]
|
|
1273
|
+
raise ResponseError, 'Noncanonical CBOR map ordering or duplicate key' if previous && (previous <=> order) >= 0
|
|
1274
|
+
raise ResponseError, 'Duplicate CBOR map key' if map.key?(key)
|
|
1275
|
+
|
|
1276
|
+
previous = order
|
|
1277
|
+
map[key] = read_value(depth + 1)
|
|
1278
|
+
end
|
|
1279
|
+
map
|
|
1280
|
+
else raise ResponseError, 'Unsupported CBOR tag/type'
|
|
1281
|
+
end
|
|
1282
|
+
end
|
|
1283
|
+
end
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
end
|