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,854 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pwn/plugins/yubikey/fido'
|
|
5
|
+
|
|
6
|
+
# The only fake boundary is Connection#exchange. Crypto inside the fake is
|
|
7
|
+
# independently implemented with OpenSSL KDF/AES, not FIDO::PinProtocol.
|
|
8
|
+
class FidoFixtureConnection
|
|
9
|
+
attr_reader :calls, :descriptor, :token
|
|
10
|
+
attr_accessor :info, :fail_code, :handler, :app_handler
|
|
11
|
+
|
|
12
|
+
def initialize(version: 2, options: {}, descriptor: { protocol: :ctap })
|
|
13
|
+
@descriptor = descriptor
|
|
14
|
+
@calls = []
|
|
15
|
+
@version = version
|
|
16
|
+
@token = 't'.b * 32
|
|
17
|
+
@private_key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
18
|
+
@info = { 1 => ['FIDO_2_1'], 3 => "\x00".b * 16, 4 => { 'clientPin' => true, 'pinUvAuthToken' => true }.merge(options), 5 => 4096, 6 => [version], 13 => 4 }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exchange(opts = {})
|
|
22
|
+
@calls << opts
|
|
23
|
+
return @handler.call(opts) if @handler
|
|
24
|
+
|
|
25
|
+
raise 'Unexpected HID command' unless opts[:protocol] == :ctap && opts[:command] == 0x10
|
|
26
|
+
|
|
27
|
+
command = opts[:data].getbyte(0)
|
|
28
|
+
payload = opts[:data].bytesize > 1 ? PWN::Plugins::YubiKey::FIDO::CBOR.decode(data: opts[:data].byteslice(1..)) : {}
|
|
29
|
+
response = case command
|
|
30
|
+
when 4 then @info
|
|
31
|
+
when 7 then {}
|
|
32
|
+
when 6 then client_pin(payload)
|
|
33
|
+
else
|
|
34
|
+
raise "Unexpected CTAP command #{command}" unless @app_handler
|
|
35
|
+
|
|
36
|
+
@app_handler.call(command, payload)
|
|
37
|
+
end
|
|
38
|
+
return [response].pack('C') if response.is_a?(Integer)
|
|
39
|
+
|
|
40
|
+
"\x00".b + (response.empty? ? ''.b : PWN::Plugins::YubiKey::FIDO::CBOR.encode(value: response))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def client_pin(payload)
|
|
44
|
+
case payload[2]
|
|
45
|
+
when 1 then { 3 => 8, 4 => false }
|
|
46
|
+
when 7 then { 5 => 5 }
|
|
47
|
+
when 2
|
|
48
|
+
pub = @private_key.public_key.to_octet_string(:uncompressed)
|
|
49
|
+
{ 1 => { 1 => 2, 3 => -25, -1 => 1, -2 => pub.byteslice(1, 32), -3 => pub.byteslice(33, 32) } }
|
|
50
|
+
when 3, 4, 5, 6, 9
|
|
51
|
+
return @fail_code if @fail_code
|
|
52
|
+
|
|
53
|
+
peer = payload.fetch(3)
|
|
54
|
+
point = OpenSSL::PKey::EC::Point.new(@private_key.group, OpenSSL::BN.new("\x04".b + peer[-2] + peer[-3], 2))
|
|
55
|
+
z = @private_key.dh_compute_key(point)
|
|
56
|
+
secret = if @version == 1
|
|
57
|
+
OpenSSL::Digest::SHA256.digest(z)
|
|
58
|
+
else
|
|
59
|
+
['CTAP2 HMAC key', 'CTAP2 AES key'].map { |info| OpenSSL::KDF.hkdf(z, salt: "\x00".b * 32, info: info, length: 32, hash: 'SHA256') }.join.b
|
|
60
|
+
end
|
|
61
|
+
raise 'Incorrect PIN encryption' if payload[6] && decrypt(secret, payload[6]) != OpenSSL::Digest.digest('SHA256', '123456').byteslice(0, 16)
|
|
62
|
+
|
|
63
|
+
if [3, 4].include?(payload[2])
|
|
64
|
+
raise 'Incorrect new PIN encryption' unless decrypt(secret, payload[5]) == '654321'.b.ljust(64, "\x00".b)
|
|
65
|
+
|
|
66
|
+
message = payload[5] + (payload[6] || ''.b)
|
|
67
|
+
raise 'Incorrect PIN MAC' unless payload[4] == mac(secret, message)
|
|
68
|
+
|
|
69
|
+
{}
|
|
70
|
+
else
|
|
71
|
+
{ 2 => encrypt(secret, @token) }
|
|
72
|
+
end
|
|
73
|
+
else raise 'Unexpected ClientPIN subcommand'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mac(key, message)
|
|
78
|
+
result = OpenSSL::HMAC.digest('SHA256', @version == 1 ? key : key.byteslice(0, 32), message)
|
|
79
|
+
@version == 1 ? result.byteslice(0, 16) : result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def encrypt(key, plain)
|
|
83
|
+
iv = @version == 1 ? "\x00".b * 16 : 'i'.b * 16
|
|
84
|
+
result = crypt(key, iv, plain, true)
|
|
85
|
+
@version == 1 ? result : iv + result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def decrypt(key, data)
|
|
89
|
+
iv = @version == 1 ? "\x00".b * 16 : data.byteslice(0, 16)
|
|
90
|
+
crypt(key, iv, @version == 1 ? data : data.byteslice(16..), false)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def crypt(key, iv_bytes, data, encrypt)
|
|
94
|
+
cipher = OpenSSL::Cipher.new('aes-256-cbc')
|
|
95
|
+
encrypt ? cipher.encrypt : cipher.decrypt
|
|
96
|
+
cipher.padding = 0
|
|
97
|
+
cipher.key = @version == 1 ? key : key.byteslice(32, 32)
|
|
98
|
+
cipher.iv = iv_bytes
|
|
99
|
+
cipher.update(data) + cipher.final
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
RSpec.describe 'Native YubiKey FIDO capability and PIN paths' do
|
|
104
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
105
|
+
|
|
106
|
+
it 'returns local help for each old group without touching a connection' do
|
|
107
|
+
[[], ['access'], ['credentials'], ['fingerprints'], ['config']].each do |args|
|
|
108
|
+
expect(fido.execute(args: args)).to include(:operations)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'reads getInfo on a serial-less Security Key and resets once with literal consent' do
|
|
113
|
+
connection = FidoFixtureConnection.new
|
|
114
|
+
result = fido.execute(connection: connection, args: ['info'])
|
|
115
|
+
expect(result[:versions]).to eq(['FIDO_2_1'])
|
|
116
|
+
expect(result[:options]['clientPin']).to be(true)
|
|
117
|
+
expect(connection.calls.first[:data]).to eq("\x04".b)
|
|
118
|
+
expect { fido.execute(connection: connection, args: ['reset'], confirm: 'true') }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
|
|
119
|
+
fido.execute(connection: connection, args: ['reset'], confirm: true)
|
|
120
|
+
expect(connection.calls.count { |call| call[:data] == "\x07".b }).to eq(1)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
[1, 2].each do |version|
|
|
124
|
+
it "sets, changes and verifies PIN with actual protocol #{version} ECDH/AES/MAC" do
|
|
125
|
+
connection = FidoFixtureConnection.new(version: version)
|
|
126
|
+
fido.execute(connection: connection, args: %w[access change-pin], pin: '123456', new_pin: '654321', confirm: true)
|
|
127
|
+
expect(fido.execute(connection: connection, args: %w[access verify-pin], pin: '123456', confirm: true)).to eq({ verified: true })
|
|
128
|
+
connection.info[4]['clientPin'] = false
|
|
129
|
+
expect(fido.execute(connection: connection, args: %w[access change-pin], new_pin: '654321', confirm: true)).to eq({ changed: true, initialized: true })
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'does not retry a wrong PIN, reveal secrets, invoke a provider before consent or accept secret argv' do
|
|
134
|
+
connection = FidoFixtureConnection.new
|
|
135
|
+
connection.fail_code = 0x31
|
|
136
|
+
expect { fido.execute(connection: connection, args: %w[access verify-pin], pin: '123456', confirm: true) }.to raise_error(fido::CTAPError) { |e|
|
|
137
|
+
expect(e.code).to eq(0x31)
|
|
138
|
+
expect(e.message).not_to include('123456')
|
|
139
|
+
}
|
|
140
|
+
attempts = connection.calls.count { |call| call[:data].start_with?("\x06".b) }
|
|
141
|
+
expect(attempts).to eq(2)
|
|
142
|
+
provider = ->(_name) { raise 'Provider must not be invoked' }
|
|
143
|
+
expect { fido.execute(connection: connection, args: %w[access change-pin], secret_provider: provider) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
|
|
144
|
+
expect { fido.execute(connection: connection, args: %w[access verify-pin --pin 123456], confirm: true) }.to raise_error(ArgumentError)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'reports U2F-only capabilities honestly and rejects unsupported CTAP2 management' do
|
|
148
|
+
connection = FidoFixtureConnection.new(descriptor: { protocol: :ctap, capabilities: 1 })
|
|
149
|
+
connection.handler = lambda do |request|
|
|
150
|
+
expect(request[:command]).to eq(3)
|
|
151
|
+
expect(request[:data]).to eq(['000300000000000000'].pack('H*'))
|
|
152
|
+
'U2F_V2'.b + "\x90\x00".b
|
|
153
|
+
end
|
|
154
|
+
expect(fido.execute(connection: connection, args: ['info'])).to include(versions: ['U2F_V2'], ctap2: false, options: {})
|
|
155
|
+
expect { fido.execute(connection: connection, args: %w[credentials list], confirm: true, pin: '123456') }.to raise_error(fido::UnsupportedError)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'propagates keepalive cancellation and malformed responses without retries' do
|
|
159
|
+
connection = FidoFixtureConnection.new
|
|
160
|
+
count = 0
|
|
161
|
+
connection.handler = lambda { |request|
|
|
162
|
+
count += 1
|
|
163
|
+
request[:on_keepalive]&.call(2)
|
|
164
|
+
"\x00".b
|
|
165
|
+
}
|
|
166
|
+
expect { fido.execute(connection: connection, args: ['reset'], confirm: true, on_keepalive: ->(_status) { raise Interrupt }) }.to raise_error(Interrupt)
|
|
167
|
+
expect(count).to eq(1)
|
|
168
|
+
connection.handler = ->(_request) { ''.b }
|
|
169
|
+
expect { fido.execute(connection: connection, args: ['info']) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
RSpec.describe 'Native YubiKey FIDO credential and config paths' do
|
|
174
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
175
|
+
let(:connection) { FidoFixtureConnection.new(options: { 'credMgmt' => true, 'authnrCfg' => true, 'setMinPINLength' => true, 'alwaysUv' => false, 'ep' => false }) }
|
|
176
|
+
let(:options) { { connection: connection, pin: '123456', confirm: true } }
|
|
177
|
+
|
|
178
|
+
def payloads
|
|
179
|
+
connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it 'enumerates every RP and credential with authenticated begins and unauthenticated nexts' do
|
|
183
|
+
expected_subcommands = [1, 2, 3, 4, 5, 4]
|
|
184
|
+
connection.app_handler = lambda do |command, params|
|
|
185
|
+
expect(command).to eq(10)
|
|
186
|
+
subcommand = expected_subcommands.shift
|
|
187
|
+
expect(params[1]).to eq(subcommand)
|
|
188
|
+
if [3, 5].include?(subcommand)
|
|
189
|
+
expect(params).to eq({ 1 => subcommand })
|
|
190
|
+
else
|
|
191
|
+
msg = [subcommand].pack('C') + (params[2] ? fido::CBOR.encode(value: params[2]) : ''.b)
|
|
192
|
+
expect(params[3]).to eq(2)
|
|
193
|
+
expect(params[4]).to eq(connection.mac(connection.token, msg))
|
|
194
|
+
end
|
|
195
|
+
case subcommand
|
|
196
|
+
when 1 then { 1 => 3, 2 => 22 }
|
|
197
|
+
when 2 then { 3 => { 'id' => 'first.example' }, 4 => 'a'.b * 32, 5 => 2 }
|
|
198
|
+
when 3 then { 3 => { 'id' => 'second.example' }, 4 => 'b'.b * 32 }
|
|
199
|
+
when 4
|
|
200
|
+
{ 6 => { 'id' => 'user'.b, 'name' => 'Example' }, 7 => { 'id' => params[2][1].byteslice(0, 1), 'type' => 'public-key' }, 8 => { 1 => 2 }, 9 => params[2][1].start_with?('a') ? 2 : 1 }
|
|
201
|
+
when 5 then { 6 => { 'id' => 'user2'.b }, 7 => { 'id' => 'c'.b, 'type' => 'public-key' }, 8 => { 1 => 2 } }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
result = fido.execute(options.merge(args: %w[credentials list]))
|
|
205
|
+
expect(result[:credentials].map { |cred| cred[:credential]['id'] }).to eq(%w[a c b])
|
|
206
|
+
expect(result[:relying_parties].size).to eq(2)
|
|
207
|
+
expect(expected_subcommands).to be_empty
|
|
208
|
+
expect(payloads.find { |payload| payload[2] == 9 }[9]).to eq(4)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it 'deletes an exact credential and updates the original user ID with authenticated canonical parameters' do
|
|
212
|
+
id = 'credential'.b
|
|
213
|
+
expected = [6, 7]
|
|
214
|
+
connection.app_handler = lambda do |command, params|
|
|
215
|
+
expect(command).to eq(10)
|
|
216
|
+
expect(params[1]).to eq(expected.shift)
|
|
217
|
+
expect(params[2][2]).to eq({ 'id' => id, 'type' => 'public-key' })
|
|
218
|
+
expect(params[4]).to eq(connection.mac(connection.token, [params[1]].pack('C') + fido::CBOR.encode(value: params[2])))
|
|
219
|
+
expect(params[2][3]).to eq({ 'id' => 'original'.b, 'name' => 'new name', 'displayName' => 'Display' }) if params[1] == 7
|
|
220
|
+
{}
|
|
221
|
+
end
|
|
222
|
+
expect(fido.execute(options.merge(args: ['credentials', 'delete', id.unpack1('H*')]))).to eq({ deleted: true })
|
|
223
|
+
expect(fido.perform(options.merge(operation: :update_credential, credential_id: id, user: { 'id' => 'original'.b, 'name' => 'new name', 'displayName' => 'Display' }))).to eq({ updated: true })
|
|
224
|
+
expect(expected).to be_empty
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'supports preview credential management but rejects update and malformed IDs before authentication' do
|
|
228
|
+
connection.info[1] = ['FIDO_2_1_PRE']
|
|
229
|
+
connection.info[4].delete('credMgmt')
|
|
230
|
+
connection.info[4]['credentialMgmtPreview'] = true
|
|
231
|
+
connection.info[4]['pinUvAuthToken'] = false
|
|
232
|
+
connection.app_handler = lambda do |command, params|
|
|
233
|
+
expect(command).to eq(0x41)
|
|
234
|
+
params[1] == 1 ? { 1 => 0, 2 => 25 } : 0x2e
|
|
235
|
+
end
|
|
236
|
+
expect(fido.execute(options.merge(args: %w[credentials list]))[:credentials]).to be_empty
|
|
237
|
+
expect(payloads.any? { |payload| payload[2] == 5 && !payload.key?(9) }).to be(true)
|
|
238
|
+
expect { fido.perform(options.merge(operation: :update_credential, credential_id: 'a'.b, user: { 'id' => 'u'.b })) }.to raise_error(fido::UnsupportedError)
|
|
239
|
+
count = connection.calls.size
|
|
240
|
+
expect { fido.execute(options.merge(args: %w[credentials delete not-hex])) }.to raise_error(ArgumentError)
|
|
241
|
+
expect(connection.calls.size).to eq(count)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it 'rejects inconsistent enumeration totals rather than claiming a partial list is complete' do
|
|
245
|
+
connection.app_handler = ->(_command, params) { params[1] == 1 ? { 1 => 2, 2 => 2 } : 0x2e }
|
|
246
|
+
expect { fido.execute(options.merge(args: %w[credentials list])) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /count/)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'signs all configuration commands with the ff32 and 0d domain separator' do
|
|
250
|
+
connection.info[16] = 2
|
|
251
|
+
subcommands = [3, 3, 2, 1]
|
|
252
|
+
expected_parameters = [{ 3 => true }, { 1 => 6, 2 => %w[first.example second.example], 3 => false }, nil, nil]
|
|
253
|
+
connection.app_handler = lambda do |command, params|
|
|
254
|
+
expect(command).to eq(13)
|
|
255
|
+
expect(params[1]).to eq(subcommands.shift)
|
|
256
|
+
expected_params = expected_parameters.shift
|
|
257
|
+
expect(params[2]).to eq(expected_params)
|
|
258
|
+
message = ("\xff".b * 32) + "\x0d".b + [params[1]].pack('C') + (expected_params ? fido::CBOR.encode(value: expected_params) : ''.b)
|
|
259
|
+
expect(params[4]).to eq(connection.mac(connection.token, message))
|
|
260
|
+
{}
|
|
261
|
+
end
|
|
262
|
+
[%w[access force-change], %w[access set-min-length 6 --rp-id first.example --rp-id second.example], %w[config toggle-always-uv], %w[config enable-ep-attestation]].each do |args|
|
|
263
|
+
expect(fido.execute(options.merge(args: args))).to include(configured: true)
|
|
264
|
+
end
|
|
265
|
+
expect(subcommands).to be_empty
|
|
266
|
+
expect(payloads.select { |payload| payload[2] == 9 }.map { |payload| payload[9] }.uniq).to eq([32])
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'uses no fake PIN when configuration is allowed before a PIN is set' do
|
|
270
|
+
connection.info[4]['clientPin'] = false
|
|
271
|
+
connection.app_handler = lambda do |command, params|
|
|
272
|
+
expect(command).to eq(13)
|
|
273
|
+
expect(params).to eq({ 1 => 2 })
|
|
274
|
+
{}
|
|
275
|
+
end
|
|
276
|
+
expect(fido.execute(options.merge(args: %w[config toggle-always-uv], pin: nil))).to include(configured: true)
|
|
277
|
+
expect(payloads).to be_empty
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
RSpec.describe 'Native YubiKey FIDO fingerprint paths' do
|
|
282
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
283
|
+
let(:connection) { FidoFixtureConnection.new(options: { 'bioEnroll' => false }) }
|
|
284
|
+
let(:options) { { connection: connection, pin: '123456', confirm: true } }
|
|
285
|
+
|
|
286
|
+
def bio_reply(command, params)
|
|
287
|
+
expect(command).to eq(9)
|
|
288
|
+
return { 1 => 1 } if params[6]
|
|
289
|
+
return { 2 => 1, 3 => 4, 8 => 20 } if params[2] == 7
|
|
290
|
+
|
|
291
|
+
expect(params[1]).to eq(1)
|
|
292
|
+
unless params[2] == 3
|
|
293
|
+
expect(params[4]).to eq(2)
|
|
294
|
+
msg = [1, params[2]].pack('CC') + (params[3] ? fido::CBOR.encode(value: params[3]) : ''.b)
|
|
295
|
+
expect(params[5]).to eq(connection.mac(connection.token, msg))
|
|
296
|
+
end
|
|
297
|
+
yield params[2], params[3]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it 'lists, renames and deletes templates with permission 08 and exact identifiers' do
|
|
301
|
+
commands = [4, 5, 6]
|
|
302
|
+
connection.app_handler = lambda do |command, params|
|
|
303
|
+
bio_reply(command, params) do |subcommand, values|
|
|
304
|
+
expect(subcommand).to eq(commands.shift)
|
|
305
|
+
expect(values).to eq({ 1 => 't'.b, 2 => 'index finger' }) if subcommand == 5
|
|
306
|
+
expect(values).to eq({ 1 => 't'.b }) if subcommand == 6
|
|
307
|
+
subcommand == 4 ? { 7 => [{ 1 => 't'.b, 2 => 'thumb' }] } : {}
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
expect(fido.execute(options.merge(args: %w[fingerprints list]))).to eq([{ template_id: 't'.b, name: 'thumb' }])
|
|
311
|
+
expect(fido.execute(options.merge(args: ['fingerprints', 'rename', '74', 'index finger']))).to eq({ renamed: true })
|
|
312
|
+
expect(fido.execute(options.merge(args: %w[fingerprints delete 74]))).to eq({ deleted: true })
|
|
313
|
+
requests = connection.calls.select { |call| call[:data].getbyte(0) == 6 }.map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) }
|
|
314
|
+
expect(requests.select { |req| req[2] == 9 }.map { |req| req[9] }.uniq).to eq([8])
|
|
315
|
+
expect(commands).to be_empty
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'enrolls bounded samples with authenticated capture-next and assigns the final friendly name' do
|
|
319
|
+
commands = [1, 2, 2, 5]
|
|
320
|
+
samples = []
|
|
321
|
+
connection.app_handler = lambda do |command, params|
|
|
322
|
+
bio_reply(command, params) do |subcommand, values|
|
|
323
|
+
expect(subcommand).to eq(commands.shift)
|
|
324
|
+
case subcommand
|
|
325
|
+
when 1
|
|
326
|
+
expect(values).to eq({ 3 => 1000 })
|
|
327
|
+
{ 4 => 't'.b, 5 => 0, 6 => 1 }
|
|
328
|
+
when 2
|
|
329
|
+
expect(values).to eq({ 1 => 't'.b, 3 => 1000 })
|
|
330
|
+
commands == [2, 5] ? { 5 => 7, 6 => 1 } : { 5 => 0, 6 => 0 }
|
|
331
|
+
when 5 then {}
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
result = fido.execute(options.merge(args: %w[fingerprints add index], capture_timeout_ms: 1000, on_sample: ->(sample) { samples << sample }))
|
|
336
|
+
expect(result).to include(enrolled: true, template_id: 't'.b, name: 'index')
|
|
337
|
+
expect(samples.map { |sample| sample[:status] }).to eq([0, 7, 0])
|
|
338
|
+
expect(commands).to be_empty
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it 'cancels a partial enrollment exactly once on callback interruption without masking the original failure' do
|
|
342
|
+
cancels = 0
|
|
343
|
+
connection.app_handler = lambda do |command, params|
|
|
344
|
+
bio_reply(command, params) do |subcommand, _values|
|
|
345
|
+
case subcommand
|
|
346
|
+
when 1 then { 4 => 't'.b, 5 => 0, 6 => 1 }
|
|
347
|
+
when 3
|
|
348
|
+
cancels += 1
|
|
349
|
+
expect(params).to eq({ 1 => 1, 2 => 3 })
|
|
350
|
+
{}
|
|
351
|
+
else raise 'Unexpected enrollment retry'
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
expect { fido.execute(options.merge(args: %w[fingerprints add], on_sample: ->(_sample) { raise Interrupt })) }.to raise_error(Interrupt)
|
|
356
|
+
expect(cancels).to eq(1)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
it 'cancels enrollment explicitly with consent and no PIN token' do
|
|
360
|
+
connection.app_handler = lambda do |command, params|
|
|
361
|
+
expect(command).to eq(9)
|
|
362
|
+
expect(params).to eq({ 1 => 1, 2 => 3 })
|
|
363
|
+
{}
|
|
364
|
+
end
|
|
365
|
+
expect(fido.perform(connection: connection, operation: :cancel_enrollment, confirm: true)).to eq({ cancelled: true })
|
|
366
|
+
expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it 'supports preview enrollment and sensor information without attempting a PIN' do
|
|
370
|
+
connection.info[1] = ['FIDO_2_1_PRE']
|
|
371
|
+
connection.info[4].delete('bioEnroll')
|
|
372
|
+
connection.info[4]['userVerificationMgmtPreview'] = false
|
|
373
|
+
connection.app_handler = lambda do |command, params|
|
|
374
|
+
expect(command).to eq(0x40)
|
|
375
|
+
params[6] ? { 1 => 1 } : { 2 => 1, 3 => 4, 8 => 20 }
|
|
376
|
+
end
|
|
377
|
+
expect(fido.perform(connection: connection, operation: :bio_sensor_info)).to include(max_samples: 4, max_name_bytes: 20)
|
|
378
|
+
expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
RSpec.describe 'Native YubiKey FIDO operational APIs' do
|
|
383
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
384
|
+
let(:connection) { FidoFixtureConnection.new }
|
|
385
|
+
let(:hash) { 'h'.b * 32 }
|
|
386
|
+
let(:options) { { connection: connection, confirm: true, pin: '123456', client_data_hash: hash } }
|
|
387
|
+
let(:auth_data) { ('r'.b * 32) + [1, 12].pack('CN') }
|
|
388
|
+
|
|
389
|
+
it 'makes credentials with caller-supplied RP/hash and permission 01 scoped to that RP' do
|
|
390
|
+
connection.app_handler = lambda do |command, params|
|
|
391
|
+
expect(command).to eq(1)
|
|
392
|
+
expect(params[1]).to eq(hash)
|
|
393
|
+
expect(params[2]).to eq({ 'id' => 'login.example', 'name' => 'Example' })
|
|
394
|
+
expect(params[3]).to eq({ 'id' => 'user'.b, 'name' => 'alice' })
|
|
395
|
+
expect(params[4]).to eq([{ 'type' => 'public-key', 'alg' => -7 }])
|
|
396
|
+
expect(params[8]).to eq(connection.mac(connection.token, hash))
|
|
397
|
+
expect(params[9]).to eq(2)
|
|
398
|
+
{ 1 => 'packed', 2 => auth_data, 3 => { 'alg' => -7, 'sig' => 'signature'.b } }
|
|
399
|
+
end
|
|
400
|
+
result = fido.make_credential(options.merge(rp: { 'id' => 'login.example', 'name' => 'Example' }, user: { 'id' => 'user'.b, 'name' => 'alice' }))
|
|
401
|
+
expect(result).to include(format: 'packed', authenticator_data: auth_data)
|
|
402
|
+
request = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |entry| entry[2] == 9 }
|
|
403
|
+
expect(request.values_at(9, 10)).to eq([1, 'login.example'])
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it 'gets all advertised assertions with permission 02 and handles built-in UV token negotiation' do
|
|
407
|
+
connection.info[4]['uv'] = true
|
|
408
|
+
expected = [2, 8]
|
|
409
|
+
connection.app_handler = lambda do |command, params|
|
|
410
|
+
expect(command).to eq(expected.shift)
|
|
411
|
+
if command == 2
|
|
412
|
+
expect(params[1]).to eq('login.example')
|
|
413
|
+
expect(params[2]).to eq(hash)
|
|
414
|
+
expect(params[6]).to eq(connection.mac(connection.token, hash))
|
|
415
|
+
expect(params[7]).to eq(2)
|
|
416
|
+
else
|
|
417
|
+
expect(params).to be_empty
|
|
418
|
+
end
|
|
419
|
+
{ 1 => { 'type' => 'public-key', 'id' => 'cred'.b }, 2 => auth_data, 3 => 'sig'.b, 5 => 2 }
|
|
420
|
+
end
|
|
421
|
+
result = fido.get_assertion(options.merge(pin: nil, uv: true, rp_id: 'login.example'))
|
|
422
|
+
expect(result.size).to eq(2)
|
|
423
|
+
expect(result.first).to include(authenticator_data: auth_data, signature: 'sig'.b)
|
|
424
|
+
request = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |entry| entry[2] == 6 }
|
|
425
|
+
expect(request.values_at(9, 10)).to eq([2, 'login.example'])
|
|
426
|
+
expect(request).not_to have_key(6)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
it 'rejects missing RP/hash, textual IDs and oversized inputs before device authentication' do
|
|
430
|
+
expect { fido.make_credential(options.merge(rp: {}, user: { 'id' => 'user'.b })) }.to raise_error(ArgumentError)
|
|
431
|
+
expect { fido.get_assertion(options.merge(client_data_hash: nil, rp_id: 'login.example')) }.to raise_error(ArgumentError)
|
|
432
|
+
expect { fido.get_assertion(options.merge(rp_id: 'login.example', allow_list: [{ 'id' => 'not-binary', 'type' => 'public-key' }])) }.to raise_error(ArgumentError)
|
|
433
|
+
expect(connection.calls).to be_empty
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it 'registers U2F and parses the raw credential and certificate without fabricating clientData' do
|
|
437
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
438
|
+
cert = OpenSSL::X509::Certificate.new
|
|
439
|
+
cert.version = 2
|
|
440
|
+
cert.serial = 1
|
|
441
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=Synthetic fixture')
|
|
442
|
+
cert.public_key = key
|
|
443
|
+
cert.not_before = Time.at(0)
|
|
444
|
+
cert.not_after = Time.at(1)
|
|
445
|
+
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
|
446
|
+
pub = key.public_key.to_octet_string(:uncompressed)
|
|
447
|
+
connection.handler = lambda do |request|
|
|
448
|
+
expect(request[:command]).to eq(3)
|
|
449
|
+
expect(request[:data]).to eq(['00010000000040'].pack('H*') + hash + ('a'.b * 32) + "\x00\x00".b)
|
|
450
|
+
"\x05".b + pub + "\x03".b + 'key'.b + cert.to_der + 'signature'.b + "\x90\x00".b
|
|
451
|
+
end
|
|
452
|
+
result = fido.u2f_register(options.merge(app_id_hash: 'a'.b * 32))
|
|
453
|
+
expect(result).to include(public_key: pub, key_handle: 'key'.b, certificate: cert.to_der, signature: 'signature'.b)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it 'authenticates and checks U2F key handles using exact extended APDU vectors without retrying touch errors' do
|
|
457
|
+
connection.handler = lambda do |request|
|
|
458
|
+
expect(request[:data].byteslice(0, 7)).to eq(['00020300000044'].pack('H*'))
|
|
459
|
+
expect(request[:data].byteslice(7, 68)).to eq(hash + ('a'.b * 32) + "\x03key".b)
|
|
460
|
+
"\x01\x00\x00\x00\x09signature\x90\x00".b
|
|
461
|
+
end
|
|
462
|
+
expect(fido.u2f_authenticate(options.merge(app_id_hash: 'a'.b * 32, key_handle: 'key'.b))).to include(user_presence: 1, counter: 9, signature: 'signature'.b)
|
|
463
|
+
connection.handler = lambda do |request|
|
|
464
|
+
expect(request[:data].getbyte(2)).to eq(7)
|
|
465
|
+
"\x69\x85".b
|
|
466
|
+
end
|
|
467
|
+
expect(fido.u2f_authenticate(options.merge(app_id_hash: 'a'.b * 32, key_handle: 'key'.b, check_only: true))).to eq({ recognized: true })
|
|
468
|
+
connection.handler = ->(_request) { "\x69\x85".b }
|
|
469
|
+
before = connection.calls.size
|
|
470
|
+
expect { fido.u2f_authenticate(options.merge(app_id_hash: 'a'.b * 32, key_handle: 'key'.b)) }.to raise_error(fido::APDUError)
|
|
471
|
+
expect(connection.calls.size).to eq(before + 1)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
RSpec.describe 'Native YubiKey FIDO smartcard and FIPS paths' do
|
|
476
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
477
|
+
|
|
478
|
+
it 'selects the FIDO AID and reads CTAP2 over NFC/PCSC with GET RESPONSE chaining' do
|
|
479
|
+
connection = FidoFixtureConnection.new(descriptor: { protocol: :apdu })
|
|
480
|
+
encoded_info = "\x00".b + fido::CBOR.encode(value: connection.info)
|
|
481
|
+
calls = 0
|
|
482
|
+
connection.handler = lambda do |request|
|
|
483
|
+
expect(request[:protocol]).to eq(:apdu)
|
|
484
|
+
calls += 1
|
|
485
|
+
case calls
|
|
486
|
+
when 1
|
|
487
|
+
expect(request[:data]).to eq(['00a4040008a0000006472f000100'].pack('H*'))
|
|
488
|
+
'FIDO_2_0'.b + "\x90\x00".b
|
|
489
|
+
when 2
|
|
490
|
+
expect(request[:data]).to eq(['80108000010400'].pack('H*'))
|
|
491
|
+
encoded_info.byteslice(0, 12) + "\x61\x00".b
|
|
492
|
+
when 3
|
|
493
|
+
expect(request[:data]).to eq(['00c0000000'].pack('H*'))
|
|
494
|
+
encoded_info.byteslice(12..) + "\x90\x00".b
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
expect(fido.execute(connection: connection, args: ['info'])[:ctap2]).to be(true)
|
|
498
|
+
expect(calls).to eq(3)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
it 'handles NFC keepalive and sends NFC cancellation when the callback interrupts' do
|
|
502
|
+
connection = FidoFixtureConnection.new(descriptor: { protocol: :apdu })
|
|
503
|
+
calls = 0
|
|
504
|
+
connection.handler = lambda do |request|
|
|
505
|
+
calls += 1
|
|
506
|
+
case request[:data].byteslice(0, 2)
|
|
507
|
+
when "\x00\xa4".b then 'FIDO_2_0'.b + "\x90\x00".b
|
|
508
|
+
when "\x80\x10".b
|
|
509
|
+
if request[:data].getbyte(5) == 4
|
|
510
|
+
"\x00".b + fido::CBOR.encode(value: connection.info) + "\x90\x00".b
|
|
511
|
+
else
|
|
512
|
+
"\x02\x91\x00".b
|
|
513
|
+
end
|
|
514
|
+
when "\x80\x11".b
|
|
515
|
+
expect(request[:data]).to eq(['8011110000'].pack('H*'))
|
|
516
|
+
"\x2d\x90\x00".b
|
|
517
|
+
else raise 'Unexpected APDU'
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
expect { fido.execute(connection: connection, args: ['reset'], confirm: true, on_keepalive: ->(_status) { raise Interrupt }) }.to raise_error(Interrupt)
|
|
521
|
+
expect(calls).to eq(4)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
it 'performs YubiKey 4 FIPS U2F PIN change, verification and one-shot reset without a CTAP2 stub' do
|
|
525
|
+
connection = FidoFixtureConnection.new(descriptor: { protocol: :ctap, capabilities: 1 })
|
|
526
|
+
mutation_ins = []
|
|
527
|
+
connection.handler = lambda do |request|
|
|
528
|
+
expect(request[:command]).to eq(3)
|
|
529
|
+
ins = request[:data].getbyte(1)
|
|
530
|
+
length = request[:data].byteslice(5, 2).unpack1('n')
|
|
531
|
+
data = request[:data].byteslice(7, length)
|
|
532
|
+
case ins
|
|
533
|
+
when 3 then 'U2F_V2'.b + "\x90\x00".b
|
|
534
|
+
when 0x46 then "\x90\x00".b
|
|
535
|
+
when 0x43
|
|
536
|
+
expect(data).to eq('123456')
|
|
537
|
+
mutation_ins << ins
|
|
538
|
+
"\x90\x00".b
|
|
539
|
+
when 0x44
|
|
540
|
+
expect(data).to eq("#{"\x06".b}123456654321")
|
|
541
|
+
mutation_ins << ins
|
|
542
|
+
"\x90\x00".b
|
|
543
|
+
when 0x45
|
|
544
|
+
mutation_ins << ins
|
|
545
|
+
"\x69\x85".b
|
|
546
|
+
else raise 'Unexpected FIPS command'
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
opts = { connection: connection, pin: '123456', new_pin: '654321', confirm: true }
|
|
550
|
+
expect(fido.execute(opts.merge(args: %w[access change-pin --u2f]))).to include(changed: true)
|
|
551
|
+
expect(fido.execute(opts.merge(args: %w[access verify-pin]))).to eq({ verified: true })
|
|
552
|
+
expect { fido.execute(opts.merge(args: ['reset'])) }.to raise_error(fido::APDUError) { |e| expect(e.code).to eq(0x6985) }
|
|
553
|
+
expect(mutation_ins).to eq([0x44, 0x43, 0x45])
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
RSpec.describe 'Native YubiKey FIDO boundary regressions' do
|
|
558
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
559
|
+
|
|
560
|
+
it 'uses capabilities learned during HID INIT to recognize legacy U2F-only keys' do
|
|
561
|
+
connection = FidoFixtureConnection.new
|
|
562
|
+
connection.handler = lambda do |request|
|
|
563
|
+
if request[:command] == 16
|
|
564
|
+
connection.descriptor[:capabilities] = 1
|
|
565
|
+
raise PWN::Plugins::YubiKey::CommandError, 'CTAPHID invalid command'
|
|
566
|
+
end
|
|
567
|
+
'U2F_V2'.b + "\x90\x00".b
|
|
568
|
+
end
|
|
569
|
+
expect(fido.execute(connection: connection, args: ['info'])[:versions]).to eq(['U2F_V2'])
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
it 'passes cancellation to the transport for a blocked read with no keepalive' do
|
|
573
|
+
connection = FidoFixtureConnection.new
|
|
574
|
+
cancelled = -> { false }
|
|
575
|
+
connection.handler = lambda do |request|
|
|
576
|
+
expect(request[:cancelled]).to equal(cancelled)
|
|
577
|
+
raise PWN::Plugins::YubiKey::TimeoutError, 'synthetic timeout'
|
|
578
|
+
end
|
|
579
|
+
expect { fido.execute(connection: connection, args: ['info'], cancelled: cancelled) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
|
|
580
|
+
expect(connection.calls.size).to eq(1)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
it 'does not turn a ClientPIN invalid-option response into an empty fingerprint list' do
|
|
584
|
+
connection = FidoFixtureConnection.new(options: { 'bioEnroll' => true })
|
|
585
|
+
connection.fail_code = 0x2c
|
|
586
|
+
connection.app_handler = ->(_command, params) { params[6] ? { 1 => 1 } : { 2 => 1, 3 => 4 } }
|
|
587
|
+
expect { fido.execute(connection: connection, args: %w[fingerprints list], pin: '123456', confirm: true) }.to raise_error(fido::CTAPError)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
it 'preserves a completed enrollment identifier if friendly naming fails, without deleting it' do
|
|
591
|
+
connection = FidoFixtureConnection.new(options: { 'bioEnroll' => false })
|
|
592
|
+
subcommands = []
|
|
593
|
+
connection.app_handler = lambda do |_command, params|
|
|
594
|
+
next({ 1 => 1 }) if params[6]
|
|
595
|
+
|
|
596
|
+
subcommands << params[2]
|
|
597
|
+
case params[2]
|
|
598
|
+
when 7 then { 2 => 1, 3 => 4 }
|
|
599
|
+
when 1 then { 4 => 'new'.b, 5 => 0, 6 => 0 }
|
|
600
|
+
when 5 then 0x30
|
|
601
|
+
else raise 'Must not delete or cancel a completed enrollment'
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
expect { fido.execute(connection: connection, args: %w[fingerprints add name], pin: '123456', confirm: true) }.to raise_error(fido::EnrollmentError) { |error| expect(error.template_id).to eq('new'.b) }
|
|
605
|
+
expect(subcommands).to eq([7, 1, 5])
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
it 'enforces literal consent even on the underlying session entry points' do
|
|
609
|
+
connection = FidoFixtureConnection.new
|
|
610
|
+
session = fido::Session.new(connection: connection, pin: '123456', new_pin: '654321', secret_provider: ->(_name) { raise 'No provider call permitted' })
|
|
611
|
+
expect { session.verify_pin }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
|
|
612
|
+
expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
RSpec.describe 'Native YubiKey FIDO validation and bounded failures' do
|
|
617
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
618
|
+
let(:connection) { FidoFixtureConnection.new }
|
|
619
|
+
|
|
620
|
+
it 'rejects nonboolean advertised capabilities and malformed PIN limits before a PIN attempt' do
|
|
621
|
+
connection.info[4]['clientPin'] = 'false'
|
|
622
|
+
expect { fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456') }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
623
|
+
connection.info[4]['clientPin'] = true
|
|
624
|
+
connection.info[13] = '6'
|
|
625
|
+
expect { fido.execute(connection: connection, args: %w[access change-pin], confirm: true, pin: '123456', new_pin: '654321') }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
626
|
+
expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
it 'does not suppress a NO_CREDENTIALS error partway through an advertised RP enumeration' do
|
|
630
|
+
connection.info[4]['credMgmt'] = true
|
|
631
|
+
connection.app_handler = lambda do |_command, params|
|
|
632
|
+
case params[1]
|
|
633
|
+
when 1 then { 1 => 0, 2 => 25 }
|
|
634
|
+
when 2 then { 3 => { 'id' => 'first.example' }, 4 => 'a'.b * 32, 5 => 2 }
|
|
635
|
+
when 3 then 0x2e
|
|
636
|
+
else raise 'unexpected command'
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
expect { fido.execute(connection: connection, args: %w[credentials list], confirm: true, pin: '123456') }.to raise_error(fido::CTAPError)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
it 'validates booleans, timeouts, and limits without opening or authenticating anything' do
|
|
643
|
+
expect { fido.execute(args: false) }.to raise_error(ArgumentError)
|
|
644
|
+
expect { fido.execute(connection: connection, args: ['info'], timeout: Complex(1, 2)) }.to raise_error(ArgumentError)
|
|
645
|
+
expect { fido.execute(connection: connection, args: %w[credentials list], confirm: true, pin: '123456', max_records: 0) }.to raise_error(ArgumentError)
|
|
646
|
+
expect(connection.calls).to be_empty
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
it 'rejects false callbacks, ambiguous boolean selectors and a current PIN on an uninitialized authenticator' do
|
|
650
|
+
expect { fido.execute(connection: connection, args: ['info'], cancelled: false) }.to raise_error(ArgumentError)
|
|
651
|
+
expect { fido.execute(connection: connection, args: %w[access change-pin], u2f: 'true', confirm: true) }.to raise_error(ArgumentError)
|
|
652
|
+
connection.info[4]['clientPin'] = false
|
|
653
|
+
expect { fido.execute(connection: connection, args: %w[access change-pin], pin: '123456', new_pin: '654321', confirm: true) }.to raise_error(ArgumentError)
|
|
654
|
+
expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
it 'does not label built-in biometric authentication as PIN verification' do
|
|
658
|
+
connection.info[4].merge!('credMgmt' => true, 'uv' => true)
|
|
659
|
+
expect { fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, uv: true) }.to raise_error(ArgumentError)
|
|
660
|
+
expect(connection.calls).to be_empty
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
it 'uses a supported non-RP token permission or an explicit RP for modern PIN verification' do
|
|
664
|
+
connection.info[4]['credMgmt'] = true
|
|
665
|
+
expect(fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456')).to eq({ verified: true })
|
|
666
|
+
first = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |params| params[2] == 9 }
|
|
667
|
+
expect(first&.values_at(9, 10)).to eq([4, nil])
|
|
668
|
+
connection.info[4].delete('credMgmt')
|
|
669
|
+
connection.calls.clear
|
|
670
|
+
fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456', rp_id: 'operator.example')
|
|
671
|
+
second = connection.calls.filter_map { |call| fido::CBOR.decode(data: call[:data].byteslice(1..)) if call[:data].getbyte(0) == 6 }.find { |params| params[2] == 9 }
|
|
672
|
+
expect(second&.values_at(9, 10)).to eq([2, 'operator.example'])
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it 'rejects an overlong padded PIN before key agreement even if a newer device advertises a larger limit' do
|
|
676
|
+
connection.info[29] = 255
|
|
677
|
+
expect { fido.execute(connection: connection, args: %w[access change-pin], confirm: true, pin: '123456', new_pin: 'x' * 250) }.to raise_error(ArgumentError)
|
|
678
|
+
expect(connection.calls.none? { |call| call[:data].getbyte(0) == 6 }).to be(true)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
it 'reads PIN retries without spending an attempt and formats explicit CSV as an in-memory return' do
|
|
682
|
+
expect(fido.perform(connection: connection, operation: :pin_retries)).to include(pin_retries: 8, power_cycle_state: false)
|
|
683
|
+
connection.info[4]['credMgmt'] = true
|
|
684
|
+
connection.app_handler = ->(_command, params) { params[1] == 1 ? { 1 => 0, 2 => 25 } : 0x2e }
|
|
685
|
+
csv = fido.execute(connection: connection, args: %w[credentials list --csv], confirm: true, pin: '123456')
|
|
686
|
+
expect(csv).to eq("credential_id,rp_id,user_id,user_name,display_name\n")
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
it 'never retries reset rejection or blocked PIN status codes' do
|
|
690
|
+
[0x31, 0x32, 0x34, 0x37].each do |status|
|
|
691
|
+
connection.fail_code = status
|
|
692
|
+
before = connection.calls.size
|
|
693
|
+
expect { fido.execute(connection: connection, args: %w[access verify-pin], confirm: true, pin: '123456') }.to raise_error(fido::CTAPError) { |error| expect(error.code).to eq(status) }
|
|
694
|
+
expect(connection.calls.size - before).to eq(3)
|
|
695
|
+
end
|
|
696
|
+
connection.handler = lambda do |request|
|
|
697
|
+
request[:data] == "\x04".b ? "\x00".b + fido::CBOR.encode(value: connection.info) : "\x30".b
|
|
698
|
+
end
|
|
699
|
+
before = connection.calls.size
|
|
700
|
+
expect { fido.execute(connection: connection, args: ['reset'], confirm: true) }.to raise_error(fido::CTAPError)
|
|
701
|
+
expect(connection.calls.size - before).to eq(2)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
it 'leaves caller-owned PIN Strings unchanged and keeps session diagnostics redacted' do
|
|
705
|
+
pin = String.new('123456')
|
|
706
|
+
new_pin = String.new('654321')
|
|
707
|
+
fido.execute(connection: connection, args: %w[access change-pin], confirm: true, pin: pin, new_pin: new_pin)
|
|
708
|
+
expect([pin, new_pin]).to eq(%w[123456 654321])
|
|
709
|
+
session = fido::Session.new(connection: connection, pin: pin, confirm: true)
|
|
710
|
+
expect(session.inspect).not_to include(pin)
|
|
711
|
+
session.verify_pin
|
|
712
|
+
owned_buffers = session.instance_variable_get(:@secrets).dup
|
|
713
|
+
session.forget
|
|
714
|
+
expect(owned_buffers).not_to be_empty
|
|
715
|
+
expect(owned_buffers.all? { |buffer| buffer.bytes.all?(&:zero?) }).to be(true)
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
RSpec.describe 'Native YubiKey FIDO transport integration' do
|
|
720
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
721
|
+
|
|
722
|
+
it 'drives the real CTAPHID framing/Connection with only native packet IO replaced' do
|
|
723
|
+
require 'pwn/plugins/yubikey/transport'
|
|
724
|
+
transport = PWN::Plugins::YubiKey::Transport
|
|
725
|
+
io_type = Class.new do
|
|
726
|
+
attr_reader :commands
|
|
727
|
+
|
|
728
|
+
def initialize(capabilities, info_bytes)
|
|
729
|
+
@capabilities = capabilities
|
|
730
|
+
@info_bytes = info_bytes
|
|
731
|
+
@queue = []
|
|
732
|
+
@commands = []
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def write_packet(packet)
|
|
736
|
+
marker = packet.getbyte(4)
|
|
737
|
+
@commands << marker
|
|
738
|
+
channel = packet.unpack1('N')
|
|
739
|
+
response = case marker
|
|
740
|
+
when 0x86 then packet.byteslice(7, 8) + [0x12345678, 2, 1, 0, 0, @capabilities].pack('NCCCCC')
|
|
741
|
+
when 0x90 then @info_bytes
|
|
742
|
+
when 0x83 then 'U2F_V2'.b + "\x90\x00".b
|
|
743
|
+
else raise 'Unexpected native packet command'
|
|
744
|
+
end
|
|
745
|
+
@queue << ([channel, marker, response.bytesize].pack('NCn') + response.byteslice(0, 57)).ljust(64, "\x00".b)
|
|
746
|
+
offset = 57
|
|
747
|
+
sequence = 0
|
|
748
|
+
while offset < response.bytesize
|
|
749
|
+
@queue << ([channel, sequence].pack('NC') + response.byteslice(offset, 59)).ljust(64, "\x00".b)
|
|
750
|
+
offset += 59
|
|
751
|
+
sequence += 1
|
|
752
|
+
end
|
|
753
|
+
packet.bytesize
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def read_packet(_timeout)
|
|
757
|
+
@queue.shift || ''.b
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def close
|
|
761
|
+
:closed
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
info = FidoFixtureConnection.new.info
|
|
765
|
+
[1, 5].each do |caps|
|
|
766
|
+
io = io_type.new(caps, "\x00".b + fido::CBOR.encode(value: info))
|
|
767
|
+
connection = transport::Connection.new(descriptor: { protocol: :ctap }, io: io, prepare: transport::Deadline.new(2))
|
|
768
|
+
begin
|
|
769
|
+
result = fido.execute(connection: connection, args: ['info'])
|
|
770
|
+
expect(result[:ctap2]).to eq(caps == 5)
|
|
771
|
+
expect(io.commands).to eq(caps == 5 ? [0x86, 0x90] : [0x86, 0x83])
|
|
772
|
+
ensure
|
|
773
|
+
connection.close
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
it 'enforces the FIPS 32-byte limit before any vendor PIN mutation' do
|
|
779
|
+
connection = FidoFixtureConnection.new(descriptor: { protocol: :ctap, capabilities: 1 })
|
|
780
|
+
expect { fido.execute(connection: connection, args: %w[access change-pin --u2f], confirm: true, new_pin: 'x' * 33) }.to raise_error(ArgumentError)
|
|
781
|
+
expect(connection.calls).to be_empty
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
RSpec.describe 'Native YubiKey FIDO PIN cryptography' do
|
|
786
|
+
let(:fido) { PWN::Plugins::YubiKey::FIDO }
|
|
787
|
+
let(:z) { ['7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978'].pack('H*') }
|
|
788
|
+
|
|
789
|
+
it 'derives protocol 1 and 2 keys against independent OpenSSL KDF golden values' do
|
|
790
|
+
expect(fido::PinProtocol.new(1).derive(z).unpack1('H*')).to eq('23775201799b2234a18e8071e409cec80d42632fe77534180afdc533c9b76f81')
|
|
791
|
+
expect(fido::PinProtocol.new(2).derive(z).unpack1('H*')).to eq('64ca541c7473e4a526a447a65f24c755d6098f8341d6731894bb241e6a6bf289ffdd11da665666f9e5a99ba18a1cf674a4f1adf68a436bf9d7f219980910e877')
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
it 'matches NIST SP800-38A AES-256 CBC including protocol 2 IV prefix and RFC4231 HMAC' do
|
|
795
|
+
key = ['603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4'].pack('H*')
|
|
796
|
+
iv = ['000102030405060708090a0b0c0d0e0f'].pack('H*')
|
|
797
|
+
plaintext = ['6bc1bee22e409f96e93d7e117393172a'].pack('H*')
|
|
798
|
+
expected = iv + ['f58c4c04d6e5f1ba779eabfb5f7bfbd6'].pack('H*')
|
|
799
|
+
allow(SecureRandom).to receive(:random_bytes).with(16).and_return(iv)
|
|
800
|
+
proto = fido::PinProtocol.new(2)
|
|
801
|
+
expect(proto.encrypt(("\x00".b * 32) + key, plaintext)).to eq(expected)
|
|
802
|
+
expect(proto.decrypt(("\x00".b * 32) + key, expected)).to eq(plaintext)
|
|
803
|
+
expect(fido::PinProtocol.new(1).encrypt("\x00".b * 32, "\x00".b * 16).unpack1('H*')).to eq('dc95c078a2408989ad48a21492842087')
|
|
804
|
+
expect(proto.authenticate("\x0b".b * 20, 'Hi There').unpack1('H*')).to eq('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7')
|
|
805
|
+
expect(fido::PinProtocol.new(1).authenticate("\x0b".b * 20, 'Hi There').unpack1('H*')).to eq('b0344c61d8db38535ca8afceaf0bf12b')
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
it 'performs actual P-256 ECDH with COSE keys and rejects invalid curve points' do
|
|
809
|
+
generator = ['046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2964fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5'].pack('H*')
|
|
810
|
+
private_der = OpenSSL::ASN1::Sequence([
|
|
811
|
+
OpenSSL::ASN1::Integer(1), OpenSSL::ASN1::OctetString(("\x00".b * 31) + "\x01".b),
|
|
812
|
+
OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT), OpenSSL::ASN1::BitString(generator, 1, :EXPLICIT)
|
|
813
|
+
]).to_der
|
|
814
|
+
allow(OpenSSL::PKey::EC).to receive(:generate).with('prime256v1').and_return(OpenSSL::PKey.read(private_der))
|
|
815
|
+
peer = { 1 => 2, 3 => -25, -1 => 1, -2 => z, -3 => ['07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1'].pack('H*') }
|
|
816
|
+
cose, shared = fido::PinProtocol.new(1).encapsulate(peer)
|
|
817
|
+
expect(cose).to eq({ 1 => 2, 3 => -25, -1 => 1, -2 => generator.byteslice(1, 32), -3 => generator.byteslice(33, 32) })
|
|
818
|
+
expect(shared.unpack1('H*')).to eq('23775201799b2234a18e8071e409cec80d42632fe77534180afdc533c9b76f81')
|
|
819
|
+
expect { fido::PinProtocol.new(1).encapsulate(peer.merge(-2 => "\x00".b * 32)) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
820
|
+
expect { fido::PinProtocol.new(2).decrypt('x' * 64, 'x' * 16) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
RSpec.describe 'Native YubiKey FIDO CBOR' do
|
|
825
|
+
def codec
|
|
826
|
+
PWN::Plugins::YubiKey::FIDO::CBOR
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
it 'encodes independent RFC 8949 integer, text, byte and container vectors' do
|
|
830
|
+
vectors = { 0 => '00', 23 => '17', 24 => '1818', 1000 => '1903e8', -1 => '20', -1000 => '3903e7', true => 'f5', false => 'f4', nil => 'f6', [1, 2, 3] => '83010203', 'IETF' => '6449455446' }
|
|
831
|
+
vectors.each do |value, hex|
|
|
832
|
+
expect(codec.encode(value: value).unpack1('H*')).to eq(hex)
|
|
833
|
+
expect(codec.decode(data: [hex].pack('H*'))).to eq(value)
|
|
834
|
+
end
|
|
835
|
+
expect(codec.encode(value: "\x01\x02".b).unpack1('H*')).to eq('420102')
|
|
836
|
+
expect(codec.decode(data: ['420102'].pack('H*')).encoding).to eq(Encoding::BINARY)
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
it 'uses CTAP canonical major-type ordering rather than length-first ordering' do
|
|
840
|
+
expect(codec.encode(value: { -1 => 1, 1000 => 2, 'z' => 3 }).unpack1('H*')).to eq('a31903e8022001617a03')
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
it 'rejects malformed, ambiguous, noncanonical and unbounded inputs' do
|
|
844
|
+
%w[1817 5f 9f01ff a201010102 a220000100 61ff 1bffffffffffffffff00 d800 f7 fa00000000 440102].each do |hex|
|
|
845
|
+
expect { codec.decode(data: [hex].pack('H*')) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
846
|
+
end
|
|
847
|
+
cycle = []
|
|
848
|
+
cycle << cycle
|
|
849
|
+
expect { codec.encode(value: cycle) }.to raise_error(ArgumentError, /depth|cyclic/)
|
|
850
|
+
expect { codec.decode(data: ("\x81".b * 40) + "\x00".b) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /depth/)
|
|
851
|
+
expect { codec.encode(value: 'a' * 1_048_577) }.to raise_error(ArgumentError, /limit/)
|
|
852
|
+
expect { codec.decode(data: ['9bffffffffffffffff'].pack('H*')) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /limit/)
|
|
853
|
+
end
|
|
854
|
+
end
|