pwn 0.5.783 → 0.5.785
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +35 -16
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/scripts/build_gate.rb +58 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +6 -9
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +599 -15
- metadata +140 -17
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'pwn/plugins/yubikey'
|
|
6
|
+
require 'pwn/plugins/yubikey/protocol' if File.file?(File.expand_path('../../../../../lib/pwn/plugins/yubikey/protocol.rb', __dir__))
|
|
7
|
+
require 'pwn/plugins/yubikey/piv'
|
|
8
|
+
|
|
9
|
+
# Synthetic card boundary; independently frames TLV and ISO APDUs.
|
|
10
|
+
class PivWireCard # rubocop:disable Metrics/ClassLength
|
|
11
|
+
attr_accessor :version, :management_key, :management_algorithm, :corrupt_auth, :pin_status, :bio
|
|
12
|
+
attr_reader :commands, :objects, :keys
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@version = [5, 7, 1]
|
|
16
|
+
@management_key = (1..24).to_a.pack('C*')
|
|
17
|
+
@management_algorithm = 3
|
|
18
|
+
@commands = []
|
|
19
|
+
@objects = {}
|
|
20
|
+
@keys = {}
|
|
21
|
+
@chain = ''.b
|
|
22
|
+
@pin_status = 0x9000
|
|
23
|
+
@pin_tries = @pin_total = 3
|
|
24
|
+
@puk_tries = @puk_total = 3
|
|
25
|
+
@default_refs = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def descriptor
|
|
29
|
+
{ protocol: :apdu }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tlv(tag, value = ''.b)
|
|
33
|
+
head = tag > 255 ? [tag].pack('n') : [tag].pack('C')
|
|
34
|
+
length = value.bytesize
|
|
35
|
+
head + (length < 128 ? [length].pack('C') : [0x82, length].pack('Cn')) + value.b
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fields(bytes)
|
|
39
|
+
result = {}
|
|
40
|
+
offset = 0
|
|
41
|
+
while offset < bytes.bytesize
|
|
42
|
+
tag = bytes.getbyte(offset)
|
|
43
|
+
offset += 1
|
|
44
|
+
if tag & 31 == 31
|
|
45
|
+
tag = (tag << 8) | bytes.getbyte(offset)
|
|
46
|
+
offset += 1
|
|
47
|
+
end
|
|
48
|
+
length = bytes.getbyte(offset)
|
|
49
|
+
offset += 1
|
|
50
|
+
if length >= 128
|
|
51
|
+
size = length & 127
|
|
52
|
+
length = bytes.byteslice(offset, size).bytes.reduce(0) { |n, b| (n << 8) | b }
|
|
53
|
+
offset += size
|
|
54
|
+
end
|
|
55
|
+
result[tag] = bytes.byteslice(offset, length)
|
|
56
|
+
offset += length
|
|
57
|
+
end
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def exchange(opts)
|
|
62
|
+
raise 'wrong boundary' unless opts[:protocol] == :apdu
|
|
63
|
+
|
|
64
|
+
bytes = opts.fetch(:data)
|
|
65
|
+
cla, ins, p1, param2 = bytes.bytes.first(4)
|
|
66
|
+
length = bytes.bytesize > 5 ? bytes.getbyte(4) : 0
|
|
67
|
+
offset = 5
|
|
68
|
+
if length.zero? && bytes.bytesize > 7
|
|
69
|
+
length = bytes.byteslice(5, 2).unpack1('n')
|
|
70
|
+
offset = 7
|
|
71
|
+
end
|
|
72
|
+
data = @chain + (bytes.byteslice(offset, length) || ''.b)
|
|
73
|
+
if cla.anybits?(16)
|
|
74
|
+
@chain = data
|
|
75
|
+
return ['9000'].pack('H*')
|
|
76
|
+
end
|
|
77
|
+
@chain = ''.b
|
|
78
|
+
@commands << [ins, p1, param2, data]
|
|
79
|
+
value = command(ins, p1, param2, data)
|
|
80
|
+
value.is_a?(Integer) ? [value].pack('n') : value + ['9000'].pack('H*')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def command(ins, param1, param2, data)
|
|
84
|
+
case ins
|
|
85
|
+
when 0xa4, 0x24 then ''.b
|
|
86
|
+
when 0xfa
|
|
87
|
+
@pin_total = @pin_tries = param1
|
|
88
|
+
@puk_total = @puk_tries = param2
|
|
89
|
+
@default_refs = true
|
|
90
|
+
''.b
|
|
91
|
+
when 0xfd then @version.pack('C*')
|
|
92
|
+
when 0xf8 then [123].pack('N')
|
|
93
|
+
when 0xf7
|
|
94
|
+
case param2
|
|
95
|
+
when 0x9b then tlv(1, [@management_algorithm].pack('C')) + tlv(2, "\x00\x01".b) + tlv(5, [@default_refs ? 1 : 0].pack('C'))
|
|
96
|
+
when 0x80, 0x81 then tlv(5, [@default_refs ? 1 : 0].pack('C')) + tlv(6, (param2 == 0x80 ? [@pin_total, @pin_tries] : [@puk_total, @puk_tries]).pack('C*'))
|
|
97
|
+
when 0x96 then @bio ? tlv(7, "\x01".b) + tlv(6, "\x03".b) + tlv(8, "\x00".b) : 0x6a88
|
|
98
|
+
else
|
|
99
|
+
record = @keys[param2]
|
|
100
|
+
record ? tlv(1, [record[:algorithm]].pack('C')) + tlv(2, record.fetch(:policy, "\x02\x01".b)) + tlv(3, [record.fetch(:origin, 1)].pack('C')) + tlv(4, public_bytes(record.fetch(:key))) : 0x6a88
|
|
101
|
+
end
|
|
102
|
+
when 0x47
|
|
103
|
+
values = fields(fields(data).fetch(0xac))
|
|
104
|
+
id = values.fetch(0x80).getbyte(0)
|
|
105
|
+
key = case id
|
|
106
|
+
when 6, 7, 5, 0x16 then OpenSSL::PKey::RSA.generate({ 6 => 1024, 7 => 2048, 5 => 3072, 0x16 => 4096 }.fetch(id))
|
|
107
|
+
when 0x11, 0x14 then OpenSSL::PKey::EC.generate(id == 0x11 ? 'prime256v1' : 'secp384r1')
|
|
108
|
+
when 0xe0, 0xe1 then OpenSSL::PKey.generate_key(id == 0xe0 ? 'ED25519' : 'X25519')
|
|
109
|
+
else raise 'invalid algorithm'
|
|
110
|
+
end
|
|
111
|
+
@keys[param2] = { key: key, algorithm: id, origin: 1, policy: values.fetch(0xaa, "\x02".b) + values.fetch(0xab, "\x01".b) }
|
|
112
|
+
tlv(0x7f49, public_bytes(key))
|
|
113
|
+
when 0xfe
|
|
114
|
+
values = fields(data)
|
|
115
|
+
key = import_key(param1, values)
|
|
116
|
+
@keys[param2] = { key: key, algorithm: param1, origin: 2, policy: values.fetch(0xaa, "\x02".b) + values.fetch(0xab, "\x01".b) }
|
|
117
|
+
''.b
|
|
118
|
+
when 0xf6
|
|
119
|
+
return 0x6a88 unless @keys.key?(param2)
|
|
120
|
+
return 0x6a80 if param1 != 0xff && @keys.key?(param1)
|
|
121
|
+
|
|
122
|
+
removed = @keys.delete(param2)
|
|
123
|
+
@keys[param1] = removed unless param1 == 0xff
|
|
124
|
+
''.b
|
|
125
|
+
when 0xf9
|
|
126
|
+
record = @keys[param1]
|
|
127
|
+
return 0x6a80 unless record && record[:origin] == 1
|
|
128
|
+
|
|
129
|
+
key = record.fetch(:key)
|
|
130
|
+
cert = OpenSSL::X509::Certificate.new
|
|
131
|
+
cert.version = 2
|
|
132
|
+
cert.serial = 17
|
|
133
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=synthetic attestation')
|
|
134
|
+
cert.public_key = OpenSSL::PKey.read(key.public_to_der)
|
|
135
|
+
cert.not_before = Time.now
|
|
136
|
+
cert.not_after = Time.now + 3600
|
|
137
|
+
cert.sign(OpenSSL::PKey::EC.generate('prime256v1'), 'SHA256')
|
|
138
|
+
cert.to_der
|
|
139
|
+
when 0x20
|
|
140
|
+
if data == "\xff".b * 8
|
|
141
|
+
@pin_tries = [@pin_tries - 1, 0].max
|
|
142
|
+
0x63c0 | @pin_tries
|
|
143
|
+
else
|
|
144
|
+
@pin_status
|
|
145
|
+
end
|
|
146
|
+
when 0x2c
|
|
147
|
+
if data == "\xff".b * 16
|
|
148
|
+
@puk_tries = [@puk_tries - 1, 0].max
|
|
149
|
+
0x63c0 | @puk_tries
|
|
150
|
+
else
|
|
151
|
+
''.b
|
|
152
|
+
end
|
|
153
|
+
when 0xfb
|
|
154
|
+
return 0x6985 unless @pin_tries.zero? && @puk_tries.zero?
|
|
155
|
+
|
|
156
|
+
@objects.clear
|
|
157
|
+
@keys.clear
|
|
158
|
+
@pin_tries = @puk_tries = 3
|
|
159
|
+
@default_refs = true
|
|
160
|
+
''.b
|
|
161
|
+
when 0x87
|
|
162
|
+
auth = fields(fields(data).fetch(0x7c))
|
|
163
|
+
return private_operation(param2, auth) unless param2 == 0x9b
|
|
164
|
+
|
|
165
|
+
size = @management_algorithm == 3 ? 8 : 16
|
|
166
|
+
if auth.fetch(0x80).empty?
|
|
167
|
+
tlv(0x7c, tlv(0x80, cipher("\xa5".b * size)))
|
|
168
|
+
else
|
|
169
|
+
raise 'incorrect witness' unless auth.fetch(0x80) == "\xa5".b * size
|
|
170
|
+
|
|
171
|
+
tlv(0x7c, tlv(0x82, @corrupt_auth ? "\x00".b * size : cipher(auth.fetch(0x81))))
|
|
172
|
+
end
|
|
173
|
+
when 0xcb
|
|
174
|
+
object_id = fields(data).fetch(0x5c).unpack1('H*').to_i(16)
|
|
175
|
+
if @objects.key?(object_id)
|
|
176
|
+
tlv(object_id == 0x7e ? 0x7e : 0x53, @objects.fetch(object_id))
|
|
177
|
+
else
|
|
178
|
+
0x6a82
|
|
179
|
+
end
|
|
180
|
+
when 0xdb
|
|
181
|
+
values = fields(data)
|
|
182
|
+
@objects[values.fetch(0x5c).unpack1('H*').to_i(16)] = values.fetch(0x53)
|
|
183
|
+
''.b
|
|
184
|
+
when 0xff
|
|
185
|
+
@management_algorithm = data.getbyte(0)
|
|
186
|
+
@management_key = fields(data.byteslice(1..)).fetch(0x9b)
|
|
187
|
+
''.b
|
|
188
|
+
else raise format('Unexpected PIV INS %02x', ins)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def private_operation(slot, values)
|
|
193
|
+
record = @keys.fetch(slot)
|
|
194
|
+
key = record.fetch(:key)
|
|
195
|
+
result = if values.key?(0x85)
|
|
196
|
+
bytes = values.fetch(0x85)
|
|
197
|
+
if key.is_a?(OpenSSL::PKey::EC)
|
|
198
|
+
peer = OpenSSL::PKey::EC::Point.new(key.group, OpenSSL::BN.new(bytes, 2))
|
|
199
|
+
key.dh_compute_key(peer)
|
|
200
|
+
else
|
|
201
|
+
asn1 = OpenSSL::ASN1
|
|
202
|
+
peer = OpenSSL::PKey.read(asn1::Sequence.new([asn1::Sequence.new([asn1::ObjectId.new('X25519')]), asn1::BitString.new(bytes)]).to_der)
|
|
203
|
+
key.derive(peer)
|
|
204
|
+
end
|
|
205
|
+
elsif key.is_a?(OpenSSL::PKey::RSA)
|
|
206
|
+
key.private_decrypt(values.fetch(0x81), OpenSSL::PKey::RSA::NO_PADDING)
|
|
207
|
+
elsif key.is_a?(OpenSSL::PKey::EC)
|
|
208
|
+
key.dsa_sign_asn1(values.fetch(0x81))
|
|
209
|
+
else
|
|
210
|
+
key.sign(nil, values.fetch(0x81))
|
|
211
|
+
end
|
|
212
|
+
tlv(0x7c, tlv(0x82, result))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def public_bytes(key)
|
|
216
|
+
if key.is_a?(OpenSSL::PKey::RSA)
|
|
217
|
+
tlv(0x81, key.n.to_s(2)) + tlv(0x82, key.e.to_s(2))
|
|
218
|
+
elsif key.is_a?(OpenSSL::PKey::EC)
|
|
219
|
+
tlv(0x86, key.public_key.to_octet_string(:uncompressed))
|
|
220
|
+
else
|
|
221
|
+
tlv(0x86, key.raw_public_key)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def import_key(id, values)
|
|
226
|
+
asn1 = OpenSSL::ASN1
|
|
227
|
+
if [6, 7, 5, 0x16].include?(id)
|
|
228
|
+
p, q, dmp, dmq, iqmp = (1..5).map { |tag| OpenSSL::BN.new(values.fetch(tag), 2) }
|
|
229
|
+
e = OpenSSL::BN.new('65537')
|
|
230
|
+
d = e.mod_inverse((p - 1) * (q - 1))
|
|
231
|
+
OpenSSL::PKey.read(asn1::Sequence.new([0, p * q, e, d, p, q, dmp, dmq, iqmp].map { |n| asn1::Integer.new(n) }).to_der)
|
|
232
|
+
elsif [0x11, 0x14].include?(id)
|
|
233
|
+
OpenSSL::PKey.read(asn1::Sequence.new([asn1::Integer.new(1), asn1::OctetString.new(values.fetch(6)), asn1::ASN1Data.new([asn1::ObjectId.new(id == 0x11 ? 'prime256v1' : 'secp384r1')], 0, :CONTEXT_SPECIFIC)]).to_der)
|
|
234
|
+
else
|
|
235
|
+
OpenSSL::PKey.read(asn1::Sequence.new([asn1::Integer.new(0), asn1::Sequence.new([asn1::ObjectId.new(id == 0xe0 ? 'ED25519' : 'X25519')]), asn1::OctetString.new(asn1::OctetString.new(values.fetch(id == 0xe0 ? 7 : 8)).to_der)]).to_der)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def cipher(data)
|
|
240
|
+
cipher = OpenSSL::Cipher.new({ 3 => 'DES-EDE3-ECB', 8 => 'AES-128-ECB', 10 => 'AES-192-ECB', 12 => 'AES-256-ECB' }.fetch(@management_algorithm))
|
|
241
|
+
cipher.encrypt
|
|
242
|
+
cipher.padding = 0
|
|
243
|
+
cipher.key = @management_key
|
|
244
|
+
cipher.update(data) + cipher.final
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
RSpec.describe 'Native PIV' do
|
|
249
|
+
let(:piv) { PWN::Plugins::YubiKey::PIV }
|
|
250
|
+
let(:card) { PivWireCard.new }
|
|
251
|
+
let(:authorized) { { connection: card, confirm: true, management_key: card.management_key, pin: '765432' } }
|
|
252
|
+
# Literal C/R-APDUs from the PIV wire specification, not the production codec.
|
|
253
|
+
def wire_card(pairs)
|
|
254
|
+
double('PCSC connection').tap do |card|
|
|
255
|
+
pairs.each do |command, response|
|
|
256
|
+
expect(card).to receive(:exchange).with(hash_including(protocol: :apdu, data: [command].pack('H*'))).ordered.and_return([response].pack('H*'))
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it 'verifies a supplied PIN with an ISO padded VERIFY command' do
|
|
262
|
+
card = wire_card([
|
|
263
|
+
%w[00a404000ba000000308000010000100 9000],
|
|
264
|
+
%w[0020008008373635343332ffff 9000]
|
|
265
|
+
])
|
|
266
|
+
expect(PWN::Plugins::YubiKey::PIV.verify_pin(connection: card, pin: '765432', confirm: true)).to eq(:verified)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
{ 3 => 24, 8 => 16, 10 => 24, 12 => 32 }.each do |algorithm, size|
|
|
270
|
+
it "mutually authenticates algorithm #{algorithm} with a fresh verified challenge" do
|
|
271
|
+
card.management_algorithm = algorithm
|
|
272
|
+
card.management_key = (1..size).to_a.pack('C*')
|
|
273
|
+
2.times { expect(piv.authenticate(authorized)).to eq(:authenticated) }
|
|
274
|
+
challenges = card.commands.select { |ins, _, slot, data| ins == 0x87 && slot == 0x9b && data.bytesize > 4 }
|
|
275
|
+
expect(challenges.size).to eq(2)
|
|
276
|
+
expect(challenges.first.last).not_to eq(challenges.last.last)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it 'rejects an invalid management witness response without performing a write' do
|
|
281
|
+
card.corrupt_auth = true
|
|
282
|
+
expect { piv.authenticate(authorized) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /authentication/)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it 'changes and unblocks references with literal ISO7816 command bytes' do
|
|
286
|
+
[[%w[access change-pin], :new_pin, 0x24, 0x80, '765432'],
|
|
287
|
+
[%w[access change-puk], :new_puk, 0x24, 0x81, '87654321'],
|
|
288
|
+
[%w[access unblock-pin], :new_pin, 0x2c, 0x80, '87654321']].each do |args, field, instruction, reference, old|
|
|
289
|
+
expect(piv.execute(authorized.merge(args: args, puk: '87654321', field => '246810'))).to include(status: :changed)
|
|
290
|
+
expect(card.commands).to include([instruction, 0, reference, old.b.ljust(8, "\xff".b) + "246810\xff\xff".b])
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'reports remaining retries without echoing credentials or retrying' do
|
|
295
|
+
card.pin_status = 0x63c2
|
|
296
|
+
expect { piv.verify_pin(authorized) }.to raise_error(PWN::Plugins::YubiKey::PIV::PinError) { |error| expect(error.attempts_remaining).to eq(2) }
|
|
297
|
+
expect(card.commands.count { |ins,| ins == 0x20 }).to eq(1)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it 'sets retry limits only after management and PIN authentication' do
|
|
301
|
+
expect(piv.execute(authorized.merge(args: %w[access set-retries 5 0]))).to include(status: :changed)
|
|
302
|
+
expect(card.commands.select { |ins,| [0x87, 0x20, 0xfa].include?(ins) }.map(&:first)).to eq([0x87, 0x87, 0x20, 0xfa])
|
|
303
|
+
expect(card.commands).to include([0xfa, 5, 0, ''.b])
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it 'rejects argv secrets and invalid retry limits before authenticating' do
|
|
307
|
+
[%w[access change-pin --pin 123456], %w[access set-retries 0 3]].each do |args|
|
|
308
|
+
expect { piv.execute(authorized.merge(args: args)) }.to raise_error(ArgumentError)
|
|
309
|
+
end
|
|
310
|
+
expect(card.commands).to be_empty
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'round-trips raw objects without text conversion and guards the protected-key object' do
|
|
314
|
+
data = "\x00\xffobject\x80".b
|
|
315
|
+
expect(piv.execute(authorized.merge(args: %w[objects import 5f1234 -], input: data))).to include(status: :written)
|
|
316
|
+
expect(piv.execute(connection: card, args: %w[objects export 5f1234 -])).to eq(data)
|
|
317
|
+
expect(card.commands).to include([0xdb, 0x3f, 0xff, ['5c035f1234530900ff6f626a65637480'].pack('H*')])
|
|
318
|
+
card.objects[0x5fff00] = ['8003810102'].pack('H*')
|
|
319
|
+
expect { piv.execute(authorized.merge(args: %w[objects import printed -], input: 'not a key')) }.to raise_error(ArgumentError, /protected/)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'generates valid CHUID and CCC objects and verifies each persisted value' do
|
|
323
|
+
%w[CHUID CCC].each do |object|
|
|
324
|
+
expect(piv.execute(authorized.merge(args: ['objects', 'generate', object]))).to include(status: :written)
|
|
325
|
+
end
|
|
326
|
+
chuid = card.fields(card.objects.fetch(0x5fc102))
|
|
327
|
+
expect(chuid.fetch(0x30)).to eq(['d4e739da739ced39ce739d836858210842108421c84210c3eb'].pack('H*'))
|
|
328
|
+
expect(chuid.fetch(0x34).bytesize).to eq(16)
|
|
329
|
+
expect(chuid.fetch(0x35)).to eq('20300101')
|
|
330
|
+
expect(card.fields(card.objects.fetch(0x5fc107)).fetch(0xf0).bytesize).to eq(21)
|
|
331
|
+
expect(card.commands.select { |ins,| ins == 0xdb }.size).to eq(2)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it 'rotates and stores an AES management key under PIN protection without returning it' do
|
|
335
|
+
replacement = 'Z'.b * 32
|
|
336
|
+
result = piv.execute(authorized.merge(args: %w[access change-management-key --algorithm AES256 --protect], new_management_key: replacement))
|
|
337
|
+
expect(result).to include(status: :changed, protected: true)
|
|
338
|
+
expect(result.inspect).not_to include(replacement)
|
|
339
|
+
expect(card.management_key).to eq(replacement)
|
|
340
|
+
expect(card.management_algorithm).to eq(12)
|
|
341
|
+
expect(card.objects.fetch(0x5fc109)).to eq(card.tlv(0x88, card.tlv(0x89, replacement)))
|
|
342
|
+
expect(card.objects.fetch(0x5fff00)).to eq(['8003810102'].pack('H*'))
|
|
343
|
+
expect(piv.execute(connection: card, confirm: true, pin: '765432', args: %w[objects generate CCC], use_protected_key: true)).to include(status: :written)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it 'rejects AES management rotation on older firmware before authentication' do
|
|
347
|
+
card.version = [5, 3, 1]
|
|
348
|
+
expect { piv.execute(authorized.merge(args: %w[access change-management-key --algorithm AES128], new_management_key: 'Z' * 16)) }.to raise_error(piv::UnsupportedError)
|
|
349
|
+
expect(!card.commands.map(&:first).intersect?([0x87, 0xff])).to be(true)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
%w[RSA1024 RSA2048 RSA3072 RSA4096 ECCP256 ECCP384 ED25519 X25519].each do |algorithm|
|
|
353
|
+
it "generates, exports and imports #{algorithm} with independent key reconstruction" do
|
|
354
|
+
pem = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm, '--pin-policy', 'ALWAYS', '--touch-policy', 'CACHED']))
|
|
355
|
+
public_key = OpenSSL::PKey.read(pem)
|
|
356
|
+
expect(public_key.public_to_der).to eq(card.keys.fetch(0x9a).fetch(:key).public_to_der)
|
|
357
|
+
expect(piv.execute(connection: card, args: %w[keys info 9a])).to include(algorithm: algorithm, pin_policy: 'ALWAYS', touch_policy: 'CACHED', generated: true)
|
|
358
|
+
expect(OpenSSL::PKey.read(piv.execute(connection: card, args: %w[keys export 9a -])).public_to_der).to eq(public_key.public_to_der)
|
|
359
|
+
private_key = card.keys.fetch(0x9a).fetch(:key)
|
|
360
|
+
expect(piv.execute(authorized.merge(args: %w[keys import 9c -], input: private_key.private_to_pem))).to include(status: :imported, algorithm: algorithm)
|
|
361
|
+
expect(card.keys.fetch(0x9c).fetch(:key).public_to_der).to eq(public_key.public_to_der)
|
|
362
|
+
expect(piv.execute(connection: card, args: %w[keys info 9c])).to include(generated: false)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it 'attests, moves and deletes only the selected key with 5.7 wire commands' do
|
|
367
|
+
pem = piv.execute(authorized.merge(args: %w[keys generate 9a - --algorithm ECCP256]))
|
|
368
|
+
attestation = OpenSSL::X509::Certificate.new(piv.execute(connection: card, args: %w[keys attest 9a -]))
|
|
369
|
+
expect(attestation.public_key.public_to_der).to eq(OpenSSL::PKey.read(pem).public_to_der)
|
|
370
|
+
expect(piv.execute(authorized.merge(args: %w[keys move 9a 82]))).to include(status: :moved)
|
|
371
|
+
expect(card.commands).to include([0xf6, 0x82, 0x9a, ''.b])
|
|
372
|
+
expect(piv.execute(authorized.merge(args: %w[keys delete 82]))).to include(status: :deleted)
|
|
373
|
+
expect(card.commands).to include([0xf6, 0xff, 0x82, ''.b])
|
|
374
|
+
expect(card.keys).to be_empty
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
it 'applies version, ROCA, FIPS and biometric policy restrictions before key mutation' do
|
|
378
|
+
[[[5, 6, 3], 'ED25519', []], [[4, 3, 4], 'RSA2048', []], [[3, 4, 0], 'ECCP384', []],
|
|
379
|
+
[[4, 4, 1], 'RSA1024', []], [[5, 7, 0], 'ECCP256', %w[--pin-policy MATCH_ALWAYS]]].each do |version, algorithm, flags|
|
|
380
|
+
card.version = version
|
|
381
|
+
expect { piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm] + flags)) }.to raise_error(piv::UnsupportedError)
|
|
382
|
+
end
|
|
383
|
+
expect(!card.commands.map(&:first).intersect?([0x47, 0x87])).to be(true)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
%w[RSA1024 RSA2048 RSA3072 RSA4096 ECCP256 ECCP384 ED25519].each do |algorithm|
|
|
387
|
+
it "signs actual messages with card-backed #{algorithm} and validates with OpenSSL" do
|
|
388
|
+
pem = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm]))
|
|
389
|
+
key = OpenSSL::PKey.read(pem)
|
|
390
|
+
message = "device-backed signature\x00".b
|
|
391
|
+
signature = piv.sign(authorized.merge(slot: '9a', algorithm: algorithm, message: message))
|
|
392
|
+
expect(key.verify(algorithm == 'ED25519' ? nil : 'SHA256', signature, message)).to be(true)
|
|
393
|
+
if algorithm.start_with?('RSA')
|
|
394
|
+
signature = piv.sign(authorized.merge(slot: '9a', algorithm: algorithm, message: message, padding: :pss))
|
|
395
|
+
expect(key.verify_pss('SHA256', signature, message, salt_length: :digest, mgf1_hash: 'SHA256')).to be(true)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
it 'decrypts RSA OAEP and PKCS1 blocks through the real card operation' do
|
|
401
|
+
key = OpenSSL::PKey.read(piv.execute(authorized.merge(args: %w[keys generate 9d - --algorithm RSA2048])))
|
|
402
|
+
message = "encrypted by the host\x00".b
|
|
403
|
+
ciphertext = key.encrypt(message, 'rsa_padding_mode' => 'oaep', 'rsa_oaep_md' => 'SHA256')
|
|
404
|
+
expect(piv.decrypt(authorized.merge(slot: '9d', ciphertext: ciphertext, padding: :oaep, hash_algorithm: 'SHA256'))).to eq(message)
|
|
405
|
+
ciphertext = key.public_encrypt(message)
|
|
406
|
+
expect(piv.decrypt(authorized.merge(slot: '9d', ciphertext: ciphertext, padding: :pkcs1))).to eq(message)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
%w[ECCP256 ECCP384 X25519].each do |algorithm|
|
|
410
|
+
it "derives matching shared secrets with #{algorithm}" do
|
|
411
|
+
key = OpenSSL::PKey.read(piv.execute(authorized.merge(args: ['keys', 'generate', '9d', '-', '--algorithm', algorithm])))
|
|
412
|
+
peer = if algorithm == 'X25519'
|
|
413
|
+
OpenSSL::PKey.generate_key('X25519')
|
|
414
|
+
else
|
|
415
|
+
OpenSSL::PKey::EC.generate(algorithm == 'ECCP256' ? 'prime256v1' : 'secp384r1')
|
|
416
|
+
end
|
|
417
|
+
actual = piv.calculate_secret(authorized.merge(slot: '9d', peer_public_key: peer.public_to_pem))
|
|
418
|
+
expect(actual).to eq(peer.derive(key))
|
|
419
|
+
expect(card.commands.select { |ins, _, slot,| ins == 0x87 && slot == 0x9d }.last.last).to include([0x85].pack('C'))
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
%w[RSA2048 ECCP256 ECCP384 ED25519].each do |algorithm|
|
|
424
|
+
it "builds verified certificates and CSRs signed by the #{algorithm} device key" do
|
|
425
|
+
pem = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', '-', '--algorithm', algorithm]))
|
|
426
|
+
result = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=Native PIV,O=Test', '--valid-days', '10'], input: pem))
|
|
427
|
+
cert = OpenSSL::X509::Certificate.new(result.fetch(:certificate))
|
|
428
|
+
expect(cert.verify(cert.public_key)).to be(true)
|
|
429
|
+
expect(cert.subject.to_s).to include('CN=Native PIV')
|
|
430
|
+
expect(cert.not_after - cert.not_before).to eq(864_000)
|
|
431
|
+
exported = OpenSSL::X509::Certificate.new(piv.execute(connection: card, args: %w[certificates export 9a -]))
|
|
432
|
+
expect(exported.to_der).to eq(cert.to_der)
|
|
433
|
+
csr = OpenSSL::X509::Request.new(piv.execute(authorized.merge(args: ['certificates', 'request', '9a', '-', '-', '--subject', 'CN=Native CSR'], input: pem)))
|
|
434
|
+
expect(csr.verify(csr.public_key)).to be(true)
|
|
435
|
+
expect(csr.public_key.public_to_der).to eq(cert.public_key.public_to_der)
|
|
436
|
+
expect(card.objects).to have_key(0x5fc102)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it 'imports compressed certificates, verifies key ownership and deletes without deleting the key' do
|
|
441
|
+
pem = piv.execute(authorized.merge(args: %w[keys generate 9a - --algorithm ECCP256]))
|
|
442
|
+
generated = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=test'], input: pem))
|
|
443
|
+
cert = OpenSSL::X509::Certificate.new(generated.fetch(:certificate))
|
|
444
|
+
expect(piv.execute(authorized.merge(args: %w[certificates import 9a - --compress --verify --no-update-chuid], input: cert.to_der))).to include(status: :written)
|
|
445
|
+
body = card.fields(card.objects.fetch(0x5fc105))
|
|
446
|
+
expect(body.fetch(0x71)).to eq("\x01".b)
|
|
447
|
+
expect(OpenSSL::X509::Certificate.new(piv.execute(connection: card, args: %w[certificates export 9a - --format DER])).to_der).to eq(cert.to_der)
|
|
448
|
+
expect(piv.execute(authorized.merge(args: %w[certificates delete 9a --no-update-chuid]))).to include(status: :written)
|
|
449
|
+
expect(card.objects.fetch(0x5fc105)).to be_empty
|
|
450
|
+
expect(card.keys).to have_key(0x9a)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
it 'preserves signed or corrupt CHUID data rather than replacing it during certificate writes' do
|
|
454
|
+
pem = piv.execute(authorized.merge(args: %w[keys generate 9a - --algorithm ECCP256]))
|
|
455
|
+
['broken'.b, card.tlv(0x34, 'x' * 16) + card.tlv(0x3e, 'signed')].each do |chuid|
|
|
456
|
+
card.objects[0x5fc102] = chuid
|
|
457
|
+
piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=test'], input: pem))
|
|
458
|
+
expect(card.objects.fetch(0x5fc102)).to eq(chuid)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
it 'resets only after explicitly authorized blocking and verifies the reset metadata' do
|
|
463
|
+
expect { piv.execute(connection: card, confirm: true, args: ['reset']) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
|
|
464
|
+
expect(!card.commands.map(&:first).intersect?([0x20, 0x2c])).to be(true)
|
|
465
|
+
expect(piv.execute(connection: card, confirm: true, block_retries: true, args: ['reset'])).to include(status: :reset)
|
|
466
|
+
expect(card.commands.count { |ins,| ins == 0x20 }).to eq(3)
|
|
467
|
+
expect(card.commands.count { |ins,| ins == 0x2c }).to eq(3)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
it 'refuses reset blocking on biometric devices with enrolled fingerprints' do
|
|
471
|
+
card.bio = true
|
|
472
|
+
expect { piv.execute(connection: card, confirm: true, block_retries: true, args: ['reset']) }.to raise_error(piv::UnsupportedError)
|
|
473
|
+
expect(!card.commands.map(&:first).intersect?([0x20, 0x2c, 0xfb])).to be(true)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
it 'returns a complete read-only PIV inventory and local group help' do
|
|
477
|
+
%w[access keys certificates objects].each { |group| expect(piv.execute(args: [group])).to include(application: :piv) }
|
|
478
|
+
expect(piv.execute(connection: card, args: ['info'])).to include(version: [5, 7, 1], serial: 123, pin: { default: false, total_attempts: 3, attempts_remaining: 3 }, slots: {})
|
|
479
|
+
expect(!card.commands.map(&:first).intersect?([0x20, 0x87, 0xdb])).to be(true)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
it 'preserves legacy PIN-derived management access when changing the PIN' do
|
|
483
|
+
salt = 'S' * 16
|
|
484
|
+
card.objects[0x5fff00] = card.tlv(0x80, card.tlv(0x82, salt))
|
|
485
|
+
card.management_key = OpenSSL::PKCS5.pbkdf2_hmac('765432', salt, 10_000, 24, 'SHA1')
|
|
486
|
+
expect(piv.execute(authorized.merge(args: %w[access change-pin], new_pin: '246810'))).to include(status: :changed)
|
|
487
|
+
new_salt = card.fields(card.fields(card.objects.fetch(0x5fff00)).fetch(0x80)).fetch(0x82)
|
|
488
|
+
expect(new_salt).not_to eq(salt)
|
|
489
|
+
expect(card.management_key).to eq(OpenSSL::PKCS5.pbkdf2_hmac('246810', new_salt, 10_000, 24, 'SHA1'))
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
RSpec.describe 'Native PIV safety and compatibility' do
|
|
494
|
+
let(:piv) { PWN::Plugins::YubiKey::PIV }
|
|
495
|
+
let(:card) { PivWireCard.new }
|
|
496
|
+
let(:authorized) { { connection: card, confirm: true, management_key: card.management_key, pin: '765432' } }
|
|
497
|
+
|
|
498
|
+
it 'preflights formats, names, secrets and policies before irreversible commands' do
|
|
499
|
+
invalid = [
|
|
500
|
+
{ args: %w[keys generate 9a - --format junk] },
|
|
501
|
+
{ args: %w[keys generate 9a - --algorithm ECCP256], pin_policy: 'invented' },
|
|
502
|
+
{ args: %w[certificates delete 9a --update-chuid --no-update-chuid] },
|
|
503
|
+
{ args: %w[access change-management-key --generate], new_management_key: 'x' * 24 },
|
|
504
|
+
{ args: %w[keys import 9a -], input: 'not a private key' }
|
|
505
|
+
]
|
|
506
|
+
invalid.each { |options| expect { piv.execute(authorized.merge(options)) }.to raise_error(ArgumentError) }
|
|
507
|
+
expect(card.commands.select { |ins,| [0x20, 0x24, 0x47, 0x87, 0xdb, 0xff, 0xfe].include?(ins) }).to be_empty
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
it 'fails malformed private responses and invalid RSA padding without returning plausible bytes' do
|
|
511
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
512
|
+
card.keys[0x9d] = { key: key, algorithm: 7 }
|
|
513
|
+
invalid = key.public_encrypt("\x00".b * 256, OpenSSL::PKey::RSA::NO_PADDING)
|
|
514
|
+
expect { piv.decrypt(authorized.merge(slot: '9d', ciphertext: invalid, padding: :pkcs1)) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /decryption failed/)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
it 'rejects unsupported padding parameters before using a private key' do
|
|
518
|
+
expect { piv.decrypt(authorized.merge(slot: '9d', ciphertext: 'x' * 256, mgf1_hash: 'bad')) }.to raise_error(ArgumentError)
|
|
519
|
+
expect(card.commands).to be_empty
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
it 'uses encrypted PEM and PKCS12 key imports without implicit password prompts' do
|
|
523
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
524
|
+
encrypted = key.private_to_pem(OpenSSL::Cipher.new('aes-256-cbc'), 'synthetic-passphrase')
|
|
525
|
+
expect(piv.execute(authorized.merge(args: %w[keys import 9a -], input: encrypted, password: 'synthetic-passphrase'))).to include(status: :imported)
|
|
526
|
+
cert = OpenSSL::X509::Certificate.new(piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=pkcs12'], input: key.public_to_pem)).fetch(:certificate))
|
|
527
|
+
archive = OpenSSL::PKCS12.create('synthetic-passphrase', 'fixture', key, cert).to_der
|
|
528
|
+
expect(piv.execute(authorized.merge(args: %w[keys import 9c -], input: archive, password: 'synthetic-passphrase'))).to include(status: :imported)
|
|
529
|
+
expect(piv.execute(authorized.merge(args: %w[certificates import 9c - --no-update-chuid], input: archive, password: 'synthetic-passphrase'))).to include(status: :written)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
it 'keeps retry metadata synchronized and refuses Bio and configured FIPS retry changes' do
|
|
533
|
+
card.objects[0x5fff00] = ['8003810101'].pack('H*')
|
|
534
|
+
piv.execute(authorized.merge(args: %w[access set-retries 4 2]))
|
|
535
|
+
expect(card.fields(card.fields(card.objects.fetch(0x5fff00)).fetch(0x80)).fetch(0x81).getbyte(0) & 1).to eq(0)
|
|
536
|
+
card.commands.clear
|
|
537
|
+
card.bio = true
|
|
538
|
+
expect { piv.execute(authorized.merge(args: %w[access set-retries 4 2])) }.to raise_error(piv::UnsupportedError)
|
|
539
|
+
card.bio = false
|
|
540
|
+
card.instance_variable_set(:@default_refs, false)
|
|
541
|
+
expect { piv.execute(authorized.merge(args: %w[access set-retries 4 2], fips_restrictions: true)) }.to raise_error(piv::UnsupportedError)
|
|
542
|
+
expect(card.commands.select { |ins,| [0x87, 0x20, 0xfa].include?(ins) }).to be_empty
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
it 'does not retry uncertain object writes or silently accept mismatched readback' do
|
|
546
|
+
expect(card).to receive(:exchange).with(hash_including(protocol: :apdu)).at_least(:once).and_call_original
|
|
547
|
+
allow(card).to receive(:command).and_wrap_original do |original, instruction, *rest|
|
|
548
|
+
if instruction == 0xdb
|
|
549
|
+
original.call(instruction, *rest)
|
|
550
|
+
raise PWN::Plugins::YubiKey::TimeoutError, 'uncertain write'
|
|
551
|
+
end
|
|
552
|
+
original.call(instruction, *rest)
|
|
553
|
+
end
|
|
554
|
+
expect { piv.execute(authorized.merge(args: %w[objects import 5f1234 -], input: 'bytes')) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
|
|
555
|
+
expect(card.commands.count { |ins,| ins == 0xdb }).to eq(1)
|
|
556
|
+
expect(card.objects.fetch(0x5f1234)).to eq('bytes')
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
it 'rejects a timeout without trying another public key source' do
|
|
560
|
+
allow(card).to receive(:command).and_wrap_original do |original, ins, *rest|
|
|
561
|
+
raise PWN::Plugins::YubiKey::TimeoutError if ins == 0xf7
|
|
562
|
+
|
|
563
|
+
original.call(ins, *rest)
|
|
564
|
+
end
|
|
565
|
+
expect { piv.execute(connection: card, args: %w[keys export 9a -]) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
|
|
566
|
+
expect(card.commands.select { |ins,| [0xf9, 0xcb].include?(ins) }).to be_empty
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
it 'supports biometric verification and session-local temporary PIN verification' do
|
|
570
|
+
card.bio = true
|
|
571
|
+
allow(card).to receive(:command).and_wrap_original do |original, ins, first, second, data|
|
|
572
|
+
if ins == 0x20 && second == 0x96
|
|
573
|
+
data == ['0200'].pack('H*') ? ('T'.b * 16) : ''.b
|
|
574
|
+
else
|
|
575
|
+
original.call(ins, first, second, data)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
expect(piv.verify_uv(connection: card, confirm: true)).to eq(:verified)
|
|
579
|
+
temporary = piv.verify_uv(connection: card, confirm: true, request_temporary_pin: true)
|
|
580
|
+
expect(temporary).to eq('T' * 16)
|
|
581
|
+
expect(piv.verify_temporary_pin(connection: card, confirm: true, temporary_pin: temporary)).to eq(:verified)
|
|
582
|
+
expect(card.commands).to include([0x20, 0, 0x96, ['0110'].pack('H*') + ('T' * 16)])
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
it 'does not confuse the transport serial selector with the certificate serial' do
|
|
586
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
587
|
+
card.keys[0x9a] = { key: key, algorithm: 0x11 }
|
|
588
|
+
result = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=test'], input: key.public_to_pem, serial: '00123', certificate_serial: 987))
|
|
589
|
+
expect(OpenSSL::X509::Certificate.new(result.fetch(:certificate)).serial.to_i).to eq(987)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
it 'publishes exact public output with mode 0600 and refuses occupied destinations before device mutation' do
|
|
593
|
+
Dir.mktmpdir('piv-output-') do |directory|
|
|
594
|
+
path = File.join(directory, 'public.pem')
|
|
595
|
+
output = piv.execute(authorized.merge(args: ['keys', 'generate', '9a', path, '--algorithm', 'ECCP256']))
|
|
596
|
+
expect(File.binread(path)).to eq(output)
|
|
597
|
+
expect(File.stat(path).mode & 0o777).to eq(0o600)
|
|
598
|
+
card.commands.clear
|
|
599
|
+
expect { piv.execute(authorized.merge(args: ['keys', 'generate', '9a', path])) }.to raise_error(Errno::EEXIST)
|
|
600
|
+
expect(card.commands).to be_empty
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
it 'generates and delivers a management key to an explicit sink and applies touch policy' do
|
|
605
|
+
delivered = {}
|
|
606
|
+
result = piv.execute(authorized.merge(args: %w[access change-management-key --algorithm AES128 --generate --touch], secret_sink: ->(field, bytes) { delivered[field] = bytes }))
|
|
607
|
+
expect(result).to include(status: :changed, algorithm: 'AES128', protected: false)
|
|
608
|
+
expect(delivered.fetch(:new_management_key)).to eq(card.management_key)
|
|
609
|
+
expect(delivered.fetch(:new_management_key).bytesize).to eq(16)
|
|
610
|
+
expect(card.commands.select { |ins,| ins == 0xff }.last[1..2]).to eq([0xff, 0xfe])
|
|
611
|
+
expect(card.objects).not_to have_key(0x5fc109)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
it 'selects legacy public-key sources explicitly rather than falling back on failure' do
|
|
615
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
616
|
+
card.keys[0x9a] = { key: key, algorithm: 0x11, origin: 1 }
|
|
617
|
+
card.version = [5, 2, 0]
|
|
618
|
+
expect { piv.execute(connection: card, args: %w[keys export 9a -]) }.to raise_error(piv::UnsupportedError)
|
|
619
|
+
expect(card.commands.select { |ins,| [0xf9, 0xcb].include?(ins) }).to be_empty
|
|
620
|
+
pem = piv.execute(connection: card, args: %w[keys export 9a -], public_key_source: :attestation)
|
|
621
|
+
expect(OpenSSL::PKey.read(pem).public_to_der).to eq(key.public_to_der)
|
|
622
|
+
expect { piv.execute(connection: card, args: %w[keys export 9a -], public_key_source: :private) }.to raise_error(ArgumentError, /private keys cannot be exported/)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
it 'decodes Net ID compressed certificates and rejects mismatched declared lengths' do
|
|
626
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
627
|
+
card.keys[0x9a] = { key: key, algorithm: 0x11 }
|
|
628
|
+
result = piv.execute(authorized.merge(args: ['certificates', 'generate', '9a', '-', '--subject', 'CN=netid'], input: key.public_to_pem))
|
|
629
|
+
der = OpenSSL::X509::Certificate.new(result.fetch(:certificate)).to_der
|
|
630
|
+
compressed = "\x01\x00".b + [der.bytesize].pack('v') + Zlib.deflate(der)
|
|
631
|
+
card.objects[0x5fc105] = card.tlv(0x70, compressed) + card.tlv(0x71, "\x01".b)
|
|
632
|
+
expect(piv.execute(connection: card, args: %w[certificates export 9a - --format DER])).to eq(der)
|
|
633
|
+
compressed.setbyte(2, 0)
|
|
634
|
+
compressed.setbyte(3, 0)
|
|
635
|
+
card.objects[0x5fc105] = card.tlv(0x70, compressed) + card.tlv(0x71, "\x01".b)
|
|
636
|
+
expect { piv.execute(connection: card, args: %w[certificates export 9a -]) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /length mismatch/)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
it 'refuses an authentication attempt without literal consent before device IO' do
|
|
640
|
+
card = double('untouched connection')
|
|
641
|
+
expect(card).not_to receive(:exchange)
|
|
642
|
+
[nil, false, 'true', 1].each do |consent|
|
|
643
|
+
expect { PWN::Plugins::YubiKey::PIV.verify_pin(connection: card, pin: '765432', confirm: consent) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pwn/plugins/yubikey/security_domain'
|
|
5
|
+
require_relative 'security_domain/support'
|
|
6
|
+
|
|
7
|
+
RSpec.describe PWN::Plugins::YubiKey::Protocol, 'secure-channel integration' do
|
|
8
|
+
it 'protects one complete command before any transport-level fragmentation' do
|
|
9
|
+
card = Scp03Card.new
|
|
10
|
+
card.handler = ->(_header, _body) { ['accepted'.b, "\x90\x00".b] }
|
|
11
|
+
key = (0...16).to_a.pack('C*')
|
|
12
|
+
channel = PWN::Plugins::YubiKey::SecurityDomain.secure_connection(connection: card, scp: :scp03, kvn: 1, key_enc: key, key_mac: key, key_dek: key, confirm: true)
|
|
13
|
+
before = card.commands.length
|
|
14
|
+
payload = 'x'.b * 300
|
|
15
|
+
expect(described_class.apdu(connection: channel, ins: 0xda, data: payload)).to eq('accepted'.b)
|
|
16
|
+
expect(card.received).to eq(payload)
|
|
17
|
+
expect(card.commands.length - before).to eq(1)
|
|
18
|
+
expect(card.commands.last.getbyte(4)).to eq(0)
|
|
19
|
+
ensure
|
|
20
|
+
channel&.close
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'rejects an explicit short-framing override for a secure connection before I/O' do
|
|
24
|
+
connection = double('secure connection', descriptor: { secure_channel: :scp03, extended_apdu: true }, exchange: nil)
|
|
25
|
+
expect(connection).not_to receive(:exchange)
|
|
26
|
+
expect { described_class.apdu(connection: connection, ins: 0xda, extended: false) }.to raise_error(ArgumentError, /extended/)
|
|
27
|
+
end
|
|
28
|
+
end
|