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,1063 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
# Applet quirks and wire layouts were checked against Yubico yubikit/openpgp.py
|
|
6
|
+
# from YubiKey Manager 5.9.1. Attribution for that BSD-2-Clause reference follows.
|
|
7
|
+
# Copyright (c) 2023 Yubico AB. All rights reserved.
|
|
8
|
+
# Redistribution and use in source and binary forms, with or without modification,
|
|
9
|
+
# are permitted provided that the following conditions are met:
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright notice,
|
|
11
|
+
# this list of conditions and the following disclaimer.
|
|
12
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
# and/or other materials provided with the distribution.
|
|
15
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
18
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
19
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
|
|
22
|
+
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
23
|
+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
24
|
+
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
25
|
+
|
|
26
|
+
module PWN
|
|
27
|
+
module Plugins
|
|
28
|
+
module YubiKey
|
|
29
|
+
# Native OpenPGP card protocol. No subprocesses, credential defaults or I/O at load.
|
|
30
|
+
# References: OpenPGP Card 3.4; Yubico yubikit/openpgp.py 5.9.1 (BSD-2-Clause).
|
|
31
|
+
module OpenPGP
|
|
32
|
+
GRAMMAR = {
|
|
33
|
+
'info' => [0, [], []],
|
|
34
|
+
'reset' => [0, ['--force'], []],
|
|
35
|
+
'access set-retries' => [3, ['--force'], []],
|
|
36
|
+
'access change-pin' => [0, [], []],
|
|
37
|
+
'access change-reset-code' => [0, [], []],
|
|
38
|
+
'access change-admin-pin' => [0, [], []],
|
|
39
|
+
'access unblock-pin' => [0, [], []],
|
|
40
|
+
'access set-signature-policy' => [1, [], []],
|
|
41
|
+
'keys info' => [1, [], []],
|
|
42
|
+
'keys set-touch' => [2, ['--force'], []],
|
|
43
|
+
'keys import' => [2, [], []],
|
|
44
|
+
'keys attest' => [2, [], ['--format']],
|
|
45
|
+
'certificates export' => [2, [], ['--format']],
|
|
46
|
+
'certificates delete' => [1, [], []],
|
|
47
|
+
'certificates import' => [2, [], []]
|
|
48
|
+
}.freeze
|
|
49
|
+
GROUPS = ['', 'access', 'keys', 'certificates'].freeze
|
|
50
|
+
|
|
51
|
+
AID = ['d27600012401'].pack('H*').freeze
|
|
52
|
+
SLOTS = {
|
|
53
|
+
sig: { ref: 1, crt: "\xB6\0".b, attribute: 0xC1, touch: 0xD6 },
|
|
54
|
+
dec: { ref: 2, crt: "\xB8\0".b, attribute: 0xC2, touch: 0xD7 },
|
|
55
|
+
aut: { ref: 3, crt: "\xA4\0".b, attribute: 0xC3, touch: 0xD8 },
|
|
56
|
+
att: { ref: 0x81, crt: "\xB6\x03\x84\x01\x81".b, attribute: 0xDA, touch: 0xD9 }
|
|
57
|
+
}.transform_values(&:freeze).freeze
|
|
58
|
+
TOUCH = { off: 0, on: 1, fixed: 2, cached: 3, 'cached-fixed': 4 }.freeze
|
|
59
|
+
CURVES = {
|
|
60
|
+
secp256r1: ['2a8648ce3d030107', 'prime256v1', 256, '1.2.840.10045.3.1.7'],
|
|
61
|
+
secp256k1: ['2b8104000a', 'secp256k1', 256, '1.3.132.0.10'],
|
|
62
|
+
secp384r1: ['2b81040022', 'secp384r1', 384, '1.3.132.0.34'],
|
|
63
|
+
secp521r1: ['2b81040023', 'secp521r1', 521, '1.3.132.0.35'],
|
|
64
|
+
brainpoolp256r1: ['2b2403030208010107', 'brainpoolP256r1', 256, '1.3.36.3.3.2.8.1.1.7'],
|
|
65
|
+
brainpoolp384r1: ['2b240303020801010b', 'brainpoolP384r1', 384, '1.3.36.3.3.2.8.1.1.11'],
|
|
66
|
+
brainpoolp512r1: ['2b240303020801010d', 'brainpoolP512r1', 512, '1.3.36.3.3.2.8.1.1.13'],
|
|
67
|
+
x25519: ['2b060104019755010501', 'X25519', 255, '1.3.101.110'],
|
|
68
|
+
ed25519: ['2b06010401da470f01', 'ED25519', 255, '1.3.101.112']
|
|
69
|
+
}.transform_values(&:freeze).freeze
|
|
70
|
+
|
|
71
|
+
class UnsupportedError < Error; end
|
|
72
|
+
|
|
73
|
+
# OpenSSL objects stay in memory; raw card ECDSA uses fixed-width r || s.
|
|
74
|
+
class Crypto # rubocop:disable Metrics/ClassLength
|
|
75
|
+
HASH_HEADERS = {
|
|
76
|
+
sha1: '3021300906052b0e03021a05000414', sha224: '302d300d06096086480165030402040500041c',
|
|
77
|
+
sha256: '3031300d060960864801650304020105000420', sha384: '3041300d060960864801650304020205000430',
|
|
78
|
+
sha512: '3051300d060960864801650304020305000440'
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
def prepare_signature(attr, message, hash_algorithm, prehashed)
|
|
82
|
+
if attr[:algorithm] == :eddsa && attr[:curve] == :ed25519
|
|
83
|
+
raise ArgumentError, 'Ed25519 requires the original message, not a prehash' if prehashed
|
|
84
|
+
|
|
85
|
+
return message
|
|
86
|
+
end
|
|
87
|
+
raise UnsupportedError, 'The slot algorithm cannot sign messages' unless %i[rsa ecdsa].include?(attr[:algorithm])
|
|
88
|
+
raise UnsupportedError, 'Unsupported signing curve' if attr[:algorithm] == :ecdsa && (!attr[:curve] || %i[x25519 ed25519].include?(attr[:curve]))
|
|
89
|
+
raise UnsupportedError, 'Unsupported RSA modulus size' if attr[:algorithm] == :rsa && ![2048, 3072, 4096].include?(attr[:bits])
|
|
90
|
+
|
|
91
|
+
name = hash_algorithm.to_s.downcase.to_sym
|
|
92
|
+
header = HASH_HEADERS[name]
|
|
93
|
+
raise ArgumentError, 'Unsupported signature hash algorithm' unless header
|
|
94
|
+
|
|
95
|
+
hash = OpenSSL::Digest.new(name.to_s.upcase)
|
|
96
|
+
digest = prehashed ? message : hash.digest(message)
|
|
97
|
+
raise ArgumentError, 'Prehashed input has the wrong digest length' unless digest.bytesize == hash.digest_length
|
|
98
|
+
|
|
99
|
+
attr[:algorithm] == :rsa ? [header].pack('H*') + digest : digest
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def signature(attr, response)
|
|
103
|
+
case attr[:algorithm]
|
|
104
|
+
when :rsa
|
|
105
|
+
raise ResponseError, 'Malformed RSA signature length' unless response.bytesize == attr[:bits] / 8
|
|
106
|
+
when :eddsa
|
|
107
|
+
raise ResponseError, 'Malformed Ed25519 signature length' unless response.bytesize == 64
|
|
108
|
+
when :ecdsa
|
|
109
|
+
width = (attr[:bits] + 7) / 8
|
|
110
|
+
raise ResponseError, 'Malformed ECDSA signature length' unless response.bytesize == width * 2
|
|
111
|
+
|
|
112
|
+
numbers = [response.byteslice(0, width), response.byteslice(width, width)].map { |bytes| OpenSSL::BN.new(bytes, 2) }
|
|
113
|
+
order = OpenSSL::PKey::EC::Group.new(CURVES.fetch(attr[:curve])[1]).order
|
|
114
|
+
raise ResponseError, 'Malformed ECDSA signature scalar' unless numbers.all? { |number| number.to_i.positive? && number < order }
|
|
115
|
+
|
|
116
|
+
return OpenSSL::ASN1::Sequence.new(numbers.map { |number| OpenSSL::ASN1::Integer.new(number) }).to_der
|
|
117
|
+
end
|
|
118
|
+
response
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def public_from_bytes(attr, bytes)
|
|
122
|
+
curve = attr[:curve]
|
|
123
|
+
entry = CURVES[curve]
|
|
124
|
+
raise UnsupportedError, 'Unknown elliptic curve' unless entry
|
|
125
|
+
|
|
126
|
+
if %i[x25519 ed25519].include?(curve)
|
|
127
|
+
raise ArgumentError, 'Raw Curve25519 public key must be 32 bytes' unless bytes.bytesize == 32
|
|
128
|
+
|
|
129
|
+
algorithm = [OpenSSL::ASN1::ObjectId.new(entry[3])]
|
|
130
|
+
else
|
|
131
|
+
raise ArgumentError, 'EC point length or encoding does not match the curve' unless bytes.bytesize == 1 + (2 * ((entry[2] + 7) / 8)) && bytes.getbyte(0) == 4
|
|
132
|
+
|
|
133
|
+
algorithm = [OpenSSL::ASN1::ObjectId.new('1.2.840.10045.2.1'), OpenSSL::ASN1::ObjectId.new(entry[3])]
|
|
134
|
+
end
|
|
135
|
+
der = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Sequence.new(algorithm), OpenSSL::ASN1::BitString.new(bytes)]).to_der
|
|
136
|
+
key = OpenSSL::PKey.read(der)
|
|
137
|
+
raise ArgumentError, 'EC point is not on the requested curve' if key.is_a?(OpenSSL::PKey::EC) && !key.public_key.on_curve?
|
|
138
|
+
|
|
139
|
+
key
|
|
140
|
+
rescue OpenSSL::OpenSSLError
|
|
141
|
+
raise ArgumentError, 'Invalid public key for the requested curve', cause: nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse_private(value, password)
|
|
145
|
+
key = value.is_a?(OpenSSL::PKey::PKey) ? value : OpenSSL::PKey.read(value, password || '')
|
|
146
|
+
if key.is_a?(OpenSSL::PKey::RSA)
|
|
147
|
+
raise ArgumentError, 'RSA private key must contain factors and use exponent 65537' unless key.private? && key.e.to_i == 65_537 && key.p && key.q
|
|
148
|
+
raise ArgumentError, 'Only RSA 2048, 3072 and 4096 are supported' unless [2048, 3072, 4096].include?(key.n.num_bits)
|
|
149
|
+
elsif key.is_a?(OpenSSL::PKey::EC)
|
|
150
|
+
raise ArgumentError, 'A valid supported EC private key is required' unless key.private? && key.check_key && CURVES.values.any? { |entry| entry[1] == key.group.curve_name }
|
|
151
|
+
elsif %w[ED25519 X25519].include?(key.oid)
|
|
152
|
+
raw_seed(key)
|
|
153
|
+
else
|
|
154
|
+
raise ArgumentError, 'Unsupported OpenPGP private key type'
|
|
155
|
+
end
|
|
156
|
+
key
|
|
157
|
+
rescue OpenSSL::OpenSSLError, TypeError
|
|
158
|
+
raise ArgumentError, 'Invalid or encrypted private key; supply key_password in memory', cause: nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def key_algorithm(key)
|
|
162
|
+
if key.is_a?(OpenSSL::PKey::RSA)
|
|
163
|
+
:"rsa#{key.n.num_bits}"
|
|
164
|
+
elsif key.is_a?(OpenSSL::PKey::EC)
|
|
165
|
+
CURVES.find { |_name, entry| entry[1] == key.group.curve_name }.first
|
|
166
|
+
else
|
|
167
|
+
key.oid.downcase.to_sym
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def raw_seed(key)
|
|
172
|
+
sequence = OpenSSL::ASN1.decode(key.private_to_der)
|
|
173
|
+
value = sequence.value
|
|
174
|
+
raise ArgumentError, 'Unexpected Curve25519 PKCS8 structure' unless value.is_a?(Array) && value[0].is_a?(OpenSSL::ASN1::Integer) && value[2].is_a?(OpenSSL::ASN1::OctetString)
|
|
175
|
+
|
|
176
|
+
inner = OpenSSL::ASN1.decode(value[2].value)
|
|
177
|
+
raise ArgumentError, 'Expected a 32-byte Curve25519 private seed' unless inner.is_a?(OpenSSL::ASN1::OctetString) && inner.value.bytesize == 32
|
|
178
|
+
|
|
179
|
+
inner.value
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def import_template(key, slot, format)
|
|
183
|
+
if key.is_a?(OpenSSL::PKey::RSA)
|
|
184
|
+
width = key.n.num_bits / 16
|
|
185
|
+
components = [[0x91, key.e.to_s(2)], [0x92, key.p.to_s(2).rjust(width, "\0")], [0x93, key.q.to_s(2).rjust(width, "\0")]]
|
|
186
|
+
components.push([0x94, key.iqmp.to_s(2).rjust(width, "\0")], [0x95, key.dmp1.to_s(2).rjust(width, "\0")], [0x96, key.dmq1.to_s(2).rjust(width, "\0")]) if [2, 3].include?(format)
|
|
187
|
+
components << [0x97, key.n.to_s(2).rjust(width * 2, "\0")] if [1, 3].include?(format)
|
|
188
|
+
elsif key.is_a?(OpenSSL::PKey::EC)
|
|
189
|
+
width = (key.group.degree + 7) / 8
|
|
190
|
+
components = [[0x92, key.private_key.to_s(2).rjust(width, "\0")]]
|
|
191
|
+
components << [0x99, key.public_key.to_octet_string(:uncompressed)] if format == 255
|
|
192
|
+
else
|
|
193
|
+
seed = raw_seed(key)
|
|
194
|
+
seed = seed.reverse if key.oid == 'X25519'
|
|
195
|
+
components = [[0x92, seed]]
|
|
196
|
+
components << [0x99, OpenSSL::ASN1.decode(key.public_to_der).value[1].value] if format == 255
|
|
197
|
+
end
|
|
198
|
+
headers = components.map { |tag, bytes| Protocol.tlv(tag: tag, value: bytes).byteslice(0...-bytes.bytesize) }.join.b
|
|
199
|
+
values = components.map(&:last).join.b
|
|
200
|
+
Protocol.tlv(tag: 0x4D, value: SLOTS.fetch(slot)[:crt] + Protocol.tlv(tag: 0x7F48, value: headers) + Protocol.tlv(tag: 0x5F48, value: values))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def public_key(attr, template)
|
|
204
|
+
fields = Protocol.tlv_map(data: template)
|
|
205
|
+
if attr[:algorithm] == :rsa
|
|
206
|
+
modulus = fields[0x81]
|
|
207
|
+
exponent = fields[0x82]
|
|
208
|
+
raise ResponseError, 'Malformed RSA public key template' unless modulus && exponent && modulus.bytesize == attr[:bits] / 8 && exponent.bytesize.between?(1, 4)
|
|
209
|
+
|
|
210
|
+
numbers = [modulus, exponent].map { |bytes| OpenSSL::BN.new(bytes, 2) }
|
|
211
|
+
raise ResponseError, 'Invalid RSA public key numbers' unless numbers[0].num_bits == attr[:bits] && numbers[1].odd? && numbers[1] >= 3
|
|
212
|
+
|
|
213
|
+
OpenSSL::PKey::RSA.new(OpenSSL::ASN1::Sequence.new(numbers.map { |number| OpenSSL::ASN1::Integer.new(number) }).to_der)
|
|
214
|
+
else
|
|
215
|
+
raise ResponseError, 'Missing EC public key template' unless fields[0x86]
|
|
216
|
+
|
|
217
|
+
public_from_bytes(attr, fields.fetch(0x86))
|
|
218
|
+
end
|
|
219
|
+
rescue OpenSSL::OpenSSLError, ArgumentError
|
|
220
|
+
raise ResponseError, 'Invalid card public key response', cause: nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def peer_bytes(attr, value)
|
|
224
|
+
raise UnsupportedError, 'Only ECDH decryption keys support key agreement' unless attr[:algorithm] == :ecdh && attr[:curve] && attr[:curve] != :ed25519
|
|
225
|
+
|
|
226
|
+
if value.is_a?(String)
|
|
227
|
+
raw = value.b
|
|
228
|
+
elsif value.is_a?(OpenSSL::PKey::EC)
|
|
229
|
+
raise ArgumentError, 'Peer curve differs from the card decryption curve' unless value.group.curve_name == CURVES.fetch(attr[:curve])[1]
|
|
230
|
+
|
|
231
|
+
raw = value.public_key.to_octet_string(:uncompressed)
|
|
232
|
+
elsif value.is_a?(OpenSSL::PKey::PKey) && value.oid == 'X25519' && attr[:curve] == :x25519
|
|
233
|
+
raw = OpenSSL::ASN1.decode(value.public_to_der).value[1].value
|
|
234
|
+
else
|
|
235
|
+
raise ArgumentError, 'Peer key type or curve does not match the card decryption key'
|
|
236
|
+
end
|
|
237
|
+
public_from_bytes(attr, raw)
|
|
238
|
+
raise ArgumentError, 'All-zero Curve25519 peer key is invalid' if attr[:curve] == :x25519 && raw.bytes.all?(&:zero?)
|
|
239
|
+
|
|
240
|
+
raw
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# A request-local session never caches PINs between public calls.
|
|
245
|
+
class Session # rubocop:disable Metrics/ClassLength
|
|
246
|
+
def initialize(opts, reset_mode: false)
|
|
247
|
+
@opts = opts.dup
|
|
248
|
+
@connection = opts[:connection]
|
|
249
|
+
raise ArgumentError, 'An APDU connection is required' unless @connection.respond_to?(:exchange) && @connection.descriptor[:protocol] == :apdu
|
|
250
|
+
|
|
251
|
+
@timeout = opts.fetch(:timeout, 30)
|
|
252
|
+
raise ArgumentError, 'timeout must be finite and positive' unless @timeout.is_a?(Numeric) && @timeout.finite? && @timeout.positive?
|
|
253
|
+
|
|
254
|
+
stage = :select
|
|
255
|
+
Protocol.select(connection: @connection, aid: AID, timeout: @timeout)
|
|
256
|
+
stage = :version
|
|
257
|
+
@version = read_version
|
|
258
|
+
stage = :application
|
|
259
|
+
load_application
|
|
260
|
+
rescue Protocol::APDUError => e
|
|
261
|
+
raise unless reset_mode && (e.status == 0x6581 || (stage == :select && [0x6285, 0x6985].include?(e.status)))
|
|
262
|
+
|
|
263
|
+
@startup_error = e.status
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def read_version
|
|
267
|
+
raw = apdu(0xF1)
|
|
268
|
+
raise ResponseError, 'Malformed OpenPGP firmware version' unless raw.bytesize == 3
|
|
269
|
+
|
|
270
|
+
raw.bytes.map { |byte| bcd(byte) }
|
|
271
|
+
rescue Protocol::APDUError => e
|
|
272
|
+
raise unless e.status == 0x6985
|
|
273
|
+
|
|
274
|
+
[1, 0, 0] # Documented pre-1.0.2 NEO applets lack GET VERSION.
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def apdu(ins, param1 = 0, param2 = 0, data = ''.b, cla: 0)
|
|
278
|
+
Protocol.apdu(connection: @connection, ins: ins, p1: param1, p2: param2, data: data, cla: cla, timeout: @timeout)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def get_data(tag)
|
|
282
|
+
apdu(0xCA, tag >> 8, tag & 0xFF)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def put_data(tag, value)
|
|
286
|
+
apdu(0xDA, tag >> 8, tag & 0xFF, value)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def unpack(tag, bytes)
|
|
290
|
+
fields = Protocol.tlv_map(data: bytes)
|
|
291
|
+
raise ResponseError, 'Unexpected OpenPGP response template' unless fields.keys == [tag]
|
|
292
|
+
|
|
293
|
+
fields.fetch(tag)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def bcd(byte)
|
|
297
|
+
raise ResponseError, 'Invalid OpenPGP BCD version' unless (byte >> 4) <= 9 && (byte & 15) <= 9
|
|
298
|
+
|
|
299
|
+
((byte >> 4) * 10) + (byte & 15)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def load_application
|
|
303
|
+
outer = Protocol.tlv_map(data: unpack(0x6E, get_data(0x6E)))
|
|
304
|
+
@aid = outer.fetch(0x4F) { raise ResponseError, 'OpenPGP AID absent' }
|
|
305
|
+
raise ResponseError, 'Malformed OpenPGP AID' unless @aid.bytesize == 16 && @aid.start_with?(AID)
|
|
306
|
+
|
|
307
|
+
@card_version = @aid.byteslice(6, 2).bytes.map { |byte| bcd(byte) }
|
|
308
|
+
@objects = outer[0x73] && outer[0x73].empty? ? outer : Protocol.tlv_map(data: outer.fetch(0x73) { raise ResponseError, 'Discretionary data absent' })
|
|
309
|
+
capabilities = @objects.fetch(0xC0) { raise ResponseError, 'Extended capabilities absent' }
|
|
310
|
+
raise ResponseError, 'Malformed extended capabilities' unless capabilities.bytesize == 10
|
|
311
|
+
|
|
312
|
+
@flags = capabilities.getbyte(0)
|
|
313
|
+
@certificate_max = capabilities.byteslice(4, 2).unpack1('n')
|
|
314
|
+
@pin_status = pin_status(@objects.fetch(0xC4) { raise ResponseError, 'PIN status absent' })
|
|
315
|
+
@historical = outer[0x5F52]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def pin_status(data = get_data(0xC4))
|
|
319
|
+
raise ResponseError, 'Malformed PIN status' unless data.bytesize == 7
|
|
320
|
+
|
|
321
|
+
bytes = data.bytes
|
|
322
|
+
{ signature_policy: { 0 => :always, 1 => :once }.fetch(bytes[0], :unknown),
|
|
323
|
+
max_user: bytes[1], max_reset: bytes[2], max_admin: bytes[3], user: bytes[4], reset: bytes[5], admin: bytes[6] }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def attributes(data)
|
|
327
|
+
raise ResponseError, 'Empty algorithm attributes' if data.nil? || data.empty?
|
|
328
|
+
|
|
329
|
+
case data.getbyte(0)
|
|
330
|
+
when 1
|
|
331
|
+
raise ResponseError, 'Malformed RSA attributes' unless data.bytesize == 6
|
|
332
|
+
|
|
333
|
+
_, bits, exponent_bits, import_format = data.unpack('CnnC')
|
|
334
|
+
{ algorithm: :rsa, bits: bits, exponent_bits: exponent_bits, import_format: import_format, encoded: data }
|
|
335
|
+
when 0x12, 0x13, 0x16
|
|
336
|
+
oid = data.byteslice(1..)
|
|
337
|
+
with_public = oid.end_with?("\xFF".b)
|
|
338
|
+
oid = oid.byteslice(0...-1) if with_public
|
|
339
|
+
curve = CURVES.find { |_name, entry| entry[0] == oid.unpack1('H*') }&.first
|
|
340
|
+
{ algorithm: { 0x12 => :ecdh, 0x13 => :ecdsa, 0x16 => :eddsa }.fetch(data.getbyte(0)), curve: curve,
|
|
341
|
+
oid: oid.unpack1('H*'), bits: curve && CURVES.fetch(curve)[2], import_format: with_public ? 255 : 0, encoded: data }
|
|
342
|
+
else
|
|
343
|
+
{ algorithm: :unknown, algorithm_id: data.getbyte(0), encoded: data }
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def key_info(slot)
|
|
348
|
+
definition = SLOTS.fetch(slot)
|
|
349
|
+
data = @objects[definition[:attribute]]
|
|
350
|
+
raise UnsupportedError, 'Key slot is not supported by this card' unless data
|
|
351
|
+
|
|
352
|
+
status = @objects[0xDE].to_s.b
|
|
353
|
+
raise ResponseError, 'Malformed key information' unless status.bytesize.even?
|
|
354
|
+
|
|
355
|
+
origin = status.bytes.each_slice(2).to_h[definition[:ref]]
|
|
356
|
+
index = SLOTS.keys.index(slot)
|
|
357
|
+
touch_data = @objects[definition[:touch]]
|
|
358
|
+
touch_data = get_data(definition[:touch]) if touch_data.nil? && @card_version[0] < 3 && @version && Protocol.version_at_least?(version: @version, minimum: [4, 2, 0])
|
|
359
|
+
raise ResponseError, 'Malformed touch policy' if touch_data && (touch_data.bytesize != 2 || !TOUCH.value?(touch_data.getbyte(0)))
|
|
360
|
+
|
|
361
|
+
attributes(data).merge(slot: slot, origin: { 0 => :none, 1 => :generated, 2 => :imported }.fetch(origin, :unknown),
|
|
362
|
+
fingerprint: @objects[0xC5]&.byteslice(index * 20, 20)&.unpack1('H*'),
|
|
363
|
+
generated_at: @objects[0xCD]&.byteslice(index * 4, 4)&.unpack1('N'),
|
|
364
|
+
touch: touch_data && TOUCH.key(touch_data.getbyte(0)))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def secret(name)
|
|
368
|
+
Protocol.secret(value: @opts[name], name: name, secret_provider: @opts[:secret_provider])
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def pin_bytes(name, role)
|
|
372
|
+
value = secret(name).b
|
|
373
|
+
raise ArgumentError, 'PIN input is too large' if value.bytesize > 4096
|
|
374
|
+
|
|
375
|
+
# The KDF object is read once per request, never inferred from PIN length.
|
|
376
|
+
if @flags.anybits?(1)
|
|
377
|
+
@kdf ||= Protocol.tlv_map(data: get_data(0xF9))
|
|
378
|
+
algorithm = @kdf[0x81]
|
|
379
|
+
unless algorithm.nil? || algorithm == "\0".b
|
|
380
|
+
raise UnsupportedError, 'Unsupported card PIN KDF' unless algorithm == "\x03".b
|
|
381
|
+
|
|
382
|
+
digest_name = { "\x08".b => 'SHA256', "\x0A".b => 'SHA512' }[@kdf[0x82]]
|
|
383
|
+
count_bytes = @kdf[0x83]
|
|
384
|
+
salt = @kdf[{ user: 0x84, reset: 0x85, admin: 0x86 }.fetch(role)] || @kdf[0x84]
|
|
385
|
+
raise ResponseError, 'Malformed card PIN KDF' unless digest_name && count_bytes&.bytesize == 4 && salt&.bytesize == 8
|
|
386
|
+
|
|
387
|
+
count = count_bytes.unpack1('N')
|
|
388
|
+
raise UnsupportedError, 'Card PIN KDF byte count is outside the supported bound' unless count.between?(1, 67_108_864)
|
|
389
|
+
|
|
390
|
+
source = salt + value
|
|
391
|
+
digest = OpenSSL::Digest.new(digest_name)
|
|
392
|
+
# RFC 4880 iterated-and-salted S2K hashes at least one full source.
|
|
393
|
+
remaining = [count, source.bytesize].max
|
|
394
|
+
chunk = source * [65_536 / source.bytesize, 1].max
|
|
395
|
+
while remaining.positive?
|
|
396
|
+
take = [remaining, chunk.bytesize].min
|
|
397
|
+
digest.update(chunk.byteslice(0, take))
|
|
398
|
+
remaining -= take
|
|
399
|
+
end
|
|
400
|
+
value = digest.digest
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
min = role == :user ? 6 : 8
|
|
404
|
+
max = @pin_status.fetch(:"max_#{role}")
|
|
405
|
+
raise ArgumentError, "#{name} length must be #{min}..#{max} bytes" unless value.bytesize.between?(min, max)
|
|
406
|
+
|
|
407
|
+
value
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def require_version(minimum, capability)
|
|
411
|
+
return if Protocol.version_at_least?(version: @version, minimum: minimum)
|
|
412
|
+
|
|
413
|
+
raise UnsupportedError, "#{capability} is not supported by this firmware"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def verify_admin
|
|
417
|
+
apdu(0x20, 0, 0x83, pin_bytes(:admin_pin, :admin))
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def configure_retries(counts)
|
|
421
|
+
require_version(@version[0] == 1 ? [1, 0, 7] : [4, 3, 1], 'Setting PIN retries')
|
|
422
|
+
verify_admin
|
|
423
|
+
apdu(0xF2, 0, 0, counts.pack('C*'))
|
|
424
|
+
status = pin_status
|
|
425
|
+
raise ResponseError, 'PIN retries readback mismatch; mutation was not replayed' unless status.values_at(:user, :reset, :admin) == counts
|
|
426
|
+
|
|
427
|
+
{ pin_status: status, verified: true, pins_reset: @version[0] < 4 }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def change_reset_code
|
|
431
|
+
reset = pin_bytes(:reset_code, :reset)
|
|
432
|
+
verify_admin
|
|
433
|
+
put_data(0xD3, reset)
|
|
434
|
+
{ changed: :reset_code, pin_status: pin_status, credential_verified: false }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def unblock_pin
|
|
438
|
+
replacement = pin_bytes(:new_pin, :user)
|
|
439
|
+
if @opts[:admin_pin] || @opts[:use_admin] == true
|
|
440
|
+
verify_admin
|
|
441
|
+
apdu(0x2C, 2, 0x81, replacement)
|
|
442
|
+
else
|
|
443
|
+
reset = pin_bytes(:reset_code, :reset)
|
|
444
|
+
apdu(0x2C, 0, 0x81, reset + replacement)
|
|
445
|
+
end
|
|
446
|
+
{ unblocked: true, pin_status: pin_status, credential_verified: false }
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def signature_policy(policy)
|
|
450
|
+
raise UnsupportedError, 'Changing the signature PIN policy is unsupported' if @flags.nobits?(0x10)
|
|
451
|
+
|
|
452
|
+
verify_admin
|
|
453
|
+
put_data(0xC4, [policy == :always ? 0 : 1].pack('C'))
|
|
454
|
+
status = pin_status
|
|
455
|
+
raise ResponseError, 'Signature policy readback mismatch; mutation was not replayed' unless status[:signature_policy] == policy
|
|
456
|
+
|
|
457
|
+
{ signature_policy: policy, verified: true, pin_status: status }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def change_pin(admin: false)
|
|
461
|
+
role = admin ? :admin : :user
|
|
462
|
+
name = admin ? :admin_pin : :pin
|
|
463
|
+
current = pin_bytes(name, role)
|
|
464
|
+
replacement = pin_bytes(admin ? :new_admin_pin : :new_pin, role)
|
|
465
|
+
apdu(0x24, 0, admin ? 0x83 : 0x81, current + replacement)
|
|
466
|
+
{ changed: name, pin_status: pin_status, credential_verified: false }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def algorithm_attributes(slot, algorithm)
|
|
470
|
+
if algorithm.to_s.start_with?('rsa')
|
|
471
|
+
[1, algorithm.to_s.delete_prefix('rsa').to_i, 17, @version[0] < 4 ? 3 : 0].pack('CnnC')
|
|
472
|
+
else
|
|
473
|
+
raise ArgumentError, 'X25519 is only valid in the decryption slot' if algorithm == :x25519 && slot != :dec
|
|
474
|
+
raise ArgumentError, 'Ed25519 is not valid in the decryption or attestation slot' if algorithm == :ed25519 && %i[dec att].include?(slot)
|
|
475
|
+
|
|
476
|
+
require_version([5, 2, 0], 'Elliptic curve keys')
|
|
477
|
+
id = if algorithm == :ed25519
|
|
478
|
+
0x16
|
|
479
|
+
else
|
|
480
|
+
(slot == :dec ? 0x12 : 0x13)
|
|
481
|
+
end
|
|
482
|
+
[id].pack('C') + [CURVES.fetch(algorithm)[0]].pack('H*')
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def algorithms
|
|
487
|
+
return SLOTS.keys.select { |slot| @objects[SLOTS[slot][:attribute]] }.to_h { |slot| [slot, [attributes(@objects[SLOTS[slot][:attribute]])]] } if @flags.nobits?(4)
|
|
488
|
+
|
|
489
|
+
unless Protocol.version_at_least?(version: @version, minimum: [5, 2, 0])
|
|
490
|
+
sizes = @version[0] < 4 || @version.take(2) == [4, 4] ? [2048] : [2048, 3072, 4096]
|
|
491
|
+
return %i[sig dec aut].to_h { |slot| [slot, sizes.map { |bits| attributes(algorithm_attributes(slot, :"rsa#{bits}")) }] }
|
|
492
|
+
end
|
|
493
|
+
raw = get_data(0xFA)
|
|
494
|
+
rows = Protocol.untlv(data: algorithm_information_body(raw))
|
|
495
|
+
result = {}
|
|
496
|
+
rows.each do |row|
|
|
497
|
+
slot = SLOTS.find { |_name, entry| entry[:attribute] == row[:tag] }&.first
|
|
498
|
+
raise ResponseError, 'Unknown key slot in algorithm information' unless slot
|
|
499
|
+
|
|
500
|
+
(result[slot] ||= []) << attributes(row[:value])
|
|
501
|
+
end
|
|
502
|
+
unless Protocol.version_at_least?(version: @version, minimum: [5, 6, 1])
|
|
503
|
+
# Published Yubico firmware erratum: X25519 was advertised as EdDSA.
|
|
504
|
+
bad_x = result.values.flatten.any? { |entry| entry[:curve] == :x25519 && entry[:algorithm] == :eddsa }
|
|
505
|
+
result.each do |slot, entries|
|
|
506
|
+
entries.reject! { |entry| (entry[:curve] == :x25519 && entry[:algorithm] != :ecdh) || (%i[dec att].include?(slot) && entry[:curve] == :ed25519) }
|
|
507
|
+
end
|
|
508
|
+
if bad_x && result[:dec]
|
|
509
|
+
fixed = attributes(algorithm_attributes(:dec, :x25519))
|
|
510
|
+
result[:dec] << fixed unless result[:dec].any? { |entry| entry[:encoded] == fixed[:encoded] }
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
result
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def algorithm_information_body(raw)
|
|
517
|
+
unpack(0xFA, raw)
|
|
518
|
+
rescue ResponseError
|
|
519
|
+
raise if Protocol.version_at_least?(version: @version, minimum: [5, 6, 1])
|
|
520
|
+
|
|
521
|
+
# The old applet's outer FA length includes two nonexistent SW bytes.
|
|
522
|
+
# Strict unpack still rejects every length difference other than +2.
|
|
523
|
+
unpack(0xFA, raw + "\0\0".b).byteslice(0...-2)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def validate_key_attributes(slot, encoded)
|
|
527
|
+
current = key_info(slot)
|
|
528
|
+
desired = attributes(encoded)
|
|
529
|
+
return attributes(current[:encoded]) if @flags.nobits?(4) && current[:algorithm] == :rsa && desired[:algorithm] == :rsa && current[:bits] == 2048 && desired[:bits] == 2048
|
|
530
|
+
|
|
531
|
+
supported = algorithms.fetch(slot, [])
|
|
532
|
+
# Prefer standard import; some cards advertise the same curve with public-point import.
|
|
533
|
+
match = supported.find { |entry| entry[:encoded] == encoded }
|
|
534
|
+
match ||= supported.find { |entry| desired[:curve] && entry[:curve] == desired[:curve] && entry[:algorithm] == desired[:algorithm] && entry[:import_format] == 255 }
|
|
535
|
+
raise UnsupportedError, 'Algorithm or key slot is not supported by this card' unless match
|
|
536
|
+
|
|
537
|
+
match
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def write_attributes(slot, attr)
|
|
541
|
+
tag = SLOTS.fetch(slot)[:attribute]
|
|
542
|
+
return if @objects[tag] == attr[:encoded]
|
|
543
|
+
|
|
544
|
+
raise UnsupportedError, 'Algorithm attributes cannot be changed on this card' if @flags.nobits?(4)
|
|
545
|
+
|
|
546
|
+
put_data(tag, attr[:encoded])
|
|
547
|
+
raise ResponseError, 'Key algorithm attributes readback mismatch; mutation was not replayed' unless get_data(tag) == attr[:encoded]
|
|
548
|
+
|
|
549
|
+
@objects[tag] = attr[:encoded]
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def public_key(slot, attr = key_info(slot))
|
|
553
|
+
raw = apdu(0x47, 0x81, 0, SLOTS.fetch(slot)[:crt])
|
|
554
|
+
Crypto.new.public_key(attr, unpack(0x7F49, raw))
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def generate_key(slot, algorithm)
|
|
558
|
+
raise UnsupportedError, 'RSA generation is disabled on vulnerable firmware; update or import a host-generated key' if algorithm.to_s.start_with?('rsa') && Protocol.version_at_least?(version: @version, minimum: [4, 2, 0]) && !Protocol.version_at_least?(version: @version, minimum: [4, 3, 5])
|
|
559
|
+
|
|
560
|
+
attr = validate_key_attributes(slot, algorithm_attributes(slot, algorithm))
|
|
561
|
+
verify_admin
|
|
562
|
+
write_attributes(slot, attr)
|
|
563
|
+
raw = apdu(0x47, 0x80, 0, SLOTS.fetch(slot)[:crt])
|
|
564
|
+
key = Crypto.new.public_key(attr, unpack(0x7F49, raw))
|
|
565
|
+
raise ResponseError, 'Generated public key readback mismatch; generation was not replayed' unless public_key(slot, attr).public_to_der == key.public_to_der
|
|
566
|
+
|
|
567
|
+
key
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def import_key(slot, key)
|
|
571
|
+
raise UnsupportedError, 'Key import is not supported by this card' if @flags.nobits?(0x20)
|
|
572
|
+
|
|
573
|
+
crypto = Crypto.new
|
|
574
|
+
attr = validate_key_attributes(slot, algorithm_attributes(slot, crypto.key_algorithm(key)))
|
|
575
|
+
format = key.is_a?(OpenSSL::PKey::RSA) && @version[0] < 4 ? 3 : attr[:import_format]
|
|
576
|
+
template = crypto.import_template(key, slot, format)
|
|
577
|
+
verify_admin
|
|
578
|
+
write_attributes(slot, attr)
|
|
579
|
+
apdu(0xDB, 0x3F, 0xFF, template)
|
|
580
|
+
actual = public_key(slot, attr)
|
|
581
|
+
raise ResponseError, 'Imported public key readback mismatch; import was not replayed' unless actual.public_to_der == key.public_to_der
|
|
582
|
+
|
|
583
|
+
{ imported: :key, slot: slot, verified: true, public_key: actual }
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def block_for_reset(role, reference)
|
|
587
|
+
remaining = @pin_status.fetch(role)
|
|
588
|
+
remaining.times do
|
|
589
|
+
begin
|
|
590
|
+
apdu(0x20, 0, reference, "\0".b * 8)
|
|
591
|
+
raise ResponseError, 'Reset sentinel unexpectedly verified; reset aborted without guessing another PIN'
|
|
592
|
+
rescue Protocol::APDUError => e
|
|
593
|
+
raise unless [0x6982, 0x6983].include?(e.status) || (e.status & 0xFFF0) == 0x63C0
|
|
594
|
+
end
|
|
595
|
+
observed = pin_status.fetch(role)
|
|
596
|
+
raise ResponseError, 'Reset retry counter did not decrease; reset aborted' unless observed < remaining
|
|
597
|
+
|
|
598
|
+
remaining = observed
|
|
599
|
+
break if remaining.zero?
|
|
600
|
+
end
|
|
601
|
+
raise ResponseError, 'PIN did not block; reset aborted' unless remaining.zero?
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def block_corrupt_for_reset(reference)
|
|
605
|
+
# Retry counters are unreadable: this is deliberate blocking, not an
|
|
606
|
+
# authentication retry. A one-byte card counter bounds attempts to 255.
|
|
607
|
+
256.times do
|
|
608
|
+
apdu(0x20, 0, reference, "\0".b * 8)
|
|
609
|
+
raise ResponseError, 'Reset sentinel unexpectedly verified; reset aborted'
|
|
610
|
+
rescue Protocol::APDUError => e
|
|
611
|
+
return if [0x6983, 0x63C0].include?(e.status)
|
|
612
|
+
raise unless e.status == 0x6982 || (e.status & 0xFFF0) == 0x63C0
|
|
613
|
+
end
|
|
614
|
+
raise ResponseError, 'Reset blocking exceeded card counter bound; reset aborted'
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def reset
|
|
618
|
+
if @startup_error == 0x6581
|
|
619
|
+
block_corrupt_for_reset(0x81)
|
|
620
|
+
block_corrupt_for_reset(0x83)
|
|
621
|
+
apdu(0xE6)
|
|
622
|
+
elsif !@startup_error
|
|
623
|
+
require_version([1, 0, 6], 'OpenPGP reset')
|
|
624
|
+
block_for_reset(:user, 0x81)
|
|
625
|
+
block_for_reset(:admin, 0x83)
|
|
626
|
+
apdu(0xE6)
|
|
627
|
+
end
|
|
628
|
+
apdu(0x44)
|
|
629
|
+
Protocol.select(connection: @connection, aid: AID, timeout: @timeout)
|
|
630
|
+
load_application
|
|
631
|
+
clear = %i[sig dec aut].all? do |slot|
|
|
632
|
+
info = key_info(slot)
|
|
633
|
+
%i[none unknown].include?(info[:origin]) && info[:fingerprint] == '00' * 20
|
|
634
|
+
end
|
|
635
|
+
raise ResponseError, 'OpenPGP reset readback still contains keys; reset was not replayed' unless clear
|
|
636
|
+
|
|
637
|
+
{ reset: true, key_slots_verified_empty: true, pin_status: @pin_status, attestation_preserved: true }
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def set_touch(slot, policy)
|
|
641
|
+
require_version([4, 2, 0], 'Touch policy')
|
|
642
|
+
require_version([5, 2, 1], 'Cached or attestation touch policy') if slot == :att || [3, 4].include?(TOUCH.fetch(policy))
|
|
643
|
+
key_info(slot)
|
|
644
|
+
tag = SLOTS.fetch(slot)[:touch]
|
|
645
|
+
current = get_data(tag)
|
|
646
|
+
raise ResponseError, 'Malformed touch policy' unless current.bytesize == 2 && TOUCH.value?(current.getbyte(0))
|
|
647
|
+
raise UnsupportedError, 'A fixed touch policy cannot be changed without replacing the key' if [2, 4].include?(current.getbyte(0))
|
|
648
|
+
|
|
649
|
+
verify_admin
|
|
650
|
+
value = [TOUCH.fetch(policy), 0x20].pack('CC')
|
|
651
|
+
put_data(tag, value)
|
|
652
|
+
raise ResponseError, 'Touch policy readback mismatch; mutation was not replayed' unless get_data(tag) == value
|
|
653
|
+
|
|
654
|
+
{ slot: slot, touch: policy, irreversible: [2, 4].include?(TOUCH.fetch(policy)), verified: true }
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def certificate_supported(slot)
|
|
658
|
+
return if slot == :aut
|
|
659
|
+
|
|
660
|
+
require_version([5, 2, 0], 'Certificate slot')
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def select_certificate(slot)
|
|
664
|
+
certificate_supported(slot)
|
|
665
|
+
return if slot == :att || !Protocol.version_at_least?(version: @version, minimum: [5, 2, 0])
|
|
666
|
+
|
|
667
|
+
value = "\x60\x04\x5C\x02\x7F\x21".b
|
|
668
|
+
value = "\x06".b + value unless Protocol.version_at_least?(version: @version, minimum: [5, 4, 4])
|
|
669
|
+
apdu(0xA5, 3 - SLOTS.fetch(slot)[:ref], 4, value)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def certificate_bytes(slot)
|
|
673
|
+
select_certificate(slot)
|
|
674
|
+
get_data(slot == :att ? 0xFC : 0x7F21)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def export_certificate(slot, format)
|
|
678
|
+
raw = certificate_bytes(slot)
|
|
679
|
+
raise ResponseError, 'No certificate stored in the selected slot' if raw.empty?
|
|
680
|
+
|
|
681
|
+
cert = OpenSSL::X509::Certificate.new(raw)
|
|
682
|
+
format == :der ? cert.to_der : cert.to_pem
|
|
683
|
+
rescue OpenSSL::X509::CertificateError
|
|
684
|
+
raise ResponseError, 'Stored certificate is not valid X.509 DER', cause: nil
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def write_certificate(slot, raw)
|
|
688
|
+
certificate_supported(slot)
|
|
689
|
+
raise UnsupportedError, 'Certificate exceeds the card certificate limit' if raw.bytesize > @certificate_max
|
|
690
|
+
|
|
691
|
+
verify_admin
|
|
692
|
+
select_certificate(slot)
|
|
693
|
+
put_data(slot == :att ? 0xFC : 0x7F21, raw)
|
|
694
|
+
raise ResponseError, 'Certificate readback mismatch; mutation was not replayed' unless certificate_bytes(slot) == raw
|
|
695
|
+
|
|
696
|
+
{ (raw.empty? ? :deleted : :imported) => :certificate, slot: slot, verified: true }
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def attest_key(slot, format)
|
|
700
|
+
require_version([5, 2, 0], 'Key attestation')
|
|
701
|
+
raise UnsupportedError, 'Only on-card generated keys can be attested' if %i[none imported].include?(key_info(slot)[:origin])
|
|
702
|
+
|
|
703
|
+
verify_user
|
|
704
|
+
apdu(0xFB, SLOTS.fetch(slot)[:ref], 0, ''.b, cla: 0x80)
|
|
705
|
+
export_certificate(slot, format)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def sign(message, hash_algorithm, prehashed: false, authentication: false)
|
|
709
|
+
attr = key_info(authentication ? :aut : :sig)
|
|
710
|
+
raise UnsupportedError, 'The selected key slot is empty' if attr[:origin] == :none
|
|
711
|
+
|
|
712
|
+
crypto = Crypto.new
|
|
713
|
+
encoded = crypto.prepare_signature(attr, message, hash_algorithm, prehashed)
|
|
714
|
+
raise ArgumentError, 'Signing payload length exceeds the APDU limit' if encoded.bytesize > 65_535
|
|
715
|
+
|
|
716
|
+
verify_user(authentication ? 0x82 : 0x81)
|
|
717
|
+
response = authentication ? apdu(0x88, 0, 0, encoded) : apdu(0x2A, 0x9E, 0x9A, encoded)
|
|
718
|
+
crypto.signature(attr, response)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def decipher(value)
|
|
722
|
+
attr = key_info(:dec)
|
|
723
|
+
raise UnsupportedError, 'The decryption key slot is empty' if attr[:origin] == :none
|
|
724
|
+
|
|
725
|
+
if attr[:algorithm] == :rsa
|
|
726
|
+
raise ArgumentError, 'RSA ciphertext length must equal the key modulus length' unless value.is_a?(String) && value.bytesize == attr[:bits] / 8
|
|
727
|
+
|
|
728
|
+
encoded = "\0".b + value.b
|
|
729
|
+
else
|
|
730
|
+
peer = Crypto.new.peer_bytes(attr, value)
|
|
731
|
+
encoded = Protocol.tlv(tag: 0xA6, value: Protocol.tlv(tag: 0x7F49, value: Protocol.tlv(tag: 0x86, value: peer)))
|
|
732
|
+
end
|
|
733
|
+
verify_user(0x82)
|
|
734
|
+
response = apdu(0x2A, 0x80, 0x86, encoded)
|
|
735
|
+
if attr[:algorithm] == :ecdh
|
|
736
|
+
width = (attr[:bits] + 7) / 8
|
|
737
|
+
raise ResponseError, 'Invalid card ECDH secret length or all-zero result' unless response.bytesize == width && response.bytes.any?(&:positive?)
|
|
738
|
+
end
|
|
739
|
+
response
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def verify_pin(mode)
|
|
743
|
+
if mode == :admin
|
|
744
|
+
verify_admin
|
|
745
|
+
else
|
|
746
|
+
verify_user(mode == :extended ? 0x82 : 0x81)
|
|
747
|
+
end
|
|
748
|
+
{ verified: true, mode: mode }
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def unverify_pin(mode)
|
|
752
|
+
require_version([5, 6, 0], 'PIN reset-verification')
|
|
753
|
+
apdu(0x20, 0xFF, { signature: 0x81, extended: 0x82, admin: 0x83 }.fetch(mode))
|
|
754
|
+
{ cleared: mode }
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def verify_user(reference = 0x81)
|
|
758
|
+
encoded = pin_bytes(:pin, :user)
|
|
759
|
+
apdu(0x20, 0, reference, encoded)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def kdf_enabled?
|
|
763
|
+
return false if @flags.nobits?(1)
|
|
764
|
+
|
|
765
|
+
@kdf ||= Protocol.tlv_map(data: get_data(0xF9))
|
|
766
|
+
@kdf.key?(0x81) && @kdf[0x81] != "\0".b
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def info
|
|
770
|
+
counter = Protocol.tlv_map(data: unpack(0x7A, get_data(0x7A))).fetch(0x93) { raise ResponseError, 'Signature counter absent' }
|
|
771
|
+
raise ResponseError, 'Malformed signature counter' unless counter.bytesize == 3
|
|
772
|
+
|
|
773
|
+
{ application: :openpgp, firmware: @version, card_version: @card_version, aid: @aid.unpack1('H*'),
|
|
774
|
+
manufacturer: @aid.byteslice(8, 2).unpack1('n'), pin_status: @pin_status,
|
|
775
|
+
signature_counter: counter.unpack1('H*').to_i(16), extended_capabilities: @flags, kdf_enabled: kdf_enabled?,
|
|
776
|
+
certificate_max_length: @certificate_max, keys: SLOTS.keys.select { |slot| @objects[SLOTS[slot][:attribute]] }.to_h { |slot| [slot, key_info(slot)] } }
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
private_constant :Session, :Crypto
|
|
781
|
+
|
|
782
|
+
public_class_method def self.operations
|
|
783
|
+
GRAMMAR.keys.freeze
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
public_class_method def self.execute(opts = {})
|
|
787
|
+
args = opts[:args] || []
|
|
788
|
+
raise ArgumentError, 'args must be an Array of Strings' unless args.is_a?(Array) && args.all?(String)
|
|
789
|
+
|
|
790
|
+
group = args.join(' ')
|
|
791
|
+
return { application: :openpgp, group: group, operations: operations } if GROUPS.include?(group)
|
|
792
|
+
|
|
793
|
+
path = GRAMMAR.keys.sort_by { |entry| -entry.split.size }.find { |entry| args.take(entry.split.size) == entry.split }
|
|
794
|
+
raise ArgumentError, 'Unknown OpenPGP operation' unless path
|
|
795
|
+
raise ArgumentError, 'Credentials must be supplied in memory, never argv' if args.any? { |arg| arg.match?(/\A--(?:pin|admin-pin|reset-code|new-pin|new-admin-pin|password)(?:=|\z)/) }
|
|
796
|
+
|
|
797
|
+
count, flags, values = GRAMMAR.fetch(path)
|
|
798
|
+
parsed = Protocol.parse_args(args: args.drop(path.split.size), flags: flags, values: values)
|
|
799
|
+
raise ArgumentError, 'Unexpected number of OpenPGP arguments' unless parsed[:positionals].length == count
|
|
800
|
+
|
|
801
|
+
positionals = parsed[:positionals]
|
|
802
|
+
if path == 'access set-retries'
|
|
803
|
+
raise ArgumentError, 'PIN retries must be integers from 1 to 99' unless positionals.all? { |value| value.match?(/\A[1-9][0-9]?\z/) }
|
|
804
|
+
elsif path == 'access set-signature-policy'
|
|
805
|
+
raise ArgumentError, 'Signature policy must be always or once' unless %w[always once].include?(positionals[0])
|
|
806
|
+
elsif path == 'access unblock-pin' && opts[:reset_code] && (opts[:admin_pin] || opts[:use_admin] == true)
|
|
807
|
+
raise ArgumentError, 'Choose either reset code or admin PIN, not both'
|
|
808
|
+
end
|
|
809
|
+
slot = validate_slot(slot: positionals[0]) if path.start_with?('keys ', 'certificates ')
|
|
810
|
+
policy = positionals[1]&.to_sym if path == 'keys set-touch'
|
|
811
|
+
raise ArgumentError, 'Unknown touch policy' if policy && !TOUCH.key?(policy)
|
|
812
|
+
raise ArgumentError, 'The attestation key cannot attest itself' if path == 'keys attest' && slot == :att
|
|
813
|
+
|
|
814
|
+
raw_format = parsed[:options][:format] || 'PEM'
|
|
815
|
+
raise ArgumentError, 'Supply exactly one certificate format' unless raw_format.is_a?(String)
|
|
816
|
+
|
|
817
|
+
format = raw_format.downcase.to_sym
|
|
818
|
+
raise ArgumentError, 'Certificate format must be PEM or DER' unless %i[pem der].include?(format)
|
|
819
|
+
|
|
820
|
+
output = positionals[1] if ['keys attest', 'certificates export'].include?(path)
|
|
821
|
+
if output && output != '-'
|
|
822
|
+
raise ArgumentError, 'Output path already exists; refusing to overwrite' if File.exist?(output) || File.symlink?(output)
|
|
823
|
+
raise ArgumentError, 'Output parent directory must exist and be writable' unless File.directory?(File.dirname(output)) && File.writable?(File.dirname(output))
|
|
824
|
+
end
|
|
825
|
+
certificate = parse_certificate(data: input_bytes(path: positionals[1], value: opts[:certificate])) if path == 'certificates import'
|
|
826
|
+
Protocol.require_confirm(opts) unless ['info', 'keys info', 'certificates export'].include?(path)
|
|
827
|
+
key = load_private_key(opts.merge(key: input_bytes(path: positionals[1], value: opts[:key]))) if path == 'keys import'
|
|
828
|
+
session = Session.new(opts, reset_mode: path == 'reset')
|
|
829
|
+
result = case path
|
|
830
|
+
when 'info' then session.info
|
|
831
|
+
when 'reset' then session.reset
|
|
832
|
+
when 'access change-pin' then session.change_pin
|
|
833
|
+
when 'access change-admin-pin' then session.change_pin(admin: true)
|
|
834
|
+
when 'access set-retries' then session.configure_retries(positionals.map(&:to_i))
|
|
835
|
+
when 'access change-reset-code' then session.change_reset_code
|
|
836
|
+
when 'access unblock-pin' then session.unblock_pin
|
|
837
|
+
when 'access set-signature-policy' then session.signature_policy(positionals[0].to_sym)
|
|
838
|
+
when 'keys info' then session.key_info(slot)
|
|
839
|
+
when 'keys set-touch' then session.set_touch(slot, policy)
|
|
840
|
+
when 'keys import' then session.import_key(slot, key)
|
|
841
|
+
when 'keys attest' then session.attest_key(slot, format)
|
|
842
|
+
when 'certificates export' then session.export_certificate(slot, format)
|
|
843
|
+
when 'certificates import' then session.write_certificate(slot, certificate.to_der)
|
|
844
|
+
when 'certificates delete' then session.write_certificate(slot, ''.b)
|
|
845
|
+
else raise ArgumentError, 'Unknown OpenPGP operation'
|
|
846
|
+
end
|
|
847
|
+
File.open(output, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(result) } if output && output != '-'
|
|
848
|
+
result
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
private_class_method def self.validate_slot(opts = {})
|
|
852
|
+
value = opts[:slot]
|
|
853
|
+
slot = value.to_s.downcase.to_sym
|
|
854
|
+
slot = :dec if slot == :enc
|
|
855
|
+
raise ArgumentError, 'slot must be sig, dec, aut or att' unless SLOTS.key?(slot)
|
|
856
|
+
|
|
857
|
+
slot
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
private_class_method def self.input_bytes(opts = {})
|
|
861
|
+
path = opts[:path]
|
|
862
|
+
value = opts[:value]
|
|
863
|
+
if path && path != '-'
|
|
864
|
+
raise ArgumentError, 'Supply a file path or in-memory data, not both' if value
|
|
865
|
+
|
|
866
|
+
value = File.binread(path, 1_048_577)
|
|
867
|
+
end
|
|
868
|
+
raise ArgumentError, 'Supply bounded PEM or DER bytes in memory when using -' unless value.is_a?(String) && value.bytesize.between?(1, 1_048_576)
|
|
869
|
+
|
|
870
|
+
value.b
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
private_class_method def self.parse_certificate(opts = {})
|
|
874
|
+
bytes = opts[:data]
|
|
875
|
+
if bytes.include?('-----BEGIN')
|
|
876
|
+
raise ArgumentError, 'Supply exactly one PEM certificate' unless bytes.scan('-----BEGIN CERTIFICATE-----').size == 1 && bytes.match?(%r{\A\s*-----BEGIN CERTIFICATE-----[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----\s*\z})
|
|
877
|
+
else
|
|
878
|
+
OpenSSL::ASN1.decode(bytes)
|
|
879
|
+
end
|
|
880
|
+
OpenSSL::X509::Certificate.new(bytes)
|
|
881
|
+
rescue OpenSSL::OpenSSLError
|
|
882
|
+
raise ArgumentError, 'Certificate is not valid PEM or DER X.509', cause: nil
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
private_class_method def self.load_private_key(opts = {})
|
|
886
|
+
value = opts[:key]
|
|
887
|
+
raise ArgumentError, 'key must be PEM/DER bytes or an OpenSSL private key' unless value.is_a?(OpenSSL::PKey::PKey) || (value.is_a?(String) && value.bytesize.between?(1, 1_048_576))
|
|
888
|
+
|
|
889
|
+
password = opts[:key_password]
|
|
890
|
+
encrypted = value.is_a?(String) && value.include?('ENCRYPTED')
|
|
891
|
+
if value.is_a?(String) && !value.include?('-----BEGIN')
|
|
892
|
+
begin
|
|
893
|
+
data = OpenSSL::ASN1.decode(value).value
|
|
894
|
+
encrypted ||= data.is_a?(Array) && data.first.is_a?(OpenSSL::ASN1::Sequence) && data[1].is_a?(OpenSSL::ASN1::OctetString)
|
|
895
|
+
rescue OpenSSL::ASN1::ASN1Error
|
|
896
|
+
raise ArgumentError, 'Malformed private key encoding', cause: nil
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
password = Protocol.secret(value: password, name: :key_password, secret_provider: opts[:secret_provider]) if encrypted
|
|
900
|
+
Crypto.new.parse_private(value, password)
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
public_class_method def self.generate_key(opts = {})
|
|
904
|
+
slot = validate_slot(slot: opts[:slot])
|
|
905
|
+
algorithm = opts[:algorithm].to_s.downcase.to_sym
|
|
906
|
+
raise ArgumentError, 'Unknown key algorithm; use rsa2048/rsa3072/rsa4096 or a named curve' unless [:rsa2048, :rsa3072, :rsa4096, *CURVES.keys].include?(algorithm)
|
|
907
|
+
|
|
908
|
+
Protocol.require_confirm(opts)
|
|
909
|
+
Session.new(opts).generate_key(slot, algorithm)
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
public_class_method def self.import_key(opts = {})
|
|
913
|
+
slot = validate_slot(slot: opts[:slot])
|
|
914
|
+
Protocol.require_confirm(opts)
|
|
915
|
+
key = load_private_key(opts)
|
|
916
|
+
Session.new(opts).import_key(slot, key)
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
public_class_method def self.sign(opts = {})
|
|
920
|
+
message = opts[:message]
|
|
921
|
+
raise ArgumentError, 'message must be a bounded binary String' unless message.is_a?(String) && message.bytesize <= 1_048_576
|
|
922
|
+
|
|
923
|
+
Protocol.require_confirm(opts)
|
|
924
|
+
Session.new(opts).sign(message.b, opts.fetch(:hash_algorithm, :sha256), prehashed: opts[:prehashed] == true)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
public_class_method def self.authenticate(opts = {})
|
|
928
|
+
message = opts[:message]
|
|
929
|
+
raise ArgumentError, 'message must be a bounded binary String' unless message.is_a?(String) && message.bytesize <= 1_048_576
|
|
930
|
+
|
|
931
|
+
Protocol.require_confirm(opts)
|
|
932
|
+
Session.new(opts).sign(message.b, opts.fetch(:hash_algorithm, :sha256), prehashed: opts[:prehashed] == true, authentication: true)
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
public_class_method def self.decipher(opts = {})
|
|
936
|
+
value = opts[:value]
|
|
937
|
+
raise ArgumentError, 'value must be ciphertext bytes or an OpenSSL peer public key' unless value.is_a?(String) || value.is_a?(OpenSSL::PKey::PKey)
|
|
938
|
+
|
|
939
|
+
Protocol.require_confirm(opts)
|
|
940
|
+
Session.new(opts).decipher(value)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
public_class_method def self.algorithms(opts = {})
|
|
944
|
+
Session.new(opts.merge(connection: opts[:connection])).algorithms
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
public_class_method def self.public_key(opts = {})
|
|
948
|
+
slot = validate_slot(slot: opts[:slot])
|
|
949
|
+
Session.new(opts).public_key(slot)
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
public_class_method def self.verify_pin(opts = {})
|
|
953
|
+
mode = opts[:mode] || :signature
|
|
954
|
+
raise ArgumentError, 'mode must be :signature, :extended or :admin' unless %i[signature extended admin].include?(mode)
|
|
955
|
+
|
|
956
|
+
Protocol.require_confirm(opts)
|
|
957
|
+
Session.new(opts).verify_pin(mode)
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
public_class_method def self.unverify_pin(opts = {})
|
|
961
|
+
mode = opts[:mode] || :signature
|
|
962
|
+
raise ArgumentError, 'mode must be :signature, :extended or :admin' unless %i[signature extended admin].include?(mode)
|
|
963
|
+
|
|
964
|
+
Protocol.require_confirm(opts)
|
|
965
|
+
Session.new(opts).unverify_pin(mode)
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
public_class_method def self.decrypt(opts = {})
|
|
969
|
+
decipher(opts.merge(value: opts[:value]))
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
public_class_method def self.authors
|
|
973
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
public_class_method def self.help
|
|
977
|
+
puts <<~HELP
|
|
978
|
+
Native Ruby OpenPGP Card application over a caller-owned APDU Connection.
|
|
979
|
+
All authentication, key use, mutation and reset operations require confirm: true.
|
|
980
|
+
Credentials stay in memory. There are no factory credentials or automatic mutation retries.
|
|
981
|
+
Changing key attributes can erase an existing key; fixed touch policies are irreversible
|
|
982
|
+
until that key is replaced. Reset preserves the attestation key and certificate.
|
|
983
|
+
PIN change results report fresh counters, not proof of the new secret value.
|
|
984
|
+
|
|
985
|
+
List implemented management leaf paths without accessing hardware.
|
|
986
|
+
#{self}.operations
|
|
987
|
+
Execute a native management operation or list a local group.
|
|
988
|
+
#{self}.execute(
|
|
989
|
+
args: 'optional - Nonsecret words after openpgp; groups are access, keys and certificates',
|
|
990
|
+
connection: 'required - Caller-owned YubiKey Connection with descriptor protocol :apdu',
|
|
991
|
+
timeout: 'optional - Finite positive per-command timeout in seconds; default 30',
|
|
992
|
+
confirm: 'optional - Literal true required for authentication, cryptographic use and every mutation',
|
|
993
|
+
pin: 'optional - Current user PIN bytes supplied directly in memory',
|
|
994
|
+
new_pin: 'optional - New user PIN bytes for change-pin or unblock-pin',
|
|
995
|
+
admin_pin: 'optional - Current admin PIN bytes supplied directly in memory',
|
|
996
|
+
new_admin_pin: 'optional - Replacement admin PIN bytes for change-admin-pin',
|
|
997
|
+
reset_code: 'optional - Reset code bytes for change-reset-code or unblock-pin',
|
|
998
|
+
use_admin: 'optional - Literal true selects provider-supplied admin authorization for unblock-pin',
|
|
999
|
+
key: 'optional - PEM or DER private key bytes when keys import uses the input marker -',
|
|
1000
|
+
key_password: 'optional - Private key decryption password kept in memory, never argv',
|
|
1001
|
+
certificate: 'optional - One PEM or DER certificate when certificates import uses -',
|
|
1002
|
+
secret_provider: 'optional - Callable receiving a required secret field Symbol and returning bytes'
|
|
1003
|
+
)
|
|
1004
|
+
Leaves: info; reset [--force]; access set-retries USER RESET ADMIN;
|
|
1005
|
+
access change-pin; access change-admin-pin; access change-reset-code;
|
|
1006
|
+
access unblock-pin; access set-signature-policy always|once;
|
|
1007
|
+
keys info SLOT; keys set-touch SLOT off|on|fixed|cached|cached-fixed [--force];
|
|
1008
|
+
keys import SLOT FILE|-; keys attest SLOT OUTPUT|- [--format PEM|DER];
|
|
1009
|
+
certificates export SLOT OUTPUT|- [--format PEM|DER];
|
|
1010
|
+
certificates import SLOT FILE|-; certificates delete SLOT.
|
|
1011
|
+
--force is syntax compatibility, never consent. Input - requires in-memory data;
|
|
1012
|
+
output - returns bytes, never writes stdout. Named output paths are created exclusively.
|
|
1013
|
+
|
|
1014
|
+
Query firmware-specific algorithms advertised by the selected card.
|
|
1015
|
+
#{self}.algorithms(connection: connection)
|
|
1016
|
+
Export a slot public key as a native OpenSSL public-key object.
|
|
1017
|
+
#{self}.public_key(slot: :sig, connection: connection)
|
|
1018
|
+
Generate an on-card private key and verify its public-key readback.
|
|
1019
|
+
#{self}.generate_key(
|
|
1020
|
+
slot: 'required - Key slot :sig, :dec, :aut or :att; :enc aliases :dec',
|
|
1021
|
+
algorithm: 'required - rsa2048, rsa3072, rsa4096, secp256r1, secp256k1, secp384r1, secp521r1, brainpoolp256r1, brainpoolp384r1, brainpoolp512r1, ed25519 or x25519',
|
|
1022
|
+
connection: connection, admin_pin: admin_pin, confirm: true
|
|
1023
|
+
)
|
|
1024
|
+
Import a native OpenSSL private key or PEM/DER bytes and verify its public key.
|
|
1025
|
+
#{self}.import_key(slot: :sig, key: key, connection: connection, admin_pin: admin_pin, confirm: true)
|
|
1026
|
+
Sign a message with the signature slot using PSO COMPUTE DIGITAL SIGNATURE.
|
|
1027
|
+
#{self}.sign(
|
|
1028
|
+
message: 'required - Original binary message; Ed25519 is not prehashed',
|
|
1029
|
+
hash_algorithm: 'optional - sha1, sha224, sha256, sha384 or sha512; defaults to sha256 and is ignored for Ed25519',
|
|
1030
|
+
prehashed: 'optional - Literal true means message already contains the named digest; not valid for Ed25519',
|
|
1031
|
+
connection: connection, pin: pin, confirm: true
|
|
1032
|
+
)
|
|
1033
|
+
Sign an authentication challenge with the authentication slot using INTERNAL AUTHENTICATE.
|
|
1034
|
+
#{self}.authenticate(message: message, connection: connection, pin: pin, confirm: true)
|
|
1035
|
+
Decrypt an RSA PKCS1-v1_5 block or perform raw ECDH with the decryption slot.
|
|
1036
|
+
#{self}.decipher(
|
|
1037
|
+
value: 'required - RSA ciphertext bytes, or an OpenSSL EC/X25519 peer public key or its raw point bytes',
|
|
1038
|
+
connection: connection, pin: pin, confirm: true
|
|
1039
|
+
)
|
|
1040
|
+
Decrypt through the same native decipher implementation.
|
|
1041
|
+
#{self}.decrypt(value: value, connection: connection, pin: pin, confirm: true)
|
|
1042
|
+
Verify a PIN once without guessing credentials or retrying a failure.
|
|
1043
|
+
#{self}.verify_pin(
|
|
1044
|
+
mode: 'optional - :signature for PW1/81, :extended for PW1/82, or :admin for PW3/83',
|
|
1045
|
+
connection: connection, pin: pin, confirm: true
|
|
1046
|
+
)
|
|
1047
|
+
Clear the selected PIN verification state on firmware 5.6 or newer.
|
|
1048
|
+
#{self}.unverify_pin(mode: :signature, connection: connection, confirm: true)
|
|
1049
|
+
Show module author information.
|
|
1050
|
+
#{self}.authors
|
|
1051
|
+
|
|
1052
|
+
RSA signatures are raw modulus-length bytes; ECDSA signatures are ASN.1 DER;
|
|
1053
|
+
Ed25519 signatures are raw 64 bytes. ECDH returns a raw shared secret, not a
|
|
1054
|
+
decrypted OpenPGP message. No keyring, OpenPGP packet codec or trust verification
|
|
1055
|
+
is implied. Attestation returns a parsed certificate, not a validated trust chain.
|
|
1056
|
+
Unsupported firmware, curves, policies and advertised capabilities fail explicitly.
|
|
1057
|
+
Readback mismatches and timeouts never cause mutation replay.
|
|
1058
|
+
HELP
|
|
1059
|
+
end
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
end
|