├── .gitignore ├── i18n.toml ├── rust-toolchain.toml ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── ux-report.md └── workflows │ ├── lints-stable.yml │ ├── lints-beta.yml │ ├── ci.yml │ └── release.yml ├── LICENSE-MIT ├── src ├── recipient.rs ├── piv_p256 │ └── recipient.rs ├── builder.rs ├── piv_p256.rs ├── error.rs ├── util.rs ├── plugin.rs ├── main.rs └── key.rs ├── Cargo.toml ├── examples └── generate-docs.rs ├── CHANGELOG.md ├── tests └── integration.rs ├── README.md ├── i18n └── en-US │ └── age_plugin_yubikey.ftl └── LICENSE-APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | # CLion IDE 2 | .idea 3 | 4 | /target 5 | **/*.rs.bk 6 | -------------------------------------------------------------------------------- /i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_language = "en-US" 2 | 3 | [fluent] 4 | assets_dir = "i18n" 5 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.70.0" 3 | components = ["clippy", "rustfmt"] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | timezone: Etc/UTC 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - str4d 11 | assignees: 12 | - str4d 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report about a bug in this implementation. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Environment 11 | 12 | * OS: 13 | * age-plugin-yubikey version: 14 | 15 | ## What were you trying to do 16 | 17 | ## What happened 18 | 19 | ``` 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ux-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: UX report 3 | about: Was age-plugin-yubikey hard to use? It's not you, it's us. We want to hear about it. 4 | title: 'UX: ' 5 | labels: 'UX report' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 14 | 15 | ## What were you trying to do 16 | 17 | ## What happened 18 | 19 | ``` 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /.github/workflows/lints-stable.yml: -------------------------------------------------------------------------------- 1 | name: Stable lints 2 | 3 | # We only run these lints on trial-merges of PRs to reduce noise. 4 | on: pull_request 5 | 6 | jobs: 7 | clippy: 8 | name: Clippy (MSRV) 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install build dependencies 13 | run: sudo apt install libpcsclite-dev 14 | - name: Run clippy 15 | uses: actions-rs/clippy-check@v1 16 | with: 17 | name: Clippy (MSRV) 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | args: --all-features --all-targets -- -D warnings 20 | -------------------------------------------------------------------------------- /.github/workflows/lints-beta.yml: -------------------------------------------------------------------------------- 1 | name: Beta lints 2 | 3 | # We only run these lints on trial-merges of PRs to reduce noise. 4 | on: pull_request 5 | 6 | jobs: 7 | clippy-beta: 8 | name: Clippy (beta) 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: dtolnay/rust-toolchain@beta 13 | id: toolchain 14 | - run: rustup override set ${{steps.toolchain.outputs.name}} 15 | - name: Install build dependencies 16 | run: sudo apt install libpcsclite-dev 17 | - name: Clippy (beta) 18 | uses: actions-rs/clippy-check@v1 19 | with: 20 | name: Clippy (beta) 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | args: --all-features --all-targets -- -W clippy::all 23 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Jack Grigg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/recipient.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use age_core::format::{FileKey, Stanza}; 4 | use sha2::{Digest, Sha256}; 5 | 6 | use crate::{piv_p256, PLUGIN_NAME}; 7 | 8 | pub(crate) const TAG_BYTES: usize = 4; 9 | 10 | #[derive(Clone, Debug)] 11 | pub(crate) enum Recipient { 12 | PivP256(piv_p256::Recipient), 13 | } 14 | 15 | impl fmt::Display for Recipient { 16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 17 | match self { 18 | Recipient::PivP256(recipient) => recipient.fmt(f), 19 | } 20 | } 21 | } 22 | 23 | impl Recipient { 24 | /// Attempts to parse a supported YubiKey recipient. 25 | pub(crate) fn from_bytes(plugin_name: &str, bytes: &[u8]) -> Option { 26 | match plugin_name { 27 | PLUGIN_NAME => piv_p256::Recipient::from_bytes(bytes).map(Self::PivP256), 28 | _ => None, 29 | } 30 | } 31 | 32 | /// Returns the static tag for this recipient. 33 | pub(crate) fn static_tag(&self) -> [u8; TAG_BYTES] { 34 | match self { 35 | Recipient::PivP256(recipient) => recipient.tag(), 36 | } 37 | } 38 | 39 | pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> Stanza { 40 | match self { 41 | Recipient::PivP256(recipient) => recipient.wrap_file_key(file_key).into(), 42 | } 43 | } 44 | } 45 | 46 | pub(crate) fn static_tag(pk: &[u8]) -> [u8; TAG_BYTES] { 47 | Sha256::digest(pk)[0..TAG_BYTES] 48 | .try_into() 49 | .expect("length is correct") 50 | } 51 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "age-plugin-yubikey" 3 | description = "YubiKey plugin for age clients" 4 | version = "0.5.0" 5 | authors = ["Jack Grigg "] 6 | repository = "https://github.com/str4d/age-plugin-yubikey" 7 | readme = "README.md" 8 | keywords = ["age", "cli", "encryption", "yubikey"] 9 | categories = ["command-line-utilities", "cryptography"] 10 | license = "MIT OR Apache-2.0" 11 | edition = "2021" 12 | rust-version = "1.70" # MSRV 13 | 14 | [package.metadata.deb] 15 | extended-description = """\ 16 | An age plugin adding support for YubiKeys and other PIV hardware tokens.""" 17 | section = "utils" 18 | assets = [ 19 | ["target/release/age-plugin-yubikey", "usr/bin/", "755"], 20 | ["target/manpages/age-plugin-yubikey.1.gz", "usr/share/man/man1/", "644"], 21 | ["README.md", "usr/share/doc/age-plugin-yubikey/README.md", "644"], 22 | ] 23 | 24 | [dependencies] 25 | age-core = "0.11" 26 | age-plugin = "0.6" 27 | base64 = "0.21" 28 | bech32 = "0.9" 29 | console = { version = "0.15", default-features = false } 30 | dialoguer = { version = "0.11", default-features = false, features = ["password"] } 31 | env_logger = "0.10" 32 | gumdrop = "0.8" 33 | hex = "0.4" 34 | log = "0.4" 35 | p256 = { version = "0.13", features = ["ecdh"] } 36 | pcsc = "2.4" 37 | rand = "0.8" 38 | sha2 = "0.10" 39 | which = "5" 40 | x509 = "0.2" 41 | x509-parser = "0.14" 42 | yubikey = { version = "=0.8.0-pre.0", features = ["untested"] } 43 | 44 | # Translations 45 | i18n-embed = { version = "0.15", features = ["desktop-requester", "fluent-system"] } 46 | i18n-embed-fl = "0.9" 47 | lazy_static = "1" 48 | rust-embed = "8" 49 | 50 | # GnuPG coexistence 51 | sysinfo = "0.29" 52 | 53 | [dev-dependencies] 54 | flate2 = "1" 55 | man = "0.3" 56 | tempfile = "3" 57 | test-with = "0.11" 58 | which = "5" 59 | -------------------------------------------------------------------------------- /src/piv_p256/recipient.rs: -------------------------------------------------------------------------------- 1 | use bech32::{ToBase32, Variant}; 2 | use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; 3 | use yubikey::{certificate::PublicKeyInfo, Certificate}; 4 | 5 | use std::fmt; 6 | 7 | use crate::recipient::{static_tag, TAG_BYTES}; 8 | 9 | const RECIPIENT_PREFIX: &str = "age1yubikey"; 10 | 11 | /// Wrapper around a compressed secp256r1 curve point. 12 | #[derive(Clone)] 13 | pub struct Recipient(p256::PublicKey); 14 | 15 | impl fmt::Debug for Recipient { 16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 17 | write!(f, "Recipient({:?})", self.to_encoded().as_bytes()) 18 | } 19 | } 20 | 21 | impl fmt::Display for Recipient { 22 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | f.write_str( 24 | bech32::encode( 25 | RECIPIENT_PREFIX, 26 | self.to_encoded().as_bytes().to_base32(), 27 | Variant::Bech32, 28 | ) 29 | .expect("HRP is valid") 30 | .as_str(), 31 | ) 32 | } 33 | } 34 | 35 | impl Recipient { 36 | /// Attempts to parse a valid YubiKey recipient from its compressed SEC-1 byte encoding. 37 | pub(crate) fn from_bytes(bytes: &[u8]) -> Option { 38 | let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?; 39 | if encoded.is_compressed() { 40 | Self::from_encoded(&encoded) 41 | } else { 42 | None 43 | } 44 | } 45 | 46 | pub(crate) fn from_certificate(cert: &Certificate) -> Option { 47 | Self::from_spki(cert.subject_pki()) 48 | } 49 | 50 | pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option { 51 | match spki { 52 | PublicKeyInfo::EcP256(pubkey) => Self::from_encoded(pubkey), 53 | _ => None, 54 | } 55 | } 56 | 57 | /// Attempts to parse a valid YubiKey recipient from its SEC-1 encoding. 58 | /// 59 | /// This accepts both compressed (as used by the plugin) and uncompressed (as used in 60 | /// the YubiKey certificate) encodings. 61 | fn from_encoded(encoded: &p256::EncodedPoint) -> Option { 62 | Option::from(p256::PublicKey::from_encoded_point(encoded)).map(Recipient) 63 | } 64 | 65 | /// Returns the compressed SEC-1 encoding of this recipient. 66 | pub(crate) fn to_encoded(&self) -> p256::EncodedPoint { 67 | self.0.to_encoded_point(true) 68 | } 69 | 70 | pub(crate) fn tag(&self) -> [u8; TAG_BYTES] { 71 | static_tag(self.to_encoded().as_bytes()) 72 | } 73 | 74 | /// Exposes the wrapped public key. 75 | pub(crate) fn public_key(&self) -> &p256::PublicKey { 76 | &self.0 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /examples/generate-docs.rs: -------------------------------------------------------------------------------- 1 | use flate2::{write::GzEncoder, Compression}; 2 | use man::prelude::*; 3 | use std::fs::{create_dir_all, File}; 4 | use std::io::prelude::*; 5 | 6 | const MANPAGES_DIR: &str = "./target/manpages"; 7 | 8 | fn generate_manpage(page: String, name: &str) { 9 | let file = File::create(format!("{MANPAGES_DIR}/{name}.1.gz")) 10 | .expect("Should be able to open file in target directory"); 11 | let mut encoder = GzEncoder::new(file, Compression::best()); 12 | encoder 13 | .write_all(page.as_bytes()) 14 | .expect("Should be able to write to file in target directory"); 15 | } 16 | 17 | fn main() { 18 | // Create the target directory if it does not exist. 19 | let _ = create_dir_all(MANPAGES_DIR); 20 | 21 | let builder = Manual::new("age-plugin-yubikey") 22 | .about("An age plugin adding support for YubiKeys and other PIV hardware tokens") 23 | .author(Author::new("Jack Grigg").email("thestr4d@gmail.com")) 24 | .flag( 25 | Flag::new() 26 | .short("-h") 27 | .long("--help") 28 | .help("Display help text and exit."), 29 | ) 30 | .flag( 31 | Flag::new() 32 | .short("-V") 33 | .long("--version") 34 | .help("Display version info and exit."), 35 | ) 36 | .flag( 37 | Flag::new() 38 | .short("-f") 39 | .long("--force") 40 | .help("Force --generate to overwrite a filled slot."), 41 | ) 42 | .flag( 43 | Flag::new() 44 | .short("-g") 45 | .long("--generate") 46 | .help("Generate a new YubiKey identity."), 47 | ) 48 | .flag( 49 | Flag::new() 50 | .short("-i") 51 | .long("--identity") 52 | .help("Print identities stored in connected YubiKeys."), 53 | ) 54 | .flag( 55 | Flag::new() 56 | .short("-l") 57 | .long("--list") 58 | .help("List recipients for age identities in connected YubiKeys."), 59 | ) 60 | .flag( 61 | Flag::new() 62 | .long("--list-all") 63 | .help("List recipients for all YubiKey keys that are compatible with age."), 64 | ) 65 | .flag( 66 | Flag::new() 67 | .long("--name") 68 | .help("Name for the generated identity. Defaults to 'age identity HEX_TAG'."), 69 | ) 70 | .flag( 71 | Flag::new() 72 | .long("--pin-policy") 73 | .help("One of [always, once, never]. Defaults to 'once'."), 74 | ) 75 | .flag( 76 | Flag::new() 77 | .long("--serial") 78 | .help("Specify which YubiKey to use, if more than one is plugged in."), 79 | ) 80 | .flag( 81 | Flag::new() 82 | .long("--slot") 83 | .help("Specify which slot to use. Defaults to first usable slot."), 84 | ) 85 | .flag( 86 | Flag::new() 87 | .long("--touch-policy") 88 | .help("One of [always, cached, never]. Defaults to 'always'."), 89 | ); 90 | let page = builder.render(); 91 | 92 | generate_manpage(page, "age-plugin-yubikey"); 93 | } 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: main 7 | 8 | jobs: 9 | test-msrv: 10 | name: Test MSRV on ${{ matrix.name }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | name: [linux, windows, macos] 15 | include: 16 | - name: linux 17 | os: ubuntu-latest 18 | build_deps: > 19 | libpcsclite-dev 20 | 21 | - name: windows 22 | os: windows-latest 23 | 24 | - name: macos 25 | os: macos-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Install build dependencies 30 | run: sudo apt install ${{ matrix.build_deps }} 31 | if: matrix.build_deps != '' 32 | - uses: dtolnay/rust-toolchain@stable 33 | id: stable-toolchain 34 | - name: Install test dependencies using latest stable Rust 35 | run: cargo +${{steps.stable-toolchain.outputs.name}} install rage 36 | - name: Run tests 37 | run: cargo test 38 | - name: Verify working directory is clean 39 | run: git diff --exit-code 40 | 41 | test-latest: 42 | name: Test latest stable on ${{ matrix.name }} 43 | runs-on: ${{ matrix.os }} 44 | strategy: 45 | matrix: 46 | name: [linux, windows, macos] 47 | include: 48 | - name: linux 49 | os: ubuntu-latest 50 | build_deps: > 51 | libpcsclite-dev 52 | 53 | - name: windows 54 | os: windows-latest 55 | 56 | - name: macos 57 | os: macos-latest 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | - name: Install build dependencies 62 | run: sudo apt install ${{ matrix.build_deps }} 63 | if: matrix.build_deps != '' 64 | - uses: dtolnay/rust-toolchain@stable 65 | - uses: dtolnay/rust-toolchain@stable 66 | id: toolchain 67 | - run: rustup override set ${{steps.toolchain.outputs.name}} 68 | - name: Install test dependencies 69 | run: cargo install rage 70 | - name: Remove lockfile to build with latest dependencies 71 | run: rm Cargo.lock 72 | - name: Run tests 73 | run: cargo test 74 | - name: Verify working directory is clean (excluding lockfile) 75 | run: git diff --exit-code ':!Cargo.lock' 76 | 77 | codecov: 78 | name: Code coverage 79 | runs-on: ubuntu-latest 80 | container: 81 | image: xd009642/tarpaulin:develop-nightly 82 | options: --security-opt seccomp=unconfined 83 | 84 | steps: 85 | - uses: actions/checkout@v4 86 | - name: Install build dependencies 87 | run: apt update && apt install -y libpcsclite-dev 88 | - name: Generate coverage report 89 | run: > 90 | cargo tarpaulin 91 | --engine llvm 92 | --timeout 180 93 | --out xml 94 | - name: Upload coverage to Codecov 95 | uses: codecov/codecov-action@v4.5.0 96 | with: 97 | fail_ci_if_error: true 98 | token: ${{ secrets.CODECOV_TOKEN }} 99 | 100 | doc-links: 101 | name: Intra-doc links 102 | runs-on: ubuntu-latest 103 | steps: 104 | - uses: actions/checkout@v4 105 | - name: Install build dependencies 106 | run: sudo apt install libpcsclite-dev 107 | - run: cargo fetch 108 | # Requires #![deny(rustdoc::broken_intra_doc_links)] in crates. 109 | - name: Check intra-doc links 110 | run: cargo doc --document-private-items 111 | 112 | fmt: 113 | name: Rustfmt 114 | runs-on: ubuntu-latest 115 | steps: 116 | - uses: actions/checkout@v4 117 | - name: Check formatting 118 | run: cargo fmt -- --check 119 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this crate will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to Rust's notion of 6 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). All versions prior 7 | to 0.3.0 are beta releases. 8 | 9 | ## [Unreleased] 10 | 11 | ### Changed 12 | - MSRV is now 1.70.0. 13 | 14 | ## [0.5.0] - 2024-08-04 15 | ### Fixed 16 | - `age-plugin-yubikey` can now be compiled with Rust 1.80 and above. 17 | 18 | ### Changed 19 | - MSRV is now 1.67.0. 20 | 21 | ## [0.4.0] - 2023-04-09 22 | ### Changed 23 | - MSRV is now 1.65.0. 24 | - The YubiKey PIV PIN and touch caches are now preserved across processes in 25 | most cases. See [README.md](README.md#agent-support) for exceptions. This has 26 | several usability effects (not applicable to YubiKey 4 series): 27 | - If a YubiKey's PIN is cached by an agent like `yubikey-agent`, and then 28 | `age-plugin-yubikey` is run (either directly or as a plugin), the agent 29 | won't request a PIN entry on its next use. 30 | - If a YubiKey's PIN was requested by either a previous invocation of 31 | `age-plugin-yubikey` or an agent like `yubikey-agent`, subsequent calls to 32 | `age-plugin-yubikey` won't request a PIN entry to decrypt a file with an 33 | identity that has a PIN policy of `once`. 34 | 35 | ### Fixed 36 | - Identities can now be generated with a PIN policy of "always" (in previous 37 | versions of `age-plugin-yubikey` this would cause an error). 38 | 39 | ## [0.3.3] - 2023-02-11 40 | ### Fixed 41 | - When `age-plugin-yubikey` assists the user in changing their PIN from the 42 | default PIN, it no longer tells the user that PINs shorter than 6 characters 43 | are allowed, and instead loops until the user enters a PIN of valid length. 44 | It also now prevents the user from setting their PIN to the default PIN, to 45 | avoid creating a cycle. 46 | - More kinds of SmartCard readers are ignored when they have no SmartCard 47 | inserted. 48 | 49 | ## [0.3.2] - 2023-01-01 50 | ### Changed 51 | - The "sharing violation" logic now also sends SIGHUP to any `yubikey-agent` 52 | that is running, to have them release any YubiKey locks they are holding. 53 | 54 | ### Fixed 55 | - The "sharing violation" logic now runs during plugin mode as intended. In the 56 | previous release it only ran during direct `age-plugin-yubikey` usage. 57 | 58 | ## [0.3.1] - 2022-12-30 59 | ### Changed 60 | - If a "sharing violation" error is encountered while opening a connection to a 61 | YubiKey, and `scdaemon` is running (which can hold exclusive access to a 62 | YubiKey indefinitely), `age-plugin-yubikey` now attempts to stop `scdaemon` by 63 | interrupting it (or killing it on Windows), and then tries again to open the 64 | connection. 65 | - Several error messages were enhanced with guidance on how to resolve their 66 | respective issue. 67 | 68 | ## [0.3.0] - 2022-05-02 69 | First non-beta release! 70 | 71 | ### Changed 72 | - MSRV is now 1.56.0. 73 | - During decryption, when asked to insert a YubiKey, you can now choose to skip 74 | it, allowing the client to move on to the next identity instead of returning 75 | an error. 76 | - Certain kinds of PIN invalidity will now cause the plugin to re-request the 77 | PIN instead of aborting: if the PIN is too short or too long, or if the user 78 | touched the YubiKey early and "typed" an OTP. 79 | 80 | ### Fixed 81 | - The "default" identity (provided by clients that invoke `age-plugin-yubikey` 82 | using `-j yubikey`) previously caused a panic. It is now correctly treated as 83 | an invalid identity (because this plugin does not support default identities). 84 | 85 | ## [0.2.0] - 2021-11-22 86 | ### Fixed 87 | - Attempts-before-blocked counter is now returned as part of the invalid PIN 88 | error string. 89 | - PIN is no longer requested when fetching the recipient for a slot, or when 90 | decrypting with a slot that has a PIN policy of Never. 91 | - Migrated to `yubikey 0.5` to fix `cargo install age-plugin-yubikey` error 92 | (caused by the `yubikey-piv` crate being yanked after it was renamed). 93 | 94 | ## [0.1.0] - 2021-05-02 95 | 96 | Initial beta release. 97 | -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::io::Write; 3 | use std::path::Path; 4 | use std::process::{Command, Stdio}; 5 | 6 | const PLUGIN_BIN: &str = env!("CARGO_BIN_EXE_age-plugin-yubikey"); 7 | 8 | #[test_with::env(YUBIKEY_SERIAL, YUBIKEY_SLOT)] 9 | #[cfg_attr(all(unix, not(target_os = "macos")), test_with::executable(pcscd))] 10 | #[test] 11 | fn recipient_and_identity_match() { 12 | let recipient = Command::new(PLUGIN_BIN) 13 | .arg("--list") 14 | .arg("--serial") 15 | .arg(env::var("YUBIKEY_SERIAL").unwrap()) 16 | .arg("--slot") 17 | .arg(env::var("YUBIKEY_SLOT").unwrap()) 18 | .output() 19 | .unwrap(); 20 | assert_eq!(recipient.status.code(), Some(0)); 21 | 22 | let identity = Command::new(PLUGIN_BIN) 23 | .arg("--identity") 24 | .arg("--serial") 25 | .arg(env::var("YUBIKEY_SERIAL").unwrap()) 26 | .arg("--slot") 27 | .arg(env::var("YUBIKEY_SLOT").unwrap()) 28 | .output() 29 | .unwrap(); 30 | assert_eq!(identity.status.code(), Some(0)); 31 | 32 | let recipient_file = String::from_utf8_lossy(&recipient.stdout); 33 | let recipient = recipient_file.lines().last().unwrap(); 34 | let identity = String::from_utf8_lossy(&identity.stdout); 35 | assert!(identity.contains(recipient)); 36 | } 37 | 38 | #[test_with::executable(rage)] 39 | #[test] 40 | fn plugin_encrypt() { 41 | let enc_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); 42 | 43 | let mut process = Command::new(which::which("rage").unwrap()) 44 | .arg("-r") 45 | .arg("age1yubikey1q2w7u3vpya839jxxuq8g0sedh3d740d4xvn639sqhr95ejj8vu3hyfumptt") 46 | .arg("-o") 47 | .arg(enc_file.path()) 48 | .stdin(Stdio::piped()) 49 | .env("PATH", Path::new(PLUGIN_BIN).parent().unwrap()) 50 | .spawn() 51 | .unwrap(); 52 | 53 | // Scope to ensure stdin is closed. 54 | { 55 | let mut stdin = process.stdin.take().unwrap(); 56 | stdin.write_all(b"Testing YubiKey encryption").unwrap(); 57 | stdin.flush().unwrap(); 58 | } 59 | 60 | let status = process.wait().unwrap(); 61 | assert_eq!(status.code(), Some(0)); 62 | } 63 | 64 | #[test_with::env(YUBIKEY_SERIAL, YUBIKEY_SLOT)] 65 | #[test_with::executable(rage)] 66 | #[cfg_attr(all(unix, not(target_os = "macos")), test_with::executable(pcscd))] 67 | #[test] 68 | fn plugin_decrypt() { 69 | let mut identity_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); 70 | let enc_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); 71 | let plaintext = "Testing YubiKey encryption"; 72 | 73 | // Write an identity file corresponding to this YubiKey slot. 74 | let identity = Command::new(PLUGIN_BIN) 75 | .arg("--identity") 76 | .arg("--serial") 77 | .arg(env::var("YUBIKEY_SERIAL").unwrap()) 78 | .arg("--slot") 79 | .arg(env::var("YUBIKEY_SLOT").unwrap()) 80 | .output() 81 | .unwrap(); 82 | assert_eq!(identity.status.code(), Some(0)); 83 | identity_file.write_all(&identity.stdout).unwrap(); 84 | identity_file.flush().unwrap(); 85 | 86 | // Encrypt to the YubiKey slot. 87 | let mut enc_process = Command::new(which::which("rage").unwrap()) 88 | .arg("-e") 89 | .arg("-i") 90 | .arg(identity_file.path()) 91 | .arg("-o") 92 | .arg(enc_file.path()) 93 | .stdin(Stdio::piped()) 94 | .env("PATH", Path::new(PLUGIN_BIN).parent().unwrap()) 95 | .spawn() 96 | .unwrap(); 97 | 98 | // Scope to ensure stdin is closed. 99 | { 100 | let mut stdin = enc_process.stdin.take().unwrap(); 101 | stdin.write_all(plaintext.as_bytes()).unwrap(); 102 | stdin.flush().unwrap(); 103 | } 104 | 105 | let enc_status = enc_process.wait().unwrap(); 106 | assert_eq!(enc_status.code(), Some(0)); 107 | 108 | // Decrypt with the YubiKey. 109 | let dec_process = Command::new(which::which("rage").unwrap()) 110 | .arg("-d") 111 | .arg("-i") 112 | .arg(identity_file.path()) 113 | .arg(enc_file.path()) 114 | .stdin(Stdio::piped()) 115 | .env("PATH", Path::new(PLUGIN_BIN).parent().unwrap()) 116 | .output() 117 | .unwrap(); 118 | 119 | let stderr = String::from_utf8_lossy(&dec_process.stderr); 120 | if !stderr.is_empty() { 121 | assert!(stderr.contains("age-plugin-yubikey")); 122 | assert!(stderr.ends_with("...\n")); 123 | } 124 | assert_eq!(String::from_utf8_lossy(&dec_process.stdout), plaintext); 125 | } 126 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish release binaries 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | test: 10 | description: 'Testing the release workflow' 11 | required: true 12 | default: 'true' 13 | 14 | jobs: 15 | build: 16 | name: Publish for ${{ matrix.name }} 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | name: 21 | - linux 22 | - macos-arm64 23 | - macos-x86_64 24 | - windows 25 | include: 26 | - name: linux 27 | os: ubuntu-20.04 28 | build_deps: > 29 | libpcsclite-dev 30 | archive_name: age-plugin-yubikey.tar.gz 31 | asset_suffix: x86_64-linux.tar.gz 32 | 33 | - name: windows 34 | os: windows-latest 35 | archive_name: age-plugin-yubikey.zip 36 | asset_suffix: x86_64-windows.zip 37 | 38 | - name: macos-arm64 39 | os: macos-latest 40 | target: aarch64-apple-darwin 41 | build_flags: --target aarch64-apple-darwin 42 | archive_name: age-plugin-yubikey.tar.gz 43 | asset_suffix: arm64-darwin.tar.gz 44 | 45 | - name: macos-x86_64 46 | os: macos-latest 47 | archive_name: age-plugin-yubikey.tar.gz 48 | asset_suffix: x86_64-darwin.tar.gz 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: dtolnay/rust-toolchain@stable 53 | id: toolchain 54 | - run: rustup override set ${{steps.toolchain.outputs.name}} 55 | - name: Add target 56 | run: rustup target add ${{ matrix.target }} 57 | if: matrix.target != '' 58 | 59 | - name: Install linux build dependencies 60 | run: sudo apt install ${{ matrix.build_deps }} 61 | if: matrix.build_deps != '' 62 | 63 | - name: Set up .cargo/config 64 | run: | 65 | mkdir .cargo 66 | echo '${{ matrix.cargo_config }}' >.cargo/config 67 | if: matrix.cargo_config != '' 68 | 69 | - name: cargo build 70 | run: cargo build --release --locked ${{ matrix.build_flags }} 71 | 72 | - name: Create archive 73 | run: | 74 | mkdir -p release/age-plugin-yubikey 75 | mv target/${{ matrix.target }}/release/age-plugin-yubikey release/age-plugin-yubikey/ 76 | tar czf ${{ matrix.archive_name }} -C release/ age-plugin-yubikey/ 77 | if: matrix.name != 'windows' 78 | 79 | - name: Create archive [Windows] 80 | run: | 81 | mkdir -p release/age-plugin-yubikey 82 | mv target/release/age-plugin-yubikey.exe release/age-plugin-yubikey/ 83 | cd release/ 84 | 7z.exe a ../${{ matrix.archive_name }} age-plugin-yubikey/ 85 | shell: bash 86 | if: matrix.name == 'windows' 87 | 88 | - name: Upload archive to release 89 | uses: svenstaro/upload-release-action@2.9.0 90 | with: 91 | file: ${{ matrix.archive_name }} 92 | asset_name: age-plugin-yubikey-$tag-${{ matrix.asset_suffix }} 93 | prerelease: true 94 | if: github.event.inputs.test != 'true' 95 | 96 | deb: 97 | name: Debian ${{ matrix.name }} 98 | runs-on: ubuntu-20.04 99 | strategy: 100 | matrix: 101 | name: [linux] 102 | include: 103 | - name: linux 104 | target: x86_64-unknown-linux-gnu 105 | build_deps: > 106 | libpcsclite-dev 107 | 108 | steps: 109 | - uses: actions/checkout@v4 110 | - uses: dtolnay/rust-toolchain@stable 111 | id: toolchain 112 | - run: rustup override set ${{steps.toolchain.outputs.name}} 113 | - name: Add target 114 | run: rustup target add ${{ matrix.target }} 115 | - name: cargo install cargo-deb 116 | uses: actions-rs/cargo@v1 117 | with: 118 | command: install 119 | args: cargo-deb 120 | 121 | - name: Install build dependencies 122 | run: sudo apt install ${{ matrix.build_deps }} 123 | if: matrix.build_deps != '' 124 | 125 | - name: Set up .cargo/config 126 | run: | 127 | mkdir .cargo 128 | echo '${{ matrix.cargo_config }}' >.cargo/config 129 | if: matrix.cargo_config != '' 130 | 131 | - name: cargo build 132 | run: cargo build --release --locked --target ${{ matrix.target }} ${{ matrix.build_flags }} 133 | 134 | - name: Generate manpages 135 | uses: actions-rs/cargo@v1 136 | with: 137 | command: run 138 | args: --example generate-docs 139 | 140 | - name: cargo deb 141 | uses: actions-rs/cargo@v1 142 | with: 143 | command: deb 144 | args: --package age-plugin-yubikey --no-build --target ${{ matrix.target }} 145 | 146 | - name: Upload Debian package to release 147 | uses: svenstaro/upload-release-action@2.9.0 148 | with: 149 | file: target/${{ matrix.target }}/debian/*.deb 150 | file_glob: true 151 | prerelease: true 152 | if: github.event.inputs.test != 'true' 153 | -------------------------------------------------------------------------------- /src/builder.rs: -------------------------------------------------------------------------------- 1 | use dialoguer::Password; 2 | use rand::{rngs::OsRng, RngCore}; 3 | use x509::RelativeDistinguishedName; 4 | use yubikey::{ 5 | certificate::Certificate, 6 | piv::{generate as yubikey_generate, AlgorithmId, RetiredSlotId, SlotId}, 7 | Key, PinPolicy, TouchPolicy, YubiKey, 8 | }; 9 | 10 | use crate::{ 11 | error::Error, 12 | fl, 13 | key::{self, Stub}, 14 | piv_p256, 15 | util::{Metadata, POLICY_EXTENSION_OID}, 16 | Recipient, BINARY_NAME, USABLE_SLOTS, 17 | }; 18 | 19 | pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once; 20 | pub(crate) const DEFAULT_TOUCH_POLICY: TouchPolicy = TouchPolicy::Always; 21 | 22 | pub(crate) struct IdentityBuilder { 23 | slot: Option, 24 | force: bool, 25 | name: Option, 26 | pin_policy: Option, 27 | touch_policy: Option, 28 | } 29 | 30 | impl IdentityBuilder { 31 | pub(crate) fn new(slot: Option) -> Self { 32 | IdentityBuilder { 33 | slot, 34 | name: None, 35 | pin_policy: None, 36 | touch_policy: None, 37 | force: false, 38 | } 39 | } 40 | 41 | pub(crate) fn with_name(mut self, name: Option) -> Self { 42 | self.name = name; 43 | self 44 | } 45 | 46 | pub(crate) fn with_pin_policy(mut self, pin_policy: Option) -> Self { 47 | self.pin_policy = pin_policy; 48 | self 49 | } 50 | 51 | pub(crate) fn with_touch_policy(mut self, touch_policy: Option) -> Self { 52 | self.touch_policy = touch_policy; 53 | self 54 | } 55 | 56 | pub(crate) fn force(mut self, force: bool) -> Self { 57 | self.force = force; 58 | self 59 | } 60 | 61 | pub(crate) fn build(self, yubikey: &mut YubiKey) -> Result<(Stub, Recipient, Metadata), Error> { 62 | let slot = match self.slot { 63 | Some(slot) => { 64 | if !self.force { 65 | // Check that the slot is empty. 66 | if Key::list(yubikey)? 67 | .into_iter() 68 | .any(|key| key.slot() == SlotId::Retired(slot)) 69 | { 70 | return Err(Error::SlotIsNotEmpty(slot)); 71 | } 72 | } 73 | 74 | // Now either the slot is empty, or --force is specified. 75 | slot 76 | } 77 | None => { 78 | // Use the first empty slot. 79 | let keys = Key::list(yubikey)?; 80 | USABLE_SLOTS 81 | .iter() 82 | .find(|&&slot| !keys.iter().any(|key| key.slot() == SlotId::Retired(slot))) 83 | .cloned() 84 | .ok_or_else(|| Error::NoEmptySlots(yubikey.serial()))? 85 | } 86 | }; 87 | 88 | let pin_policy = self.pin_policy.unwrap_or(DEFAULT_PIN_POLICY); 89 | let touch_policy = self.touch_policy.unwrap_or(DEFAULT_TOUCH_POLICY); 90 | 91 | eprintln!("{}", fl!("builder-gen-key")); 92 | 93 | // No need to ask for users to enter their PIN if the PIN policy requires it, 94 | // because here we _always_ require them to enter their PIN in order to access the 95 | // protected management key (which is necessary in order to generate identities). 96 | key::manage(yubikey)?; 97 | 98 | // Generate a new key in the selected slot. 99 | let generated = yubikey_generate( 100 | yubikey, 101 | SlotId::Retired(slot), 102 | AlgorithmId::EccP256, 103 | pin_policy, 104 | touch_policy, 105 | )?; 106 | 107 | let recipient = Recipient::PivP256( 108 | piv_p256::Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"), 109 | ); 110 | let stub = Stub::new(yubikey.serial(), slot, &recipient); 111 | 112 | eprintln!(); 113 | eprintln!("{}", fl!("builder-gen-cert")); 114 | 115 | // Pick a random serial for the new self-signed certificate. 116 | let mut serial = [0; 20]; 117 | OsRng.fill_bytes(&mut serial); 118 | 119 | let name = self 120 | .name 121 | .unwrap_or(format!("age identity {}", hex::encode(stub.tag))); 122 | 123 | if let PinPolicy::Always = pin_policy { 124 | // We need to enter the PIN again. 125 | let pin = Password::new() 126 | .with_prompt(fl!( 127 | "plugin-enter-pin", 128 | yubikey_serial = yubikey.serial().to_string(), 129 | )) 130 | .report(true) 131 | .interact()?; 132 | yubikey.verify_pin(pin.as_bytes())?; 133 | } 134 | if let TouchPolicy::Never = touch_policy { 135 | // No need to touch YubiKey 136 | } else { 137 | eprintln!("{}", fl!("builder-touch-yk")); 138 | } 139 | 140 | let cert = Certificate::generate_self_signed( 141 | yubikey, 142 | SlotId::Retired(slot), 143 | serial, 144 | None, 145 | &[ 146 | RelativeDistinguishedName::organization(BINARY_NAME), 147 | RelativeDistinguishedName::organizational_unit(env!("CARGO_PKG_VERSION")), 148 | RelativeDistinguishedName::common_name(&name), 149 | ], 150 | generated, 151 | &[x509::Extension::regular( 152 | POLICY_EXTENSION_OID, 153 | &[pin_policy.into(), touch_policy.into()], 154 | )], 155 | )?; 156 | 157 | let metadata = Metadata::extract(yubikey, slot, &cert, false).unwrap(); 158 | 159 | Ok(( 160 | Stub::new(yubikey.serial(), slot, &recipient), 161 | recipient, 162 | metadata, 163 | )) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/piv_p256.rs: -------------------------------------------------------------------------------- 1 | use age_core::{ 2 | format::{FileKey, Stanza, FILE_KEY_BYTES}, 3 | primitives::{aead_decrypt, aead_encrypt, hkdf}, 4 | secrecy::{zeroize::Zeroize, ExposeSecret}, 5 | }; 6 | use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; 7 | use p256::{ 8 | ecdh::EphemeralSecret, 9 | elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}, 10 | }; 11 | use rand::rngs::OsRng; 12 | use sha2::Sha256; 13 | 14 | use crate::{key::Connection, recipient::TAG_BYTES, util::base64_arg}; 15 | 16 | mod recipient; 17 | pub(crate) use recipient::Recipient; 18 | 19 | const STANZA_TAG: &str = "piv-p256"; 20 | pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256"; 21 | 22 | const EPK_BYTES: usize = 33; 23 | const ENCRYPTED_FILE_KEY_BYTES: usize = 32; 24 | 25 | /// The ephemeral key bytes in a piv-p256 stanza. 26 | /// 27 | /// The bytes contain a compressed SEC-1 encoding of a valid point. 28 | #[derive(Debug)] 29 | pub(crate) struct EphemeralKeyBytes(p256::EncodedPoint); 30 | 31 | impl EphemeralKeyBytes { 32 | fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option { 33 | let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?; 34 | if encoded.is_compressed() 35 | && p256::PublicKey::from_encoded_point(&encoded) 36 | .is_some() 37 | .into() 38 | { 39 | Some(EphemeralKeyBytes(encoded)) 40 | } else { 41 | None 42 | } 43 | } 44 | 45 | fn from_public_key(epk: &p256::PublicKey) -> Self { 46 | EphemeralKeyBytes(epk.to_encoded_point(true)) 47 | } 48 | 49 | pub(crate) fn as_bytes(&self) -> &[u8] { 50 | self.0.as_bytes() 51 | } 52 | 53 | pub(crate) fn decompress(&self) -> p256::EncodedPoint { 54 | // EphemeralKeyBytes is a valid compressed encoding by construction. 55 | let p = p256::PublicKey::from_encoded_point(&self.0).unwrap(); 56 | p.to_encoded_point(false) 57 | } 58 | } 59 | 60 | #[derive(Debug)] 61 | pub(crate) struct RecipientLine { 62 | pub(crate) tag: [u8; TAG_BYTES], 63 | pub(crate) epk_bytes: EphemeralKeyBytes, 64 | pub(crate) encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES], 65 | } 66 | 67 | impl From for Stanza { 68 | fn from(r: RecipientLine) -> Self { 69 | Stanza { 70 | tag: STANZA_TAG.to_owned(), 71 | args: vec![ 72 | BASE64_STANDARD_NO_PAD.encode(r.tag), 73 | BASE64_STANDARD_NO_PAD.encode(r.epk_bytes.as_bytes()), 74 | ], 75 | body: r.encrypted_file_key.to_vec(), 76 | } 77 | } 78 | } 79 | 80 | impl RecipientLine { 81 | pub(super) fn from_stanza(s: &Stanza) -> Option> { 82 | if s.tag != STANZA_TAG { 83 | return None; 84 | } 85 | 86 | let (tag, epk_bytes) = match &s.args[..] { 87 | [tag, epk_bytes] => ( 88 | base64_arg(tag, [0; TAG_BYTES]), 89 | base64_arg(epk_bytes, [0; EPK_BYTES]).and_then(EphemeralKeyBytes::from_bytes), 90 | ), 91 | _ => (None, None), 92 | }; 93 | 94 | Some(match (tag, epk_bytes, s.body[..].try_into()) { 95 | (Some(tag), Some(epk_bytes), Ok(encrypted_file_key)) => Ok(RecipientLine { 96 | tag, 97 | epk_bytes, 98 | encrypted_file_key, 99 | }), 100 | // Anything else indicates a structurally-invalid stanza. 101 | _ => Err(()), 102 | }) 103 | } 104 | } 105 | 106 | impl Recipient { 107 | pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> RecipientLine { 108 | let esk = EphemeralSecret::random(&mut OsRng); 109 | let epk = esk.public_key(); 110 | let epk_bytes = EphemeralKeyBytes::from_public_key(&epk); 111 | 112 | let shared_secret = esk.diffie_hellman(self.public_key()); 113 | 114 | let salt = salt(&epk_bytes, self); 115 | 116 | let enc_key = { 117 | let mut okm = [0; 32]; 118 | shared_secret 119 | .extract::(Some(&salt)) 120 | .expand(STANZA_KEY_LABEL, &mut okm) 121 | .expect("okm is the correct length"); 122 | okm 123 | }; 124 | 125 | let encrypted_file_key = { 126 | let mut key = [0; ENCRYPTED_FILE_KEY_BYTES]; 127 | key.copy_from_slice(&aead_encrypt(&enc_key, file_key.expose_secret())); 128 | key 129 | }; 130 | 131 | RecipientLine { 132 | tag: self.tag(), 133 | epk_bytes, 134 | encrypted_file_key, 135 | } 136 | } 137 | } 138 | 139 | impl RecipientLine { 140 | pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result { 141 | let crate::recipient::Recipient::PivP256(recipient) = conn.recipient(); 142 | assert_eq!(self.tag, recipient.tag()); 143 | 144 | let salt = salt(&self.epk_bytes, recipient); 145 | 146 | // The YubiKey API for performing scalar multiplication takes the point in its 147 | // uncompressed SEC-1 encoding. 148 | let shared_secret = conn.p256_ecdh(self.epk_bytes.decompress().as_bytes())?; 149 | 150 | let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_ref()); 151 | 152 | // A failure to decrypt is fatal, because we assume that we won't 153 | // encounter 32-bit collisions on the key tag embedded in the header. 154 | aead_decrypt(&enc_key, FILE_KEY_BYTES, &self.encrypted_file_key) 155 | .map_err(|_| ()) 156 | .map(|mut pt| { 157 | FileKey::init_with_mut(|file_key| { 158 | file_key.copy_from_slice(&pt); 159 | pt.zeroize(); 160 | }) 161 | }) 162 | } 163 | } 164 | 165 | fn salt(epk_bytes: &EphemeralKeyBytes, pk: &Recipient) -> Vec { 166 | let mut salt = vec![]; 167 | salt.extend_from_slice(epk_bytes.as_bytes()); 168 | salt.extend_from_slice(pk.to_encoded().as_bytes()); 169 | salt 170 | } 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YubiKey plugin for age clients 2 | 3 | `age-plugin-yubikey` is a plugin for [age](https://age-encryption.org/v1) clients 4 | like [`age`](https://age-encryption.org) and [`rage`](https://str4d.xyz/rage), 5 | which enables files to be encrypted to age identities stored on YubiKeys. 6 | 7 | ## Installation 8 | 9 | | Environment | CLI command | 10 | |-------------|-------------| 11 | | Cargo (Rust 1.70+) | `cargo install age-plugin-yubikey` | 12 | | Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` | 13 | | Arch Linux | `pacman -S age-plugin-yubikey` | 14 | | Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) | 15 | | NixOS | Add to config:
`environment.systemPackages = [`
` pkgs.age-plugin-yubikey`
`];`
Or run `nix-env -i age-plugin-yubikey` | 16 | | Ubuntu 20.04+ | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) | 17 | | OpenBSD | `pkg_add age-plugin-yubikey` (security/age-plugin-yubikey) | 18 | 19 | On Windows, Linux, and macOS, you can use the 20 | [pre-built binaries](https://github.com/str4d/age-plugin-yubikey/releases). 21 | 22 | Help from new packagers is very welcome. 23 | 24 | ### Linux, BSD, etc. 25 | 26 | On non-Windows, non-macOS systems, you need to ensure that the `pcscd` service 27 | is installed and running. 28 | 29 | | Environment | CLI command | 30 | |-------------|-------------| 31 | | Debian or Ubuntu | `sudo apt-get install pcscd` | 32 | | Fedora | `sudo dnf install pcsc-lite` | 33 | | OpenBSD | As ```root``` do:
`pkg_add pcsc-lite ccid`
`rcctl enable pcscd`
`rcctl start pcscd` | 34 | | FreeBSD | As ```root``` do:
`pkg install pcsc-lite libccid`
`service pcscd enable`
`service pcscd start` | 35 | | Arch | `sudo pacman -S pcsclite pcsc-tools yubikey-manager`
`sudo systemctl enable pcscd`
`sudo systemctl start pcscd`| 36 | 37 | When installing via Cargo, you also need to ensure that the development headers 38 | for the `pcsc-lite` library are available, so that the `pcsc-sys` crate can be 39 | compiled. 40 | 41 | | Environment | CLI command | 42 | |-------------|-------------| 43 | | Debian or Ubuntu | `sudo apt-get install libpcsclite-dev` | 44 | | Fedora | `sudo dnf install pcsc-lite-devel` | 45 | 46 | ### Windows Subsystem for Linux (WSL) 47 | 48 | WSL does not currently provide native support for USB devices. However, Windows 49 | binaries installed on the host can be run from inside a WSL environment. This 50 | means that you can encrypt or decrypt files inside a WSL environment with a 51 | YubiKey: 52 | 53 | 1. Install `age-plugin-yubikey` on the Windows host. 54 | 2. Install an age client inside the WSL environment. 55 | 3. Ensure that `age-plugin-yubikey.exe` is available in the WSL environment's 56 | `PATH`. For default WSL setups, the Windows host's `PATH` is automatically 57 | added to the WSL environment's `PATH` (see 58 | [this Microsoft blog post](https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/) 59 | for more details). 60 | 61 | ## Configuration 62 | 63 | `age-plugin-yubikey` identities have two parts: 64 | - The secret key material, which is stored inside a YubiKey. 65 | - An age identity file, which contains information that an age client can use to 66 | figure out which YubiKey secret key should be used. 67 | 68 | There are two ways to configure a YubiKey as an age identity. You can run the 69 | plugin binary directly to use a simple text interface, which will create an age 70 | identity file: 71 | 72 | ``` 73 | $ age-plugin-yubikey 74 | ``` 75 | 76 | Or you can use command-line flags to programmatically generate an identity and 77 | print it to standard output: 78 | 79 | ``` 80 | $ age-plugin-yubikey --generate \ 81 | [--serial SERIAL] \ 82 | [--slot SLOT] \ 83 | [--name NAME] \ 84 | [--pin-policy PIN-POLICY] \ 85 | [--touch-policy TOUCH-POLICY] 86 | ``` 87 | 88 | Once an identity has been created, you can regenerate it later: 89 | 90 | ``` 91 | $ age-plugin-yubikey --identity [--serial SERIAL] --slot SLOT 92 | ``` 93 | 94 | To use the identity with an age client, it needs to be stored in a file. When 95 | using the above programmatic flags, you can do this by redirecting standard 96 | output to a file. On a Unix system like macOS or Ubuntu: 97 | 98 | ``` 99 | $ age-plugin-yubikey --identity --slot SLOT > yubikey-identity.txt 100 | ``` 101 | 102 | ## Usage 103 | 104 | The age recipients contained in all connected YubiKeys can be printed on 105 | standard output: 106 | 107 | ``` 108 | $ age-plugin-yubikey --list 109 | ``` 110 | 111 | To encrypt files to these YubiKey recipients, ensure that `age-plugin-yubikey` 112 | is accessible in your `PATH`, and then use the recipients with an age client as 113 | normal (e.g. `rage -r age1yubikey1...`). 114 | 115 | The output of the `--list` command can also be used directly to encrypt files to 116 | all recipients (e.g. `age -R filename.txt`). 117 | 118 | To decrypt files encrypted to a YubiKey identity, pass the identity file to the 119 | age client as normal (e.g. `rage -d -i yubikey-identity.txt`). 120 | 121 | ## Advanced topics 122 | 123 | ### Agent support 124 | 125 | `age-plugin-yubikey` does not provide or interact with an agent for decryption. 126 | It does however attempt to preserve the PIN cache by not soft-resetting the 127 | YubiKey after a decryption or read-only operation, which enables YubiKey 128 | identities configured with a PIN policy of `once` to not prompt for the PIN on 129 | every decryption. **This does not work for YubiKey 4 series.** 130 | 131 | The session that corresponds to the `once` policy can be ended in several ways, 132 | not all of which are necessarily intuitive: 133 | 134 | - Unplugging the YubiKey (the obvious way). 135 | - Using a different applet (e.g. FIDO2). This causes the PIV applet to be closed 136 | which clears its state. 137 | - This is why the YubiKey 4 series does not support PIN cache preservation: 138 | their serial can only be obtained by switching to the OTP applet. 139 | - Generating a new age identity via `age-plugin-yubikey --generate` or the CLI 140 | interface. This is to avoid leaving the YubiKey authenticated with the 141 | management key. 142 | 143 | If the current PIN UX proves to be insufficient, a decryption agent will most 144 | likely be implemented as a separate age plugin that interacts with 145 | [`yubikey-agent`](https://github.com/FiloSottile/yubikey-agent), enabling 146 | YubiKeys to be used simultaneously with age and SSH. 147 | 148 | ### Manual setup and technical details 149 | 150 | `age-plugin-yubikey` only officially supports the following YubiKey variants, 151 | set up either via the text interface or the `--generate` flag: 152 | 153 | - YubiKey 4 series 154 | - YubiKey 5 series 155 | 156 | NOTE: Nano and USB-C variants of the above are also supported. The pre-YK4 157 | YubiKey NEO series is **NOT** supported. The blue "Security Key by Yubico" will 158 | also not work (as it doesn't support PIV). 159 | 160 | In practice, any PIV token with an ECDSA P-256 key and certificate in one of the 161 | 20 "retired" slots should work. You can list all age-compatible keys with: 162 | 163 | ``` 164 | $ age-plugin-yubikey --list-all 165 | ``` 166 | 167 | `age-plugin-yubikey` implements several automatic security management features: 168 | 169 | - If it detects that the default PIN is being used, it will prompt the user to 170 | change the PIN. The PUK is then set to the same value as the PIN. 171 | - If it detects that the default management key is being used, it generates a 172 | random management key and stores it in PIN-protected metadata. 173 | `age-plugin-yubikey` does not support custom management keys. 174 | 175 | ## License 176 | 177 | Licensed under either of 178 | 179 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 180 | http://www.apache.org/licenses/LICENSE-2.0) 181 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 182 | 183 | at your option. 184 | 185 | ### Contribution 186 | 187 | Unless you explicitly state otherwise, any contribution intentionally 188 | submitted for inclusion in the work by you, as defined in the Apache-2.0 189 | license, shall be dual licensed as above, without any additional terms or 190 | conditions. 191 | 192 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::io; 3 | use yubikey::{piv::RetiredSlotId, Serial}; 4 | 5 | use crate::util::slot_to_ui; 6 | 7 | macro_rules! wlnfl { 8 | ($f:ident, $message_id:literal) => { 9 | writeln!($f, "{}", $crate::fl!($message_id)) 10 | }; 11 | ($f:ident, $message_id:literal, $($kwarg:expr),* $(,)*) => {{ 12 | writeln!($f, "{}", $crate::fl!($message_id, $($kwarg,)*)) 13 | }}; 14 | } 15 | 16 | pub enum Error { 17 | CustomManagementKey, 18 | Dialog(dialoguer::Error), 19 | InvalidFlagCommand(String, String), 20 | InvalidFlagTui(String), 21 | InvalidPinPolicy(String), 22 | InvalidSlot(u8), 23 | InvalidTouchPolicy(String), 24 | Io(io::Error), 25 | ManagementKeyAuth, 26 | MultipleCommands, 27 | MultipleYubiKeys, 28 | NoEmptySlots(Serial), 29 | NoMatchingSerial(Serial), 30 | PukLocked, 31 | SlotHasNoIdentity(RetiredSlotId), 32 | SlotIsNotEmpty(RetiredSlotId), 33 | TimedOut, 34 | UseListForSingleSlot, 35 | WrongPuk(u8), 36 | YubiKey(yubikey::Error), 37 | } 38 | 39 | impl From for Error { 40 | fn from(e: dialoguer::Error) -> Self { 41 | Error::Dialog(e) 42 | } 43 | } 44 | 45 | impl From for Error { 46 | fn from(e: io::Error) -> Self { 47 | Error::Io(e) 48 | } 49 | } 50 | 51 | impl From for Error { 52 | fn from(e: yubikey::Error) -> Self { 53 | Error::YubiKey(e) 54 | } 55 | } 56 | 57 | // Rust only supports `fn main() -> Result<(), E: Debug>`, so we implement `Debug` 58 | // manually to provide the error output we want. 59 | impl fmt::Debug for Error { 60 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 61 | const CHANGE_MGMT_KEY_CMD: &str = 62 | "ykman piv access change-management-key -a TDES --protect"; 63 | const CHANGE_MGMT_KEY_URL: &str = "https://developers.yubico.com/yubikey-manager/"; 64 | 65 | match self { 66 | Error::CustomManagementKey => { 67 | wlnfl!(f, "err-custom-mgmt-key")?; 68 | wlnfl!( 69 | f, 70 | "rec-change-mgmt-key", 71 | cmd = CHANGE_MGMT_KEY_CMD, 72 | url = CHANGE_MGMT_KEY_URL 73 | )?; 74 | } 75 | Error::Dialog(e) => wlnfl!(f, "err-io-user", err = e.to_string())?, 76 | Error::InvalidFlagCommand(flag, command) => wlnfl!( 77 | f, 78 | "err-invalid-flag-command", 79 | flag = flag.as_str(), 80 | command = command.as_str(), 81 | )?, 82 | Error::InvalidFlagTui(flag) => wlnfl!(f, "err-invalid-flag-tui", flag = flag.as_str())?, 83 | Error::InvalidPinPolicy(s) => wlnfl!( 84 | f, 85 | "err-invalid-pin-policy", 86 | policy = s.as_str(), 87 | expected = "always, once, never", 88 | )?, 89 | Error::InvalidSlot(slot) => wlnfl!(f, "err-invalid-slot", slot = slot)?, 90 | Error::InvalidTouchPolicy(s) => wlnfl!( 91 | f, 92 | "err-invalid-touch-policy", 93 | policy = s.as_str(), 94 | expected = "always, cached, never", 95 | )?, 96 | Error::Io(e) => wlnfl!(f, "err-io", err = e.to_string())?, 97 | Error::ManagementKeyAuth => { 98 | let aes_url = "https://github.com/str4d/age-plugin-yubikey/issues/92"; 99 | wlnfl!(f, "err-mgmt-key-auth")?; 100 | wlnfl!(f, "rec-mgmt-key-auth", aes_url = aes_url)?; 101 | wlnfl!( 102 | f, 103 | "rec-change-mgmt-key", 104 | cmd = CHANGE_MGMT_KEY_CMD, 105 | url = CHANGE_MGMT_KEY_URL 106 | )?; 107 | } 108 | Error::MultipleCommands => wlnfl!(f, "err-multiple-commands")?, 109 | Error::MultipleYubiKeys => wlnfl!(f, "err-multiple-yubikeys")?, 110 | Error::NoEmptySlots(serial) => { 111 | wlnfl!(f, "err-no-empty-slots", serial = serial.to_string())? 112 | } 113 | Error::NoMatchingSerial(serial) => { 114 | wlnfl!(f, "err-no-matching-serial", serial = serial.to_string())? 115 | } 116 | Error::PukLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PUK")?, 117 | Error::SlotHasNoIdentity(slot) => { 118 | wlnfl!(f, "err-slot-has-no-identity", slot = slot_to_ui(slot))? 119 | } 120 | Error::SlotIsNotEmpty(slot) => { 121 | wlnfl!(f, "err-slot-is-not-empty", slot = slot_to_ui(slot))? 122 | } 123 | Error::TimedOut => wlnfl!(f, "err-timed-out")?, 124 | Error::UseListForSingleSlot => wlnfl!(f, "err-use-list-for-single")?, 125 | Error::WrongPuk(tries) => { 126 | wlnfl!(f, "err-yk-wrong-pin", pin_kind = "PUK", tries = tries)? 127 | } 128 | Error::YubiKey(e) => match e { 129 | yubikey::Error::NotFound => wlnfl!(f, "err-yk-not-found")?, 130 | yubikey::Error::PcscError { 131 | inner: Some(pcsc::Error::NoService), 132 | } => { 133 | if cfg!(windows) { 134 | wlnfl!(f, "err-yk-no-service-win")?; 135 | let url = "https://learn.microsoft.com/en-us/windows/security/identity-protection/smart-cards/smart-card-debugging-information#smart-card-service"; 136 | wlnfl!(f, "rec-yk-no-service-win", url = url)?; 137 | } else if cfg!(target_os = "macos") { 138 | wlnfl!(f, "err-yk-no-service-macos")?; 139 | let url = "https://apple.stackexchange.com/a/438198"; 140 | wlnfl!(f, "rec-yk-no-service-macos", url = url)?; 141 | } else if cfg!(target_os = "openbsd") { 142 | wlnfl!(f, "err-yk-no-service-pcscd")?; 143 | let pkg = "pkg_add pcsc-lite ccid"; 144 | let service_enable = "rcctl enable pcscd"; 145 | let service_start = "rcctl start pcscd"; 146 | wlnfl!( 147 | f, 148 | "rec-yk-no-service-pcscd-bsd", 149 | pkg = pkg, 150 | service_enable = service_enable, 151 | service_start = service_start 152 | )?; 153 | } else if cfg!(target_os = "freebsd") { 154 | wlnfl!(f, "err-yk-no-service-pcscd")?; 155 | let pkg = "pkg install pcsc-lite libccid"; 156 | let service_enable = "service pcscd enable"; 157 | let service_start = "service pcscd start"; 158 | wlnfl!( 159 | f, 160 | "rec-yk-no-service-pcscd-bsd", 161 | pkg = pkg, 162 | service_enable = service_enable, 163 | service_start = service_start 164 | )?; 165 | } else { 166 | wlnfl!(f, "err-yk-no-service-pcscd")?; 167 | let apt = "sudo apt-get install pcscd"; 168 | wlnfl!(f, "rec-yk-no-service-pcscd", apt = apt)?; 169 | } 170 | } 171 | yubikey::Error::PinLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PIN")?, 172 | yubikey::Error::WrongPin { tries } => { 173 | wlnfl!(f, "err-yk-wrong-pin", pin_kind = "PIN", tries = tries)? 174 | } 175 | e => { 176 | wlnfl!(f, "err-yk-general", err = e.to_string())?; 177 | use std::error::Error; 178 | if let Some(inner) = e.source() { 179 | wlnfl!(f, "err-yk-general-cause", inner_err = inner.to_string())?; 180 | } 181 | } 182 | }, 183 | } 184 | writeln!(f)?; 185 | writeln!(f, "[ {} ]", crate::fl!("err-ux-A"))?; 186 | write!( 187 | f, 188 | "[ {}: https://str4d.xyz/age-plugin-yubikey/report {} ]", 189 | crate::fl!("err-ux-B"), 190 | crate::fl!("err-ux-C") 191 | ) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::iter; 3 | 4 | use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; 5 | use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid}; 6 | use yubikey::{ 7 | piv::{RetiredSlotId, SlotId}, 8 | Certificate, PinPolicy, Serial, TouchPolicy, YubiKey, 9 | }; 10 | 11 | use crate::fl; 12 | use crate::{error::Error, key::Stub, Recipient, BINARY_NAME, USABLE_SLOTS}; 13 | 14 | pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8]; 15 | 16 | pub(crate) fn ui_to_slot(slot: u8) -> Result { 17 | // Use 1-indexing in the UI for niceness 18 | USABLE_SLOTS 19 | .get(slot as usize - 1) 20 | .cloned() 21 | .ok_or(Error::InvalidSlot(slot)) 22 | } 23 | 24 | pub(crate) fn slot_to_ui(slot: &RetiredSlotId) -> u8 { 25 | // Use 1-indexing in the UI for niceness 26 | USABLE_SLOTS.iter().position(|s| s == slot).unwrap() as u8 + 1 27 | } 28 | 29 | pub(crate) fn pin_policy_from_string(s: String) -> Result { 30 | match s.as_str() { 31 | "always" => Ok(PinPolicy::Always), 32 | "once" => Ok(PinPolicy::Once), 33 | "never" => Ok(PinPolicy::Never), 34 | _ => Err(Error::InvalidPinPolicy(s)), 35 | } 36 | } 37 | 38 | pub(crate) fn touch_policy_from_string(s: String) -> Result { 39 | match s.as_str() { 40 | "always" => Ok(TouchPolicy::Always), 41 | "cached" => Ok(TouchPolicy::Cached), 42 | "never" => Ok(TouchPolicy::Never), 43 | _ => Err(Error::InvalidTouchPolicy(s)), 44 | } 45 | } 46 | 47 | pub(crate) fn pin_policy_to_str(policy: Option) -> String { 48 | match policy { 49 | Some(PinPolicy::Always) => fl!("pin-policy-always"), 50 | Some(PinPolicy::Once) => fl!("pin-policy-once"), 51 | Some(PinPolicy::Never) => fl!("pin-policy-never"), 52 | _ => fl!("unknown-policy"), 53 | } 54 | } 55 | 56 | pub(crate) fn touch_policy_to_str(policy: Option) -> String { 57 | match policy { 58 | Some(TouchPolicy::Always) => fl!("touch-policy-always"), 59 | Some(TouchPolicy::Cached) => fl!("touch-policy-cached"), 60 | Some(TouchPolicy::Never) => fl!("touch-policy-never"), 61 | _ => fl!("unknown-policy"), 62 | } 63 | } 64 | 65 | const MODHEX: &str = "cbdefghijklnrtuv"; 66 | pub(crate) fn otp_serial_prefix(serial: Serial) -> String { 67 | iter::repeat(0) 68 | .take(4) 69 | .chain((0..8).rev().map(|i| (serial.0 >> (4 * i)) & 0x0f)) 70 | .map(|i| MODHEX.char_indices().nth(i as usize).unwrap().1) 71 | .collect() 72 | } 73 | 74 | pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String, bool)> { 75 | // Look at Subject Organization to determine if we created this. 76 | match cert.subject().iter_organization().next() { 77 | Some(org) if org.as_str() == Ok(BINARY_NAME) => { 78 | // We store the identity name as a Common Name attribute. 79 | let name = cert 80 | .subject() 81 | .iter_common_name() 82 | .next() 83 | .and_then(|cn| cn.as_str().ok()) 84 | .map(|s| s.to_owned()) 85 | .unwrap_or_default(); // TODO: This should always be present. 86 | 87 | Some((name, true)) 88 | } 89 | _ => { 90 | // Not one of ours, but we've already filtered for compatibility. 91 | if !all { 92 | return None; 93 | } 94 | 95 | // Display the entire subject. 96 | let name = cert.subject().to_string(); 97 | 98 | Some((name, false)) 99 | } 100 | } 101 | } 102 | 103 | pub(crate) struct Metadata { 104 | serial: Serial, 105 | slot: RetiredSlotId, 106 | name: String, 107 | created: String, 108 | pub(crate) pin_policy: Option, 109 | pub(crate) touch_policy: Option, 110 | } 111 | 112 | impl Metadata { 113 | pub(crate) fn extract( 114 | yubikey: &mut YubiKey, 115 | slot: RetiredSlotId, 116 | cert: &Certificate, 117 | all: bool, 118 | ) -> Option { 119 | let (_, cert) = x509_parser::parse_x509_certificate(cert.as_ref()).ok()?; 120 | 121 | // We store the PIN and touch policies for identities in their certificates 122 | // using the same certificate extension as PIV attestations. 123 | // https://developers.yubico.com/PIV/Introduction/PIV_attestation.html 124 | let policies = |c: &X509Certificate| { 125 | c.tbs_certificate 126 | .get_extension_unique(&Oid::from(POLICY_EXTENSION_OID).unwrap()) 127 | // If the extension is duplicated, we assume it is invalid. 128 | .ok() 129 | .flatten() 130 | // If the encoded extension doesn't have 2 bytes, we assume it is invalid. 131 | .filter(|policy| policy.value.len() >= 2) 132 | .map(|policy| { 133 | // We should only ever see one of three values for either policy, but 134 | // handle unknown values just in case. 135 | let pin_policy = match policy.value[0] { 136 | 0x01 => Some(PinPolicy::Never), 137 | 0x02 => Some(PinPolicy::Once), 138 | 0x03 => Some(PinPolicy::Always), 139 | _ => None, 140 | }; 141 | let touch_policy = match policy.value[1] { 142 | 0x01 => Some(TouchPolicy::Never), 143 | 0x02 => Some(TouchPolicy::Always), 144 | 0x03 => Some(TouchPolicy::Cached), 145 | _ => None, 146 | }; 147 | (pin_policy, touch_policy) 148 | }) 149 | .unwrap_or((None, None)) 150 | }; 151 | 152 | extract_name(&cert, all) 153 | .map(|(name, ours)| { 154 | if ours { 155 | let (pin_policy, touch_policy) = policies(&cert); 156 | (name, pin_policy, touch_policy) 157 | } else { 158 | // We can extract the PIN and touch policies via an attestation. This 159 | // is slow, but the user has asked for all compatible keys, so... 160 | let (pin_policy, touch_policy) = 161 | yubikey::piv::attest(yubikey, SlotId::Retired(slot)) 162 | .ok() 163 | .and_then(|buf| { 164 | x509_parser::parse_x509_certificate(&buf) 165 | .map(|(_, c)| policies(&c)) 166 | .ok() 167 | }) 168 | .unwrap_or((None, None)); 169 | 170 | (name, pin_policy, touch_policy) 171 | } 172 | }) 173 | .map(|(name, pin_policy, touch_policy)| Metadata { 174 | serial: yubikey.serial(), 175 | slot, 176 | name, 177 | created: cert 178 | .validity() 179 | .not_before 180 | .to_rfc2822() 181 | .unwrap_or_else(|e| format!("Invalid date: {e}")), 182 | pin_policy, 183 | touch_policy, 184 | }) 185 | } 186 | } 187 | 188 | impl fmt::Display for Metadata { 189 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 190 | write!( 191 | f, 192 | "{}", 193 | fl!( 194 | "yubikey-metadata", 195 | serial = self.serial.to_string(), 196 | slot = slot_to_ui(&self.slot), 197 | name = self.name.as_str(), 198 | created = self.created.as_str(), 199 | pin_policy = pin_policy_to_str(self.pin_policy), 200 | touch_policy = touch_policy_to_str(self.touch_policy), 201 | ) 202 | ) 203 | } 204 | } 205 | 206 | pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) { 207 | let recipient = recipient.to_string(); 208 | if !console::user_attended() { 209 | let recipient = recipient.as_str(); 210 | eprintln!("{}", fl!("print-recipient", recipient = recipient)); 211 | } 212 | 213 | println!( 214 | "{}", 215 | fl!( 216 | "yubikey-identity", 217 | yubikey_metadata = metadata.to_string(), 218 | recipient = recipient, 219 | identity = stub.to_string(), 220 | ) 221 | ); 222 | } 223 | 224 | pub(crate) fn base64_arg, B: AsMut<[u8]>>(arg: &A, mut buf: B) -> Option { 225 | if arg.as_ref().len() != ((4 * buf.as_mut().len()) + 2) / 3 { 226 | return None; 227 | } 228 | 229 | BASE64_STANDARD_NO_PAD 230 | .decode_slice_unchecked(arg, buf.as_mut()) 231 | .ok() 232 | .and_then(|len| (len == buf.as_mut().len()).then_some(buf)) 233 | } 234 | -------------------------------------------------------------------------------- /src/plugin.rs: -------------------------------------------------------------------------------- 1 | use age_core::format::{FileKey, Stanza}; 2 | use age_plugin::{ 3 | identity::{self, IdentityPluginV1}, 4 | recipient::{self, RecipientPluginV1}, 5 | Callbacks, PluginHandler, 6 | }; 7 | use std::collections::{HashMap, HashSet}; 8 | use std::io; 9 | 10 | use crate::{fl, key, piv_p256, Recipient, PLUGIN_NAME}; 11 | 12 | pub(crate) struct Handler; 13 | 14 | impl PluginHandler for Handler { 15 | type RecipientV1 = RecipientPlugin; 16 | type IdentityV1 = IdentityPlugin; 17 | 18 | fn recipient_v1(self) -> io::Result { 19 | Ok(RecipientPlugin::default()) 20 | } 21 | 22 | fn identity_v1(self) -> io::Result { 23 | Ok(IdentityPlugin::default()) 24 | } 25 | } 26 | 27 | #[derive(Debug, Default)] 28 | pub(crate) struct RecipientPlugin { 29 | recipients: Vec, 30 | yubikeys: Vec, 31 | } 32 | 33 | impl RecipientPluginV1 for RecipientPlugin { 34 | fn add_recipient( 35 | &mut self, 36 | index: usize, 37 | plugin_name: &str, 38 | bytes: &[u8], 39 | ) -> Result<(), recipient::Error> { 40 | if let Some(pk) = Recipient::from_bytes(plugin_name, bytes) { 41 | self.recipients.push(pk); 42 | Ok(()) 43 | } else { 44 | Err(recipient::Error::Recipient { 45 | index, 46 | message: fl!("plugin-err-invalid-recipient"), 47 | }) 48 | } 49 | } 50 | 51 | fn add_identity( 52 | &mut self, 53 | index: usize, 54 | plugin_name: &str, 55 | bytes: &[u8], 56 | ) -> Result<(), recipient::Error> { 57 | if let Some(stub) = if plugin_name == PLUGIN_NAME { 58 | key::Stub::from_bytes(bytes, index) 59 | } else { 60 | None 61 | } { 62 | self.yubikeys.push(stub); 63 | Ok(()) 64 | } else { 65 | Err(recipient::Error::Identity { 66 | index, 67 | message: fl!("plugin-err-invalid-identity"), 68 | }) 69 | } 70 | } 71 | 72 | fn labels(&mut self) -> HashSet { 73 | HashSet::new() 74 | } 75 | 76 | fn wrap_file_keys( 77 | &mut self, 78 | file_keys: Vec, 79 | mut callbacks: impl Callbacks, 80 | ) -> io::Result>, Vec>> { 81 | // Connect to any listed YubiKey identities to obtain the corresponding recipients. 82 | let mut yk_recipients = vec![]; 83 | let mut yk_errors = vec![]; 84 | for stub in &self.yubikeys { 85 | match stub.connect(&mut callbacks)? { 86 | Ok(Some(conn)) => yk_recipients.push(conn.recipient().clone()), 87 | Ok(None) => yk_errors.push(recipient::Error::Identity { 88 | index: stub.identity_index, 89 | message: fl!( 90 | "plugin-err-yk-opening", 91 | yubikey_serial = stub.serial.to_string(), 92 | ), 93 | }), 94 | Err(e) => yk_errors.push(match e { 95 | identity::Error::Identity { index, message } => { 96 | recipient::Error::Identity { index, message } 97 | } 98 | // stub.connect() only returns identity::Error::Identity 99 | _ => unreachable!(), 100 | }), 101 | } 102 | } 103 | 104 | // If any errors occurred while fetching recipients from YubiKeys, don't encrypt 105 | // the file to any of the other recipients. 106 | Ok(if yk_errors.is_empty() { 107 | Ok(file_keys 108 | .into_iter() 109 | .map(|file_key| { 110 | self.recipients 111 | .iter() 112 | .chain(yk_recipients.iter()) 113 | .map(|pk| pk.wrap_file_key(&file_key)) 114 | .collect() 115 | }) 116 | .collect()) 117 | } else { 118 | Err(yk_errors) 119 | }) 120 | } 121 | } 122 | 123 | #[derive(Debug, Default)] 124 | pub(crate) struct IdentityPlugin { 125 | yubikeys: Vec, 126 | } 127 | 128 | impl IdentityPluginV1 for IdentityPlugin { 129 | fn add_identity( 130 | &mut self, 131 | index: usize, 132 | plugin_name: &str, 133 | bytes: &[u8], 134 | ) -> Result<(), identity::Error> { 135 | if let Some(stub) = if plugin_name == PLUGIN_NAME { 136 | key::Stub::from_bytes(bytes, index) 137 | } else { 138 | None 139 | } { 140 | self.yubikeys.push(stub); 141 | Ok(()) 142 | } else { 143 | Err(identity::Error::Identity { 144 | index, 145 | message: fl!("plugin-err-invalid-identity"), 146 | }) 147 | } 148 | } 149 | 150 | fn unwrap_file_keys( 151 | &mut self, 152 | files: Vec>, 153 | mut callbacks: impl Callbacks, 154 | ) -> io::Result>>> { 155 | let mut file_keys = HashMap::with_capacity(files.len()); 156 | 157 | // Filter to files / stanzas for which we have matching YubiKeys 158 | let mut candidate_stanzas: Vec<(&key::Stub, HashMap>)> = self 159 | .yubikeys 160 | .iter() 161 | .map(|stub| (stub, HashMap::new())) 162 | .collect(); 163 | 164 | for (file, stanzas) in files.into_iter().enumerate() { 165 | for (stanza_index, stanza) in stanzas.into_iter().enumerate() { 166 | match ( 167 | SupportedStanza::parse(stanza).map(|res| { 168 | res.map_err(|_| identity::Error::Stanza { 169 | file_index: file, 170 | stanza_index, 171 | message: fl!("plugin-err-invalid-stanza"), 172 | }) 173 | }), 174 | file_keys.contains_key(&file), 175 | ) { 176 | // Only record candidate stanzas for files without structural errors. 177 | (Some(Ok(line)), false) => { 178 | // A line will match at most one YubiKey. 179 | if let Some(files) = 180 | candidate_stanzas.iter_mut().find_map(|(stub, files)| { 181 | if line.matches_stub(stub) { 182 | Some(files) 183 | } else { 184 | None 185 | } 186 | }) 187 | { 188 | files.entry(file).or_default().push(line); 189 | } 190 | } 191 | (Some(Err(e)), _) => { 192 | // This is a structurally-invalid stanza, so we MUST return errors 193 | // and MUST NOT unwrap any stanzas in the same file. Let's collect 194 | // these errors to return to the client. 195 | match file_keys.entry(file).or_insert_with(|| Err(vec![])) { 196 | Err(errors) => errors.push(e), 197 | Ok(_) => unreachable!(), 198 | } 199 | // Drop any existing candidate stanzas from this file. 200 | for (_, candidates) in candidate_stanzas.iter_mut() { 201 | candidates.remove(&file); 202 | } 203 | } 204 | _ => (), 205 | } 206 | } 207 | } 208 | 209 | // Sort by effectiveness (YubiKey that can trial-decrypt the most stanzas) 210 | candidate_stanzas.sort_by_key(|(_, files)| { 211 | files 212 | .iter() 213 | .map(|(_, stanzas)| stanzas.len()) 214 | .sum::() 215 | }); 216 | candidate_stanzas.reverse(); 217 | // Remove any YubiKeys without stanzas. 218 | candidate_stanzas.retain(|(_, files)| { 219 | files 220 | .iter() 221 | .map(|(_, stanzas)| stanzas.len()) 222 | .sum::() 223 | > 0 224 | }); 225 | 226 | for (stub, files) in candidate_stanzas.iter() { 227 | let mut conn = match stub.connect(&mut callbacks)? { 228 | // The user skipped this YubiKey. 229 | Ok(None) => continue, 230 | // We connected to this YubiKey. 231 | Ok(Some(conn)) => conn, 232 | // We failed to connect to this YubiKey. 233 | Err(e) => { 234 | callbacks.error(e)?.unwrap(); 235 | continue; 236 | } 237 | }; 238 | 239 | if let Err(e) = conn.request_pin_if_necessary(&mut callbacks)? { 240 | callbacks.error(e)?.unwrap(); 241 | continue; 242 | } 243 | 244 | for (&file_index, stanzas) in files { 245 | if file_keys.contains_key(&file_index) { 246 | // We decrypted this file with an earlier YubiKey. 247 | continue; 248 | } 249 | 250 | for (stanza_index, line) in stanzas.iter().enumerate() { 251 | match line.unwrap_file_key(&mut conn) { 252 | Ok(file_key) => { 253 | // We've managed to decrypt this file! 254 | file_keys.entry(file_index).or_insert(Ok(file_key)); 255 | break; 256 | } 257 | Err(_) => callbacks 258 | .error(identity::Error::Stanza { 259 | file_index, 260 | stanza_index, 261 | message: fl!("plugin-err-decryption-failed"), 262 | })? 263 | .unwrap(), 264 | } 265 | } 266 | } 267 | 268 | conn.disconnect_without_reset(); 269 | } 270 | Ok(file_keys) 271 | } 272 | } 273 | 274 | enum SupportedStanza { 275 | PivP256(piv_p256::RecipientLine), 276 | } 277 | 278 | impl SupportedStanza { 279 | fn parse(stanza: Stanza) -> Option> { 280 | piv_p256::RecipientLine::from_stanza(&stanza).map(|res| res.map(Self::PivP256)) 281 | } 282 | 283 | pub(crate) fn matches_stub(&self, stub: &key::Stub) -> bool { 284 | match self { 285 | SupportedStanza::PivP256(line) => stub.tag == line.tag, 286 | } 287 | } 288 | 289 | pub(crate) fn unwrap_file_key(&self, conn: &mut key::Connection) -> Result { 290 | match self { 291 | SupportedStanza::PivP256(line) => line.unwrap_file_key(conn), 292 | } 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /i18n/en-US/age_plugin_yubikey.ftl: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Jack Grigg 2 | # 3 | # Licensed under the Apache License, Version 2.0 or the MIT license 5 | # , at your 6 | # option. This file may not be copied, modified, or distributed 7 | # except according to those terms. 8 | 9 | ### Localization for strings in age-plugin-yubikey 10 | 11 | -age = age 12 | -yubikey = YubiKey 13 | -yubikeys = YubiKeys 14 | -age-plugin-yubikey = age-plugin-yubikey 15 | -pcscd = pcscd 16 | 17 | ## CLI commands and flags 18 | 19 | -cmd-generate = --generate 20 | -cmd-identity = --identity 21 | -cmd-list = --list 22 | -cmd-list-all = --list-all 23 | 24 | -flag-force = --force 25 | -flag-serial = --serial 26 | -flag-slot = --slot 27 | 28 | ## YubiKey metadata 29 | 30 | pin-policy-always = Always (A PIN is required for every decryption, if set) 31 | pin-policy-once = Once (A PIN is required once per session, if set) 32 | pin-policy-never = Never (A PIN is NOT required to decrypt) 33 | 34 | touch-policy-always = Always (A physical touch is required for every decryption) 35 | touch-policy-cached = Cached (A physical touch is required for decryption, and is cached for 15 seconds) 36 | touch-policy-never = Never (A physical touch is NOT required to decrypt) 37 | 38 | unknown-policy = Unknown 39 | 40 | yubikey-metadata = 41 | # Serial: {$serial}, Slot: {$slot} 42 | # Name: {$name} 43 | # Created: {$created} 44 | # PIN policy: {$pin_policy} 45 | # Touch policy: {$touch_policy} 46 | yubikey-identity = 47 | {$yubikey_metadata} 48 | # Recipient: {$recipient} 49 | {$identity} 50 | 51 | ## CLI setup via text interface 52 | 53 | cli-setup-intro = 54 | ✨ Let's get your {-yubikey} set up for {-age}! ✨ 55 | 56 | This tool can create a new {-age} identity in a free slot of your {-yubikey}. 57 | It will generate an identity file that you can use with an {-age} client, 58 | along with the corresponding recipient. You can also do this directly 59 | with: 60 | {" "}{$generate_usage} 61 | 62 | If you are already using a {-yubikey} with {-age}, you can select an existing 63 | slot to recreate its corresponding identity file and recipient. 64 | 65 | When asked below to select an option, use the up/down arrow keys to 66 | make your choice, or press [Esc] or [q] to quit. 67 | 68 | cli-setup-insert-yk = ⏳ Please insert the {-yubikey} you want to set up. 69 | cli-setup-yk-name = {$yubikey_name} (Serial: {$yubikey_serial}) 70 | cli-setup-select-yk = 🔑 Select a {-yubikey} 71 | cli-setup-slot-usable = Slot {$slot_index} ({$slot_name}) 72 | cli-setup-slot-unusable = Slot {$slot_index} (Unusable) 73 | cli-setup-slot-empty = Slot {$slot_index} (Empty) 74 | cli-setup-select-slot = 🕳️ Select a slot for your {-age} identity 75 | cli-setup-name-identity = 📛 Name this identity 76 | cli-setup-select-pin-policy = 🔤 Select a PIN policy 77 | cli-setup-select-touch-policy = 👆 Select a touch policy 78 | 79 | cli-setup-yk4-pin-policy = 80 | ⚠️ Your {-yubikey} is a {-yubikey} 4 series. With ephemeral applications like 81 | {-age-plugin-yubikey}, a PIN policy of "Once" behaves like a PIN policy of 82 | "Always", and your PIN will be requested for every decryption. However, you 83 | might still benefit from a PIN policy of "Once" in long-running applications 84 | like agents. 85 | cli-setup-yk4-pin-policy-confirm = Use PIN policy of "Once" with {-yubikey} 4? 86 | 87 | cli-setup-generate-new = Generate new identity in slot {$slot_index}? 88 | cli-setup-use-existing = Use existing identity in slot {$slot_index}? 89 | 90 | cli-setup-identity-file-name = 📝 File name to write this identity to 91 | cli-setup-identity-file-exists = File exists. Overwrite it? 92 | 93 | cli-setup-finished = 94 | ✅ Done! This {-yubikey} identity is ready to go. 95 | 96 | 🔑 { $is_new -> 97 | [true] Here's your shiny new {-yubikey} recipient: 98 | *[false] Here's the corresponding {-yubikey} recipient: 99 | } 100 | {" "}{$recipient} 101 | 102 | Here are some example things you can do with it: 103 | 104 | - Encrypt a file to this identity: 105 | {" "}{$encrypt_usage} 106 | 107 | - Decrypt a file with this identity: 108 | {" "}{$decrypt_usage} 109 | 110 | - Recreate the identity file: 111 | {" "}{$identity_usage} 112 | 113 | - Recreate the recipient: 114 | {" "}{$recipient_usage} 115 | 116 | 💭 Remember: everything breaks, have a backup plan for when this {-yubikey} does. 117 | 118 | ## Programmatic usage 119 | 120 | open-yk-with-serial = ⏳ Please insert the {-yubikey} with serial {$yubikey_serial}. 121 | open-yk-without-serial = ⏳ Please insert the {-yubikey}. 122 | warn-yk-not-connected = Ignoring {$yubikey_name}: not connected 123 | warn-yk-missing-applet = Ignoring {$yubikey_name}: Missing {$applet_name} applet 124 | 125 | print-recipient = Recipient: {$recipient} 126 | 127 | printed-kind-identities = identities 128 | printed-kind-recipients = recipients 129 | printed-multiple = Generated {$kind} for {$count} slots. If you intended to select a slot, use {-flag-slot}. 130 | 131 | ## YubiKey management 132 | 133 | mgr-enter-pin = Enter PIN for {-yubikey} with serial {$yubikey_serial} (default is {$default_pin}) 134 | 135 | mgr-change-default-pin = 136 | ✨ Your {-yubikey} is using the default PIN. Let's change it! 137 | ✨ We'll also set the PUK equal to the PIN. 138 | 139 | 🔐 The PIN can be numbers, letters, or symbols. Not just numbers! 140 | 📏 The PIN must be at least 6 and at most 8 characters in length. 141 | ❌ Your keys will be lost if the PIN and PUK are locked after 3 incorrect tries. 142 | 143 | mgr-enter-current-puk = Enter current PUK (default is {$default_puk}) 144 | mgr-choose-new-pin = Choose a new PIN/PUK 145 | mgr-repeat-new-pin = Repeat the PIN/PUK 146 | mgr-pin-mismatch = PINs don't match 147 | mgr-nope-default-pin = You entered the default PIN again. You need to change it. 148 | 149 | mgr-changing-mgmt-key = 150 | ✨ Your {-yubikey} is using the default management key. 151 | ✨ We'll migrate it to a PIN-protected management key. 152 | mgr-changing-mgmt-key-error = 153 | An error occurred while setting the new management key. 154 | ⚠️ SAVE THIS MANAGEMENT KEY - YOU MAY NEED IT TO MANAGE YOUR {-yubikey}! ⚠️ 155 | {" "}{$management_key} 156 | mgr-changing-mgmt-key-success = Success! 157 | 158 | ## YubiKey keygen 159 | 160 | builder-gen-key = 🎲 Generating key... 161 | builder-gen-cert = 🔏 Generating certificate... 162 | builder-touch-yk = 👆 Please touch the {-yubikey} 163 | 164 | ## Plugin usage 165 | 166 | plugin-err-invalid-recipient = Invalid recipient 167 | plugin-err-invalid-identity = Invalid {-yubikey} stub 168 | plugin-err-invalid-stanza = Invalid {-yubikey} stanza 169 | plugin-err-decryption-failed = Failed to decrypt {-yubikey} stanza 170 | 171 | plugin-insert-yk = Please insert {-yubikey} with serial {$yubikey_serial} 172 | plugin-yk-is-plugged-in = {-yubikey} is plugged in 173 | plugin-skip-this-yk = Skip this {-yubikey} 174 | plugin-insert-yk-retry = Could not open {-yubikey}. Please insert {-yubikey} with serial {$yubikey_serial} 175 | plugin-err-yk-not-found = Could not find {-yubikey} with serial {$yubikey_serial} 176 | plugin-err-yk-opening = Could not open {-yubikey} with serial {$yubikey_serial} 177 | plugin-err-yk-timed-out = Timed out while waiting for {-yubikey} with serial {$yubikey_serial} to be inserted 178 | plugin-err-yk-stub-mismatch = A {-yubikey} stub did not match the {-yubikey} 179 | 180 | plugin-err-yk-invalid-pin-policy = Certificate for {-yubikey} identity contains an invalid PIN policy 181 | 182 | plugin-enter-pin = Enter PIN for {-yubikey} with serial {$yubikey_serial} 183 | plugin-err-accidental-touch = Did you touch the {-yubikey} by accident? 184 | plugin-err-pin-too-short = PIN was too short. 185 | plugin-err-pin-too-long = PIN was too long. 186 | plugin-err-pin-required = A PIN is required for {-yubikey} with serial {$yubikey_serial} 187 | 188 | ## Errors 189 | 190 | err-mgmt-key-auth = Failed to authenticate with the PIN-protected management key. 191 | rec-mgmt-key-auth = 192 | Check whether your management key is using the TDES algorithm. 193 | AES is not supported yet: {$aes_url} 194 | err-custom-mgmt-key = Custom unprotected non-TDES management keys are not supported. 195 | rec-change-mgmt-key = 196 | You can use the {-yubikey} Manager CLI to change to a protected management key: 197 | {" "}{$cmd} 198 | 199 | See here for more information about {-yubikey} Manager: 200 | {" "}{$url} 201 | 202 | err-invalid-flag-command = Flag '{$flag}' cannot be used with '{$command}'. 203 | err-invalid-flag-tui = Flag '{$flag}' cannot be used with the interactive interface. 204 | err-invalid-pin-policy = Invalid PIN policy '{$policy}' (expected [{$expected}]). 205 | err-invalid-slot = Invalid slot '{$slot}' (expected number between 1 and 20). 206 | err-invalid-touch-policy = Invalid touch policy '{$policy}' (expected [{$expected}]). 207 | err-io-user = Failed to get input from user: {$err} 208 | err-io = Failed to set up {-yubikey}: {$err} 209 | err-multiple-commands = Only one of {-cmd-generate}, {-cmd-identity}, {-cmd-list}, {-cmd-list-all} can be specified. 210 | err-multiple-yubikeys = Multiple {-yubikeys} are plugged in. Use {-flag-serial} to select a single {-yubikey}. 211 | err-no-empty-slots = {-yubikey} with serial {$serial} has no empty slots. 212 | err-no-matching-serial = Could not find {-yubikey} with serial {$serial}. 213 | err-slot-has-no-identity = Slot {$slot} does not contain an {-age} identity or compatible key. 214 | err-slot-is-not-empty = Slot {$slot} is not empty. Use {-flag-force} to overwrite the slot. 215 | err-timed-out = Timed out while waiting for a {-yubikey} to be inserted. 216 | err-use-list-for-single = Use {-cmd-list} to print the recipient for a single slot. 217 | 218 | err-yk-no-service-macos = The Crypto Token Kit service is not running. 219 | rec-yk-no-service-macos = 220 | You may need to restart it. See this Stack Exchange answer for more help: 221 | {" "}{$url} 222 | 223 | err-yk-no-service-pcscd = {-pcscd} is not running. 224 | rec-yk-no-service-pcscd = 225 | If you are on Debian or Ubuntu, you can install it with: 226 | {" "}{$apt} 227 | 228 | rec-yk-no-service-pcscd-bsd = 229 | You can install and run it as root with: 230 | {" "}{$pkg} 231 | {" "}{$service_enable} 232 | {" "}{$service_start} 233 | 234 | err-yk-no-service-win = The Smart Cards for Windows service is not running. 235 | rec-yk-no-service-win = 236 | See this troubleshooting guide for more help: 237 | {" "}{$url} 238 | 239 | err-yk-not-found = Please insert the {-yubikey} you want to set up 240 | err-yk-general = Error while communicating with {-yubikey}: {$err} 241 | err-yk-general-cause = Cause: {$inner_err} 242 | 243 | err-yk-wrong-pin = Invalid {$pin_kind} ({$tries -> 244 | [one] {$tries} try remaining 245 | *[other] {$tries} tries remaining 246 | } before it is blocked) 247 | err-yk-pin-locked = {$pin_kind} locked 248 | 249 | err-ux-A = Did this not do what you expected? Could an error be more useful? 250 | err-ux-B = Tell us 251 | # Put (len(A) - len(B) - 46) spaces here. 252 | err-ux-C = {" "} 253 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | use std::fs::{File, OpenOptions}; 4 | use std::io::{self, Write}; 5 | 6 | use age_plugin::run_state_machine; 7 | use dialoguer::{Confirm, Input, Select}; 8 | use gumdrop::Options; 9 | use i18n_embed::{ 10 | fluent::{fluent_language_loader, FluentLanguageLoader}, 11 | DesktopLanguageRequester, 12 | }; 13 | use lazy_static::lazy_static; 14 | use rust_embed::RustEmbed; 15 | use yubikey::{piv::RetiredSlotId, reader::Context, PinPolicy, Serial, TouchPolicy}; 16 | 17 | mod builder; 18 | mod error; 19 | mod key; 20 | mod piv_p256; 21 | mod plugin; 22 | mod util; 23 | 24 | mod recipient; 25 | use recipient::Recipient; 26 | 27 | use error::Error; 28 | 29 | const PLUGIN_NAME: &str = "yubikey"; 30 | const BINARY_NAME: &str = "age-plugin-yubikey"; 31 | const IDENTITY_PREFIX: &str = "age-plugin-yubikey-"; 32 | 33 | const USABLE_SLOTS: [RetiredSlotId; 20] = [ 34 | RetiredSlotId::R1, 35 | RetiredSlotId::R2, 36 | RetiredSlotId::R3, 37 | RetiredSlotId::R4, 38 | RetiredSlotId::R5, 39 | RetiredSlotId::R6, 40 | RetiredSlotId::R7, 41 | RetiredSlotId::R8, 42 | RetiredSlotId::R9, 43 | RetiredSlotId::R10, 44 | RetiredSlotId::R11, 45 | RetiredSlotId::R12, 46 | RetiredSlotId::R13, 47 | RetiredSlotId::R14, 48 | RetiredSlotId::R15, 49 | RetiredSlotId::R16, 50 | RetiredSlotId::R17, 51 | RetiredSlotId::R18, 52 | RetiredSlotId::R19, 53 | RetiredSlotId::R20, 54 | ]; 55 | 56 | #[derive(RustEmbed)] 57 | #[folder = "i18n"] 58 | struct Translations; 59 | 60 | const TRANSLATIONS: Translations = Translations {}; 61 | 62 | lazy_static! { 63 | static ref LANGUAGE_LOADER: FluentLanguageLoader = fluent_language_loader!(); 64 | } 65 | 66 | #[macro_export] 67 | macro_rules! fl { 68 | ($message_id:literal) => {{ 69 | i18n_embed_fl::fl!($crate::LANGUAGE_LOADER, $message_id) 70 | }}; 71 | ($message_id:literal, $($kwarg:expr),* $(,)*) => {{ 72 | i18n_embed_fl::fl!($crate::LANGUAGE_LOADER, $message_id, $($kwarg,)*) 73 | }}; 74 | } 75 | 76 | #[derive(Debug, Options)] 77 | struct PluginOptions { 78 | #[options(help = "Print this help message and exit.")] 79 | help: bool, 80 | 81 | #[options(help = "Print version info and exit.", short = "V")] 82 | version: bool, 83 | 84 | #[options( 85 | help = "Run the given age plugin state machine. Internal use only.", 86 | meta = "STATE-MACHINE", 87 | no_short 88 | )] 89 | age_plugin: Option, 90 | 91 | #[options(help = "Force --generate to overwrite a filled slot.")] 92 | force: bool, 93 | 94 | #[options(help = "Generate a new YubiKey identity.")] 95 | generate: bool, 96 | 97 | #[options(help = "Print identities stored in connected YubiKeys.")] 98 | identity: bool, 99 | 100 | #[options(help = "List recipients for age identities in connected YubiKeys.")] 101 | list: bool, 102 | 103 | #[options( 104 | help = "List recipients for all YubiKey keys that are compatible with age.", 105 | no_short 106 | )] 107 | list_all: bool, 108 | 109 | #[options( 110 | help = "Name for the generated identity. Defaults to 'age identity HEX_TAG'.", 111 | no_short 112 | )] 113 | name: Option, 114 | 115 | #[options(help = "One of [always, once, never]. Defaults to 'once'.", no_short)] 116 | pin_policy: Option, 117 | 118 | #[options( 119 | help = "Specify which YubiKey to use, if more than one is plugged in.", 120 | no_short 121 | )] 122 | serial: Option, 123 | 124 | #[options( 125 | help = "Specify which slot to use. Defaults to first usable slot.", 126 | no_short 127 | )] 128 | slot: Option, 129 | 130 | #[options( 131 | help = "One of [always, cached, never]. Defaults to 'always'.", 132 | no_short 133 | )] 134 | touch_policy: Option, 135 | } 136 | 137 | struct PluginFlags { 138 | serial: Option, 139 | slot: Option, 140 | name: Option, 141 | pin_policy: Option, 142 | touch_policy: Option, 143 | force: bool, 144 | } 145 | 146 | impl TryFrom for PluginFlags { 147 | type Error = Error; 148 | 149 | fn try_from(opts: PluginOptions) -> Result { 150 | let serial = opts.serial.map(|s| s.into()); 151 | let slot = opts.slot.map(util::ui_to_slot).transpose()?; 152 | let pin_policy = opts 153 | .pin_policy 154 | .map(util::pin_policy_from_string) 155 | .transpose()?; 156 | let touch_policy = opts 157 | .touch_policy 158 | .map(util::touch_policy_from_string) 159 | .transpose()?; 160 | 161 | Ok(PluginFlags { 162 | serial, 163 | slot, 164 | name: opts.name, 165 | pin_policy, 166 | touch_policy, 167 | force: opts.force, 168 | }) 169 | } 170 | } 171 | 172 | fn generate(flags: PluginFlags) -> Result<(), Error> { 173 | let mut yubikey = key::open(flags.serial)?; 174 | 175 | let (stub, recipient, metadata) = builder::IdentityBuilder::new(flags.slot) 176 | .with_name(flags.name) 177 | .with_pin_policy(flags.pin_policy) 178 | .with_touch_policy(flags.touch_policy) 179 | .force(flags.force) 180 | .build(&mut yubikey)?; 181 | 182 | util::print_identity(stub, recipient, metadata); 183 | 184 | // We have written to the YubiKey, which means we've authenticated with the management 185 | // key. Out of an abundance of caution, we let the YubiKey be reset on disconnect, 186 | // which will clear its PIN and touch caches. This has as small negative UX effect, 187 | // but identity generation is a relatively infrequent occurrence, and users are more 188 | // likely to see their cached PINs reset due to switching applets (e.g. from PIV to 189 | // FIDO2). 190 | 191 | Ok(()) 192 | } 193 | 194 | fn print_single( 195 | serial: Option, 196 | slot: RetiredSlotId, 197 | printer: impl Fn(key::Stub, Recipient, util::Metadata), 198 | ) -> Result<(), Error> { 199 | let mut yubikey = key::open(serial)?; 200 | 201 | let (key, slot, recipient) = key::list_compatible(&mut yubikey)? 202 | .find(|(_, s, _)| s == &slot) 203 | .ok_or(Error::SlotHasNoIdentity(slot))?; 204 | 205 | let stub = key::Stub::new(yubikey.serial(), slot, &recipient); 206 | let metadata = util::Metadata::extract(&mut yubikey, slot, key.certificate(), true).unwrap(); 207 | 208 | printer(stub, recipient, metadata); 209 | 210 | key::disconnect_without_reset(yubikey); 211 | 212 | Ok(()) 213 | } 214 | 215 | fn print_multiple( 216 | kind: &str, 217 | serial: Option, 218 | all: bool, 219 | printer: impl Fn(key::Stub, Recipient, util::Metadata), 220 | ) -> Result<(), Error> { 221 | let mut readers = Context::open()?; 222 | 223 | let mut printed = 0; 224 | for reader in readers.iter()?.filter(key::filter_connected) { 225 | let mut yubikey = key::open_connection(&reader)?; 226 | if let Some(serial) = serial { 227 | if yubikey.serial() != serial { 228 | continue; 229 | } 230 | } 231 | 232 | for (key, slot, recipient) in key::list_compatible(&mut yubikey)? { 233 | let stub = key::Stub::new(yubikey.serial(), slot, &recipient); 234 | let metadata = match util::Metadata::extract(&mut yubikey, slot, key.certificate(), all) 235 | { 236 | Some(res) => res, 237 | None => continue, 238 | }; 239 | 240 | printer(stub, recipient, metadata); 241 | printed += 1; 242 | println!(); 243 | } 244 | println!(); 245 | 246 | key::disconnect_without_reset(yubikey); 247 | } 248 | if printed > 1 { 249 | eprintln!("{}", fl!("printed-multiple", kind = kind, count = printed)); 250 | } 251 | 252 | Ok(()) 253 | } 254 | 255 | fn print_details( 256 | kind: &str, 257 | flags: PluginFlags, 258 | all: bool, 259 | printer: impl Fn(key::Stub, Recipient, util::Metadata), 260 | ) -> Result<(), Error> { 261 | if let Some(slot) = flags.slot { 262 | print_single(flags.serial, slot, printer) 263 | } else { 264 | print_multiple(kind, flags.serial, all, printer) 265 | } 266 | } 267 | 268 | fn identity(flags: PluginFlags) -> Result<(), Error> { 269 | if flags.force { 270 | return Err(Error::InvalidFlagCommand( 271 | "--force".into(), 272 | "--identity".into(), 273 | )); 274 | } 275 | print_details( 276 | &fl!("printed-kind-identities"), 277 | flags, 278 | false, 279 | util::print_identity, 280 | ) 281 | } 282 | 283 | fn list(flags: PluginFlags, all: bool) -> Result<(), Error> { 284 | if all && flags.slot.is_some() { 285 | return Err(Error::UseListForSingleSlot); 286 | } 287 | if flags.force { 288 | return Err(Error::InvalidFlagCommand( 289 | "--force".into(), 290 | format!("--list{}", if all { "-all" } else { "" }), 291 | )); 292 | } 293 | 294 | print_details( 295 | &fl!("printed-kind-recipients"), 296 | flags, 297 | all, 298 | |_, recipient, metadata| { 299 | println!("{metadata}"); 300 | println!("{recipient}"); 301 | }, 302 | ) 303 | } 304 | 305 | fn main() -> Result<(), Error> { 306 | env_logger::builder() 307 | .format_timestamp(None) 308 | .filter_level(log::LevelFilter::Off) 309 | .parse_default_env() 310 | .init(); 311 | 312 | let requested_languages = DesktopLanguageRequester::requested_languages(); 313 | i18n_embed::select(&*LANGUAGE_LOADER, &TRANSLATIONS, &requested_languages).unwrap(); 314 | // Unfortunately the common Windows terminals don't support Unicode Directionality 315 | // Isolation Marks, so we disable them for now. 316 | LANGUAGE_LOADER.set_use_isolating(false); 317 | 318 | let opts = PluginOptions::parse_args_default_or_exit(); 319 | 320 | if [opts.generate, opts.identity, opts.list, opts.list_all] 321 | .iter() 322 | .filter(|&&b| b) 323 | .count() 324 | > 1 325 | { 326 | return Err(Error::MultipleCommands); 327 | } 328 | 329 | if let Some(state_machine) = opts.age_plugin { 330 | run_state_machine(&state_machine, plugin::Handler)?; 331 | Ok(()) 332 | } else if opts.version { 333 | println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION")); 334 | Ok(()) 335 | } else if opts.generate { 336 | generate(opts.try_into()?) 337 | } else if opts.identity { 338 | identity(opts.try_into()?) 339 | } else if opts.list { 340 | list(opts.try_into()?, false) 341 | } else if opts.list_all { 342 | list(opts.try_into()?, true) 343 | } else { 344 | if opts.force { 345 | return Err(Error::InvalidFlagTui("--force".into())); 346 | } 347 | let flags: PluginFlags = opts.try_into()?; 348 | 349 | eprintln!( 350 | "{}", 351 | fl!( 352 | "cli-setup-intro", 353 | generate_usage = "age-plugin-yubikey --generate", 354 | ) 355 | ); 356 | eprintln!(); 357 | 358 | if !Context::open()?.iter()?.any(key::is_connected) { 359 | eprintln!("{}", fl!("cli-setup-insert-yk")); 360 | }; 361 | let mut readers = key::wait_for_readers()?; 362 | 363 | // Filter out readers we can't connect to. 364 | let readers_list: Vec<_> = readers.iter()?.filter(key::filter_connected).collect(); 365 | 366 | let reader_names = readers_list 367 | .iter() 368 | .map(|reader| { 369 | key::open_connection(reader).map(|yk| { 370 | let name = fl!( 371 | "cli-setup-yk-name", 372 | yubikey_name = reader.name(), 373 | yubikey_serial = yk.serial().to_string(), 374 | ); 375 | key::disconnect_without_reset(yk); 376 | name 377 | }) 378 | }) 379 | .collect::, _>>()?; 380 | let mut yubikey = match Select::new() 381 | .with_prompt(fl!("cli-setup-select-yk")) 382 | .items(&reader_names) 383 | .default(0) 384 | .report(true) 385 | .interact_opt()? 386 | { 387 | Some(yk) => readers_list[yk].open()?, 388 | None => return Ok(()), 389 | }; 390 | 391 | let keys = key::list_slots(&mut yubikey)?.collect::>(); 392 | 393 | // Identify slots that we can't allow the user to select. 394 | let slot_details: Vec<_> = USABLE_SLOTS 395 | .iter() 396 | .map(|&slot| { 397 | keys.iter() 398 | .find(|(_, s, _)| s == &slot) 399 | .map(|(key, _, recipient)| { 400 | recipient.as_ref().map(|_| { 401 | // Cache the details we need to display to the user. 402 | let (_, cert) = 403 | x509_parser::parse_x509_certificate(key.certificate().as_ref()) 404 | .unwrap(); 405 | let (name, _) = util::extract_name(&cert, true).unwrap(); 406 | let created = cert 407 | .validity() 408 | .not_before 409 | .to_rfc2822() 410 | .unwrap_or_else(|e| format!("Invalid date: {e}")); 411 | 412 | format!("{name}, created: {created}") 413 | }) 414 | }) 415 | }) 416 | .collect(); 417 | 418 | let slots: Vec<_> = slot_details 419 | .iter() 420 | .enumerate() 421 | .map(|(i, occupied)| { 422 | // Use 1-indexing in the UI for niceness 423 | let i = i + 1; 424 | 425 | match occupied { 426 | Some(Some(name)) => fl!( 427 | "cli-setup-slot-usable", 428 | slot_index = i, 429 | slot_name = name.as_str(), 430 | ), 431 | Some(None) => fl!("cli-setup-slot-unusable", slot_index = i), 432 | None => fl!("cli-setup-slot-empty", slot_index = i), 433 | } 434 | }) 435 | .collect(); 436 | 437 | let ((stub, recipient, metadata), is_new) = { 438 | let (slot_index, slot) = loop { 439 | match Select::new() 440 | .with_prompt(fl!("cli-setup-select-slot")) 441 | .items(&slots) 442 | .default(0) 443 | .report(true) 444 | .interact_opt()? 445 | { 446 | Some(slot) => { 447 | if let Some(None) = slot_details[slot] { 448 | } else { 449 | break (slot + 1, USABLE_SLOTS[slot]); 450 | } 451 | } 452 | None => return Ok(()), 453 | } 454 | }; 455 | 456 | if let Some((key, _, recipient)) = keys.into_iter().find(|(_, s, _)| s == &slot) { 457 | let recipient = recipient.expect("We checked this above"); 458 | 459 | if Confirm::new() 460 | .with_prompt(fl!("cli-setup-use-existing", slot_index = slot_index)) 461 | .report(true) 462 | .interact()? 463 | { 464 | let stub = key::Stub::new(yubikey.serial(), slot, &recipient); 465 | let metadata = 466 | util::Metadata::extract(&mut yubikey, slot, key.certificate(), true) 467 | .unwrap(); 468 | 469 | key::disconnect_without_reset(yubikey); 470 | ((stub, recipient, metadata), false) 471 | } else { 472 | key::disconnect_without_reset(yubikey); 473 | return Ok(()); 474 | } 475 | } else { 476 | let name = Input::::new() 477 | .with_prompt(format!( 478 | "{} [{}]", 479 | fl!("cli-setup-name-identity"), 480 | flags.name.as_deref().unwrap_or("age identity TAG_HEX") 481 | )) 482 | .allow_empty(true) 483 | .report(true) 484 | .interact_text()?; 485 | 486 | let mut displayed_yk4_warning = false; 487 | let pin_policy = loop { 488 | let pin_policy = match Select::new() 489 | .with_prompt(fl!("cli-setup-select-pin-policy")) 490 | .items(&[ 491 | fl!("pin-policy-always"), 492 | fl!("pin-policy-once"), 493 | fl!("pin-policy-never"), 494 | ]) 495 | .default( 496 | [PinPolicy::Always, PinPolicy::Once, PinPolicy::Never] 497 | .iter() 498 | .position(|p| { 499 | p == &flags.pin_policy.unwrap_or(builder::DEFAULT_PIN_POLICY) 500 | }) 501 | .unwrap(), 502 | ) 503 | .report(true) 504 | .interact_opt()? 505 | { 506 | Some(0) => PinPolicy::Always, 507 | Some(1) => PinPolicy::Once, 508 | Some(2) => PinPolicy::Never, 509 | Some(_) => unreachable!(), 510 | None => return Ok(()), 511 | }; 512 | 513 | // We can't preserve the PIN cache for YubiKey 4 series, because to 514 | // retrieve the serial we switch to the OTP applet. 515 | match (pin_policy, yubikey.version().major) { 516 | (PinPolicy::Once, 4) => { 517 | if !displayed_yk4_warning { 518 | eprintln!(); 519 | eprintln!("{}", fl!("cli-setup-yk4-pin-policy")); 520 | eprintln!(); 521 | displayed_yk4_warning = true; 522 | } 523 | 524 | if Confirm::new() 525 | .with_prompt(fl!("cli-setup-yk4-pin-policy-confirm")) 526 | .report(true) 527 | .interact()? 528 | { 529 | break pin_policy; 530 | } 531 | } 532 | _ => break pin_policy, 533 | } 534 | }; 535 | 536 | let touch_policy = match Select::new() 537 | .with_prompt(fl!("cli-setup-select-touch-policy")) 538 | .items(&[ 539 | fl!("touch-policy-always"), 540 | fl!("touch-policy-cached"), 541 | fl!("touch-policy-never"), 542 | ]) 543 | .default( 544 | [TouchPolicy::Always, TouchPolicy::Cached, TouchPolicy::Never] 545 | .iter() 546 | .position(|p| { 547 | p == &flags.touch_policy.unwrap_or(builder::DEFAULT_TOUCH_POLICY) 548 | }) 549 | .unwrap(), 550 | ) 551 | .report(true) 552 | .interact_opt()? 553 | { 554 | Some(0) => TouchPolicy::Always, 555 | Some(1) => TouchPolicy::Cached, 556 | Some(2) => TouchPolicy::Never, 557 | Some(_) => unreachable!(), 558 | None => return Ok(()), 559 | }; 560 | 561 | if Confirm::new() 562 | .with_prompt(fl!("cli-setup-generate-new", slot_index = slot_index)) 563 | .report(true) 564 | .interact()? 565 | { 566 | eprintln!(); 567 | ( 568 | builder::IdentityBuilder::new(Some(slot)) 569 | .with_name(match name { 570 | s if s.is_empty() => flags.name, 571 | s => Some(s), 572 | }) 573 | .with_pin_policy(Some(pin_policy)) 574 | .with_touch_policy(Some(touch_policy)) 575 | .build(&mut yubikey)?, 576 | true, 577 | ) 578 | } else { 579 | key::disconnect_without_reset(yubikey); 580 | return Ok(()); 581 | } 582 | } 583 | }; 584 | 585 | eprintln!(); 586 | let file_name = Input::::new() 587 | .with_prompt(fl!("cli-setup-identity-file-name")) 588 | .default(format!( 589 | "age-yubikey-identity-{}.txt", 590 | hex::encode(stub.tag) 591 | )) 592 | .report(true) 593 | .interact_text()?; 594 | 595 | let mut file = match OpenOptions::new() 596 | .create_new(true) 597 | .write(true) 598 | .open(&file_name) 599 | { 600 | Ok(file) => file, 601 | Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { 602 | if Confirm::new() 603 | .with_prompt(fl!("cli-setup-identity-file-exists")) 604 | .report(true) 605 | .interact()? 606 | { 607 | File::create(&file_name)? 608 | } else { 609 | return Ok(()); 610 | } 611 | } 612 | Err(e) => return Err(e.into()), 613 | }; 614 | 615 | writeln!( 616 | file, 617 | "{}", 618 | fl!( 619 | "yubikey-identity", 620 | yubikey_metadata = metadata.to_string(), 621 | recipient = recipient.to_string(), 622 | identity = stub.to_string(), 623 | ) 624 | )?; 625 | file.sync_data()?; 626 | 627 | // If `rage` binary is installed, use it in examples. Otherwise default to `age`. 628 | let age_binary = which::which("rage").map(|_| "rage").unwrap_or("age"); 629 | 630 | let encrypt_usage = format!("$ cat foo.txt | {age_binary} -r {recipient} -o foo.txt.age"); 631 | let decrypt_usage = format!("$ cat foo.txt.age | {age_binary} -d -i {file_name} > foo.txt"); 632 | let identity_usage = format!( 633 | "$ age-plugin-yubikey -i --serial {} --slot {} > {}", 634 | stub.serial, 635 | util::slot_to_ui(&stub.slot), 636 | file_name, 637 | ); 638 | let recipient_usage = format!( 639 | "$ age-plugin-yubikey -l --serial {} --slot {}", 640 | stub.serial, 641 | util::slot_to_ui(&stub.slot), 642 | ); 643 | 644 | eprintln!(); 645 | eprintln!( 646 | "{}", 647 | fl!( 648 | "cli-setup-finished", 649 | is_new = if is_new { "true" } else { "false" }, 650 | recipient = recipient.to_string(), 651 | encrypt_usage = encrypt_usage, 652 | decrypt_usage = decrypt_usage, 653 | identity_usage = identity_usage, 654 | recipient_usage = recipient_usage, 655 | ) 656 | ); 657 | 658 | Ok(()) 659 | } 660 | } 661 | -------------------------------------------------------------------------------- /src/key.rs: -------------------------------------------------------------------------------- 1 | //! Structs for handling YubiKeys. 2 | 3 | use age_core::secrecy::{ExposeSecret, SecretString}; 4 | use age_plugin::{identity, Callbacks}; 5 | use bech32::{ToBase32, Variant}; 6 | use dialoguer::Password; 7 | use log::{debug, error, warn}; 8 | use std::convert::Infallible; 9 | use std::fmt; 10 | use std::io; 11 | use std::iter; 12 | use std::thread::sleep; 13 | use std::time::{Duration, Instant, SystemTime}; 14 | use yubikey::{ 15 | certificate::Certificate, 16 | piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId}, 17 | reader::{Context, Reader}, 18 | Key, MgmKey, PinPolicy, Serial, TouchPolicy, YubiKey, 19 | }; 20 | 21 | use crate::{ 22 | error::Error, 23 | fl, piv_p256, 24 | recipient::TAG_BYTES, 25 | util::{otp_serial_prefix, Metadata}, 26 | Recipient, IDENTITY_PREFIX, 27 | }; 28 | 29 | const ONE_SECOND: Duration = Duration::from_secs(1); 30 | const FIFTEEN_SECONDS: Duration = Duration::from_secs(15); 31 | 32 | pub(crate) fn is_connected(reader: Reader) -> bool { 33 | filter_connected(&reader) 34 | } 35 | 36 | pub(crate) fn filter_connected(reader: &Reader) -> bool { 37 | match reader.open() { 38 | Err(yubikey::Error::PcscError { 39 | inner: Some(pcsc::Error::NoSmartcard | pcsc::Error::RemovedCard), 40 | }) => { 41 | warn!( 42 | "{}", 43 | fl!("warn-yk-not-connected", yubikey_name = reader.name()) 44 | ); 45 | false 46 | } 47 | Err(yubikey::Error::AppletNotFound { applet_name }) => { 48 | warn!( 49 | "{}", 50 | fl!( 51 | "warn-yk-missing-applet", 52 | yubikey_name = reader.name(), 53 | applet_name = applet_name, 54 | ), 55 | ); 56 | false 57 | } 58 | Err(_) => true, 59 | Ok(yubikey) => { 60 | // We only connected as a side-effect of confirming that we can connect, so 61 | // avoid resetting the YubiKey. 62 | disconnect_without_reset(yubikey); 63 | true 64 | } 65 | } 66 | } 67 | 68 | pub(crate) fn wait_for_readers() -> Result { 69 | // Start a 15-second timer waiting for a YubiKey to be inserted (if necessary). 70 | let start = SystemTime::now(); 71 | loop { 72 | let mut readers = Context::open()?; 73 | if readers.iter()?.any(is_connected) { 74 | break Ok(readers); 75 | } 76 | 77 | match SystemTime::now().duration_since(start) { 78 | Ok(end) if end >= FIFTEEN_SECONDS => return Err(Error::TimedOut), 79 | _ => sleep(ONE_SECOND), 80 | } 81 | } 82 | } 83 | 84 | /// Looks for agent processes that might be holding exclusive access to a YubiKey, and 85 | /// asks them as nicely as possible to release it. 86 | /// 87 | /// Returns `true` if any known agent was running and was successfully interrupted (or 88 | /// killed if the platform doesn't support interrupts). 89 | fn hunt_agents() -> bool { 90 | debug!("Sharing violation encountered, looking for agent processes"); 91 | 92 | use sysinfo::{ProcessExt, ProcessRefreshKind, RefreshKind, Signal, System, SystemExt}; 93 | 94 | let mut interrupted = false; 95 | 96 | let sys = 97 | System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new())); 98 | 99 | for process in sys.processes().values() { 100 | match process.name() { 101 | "scdaemon" | "scdaemon.exe" => { 102 | // gpg-agent runs scdaemon to interact with smart cards. The canonical way 103 | // to reload it is `gpgconf --reload scdaemon`, which kills and restarts 104 | // the process. We emulate that here with SIGINT (which it listens to). 105 | if process 106 | .kill_with(Signal::Interrupt) 107 | .unwrap_or_else(|| process.kill()) 108 | { 109 | debug!("Stopped scdaemon (PID {})", process.pid()); 110 | interrupted = true; 111 | } 112 | } 113 | "yubikey-agent" | "yubikey-agent.exe" => { 114 | // yubikey-agent releases all YubiKey locks when it receives a SIGHUP. 115 | match process.kill_with(Signal::Hangup) { 116 | Some(true) => { 117 | debug!("Sent SIGHUP to yubikey-agent (PID {})", process.pid()); 118 | interrupted = true; 119 | } 120 | Some(false) => (), 121 | None => debug!( 122 | "Found yubikey-agent (PID {}) but platform doesn't support SIGHUP", 123 | process.pid(), 124 | ), 125 | } 126 | } 127 | _ => (), 128 | } 129 | } 130 | 131 | // If we did interrupt an agent, pause briefly to allow it to finish up. 132 | if interrupted { 133 | sleep(Duration::from_millis(100)); 134 | } 135 | 136 | interrupted 137 | } 138 | 139 | fn open_sesame( 140 | op: impl Fn() -> Result, 141 | ) -> Result { 142 | op().or_else(|e| match e { 143 | yubikey::Error::PcscError { 144 | inner: Some(pcsc::Error::SharingViolation), 145 | } if hunt_agents() => op(), 146 | _ => Err(e), 147 | }) 148 | } 149 | 150 | /// Opens a connection to this reader, returning a `YubiKey` if successful. 151 | /// 152 | /// This is equivalent to [`Reader::open`], but additionally handles the presence of 153 | /// agents (which can indefinitely hold exclusive access to a YubiKey). 154 | pub(crate) fn open_connection(reader: &Reader) -> Result { 155 | open_sesame(|| reader.open()) 156 | } 157 | 158 | /// Opens a YubiKey with a specific serial number. 159 | /// 160 | /// This is equivalent to [`YubiKey::open_by_serial`], but additionally handles the 161 | /// presence of agents (which can indefinitely hold exclusive access to a YubiKey). 162 | fn open_by_serial(serial: Serial) -> Result { 163 | // `YubiKey::open_by_serial` has a bug where it ignores all opening errors, even if 164 | // it potentially could have found a matching YubiKey if not for an error, and thus 165 | // returns `Error::NotFound` if another agent is holding exclusive access to the 166 | // required YubiKey. This gives misleading UX behaviour where age-plugin-yubikey asks 167 | // the user to insert a YubiKey they have already inserted. 168 | // 169 | // For now, we instead implement the correct behaviour manually. Once MSRV has been 170 | // raised to 1.60, we can upstream this into the `yubikey` crate. 171 | open_sesame(|| { 172 | let mut readers = Context::open()?; 173 | 174 | let mut open_error = None; 175 | 176 | for reader in readers.iter()? { 177 | let yubikey = match reader.open() { 178 | Ok(yk) => yk, 179 | Err(e) => { 180 | // Save the first error we see that indicates we might have been able 181 | // to find a matching YubiKey. 182 | if open_error.is_none() { 183 | if let yubikey::Error::PcscError { 184 | inner: Some(pcsc::Error::SharingViolation), 185 | } = e 186 | { 187 | open_error = Some(e); 188 | } 189 | } 190 | continue; 191 | } 192 | }; 193 | 194 | if serial == yubikey.serial() { 195 | return Ok(yubikey); 196 | } else { 197 | // We didn't want this YubiKey; don't reset it. 198 | disconnect_without_reset(yubikey); 199 | } 200 | } 201 | 202 | Err(if let Some(e) = open_error { 203 | e 204 | } else { 205 | error!("no YubiKey detected with serial: {}", serial); 206 | yubikey::Error::NotFound 207 | }) 208 | }) 209 | } 210 | 211 | pub(crate) fn open(serial: Option) -> Result { 212 | if !Context::open()?.iter()?.any(is_connected) { 213 | if let Some(serial) = serial { 214 | eprintln!( 215 | "{}", 216 | fl!("open-yk-with-serial", yubikey_serial = serial.to_string()) 217 | ); 218 | } else { 219 | eprintln!("{}", fl!("open-yk-without-serial")); 220 | } 221 | } 222 | let mut readers = wait_for_readers()?; 223 | let mut readers_iter = readers.iter()?.filter(filter_connected); 224 | 225 | // --serial selects the YubiKey to use. If not provided, and more than one YubiKey is 226 | // connected, an error is returned. 227 | let yubikey = match (readers_iter.next(), readers_iter.next(), serial) { 228 | (None, _, _) => unreachable!(), 229 | (Some(reader), None, None) => open_connection(&reader)?, 230 | (Some(reader), None, Some(serial)) => { 231 | let yubikey = open_connection(&reader)?; 232 | if yubikey.serial() != serial { 233 | return Err(Error::NoMatchingSerial(serial)); 234 | } 235 | yubikey 236 | } 237 | (Some(a), Some(b), Some(serial)) => { 238 | let reader = iter::empty() 239 | .chain(Some(a)) 240 | .chain(Some(b)) 241 | .chain(readers_iter) 242 | .find(|reader| match open_connection(reader) { 243 | Ok(yk) => yk.serial() == serial, 244 | _ => false, 245 | }) 246 | .ok_or(Error::NoMatchingSerial(serial))?; 247 | open_connection(&reader)? 248 | } 249 | (Some(_), Some(_), None) => return Err(Error::MultipleYubiKeys), 250 | }; 251 | 252 | Ok(yubikey) 253 | } 254 | 255 | /// Disconnect from the YubiKey without resetting it. 256 | /// 257 | /// This can be used to preserve the YubiKey's PIN and touch caches. There are two cases 258 | /// where we want to do this: 259 | /// 260 | /// - We connected to this YubiKey in a read-only context, so we have not made any changes 261 | /// to the YubiKey's state. However, we might have asked an agent to release the YubiKey 262 | /// in `key::open_connection`, and we want to allow any state it may have left behind 263 | /// (such as cached PINs or touches) to persist beyond our execution, for usability. 264 | /// - We opened this connection in a decryption context, so the only changes to the 265 | /// YubiKey's state were to potentially cache the PIN and/or touch (depending on the 266 | /// policies of the slot). We want to allow these to persist beyond our execution, for 267 | /// usability. 268 | pub(crate) fn disconnect_without_reset(yubikey: YubiKey) { 269 | let _ = yubikey.disconnect(pcsc::Disposition::LeaveCard); 270 | } 271 | 272 | fn request_pin( 273 | mut prompt: impl FnMut(Option) -> Result, E2>, 274 | serial: Serial, 275 | ) -> Result, E2> { 276 | let mut prev_error = None; 277 | loop { 278 | prev_error = Some(match prompt(prev_error)? { 279 | Ok(pin) => match pin.expose_secret().len() { 280 | // A PIN must be between 6 and 8 characters. 281 | 6..=8 => break Ok(Ok(pin)), 282 | // If the string is 44 bytes and starts with the YubiKey's serial 283 | // encoded as 12-byte modhex, the user probably touched the YubiKey 284 | // early and "typed" an OTP. 285 | 44 if pin.expose_secret().starts_with(&otp_serial_prefix(serial)) => { 286 | fl!("plugin-err-accidental-touch") 287 | } 288 | // Otherwise, the PIN is either too short or too long. 289 | 0..=5 => fl!("plugin-err-pin-too-short"), 290 | _ => fl!("plugin-err-pin-too-long"), 291 | }, 292 | Err(e) => break Ok(Err(e)), 293 | }); 294 | } 295 | } 296 | 297 | pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> { 298 | const DEFAULT_PIN: &str = "123456"; 299 | const DEFAULT_PUK: &str = "12345678"; 300 | 301 | eprintln!(); 302 | let pin = Password::new() 303 | .with_prompt(fl!( 304 | "mgr-enter-pin", 305 | yubikey_serial = yubikey.serial().to_string(), 306 | default_pin = DEFAULT_PIN, 307 | )) 308 | .report(true) 309 | .interact()?; 310 | yubikey.verify_pin(pin.as_bytes())?; 311 | 312 | // If the user is using the default PIN, help them to change it. 313 | if pin == DEFAULT_PIN { 314 | eprintln!(); 315 | eprintln!("{}", fl!("mgr-change-default-pin")); 316 | eprintln!(); 317 | let current_puk = Password::new() 318 | .with_prompt(fl!("mgr-enter-current-puk", default_puk = DEFAULT_PUK)) 319 | .interact()?; 320 | let new_pin = loop { 321 | let pin = request_pin( 322 | |prev_error| { 323 | if let Some(err) = prev_error { 324 | eprintln!("{err}"); 325 | } 326 | Password::new() 327 | .with_prompt(fl!("mgr-choose-new-pin")) 328 | .with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch")) 329 | .interact() 330 | .map(|pin| Result::<_, Infallible>::Ok(SecretString::from(pin))) 331 | }, 332 | yubikey.serial(), 333 | )? 334 | .unwrap(); 335 | if pin.expose_secret() == DEFAULT_PIN { 336 | eprintln!("{}", fl!("mgr-nope-default-pin")); 337 | } else { 338 | break pin; 339 | } 340 | }; 341 | let new_pin = new_pin.expose_secret(); 342 | yubikey 343 | .change_puk(current_puk.as_bytes(), new_pin.as_bytes()) 344 | .map_err(|e| match e { 345 | yubikey::Error::PinLocked => Error::PukLocked, 346 | yubikey::Error::WrongPin { tries } => Error::WrongPuk(tries), 347 | _ => Error::YubiKey(e), 348 | })?; 349 | yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?; 350 | } 351 | 352 | match MgmKey::get_protected(yubikey) { 353 | Ok(mgm_key) => yubikey.authenticate(mgm_key).map_err(|e| match e { 354 | yubikey::Error::AuthenticationError => Error::ManagementKeyAuth, 355 | _ => e.into(), 356 | })?, 357 | Err(yubikey::Error::AuthenticationError) => Err(Error::ManagementKeyAuth)?, 358 | _ => { 359 | // Try to authenticate with the default management key. 360 | yubikey 361 | .authenticate(MgmKey::default()) 362 | .map_err(|_| Error::CustomManagementKey)?; 363 | 364 | // Migrate to a PIN-protected management key. 365 | let mgm_key = MgmKey::generate(); 366 | eprintln!(); 367 | eprintln!("{}", fl!("mgr-changing-mgmt-key")); 368 | eprint!("... "); 369 | mgm_key.set_protected(yubikey).map_err(|e| { 370 | eprintln!( 371 | "{}", 372 | fl!( 373 | "mgr-changing-mgmt-key-error", 374 | management_key = hex::encode(mgm_key.as_ref()), 375 | ) 376 | ); 377 | e 378 | })?; 379 | eprintln!("{}", fl!("mgr-changing-mgmt-key-success")); 380 | } 381 | } 382 | 383 | Ok(()) 384 | } 385 | 386 | /// Returns an iterator of keys that are occupying plugin-compatible slots, along with the 387 | /// corresponding recipient if the key is compatible with this plugin. 388 | pub(crate) fn list_slots( 389 | yubikey: &mut YubiKey, 390 | ) -> Result)>, Error> { 391 | Ok(Key::list(yubikey)?.into_iter().filter_map(|key| { 392 | // We only use the retired slots. 393 | match key.slot() { 394 | SlotId::Retired(slot) => { 395 | // Only P-256 keys are compatible with us. 396 | let recipient = piv_p256::Recipient::from_certificate(key.certificate()) 397 | .map(Recipient::PivP256); 398 | Some((key, slot, recipient)) 399 | } 400 | _ => None, 401 | } 402 | })) 403 | } 404 | 405 | /// Returns an iterator of keys that are compatible with this plugin. 406 | pub(crate) fn list_compatible( 407 | yubikey: &mut YubiKey, 408 | ) -> Result, Error> { 409 | list_slots(yubikey) 410 | .map(|iter| iter.filter_map(|(key, slot, res)| res.map(|recipient| (key, slot, recipient)))) 411 | } 412 | 413 | /// A reference to an age key stored in a YubiKey. 414 | #[derive(Debug)] 415 | pub struct Stub { 416 | pub(crate) serial: Serial, 417 | pub(crate) slot: RetiredSlotId, 418 | pub(crate) tag: [u8; TAG_BYTES], 419 | pub(crate) identity_index: usize, 420 | } 421 | 422 | impl fmt::Display for Stub { 423 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 424 | f.write_str( 425 | bech32::encode( 426 | IDENTITY_PREFIX, 427 | self.to_bytes().to_base32(), 428 | Variant::Bech32, 429 | ) 430 | .expect("HRP is valid") 431 | .to_uppercase() 432 | .as_str(), 433 | ) 434 | } 435 | } 436 | 437 | impl PartialEq for Stub { 438 | fn eq(&self, other: &Self) -> bool { 439 | self.to_bytes().eq(&other.to_bytes()) 440 | } 441 | } 442 | 443 | impl Stub { 444 | /// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple. 445 | /// 446 | /// Does not check that the `PublicKey` matches the given `(Serial, SlotId)` tuple; 447 | /// this is checked at decryption time. 448 | pub(crate) fn new(serial: Serial, slot: RetiredSlotId, recipient: &Recipient) -> Self { 449 | Stub { 450 | serial, 451 | slot, 452 | tag: recipient.static_tag(), 453 | identity_index: 0, 454 | } 455 | } 456 | 457 | pub(crate) fn from_bytes(bytes: &[u8], identity_index: usize) -> Option { 458 | if bytes.len() < 9 { 459 | return None; 460 | } 461 | let serial = Serial::from(u32::from_le_bytes(bytes[0..4].try_into().unwrap())); 462 | let slot: RetiredSlotId = bytes[4].try_into().ok()?; 463 | Some(Stub { 464 | serial, 465 | slot, 466 | tag: bytes[5..9].try_into().unwrap(), 467 | identity_index, 468 | }) 469 | } 470 | 471 | fn to_bytes(&self) -> Vec { 472 | let mut bytes = Vec::with_capacity(9); 473 | bytes.extend_from_slice(&self.serial.0.to_le_bytes()); 474 | bytes.push(self.slot.into()); 475 | bytes.extend_from_slice(&self.tag); 476 | bytes 477 | } 478 | 479 | /// Returns: 480 | /// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey. 481 | /// - `Ok(Ok(None))` if the user told us to skip this YubiKey. 482 | /// - `Ok(Err(_))` if we encountered an error while trying to connect to the YubiKey. 483 | /// - `Err(_)` on communication errors with the age client. 484 | pub(crate) fn connect( 485 | &self, 486 | callbacks: &mut dyn Callbacks, 487 | ) -> io::Result, identity::Error>> { 488 | let mut yubikey = match open_by_serial(self.serial) { 489 | Ok(yk) => yk, 490 | Err(yubikey::Error::NotFound) => { 491 | let mut message = fl!("plugin-insert-yk", yubikey_serial = self.serial.to_string()); 492 | 493 | // If the `confirm` command is available, we loop until either the YubiKey 494 | // we want is inserted, or the used explicitly skips. 495 | let yubikey = loop { 496 | match callbacks.confirm( 497 | &message, 498 | &fl!("plugin-yk-is-plugged-in"), 499 | Some(&fl!("plugin-skip-this-yk")), 500 | )? { 501 | // `confirm` command is not available. 502 | Err(age_core::plugin::Error::Unsupported) => break None, 503 | // User told us to skip this key. 504 | Ok(false) => return Ok(Ok(None)), 505 | // User said they plugged it in; try it. 506 | Ok(true) => match open_by_serial(self.serial) { 507 | Ok(yubikey) => break Some(yubikey), 508 | Err(yubikey::Error::NotFound) => (), 509 | Err(_) => { 510 | return Ok(Err(identity::Error::Identity { 511 | index: self.identity_index, 512 | message: fl!( 513 | "plugin-err-yk-opening", 514 | yubikey_serial = self.serial.to_string(), 515 | ), 516 | })); 517 | } 518 | }, 519 | // We can't communicate with the user. 520 | Err(age_core::plugin::Error::Fail) => { 521 | return Ok(Err(identity::Error::Identity { 522 | index: self.identity_index, 523 | message: fl!( 524 | "plugin-err-yk-opening", 525 | yubikey_serial = self.serial.to_string(), 526 | ), 527 | })) 528 | } 529 | } 530 | 531 | // We're going to loop around, meaning that the first attempt failed. 532 | // Change the message to indicate this to the user. 533 | message = fl!( 534 | "plugin-insert-yk-retry", 535 | yubikey_serial = self.serial.to_string(), 536 | ); 537 | }; 538 | 539 | if let Some(yk) = yubikey { 540 | yk 541 | } else { 542 | // `confirm` is not available; fall back to `message` with a timeout. 543 | if callbacks.message(&message)?.is_err() { 544 | return Ok(Err(identity::Error::Identity { 545 | index: self.identity_index, 546 | message: fl!( 547 | "plugin-err-yk-not-found", 548 | yubikey_serial = self.serial.to_string(), 549 | ), 550 | })); 551 | } 552 | 553 | // Start a 15-second timer waiting for the YubiKey to be inserted 554 | let start = SystemTime::now(); 555 | loop { 556 | match open_by_serial(self.serial) { 557 | Ok(yubikey) => break yubikey, 558 | Err(yubikey::Error::NotFound) => (), 559 | Err(_) => { 560 | return Ok(Err(identity::Error::Identity { 561 | index: self.identity_index, 562 | message: fl!( 563 | "plugin-err-yk-opening", 564 | yubikey_serial = self.serial.to_string(), 565 | ), 566 | })); 567 | } 568 | } 569 | 570 | match SystemTime::now().duration_since(start) { 571 | Ok(end) if end >= FIFTEEN_SECONDS => { 572 | return Ok(Err(identity::Error::Identity { 573 | index: self.identity_index, 574 | message: fl!( 575 | "plugin-err-yk-timed-out", 576 | yubikey_serial = self.serial.to_string(), 577 | ), 578 | })) 579 | } 580 | _ => sleep(ONE_SECOND), 581 | } 582 | } 583 | } 584 | } 585 | Err(_) => { 586 | return Ok(Err(identity::Error::Identity { 587 | index: self.identity_index, 588 | message: fl!( 589 | "plugin-err-yk-opening", 590 | yubikey_serial = self.serial.to_string(), 591 | ), 592 | })) 593 | } 594 | }; 595 | 596 | // Read the pubkey from the YubiKey slot and check it still matches. 597 | let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot)) 598 | .ok() 599 | .and_then(|cert| { 600 | piv_p256::Recipient::from_certificate(&cert) 601 | .filter(|pk| pk.tag() == self.tag) 602 | .map(|pk| (cert, Recipient::PivP256(pk))) 603 | }) { 604 | Some(pk) => pk, 605 | None => { 606 | return Ok(Err(identity::Error::Identity { 607 | index: self.identity_index, 608 | message: fl!("plugin-err-yk-stub-mismatch"), 609 | })) 610 | } 611 | }; 612 | 613 | Ok(Ok(Some(Connection { 614 | yubikey, 615 | cert, 616 | pk, 617 | slot: self.slot, 618 | identity_index: self.identity_index, 619 | cached_metadata: None, 620 | last_touch: None, 621 | }))) 622 | } 623 | } 624 | 625 | pub(crate) struct Connection { 626 | yubikey: YubiKey, 627 | cert: Certificate, 628 | pk: Recipient, 629 | slot: RetiredSlotId, 630 | identity_index: usize, 631 | cached_metadata: Option, 632 | last_touch: Option, 633 | } 634 | 635 | impl Connection { 636 | pub(crate) fn recipient(&self) -> &Recipient { 637 | &self.pk 638 | } 639 | 640 | pub(crate) fn request_pin_if_necessary( 641 | &mut self, 642 | callbacks: &mut dyn Callbacks, 643 | ) -> io::Result> { 644 | // Check if we can skip requesting a PIN. 645 | if self.cached_metadata.is_none() { 646 | self.cached_metadata = 647 | match Metadata::extract(&mut self.yubikey, self.slot, &self.cert, true) { 648 | None => { 649 | return Ok(Err(identity::Error::Identity { 650 | index: self.identity_index, 651 | message: fl!("plugin-err-yk-invalid-pin-policy"), 652 | })) 653 | } 654 | metadata => metadata, 655 | }; 656 | } 657 | match self.cached_metadata.as_ref().and_then(|m| m.pin_policy) { 658 | Some(PinPolicy::Never) => return Ok(Ok(())), 659 | Some(PinPolicy::Once) if self.yubikey.verify_pin(&[]).is_ok() => return Ok(Ok(())), 660 | _ => (), 661 | } 662 | 663 | // The policy requires a PIN, so request it. 664 | let pin = match request_pin( 665 | |prev_error| { 666 | callbacks.request_secret(&format!( 667 | "{}{}{}", 668 | prev_error.as_deref().unwrap_or(""), 669 | prev_error.as_deref().map(|_| " ").unwrap_or(""), 670 | fl!( 671 | "plugin-enter-pin", 672 | yubikey_serial = self.yubikey.serial().to_string(), 673 | ) 674 | )) 675 | }, 676 | self.yubikey.serial(), 677 | )? { 678 | Ok(pin) => pin, 679 | Err(_) => { 680 | return Ok(Err(identity::Error::Identity { 681 | index: self.identity_index, 682 | message: fl!( 683 | "plugin-err-pin-required", 684 | yubikey_serial = self.yubikey.serial().to_string(), 685 | ), 686 | })) 687 | } 688 | }; 689 | if let Err(e) = self.yubikey.verify_pin(pin.expose_secret().as_bytes()) { 690 | return Ok(Err(identity::Error::Identity { 691 | index: self.identity_index, 692 | message: format!("{:?}", Error::YubiKey(e)), 693 | })); 694 | } 695 | Ok(Ok(())) 696 | } 697 | 698 | pub(crate) fn p256_ecdh(&mut self, epk_bytes: &[u8]) -> Result { 699 | // The YubiKey API for performing scalar multiplication takes the point in its 700 | // uncompressed SEC-1 encoding. 701 | assert_eq!(epk_bytes.len(), 65); 702 | 703 | // Check if the touch policy requires a touch. 704 | let needs_touch = match ( 705 | self.cached_metadata.as_ref().and_then(|m| m.touch_policy), 706 | self.last_touch, 707 | ) { 708 | (Some(TouchPolicy::Always), _) | (Some(TouchPolicy::Cached), None) => true, 709 | (Some(TouchPolicy::Cached), Some(last)) if last.elapsed() >= FIFTEEN_SECONDS => true, 710 | _ => false, 711 | }; 712 | 713 | let shared_secret = match decrypt_data( 714 | &mut self.yubikey, 715 | epk_bytes, 716 | AlgorithmId::EccP256, 717 | SlotId::Retired(self.slot), 718 | ) { 719 | Ok(res) => res, 720 | Err(_) => return Err(()), 721 | }; 722 | 723 | // If we requested a touch and reached here, the user touched the YubiKey. 724 | if needs_touch { 725 | if let Some(TouchPolicy::Cached) = 726 | self.cached_metadata.as_ref().and_then(|m| m.touch_policy) 727 | { 728 | self.last_touch = Some(Instant::now()); 729 | } 730 | } 731 | 732 | Ok(shared_secret) 733 | } 734 | 735 | /// Close this connection without resetting the YubiKey. 736 | /// 737 | /// This can be used to preserve the YubiKey's PIN and touch caches. 738 | pub(crate) fn disconnect_without_reset(self) { 739 | disconnect_without_reset(self.yubikey); 740 | } 741 | } 742 | 743 | #[cfg(test)] 744 | mod tests { 745 | use yubikey::{piv::RetiredSlotId, Serial}; 746 | 747 | use super::Stub; 748 | 749 | #[test] 750 | fn stub_round_trip() { 751 | let stub = Stub { 752 | serial: Serial::from(42), 753 | slot: RetiredSlotId::R1, 754 | tag: [7; 4], 755 | identity_index: 0, 756 | }; 757 | 758 | let encoded = stub.to_bytes(); 759 | assert_eq!(Stub::from_bytes(&[], 0), None); 760 | assert_eq!(Stub::from_bytes(&encoded, 0), Some(stub)); 761 | assert_eq!(Stub::from_bytes(&encoded[..encoded.len() - 1], 0), None); 762 | } 763 | } 764 | --------------------------------------------------------------------------------