2 Commits

Author SHA1 Message Date
Jack Grigg bf081835c4 Release 0.3.4
CI checks / Test on linux (push) Has been cancelled
CI checks / Test on macos (push) Has been cancelled
CI checks / Test on windows (push) Has been cancelled
CI checks / Clippy (1.56.0) (push) Has been cancelled
CI checks / Clippy (nightly) (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
Publish release binaries / Publish for macos-arm64 (push) Has been cancelled
Publish release binaries / Publish for macos-x86_64 (push) Has been cancelled
Publish release binaries / Publish for linux (push) Has been cancelled
Publish release binaries / Publish for windows (push) Has been cancelled
Publish release binaries / Debian linux (push) Has been cancelled
2026-04-08 04:14:54 +01:00
Jack Grigg 9503f406ae Reject identities with unrecognised critical extensions
We don't know how to correctly use these identities. In particular, some
identities store parts of their private key material in certificate
extensions to work around hardware limitations. Not understanding these
extensions could lead to encrypting with the wrong protocol and
violating security assumptions.
2026-04-08 04:12:35 +01:00
21 changed files with 783 additions and 2382 deletions
+102 -68
View File
@@ -1,13 +1,10 @@
name: CI checks name: CI checks
on: on: [push, pull_request]
pull_request:
push:
branches: main
jobs: jobs:
test-msrv: test:
name: Test MSRV on ${{ matrix.name }} name: Test on ${{ matrix.name }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
@@ -25,94 +22,131 @@ jobs:
os: macos-latest os: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.56.0
override: true
- name: Install build dependencies - name: Install build dependencies
run: sudo apt install ${{ matrix.build_deps }} run: sudo apt install ${{ matrix.build_deps }}
if: matrix.build_deps != '' if: matrix.build_deps != ''
- uses: dtolnay/rust-toolchain@stable - name: cargo fetch
id: stable-toolchain uses: actions-rs/cargo@v1
- name: Install test dependencies using latest stable Rust with:
run: cargo +${{steps.stable-toolchain.outputs.name}} install rage command: fetch
- name: Build tests
uses: actions-rs/cargo@v1
with:
command: build
args: --verbose --tests
- name: Run tests - name: Run tests
run: cargo test uses: actions-rs/cargo@v1
- name: Verify working directory is clean with:
run: git diff --exit-code command: test
args: --verbose
test-latest:
name: Test latest stable on ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
name: [linux, windows, macos]
include:
- name: linux
os: ubuntu-latest
build_deps: >
libpcsclite-dev
- name: windows
os: windows-latest
- name: macos
os: macos-latest
clippy:
name: Clippy (1.56.0)
timeout-minutes: 30
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.56.0
components: clippy
override: true
- name: Install build dependencies - name: Install build dependencies
run: sudo apt install ${{ matrix.build_deps }} run: sudo apt install libpcsclite-dev
if: matrix.build_deps != '' - name: Run clippy
- uses: dtolnay/rust-toolchain@stable uses: actions-rs/clippy-check@v1
- uses: dtolnay/rust-toolchain@stable with:
id: toolchain name: Clippy (1.56.0)
- run: rustup override set ${{steps.toolchain.outputs.name}} token: ${{ secrets.GITHUB_TOKEN }}
- name: Install test dependencies args: --all-features --all-targets -- -D warnings
run: cargo install rage
- name: Remove lockfile to build with latest dependencies clippy-nightly:
run: rm Cargo.lock name: Clippy (nightly)
- name: Run tests timeout-minutes: 30
run: cargo test runs-on: ubuntu-latest
- name: Verify working directory is clean (excluding lockfile) continue-on-error: true
run: git diff --exit-code ':!Cargo.lock' steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
components: clippy
override: true
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Run Clippy (nightly)
uses: actions-rs/clippy-check@v1
continue-on-error: true
with:
name: Clippy (nightly)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets
codecov: codecov:
name: Code coverage name: Code coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: xd009642/tarpaulin:develop-nightly
options: --security-opt seccomp=unconfined
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Install build dependencies # Use stable for this to ensure that cargo-tarpaulin can be built.
run: apt update && apt install -y libpcsclite-dev - uses: actions-rs/toolchain@v1
- name: Generate coverage report with:
run: > toolchain: stable
cargo tarpaulin override: true
--engine llvm - name: Install build dependencies
--timeout 180 run: sudo apt install libpcsclite-dev
--out xml - name: Generate coverage report
- name: Upload coverage to Codecov uses: actions-rs/tarpaulin@v0.1
uses: codecov/codecov-action@v4.5.0 with:
args: --release --timeout 180 --out Xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.1
with: with:
fail_ci_if_error: true
token: ${{secrets.CODECOV_TOKEN}} token: ${{secrets.CODECOV_TOKEN}}
doc-links: doc-links:
name: Intra-doc links name: Intra-doc links
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.56.0
override: true
- name: Install build dependencies - name: Install build dependencies
run: sudo apt install libpcsclite-dev run: sudo apt install libpcsclite-dev
- run: cargo fetch - name: cargo fetch
# Requires #![deny(rustdoc::broken_intra_doc_links)] in crates. uses: actions-rs/cargo@v1
with:
command: fetch
# Ensure intra-documentation links all resolve correctly
# Requires #![deny(intra_doc_link_resolution_failure)] in crates.
- name: Check intra-doc links - name: Check intra-doc links
run: cargo doc --document-private-items uses: actions-rs/cargo@v1
with:
command: doc
args: --document-private-items
fmt: fmt:
name: Rustfmt name: Rustfmt
timeout-minutes: 30
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.56.0
components: rustfmt
override: true
- name: Check formatting - name: Check formatting
run: cargo fmt -- --check uses: actions-rs/cargo@v1
with:
command: fmt
args: -- --check
-22
View File
@@ -1,22 +0,0 @@
name: Beta lints
# We only run these lints on trial-merges of PRs to reduce noise.
on: pull_request
jobs:
clippy-beta:
name: Clippy (beta)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@beta
id: toolchain
- run: rustup override set ${{steps.toolchain.outputs.name}}
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Clippy (beta)
uses: actions-rs/clippy-check@v1
with:
name: Clippy (beta)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -W clippy::all
-19
View File
@@ -1,19 +0,0 @@
name: Stable lints
# We only run these lints on trial-merges of PRs to reduce noise.
on: pull_request
jobs:
clippy:
name: Clippy (MSRV)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Run clippy
uses: actions-rs/clippy-check@v1
with:
name: Clippy (MSRV)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -D warnings
+14 -12
View File
@@ -24,7 +24,7 @@ jobs:
- windows - windows
include: include:
- name: linux - name: linux
os: ubuntu-20.04 os: ubuntu-18.04
build_deps: > build_deps: >
libpcsclite-dev libpcsclite-dev
archive_name: age-plugin-yubikey.tar.gz archive_name: age-plugin-yubikey.tar.gz
@@ -48,10 +48,11 @@ jobs:
asset_suffix: x86_64-darwin.tar.gz asset_suffix: x86_64-darwin.tar.gz
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable - uses: actions-rs/toolchain@v1
id: toolchain with:
- run: rustup override set ${{steps.toolchain.outputs.name}} toolchain: stable
override: true
- name: Add target - name: Add target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
if: matrix.target != '' if: matrix.target != ''
@@ -86,7 +87,7 @@ jobs:
if: matrix.name == 'windows' if: matrix.name == 'windows'
- name: Upload archive to release - name: Upload archive to release
uses: svenstaro/upload-release-action@2.6.1 uses: svenstaro/upload-release-action@2.3.0
with: with:
file: ${{ matrix.archive_name }} file: ${{ matrix.archive_name }}
asset_name: age-plugin-yubikey-$tag-${{ matrix.asset_suffix }} asset_name: age-plugin-yubikey-$tag-${{ matrix.asset_suffix }}
@@ -95,7 +96,7 @@ jobs:
deb: deb:
name: Debian ${{ matrix.name }} name: Debian ${{ matrix.name }}
runs-on: ubuntu-20.04 runs-on: ubuntu-18.04
strategy: strategy:
matrix: matrix:
name: [linux] name: [linux]
@@ -106,10 +107,11 @@ jobs:
libpcsclite-dev libpcsclite-dev
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable - uses: actions-rs/toolchain@v1
id: toolchain with:
- run: rustup override set ${{steps.toolchain.outputs.name}} toolchain: stable
override: true
- name: Add target - name: Add target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: cargo install cargo-deb - name: cargo install cargo-deb
@@ -144,7 +146,7 @@ jobs:
args: --package age-plugin-yubikey --no-build --target ${{ matrix.target }} args: --package age-plugin-yubikey --no-build --target ${{ matrix.target }}
- name: Upload Debian package to release - name: Upload Debian package to release
uses: svenstaro/upload-release-action@2.6.1 uses: svenstaro/upload-release-action@2.3.0
with: with:
file: target/${{ matrix.target }}/debian/*.deb file: target/${{ matrix.target }}/debian/*.deb
file_glob: true file_glob: true
+4 -23
View File
@@ -8,30 +8,11 @@ to 0.3.0 are beta releases.
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2024-08-04 ## [0.3.4] - 2026-04-08
### Fixed ### Fixed
- `age-plugin-yubikey` can now be compiled with Rust 1.80 and above. - `age-plugin-yubikey` now completely ignores any identity that has unrecognised
critical extensions in its certificate, to ensure it doesn't misuse a newer
### Changed identity type.
- MSRV is now 1.67.0.
## [0.4.0] - 2023-04-09
### Changed
- MSRV is now 1.65.0.
- The YubiKey PIV PIN and touch caches are now preserved across processes in
most cases. See [README.md](README.md#agent-support) for exceptions. This has
several usability effects (not applicable to YubiKey 4 series):
- If a YubiKey's PIN is cached by an agent like `yubikey-agent`, and then
`age-plugin-yubikey` is run (either directly or as a plugin), the agent
won't request a PIN entry on its next use.
- If a YubiKey's PIN was requested by either a previous invocation of
`age-plugin-yubikey` or an agent like `yubikey-agent`, subsequent calls to
`age-plugin-yubikey` won't request a PIN entry to decrypt a file with an
identity that has a PIN policy of `once`.
### Fixed
- Identities can now be generated with a PIN policy of "always" (in previous
versions of `age-plugin-yubikey` this would cause an error).
## [0.3.3] - 2023-02-11 ## [0.3.3] - 2023-02-11
### Fixed ### Fixed
Generated
+493 -1683
View File
File diff suppressed because it is too large Load Diff
+17 -20
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "age-plugin-yubikey" name = "age-plugin-yubikey"
description = "YubiKey plugin for age clients" description = "YubiKey plugin for age clients"
version = "0.5.0" version = "0.3.4"
authors = ["Jack Grigg <thestr4d@gmail.com>"] authors = ["Jack Grigg <thestr4d@gmail.com>"]
repository = "https://github.com/str4d/age-plugin-yubikey" repository = "https://github.com/str4d/age-plugin-yubikey"
readme = "README.md" readme = "README.md"
@@ -9,7 +9,7 @@ keywords = ["age", "cli", "encryption", "yubikey"]
categories = ["command-line-utilities", "cryptography"] categories = ["command-line-utilities", "cryptography"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
edition = "2021" edition = "2021"
rust-version = "1.67" # MSRV rust-version = "1.56" # MSRV
[package.metadata.deb] [package.metadata.deb]
extended-description = """\ extended-description = """\
@@ -22,37 +22,34 @@ assets = [
] ]
[dependencies] [dependencies]
age-core = "0.10" age-core = "0.8"
age-plugin = "0.5" age-plugin = "0.3"
base64 = "0.21" base64 = "0.13"
bech32 = "0.9" bech32 = "0.8"
console = { version = "0.15", default-features = false } console = { version = "0.15", default-features = false }
dialoguer = { version = "0.11", default-features = false, features = ["password"] } dialoguer = { version = "0.9", default-features = false, features = ["password"] }
env_logger = "0.10" env_logger = "0.9"
gumdrop = "0.8" gumdrop = "0.8"
hex = "0.4" hex = "0.4"
log = "0.4" log = "0.4"
p256 = { version = "0.13", features = ["ecdh"] } p256 = { version = "0.9", features = ["ecdh"] }
pcsc = "2.4" pcsc = "2.4"
rand = "0.8" rand = "0.8"
sha2 = "0.10" sha2 = "0.9"
which = "5" which = "4.1"
x509 = "0.2" x509 = "0.2"
x509-parser = "0.14" x509-parser = "0.12"
yubikey = { version = "=0.8.0-pre.0", features = ["untested"] } yubikey = { version = "0.5", features = ["untested"] }
# Translations # Translations
i18n-embed = { version = "0.14", features = ["desktop-requester", "fluent-system"] } i18n-embed = { version = "0.13", features = ["desktop-requester", "fluent-system"] }
i18n-embed-fl = "0.8" i18n-embed-fl = "0.6"
lazy_static = "1" lazy_static = "1"
rust-embed = "8" rust-embed = "6"
# GnuPG coexistence # GnuPG coexistence
sysinfo = "0.29" sysinfo = ">=0.26, <0.26.4"
[dev-dependencies] [dev-dependencies]
flate2 = "1" flate2 = "1"
man = "0.3" man = "0.3"
tempfile = "3"
test-with = "0.11"
which = "5"
+18 -61
View File
@@ -6,42 +6,26 @@ which enables files to be encrypted to age identities stored on YubiKeys.
## Installation ## Installation
| Environment | CLI command |
|-------------|-------------|
| Cargo (Rust 1.67+) | `cargo install age-plugin-yubikey` |
| Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` |
| Arch Linux | `pacman -S age-plugin-yubikey` |
| Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) |
| NixOS | Add to config:<br>`environment.systemPackages = [`<br>` pkgs.age-plugin-yubikey`<br>`];`<br>Or run `nix-env -i age-plugin-yubikey` |
| Ubuntu 20.04+ | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) |
| OpenBSD | `pkg_add age-plugin-yubikey` (security/age-plugin-yubikey) |
On Windows, Linux, and macOS, you can use the On Windows, Linux, and macOS, you can use the
[pre-built binaries](https://github.com/str4d/age-plugin-yubikey/releases). [pre-built binaries](https://github.com/str4d/age-plugin-yubikey/releases).
If your system has Rust 1.56+ installed (either via `rustup` or a system
package), you can build directly from source:
```
cargo install age-plugin-yubikey
```
Help from new packagers is very welcome. Help from new packagers is very welcome.
### Linux, BSD, etc. ### Linux, BSD, etc.
On non-Windows, non-macOS systems, you need to ensure that the `pcscd` service On non-Windows, non-macOS systems, you need to ensure that the `pcscd` service
is installed and running. is installed and running. On Debian or Ubuntu, you can do this with:
| Environment | CLI command | ```
|-------------|-------------| $ sudo apt-get install pcscd
| Debian or Ubuntu | `sudo apt-get install pcscd` | ```
| Fedora | `sudo dnf install pcsc-lite` |
| OpenBSD | As ```root``` do:<br>`pkg_add pcsc-lite ccid`<br>`rcctl enable pcscd`<br>`rcctl start pcscd` |
| FreeBSD | As ```root``` do:<br>`pkg install pcsc-lite libccid`<br>`service pcscd enable`<br>`service pcscd start` |
| Arch | `sudo pacman -S pcsclite pcsc-tools yubikey-manager`<br>`sudo systemctl enable pcscd`<br>`sudo systemctl start pcscd`|
When installing via Cargo, you also need to ensure that the development headers
for the `pcsc-lite` library are available, so that the `pcsc-sys` crate can be
compiled.
| Environment | CLI command |
|-------------|-------------|
| Debian or Ubuntu | `sudo apt-get install libpcsclite-dev` |
| Fedora | `sudo dnf install pcsc-lite-devel` |
### Windows Subsystem for Linux (WSL) ### Windows Subsystem for Linux (WSL)
@@ -60,12 +44,7 @@ YubiKey:
## Configuration ## Configuration
`age-plugin-yubikey` identities have two parts: There are two ways to configure a YubiKey as an `age` identity. You can run the
- The secret key material, which is stored inside a YubiKey.
- An age identity file, which contains information that an age client can use to
figure out which YubiKey secret key should be used.
There are two ways to configure a YubiKey as an age identity. You can run the
plugin binary directly to use a simple text interface, which will create an age plugin binary directly to use a simple text interface, which will create an age
identity file: identity file:
@@ -91,14 +70,6 @@ Once an identity has been created, you can regenerate it later:
$ age-plugin-yubikey --identity [--serial SERIAL] --slot SLOT $ age-plugin-yubikey --identity [--serial SERIAL] --slot SLOT
``` ```
To use the identity with an age client, it needs to be stored in a file. When
using the above programmatic flags, you can do this by redirecting standard
output to a file. On a Unix system like macOS or Ubuntu:
```
$ age-plugin-yubikey --identity --slot SLOT > yubikey-identity.txt
```
## Usage ## Usage
The age recipients contained in all connected YubiKeys can be printed on The age recipients contained in all connected YubiKeys can be printed on
@@ -123,27 +94,13 @@ age client as normal (e.g. `rage -d -i yubikey-identity.txt`).
### Agent support ### Agent support
`age-plugin-yubikey` does not provide or interact with an agent for decryption. `age-plugin-yubikey` does not provide or interact with an agent for decryption.
It does however attempt to preserve the PIN cache by not soft-resetting the As age plugin binaries have short lifetimes (they only run while the age client
YubiKey after a decryption or read-only operation, which enables YubiKey is running), this means that YubiKey identities configured with a PIN policy of
identities configured with a PIN policy of `once` to not prompt for the PIN on `once` will actually prompt for the PIN on every decryption.
every decryption. **This does not work for YubiKey 4 series.**
The session that corresponds to the `once` policy can be ended in several ways, A decryption agent will most likely be implemented as a separate age plugin that
not all of which are necessarily intuitive: interacts with [`yubikey-agent`](https://github.com/FiloSottile/yubikey-agent),
enabling YubiKeys to be used simultaneously with age and SSH.
- Unplugging the YubiKey (the obvious way).
- Using a different applet (e.g. FIDO2). This causes the PIV applet to be closed
which clears its state.
- This is why the YubiKey 4 series does not support PIN cache preservation:
their serial can only be obtained by switching to the OTP applet.
- Generating a new age identity via `age-plugin-yubikey --generate` or the CLI
interface. This is to avoid leaving the YubiKey authenticated with the
management key.
If the current PIN UX proves to be insufficient, a decryption agent will most
likely be implemented as a separate age plugin that interacts with
[`yubikey-agent`](https://github.com/FiloSottile/yubikey-agent), enabling
YubiKeys to be used simultaneously with age and SSH.
### Manual setup and technical details ### Manual setup and technical details
+1 -1
View File
@@ -6,7 +6,7 @@ use std::io::prelude::*;
const MANPAGES_DIR: &str = "./target/manpages"; const MANPAGES_DIR: &str = "./target/manpages";
fn generate_manpage(page: String, name: &str) { fn generate_manpage(page: String, name: &str) {
let file = File::create(format!("{MANPAGES_DIR}/{name}.1.gz")) let file = File::create(format!("{}/{}.1.gz", MANPAGES_DIR, name))
.expect("Should be able to open file in target directory"); .expect("Should be able to open file in target directory");
let mut encoder = GzEncoder::new(file, Compression::best()); let mut encoder = GzEncoder::new(file, Compression::best());
encoder encoder
+2 -33
View File
@@ -76,14 +76,6 @@ cli-setup-name-identity = 📛 Name this identity
cli-setup-select-pin-policy = 🔤 Select a PIN policy cli-setup-select-pin-policy = 🔤 Select a PIN policy
cli-setup-select-touch-policy = 👆 Select a touch policy cli-setup-select-touch-policy = 👆 Select a touch policy
cli-setup-yk4-pin-policy =
⚠️ Your {-yubikey} is a {-yubikey} 4 series. With ephemeral applications like
{-age-plugin-yubikey}, a PIN policy of "Once" behaves like a PIN policy of
"Always", and your PIN will be requested for every decryption. However, you
might still benefit from a PIN policy of "Once" in long-running applications
like agents.
cli-setup-yk4-pin-policy-confirm = Use PIN policy of "Once" with {-yubikey} 4?
cli-setup-generate-new = Generate new identity in slot {$slot_index}? cli-setup-generate-new = Generate new identity in slot {$slot_index}?
cli-setup-use-existing = Use existing identity in slot {$slot_index}? cli-setup-use-existing = Use existing identity in slot {$slot_index}?
@@ -120,7 +112,6 @@ cli-setup-finished =
open-yk-with-serial = ⏳ Please insert the {-yubikey} with serial {$yubikey_serial}. open-yk-with-serial = ⏳ Please insert the {-yubikey} with serial {$yubikey_serial}.
open-yk-without-serial = ⏳ Please insert the {-yubikey}. open-yk-without-serial = ⏳ Please insert the {-yubikey}.
warn-yk-not-connected = Ignoring {$yubikey_name}: not connected warn-yk-not-connected = Ignoring {$yubikey_name}: not connected
warn-yk-missing-applet = Ignoring {$yubikey_name}: Missing {$applet_name} applet
print-recipient = Recipient: {$recipient} print-recipient = Recipient: {$recipient}
@@ -155,12 +146,6 @@ mgr-changing-mgmt-key-error =
{" "}{$management_key} {" "}{$management_key}
mgr-changing-mgmt-key-success = Success! mgr-changing-mgmt-key-success = Success!
## YubiKey keygen
builder-gen-key = 🎲 Generating key...
builder-gen-cert = 🔏 Generating certificate...
builder-touch-yk = 👆 Please touch the {-yubikey}
## Plugin usage ## Plugin usage
plugin-err-invalid-recipient = Invalid recipient plugin-err-invalid-recipient = Invalid recipient
@@ -187,12 +172,8 @@ plugin-err-pin-required = A PIN is required for {-yubikey} with serial {$yub
## Errors ## Errors
err-mgmt-key-auth = Failed to authenticate with the PIN-protected management key.
rec-mgmt-key-auth =
Check whether your management key is using the TDES algorithm.
AES is not supported yet: {$aes_url}
err-custom-mgmt-key = Custom unprotected non-TDES management keys are not supported. err-custom-mgmt-key = Custom unprotected non-TDES management keys are not supported.
rec-change-mgmt-key = rec-custom-mgmt-key =
You can use the {-yubikey} Manager CLI to change to a protected management key: You can use the {-yubikey} Manager CLI to change to a protected management key:
{" "}{$cmd} {" "}{$cmd}
@@ -204,7 +185,6 @@ err-invalid-flag-tui = Flag '{$flag}' cannot be used with the interactive in
err-invalid-pin-policy = Invalid PIN policy '{$policy}' (expected [{$expected}]). err-invalid-pin-policy = Invalid PIN policy '{$policy}' (expected [{$expected}]).
err-invalid-slot = Invalid slot '{$slot}' (expected number between 1 and 20). err-invalid-slot = Invalid slot '{$slot}' (expected number between 1 and 20).
err-invalid-touch-policy = Invalid touch policy '{$policy}' (expected [{$expected}]). err-invalid-touch-policy = Invalid touch policy '{$policy}' (expected [{$expected}]).
err-io-user = Failed to get input from user: {$err}
err-io = Failed to set up {-yubikey}: {$err} err-io = Failed to set up {-yubikey}: {$err}
err-multiple-commands = Only one of {-cmd-generate}, {-cmd-identity}, {-cmd-list}, {-cmd-list-all} can be specified. err-multiple-commands = Only one of {-cmd-generate}, {-cmd-identity}, {-cmd-list}, {-cmd-list-all} can be specified.
err-multiple-yubikeys = Multiple {-yubikeys} are plugged in. Use {-flag-serial} to select a single {-yubikey}. err-multiple-yubikeys = Multiple {-yubikeys} are plugged in. Use {-flag-serial} to select a single {-yubikey}.
@@ -225,27 +205,16 @@ rec-yk-no-service-pcscd =
If you are on Debian or Ubuntu, you can install it with: If you are on Debian or Ubuntu, you can install it with:
{" "}{$apt} {" "}{$apt}
rec-yk-no-service-pcscd-bsd =
You can install and run it as root with:
{" "}{$pkg}
{" "}{$service_enable}
{" "}{$service_start}
err-yk-no-service-win = The Smart Cards for Windows service is not running. err-yk-no-service-win = The Smart Cards for Windows service is not running.
rec-yk-no-service-win = rec-yk-no-service-win =
See this troubleshooting guide for more help: See this troubleshooting guide for more help:
{" "}{$url} {" "}{$url}
err-yk-not-found = Please insert the {-yubikey} you want to set up err-yk-not-found = Please insert the {-yubikey} you want to set up
err-yk-wrong-pin = Invalid PIN ({$tries} tries remaining before it is blocked)
err-yk-general = Error while communicating with {-yubikey}: {$err} err-yk-general = Error while communicating with {-yubikey}: {$err}
err-yk-general-cause = Cause: {$inner_err} err-yk-general-cause = Cause: {$inner_err}
err-yk-wrong-pin = Invalid {$pin_kind} ({$tries ->
[one] {$tries} try remaining
*[other] {$tries} tries remaining
} before it is blocked)
err-yk-pin-locked = {$pin_kind} locked
err-ux-A = Did this not do what you expected? Could an error be more useful? err-ux-A = Did this not do what you expected? Could an error be more useful?
err-ux-B = Tell us err-ux-B = Tell us
# Put (len(A) - len(B) - 46) spaces here. # Put (len(A) - len(B) - 46) spaces here.
+1
View File
@@ -0,0 +1 @@
1.56.0
-3
View File
@@ -1,3 +0,0 @@
[toolchain]
channel = "1.67.0"
components = ["clippy", "rustfmt"]
+6 -24
View File
@@ -1,4 +1,3 @@
use dialoguer::Password;
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use x509::RelativeDistinguishedName; use x509::RelativeDistinguishedName;
use yubikey::{ use yubikey::{
@@ -9,7 +8,6 @@ use yubikey::{
use crate::{ use crate::{
error::Error, error::Error,
fl,
key::{self, Stub}, key::{self, Stub},
p256::Recipient, p256::Recipient,
util::{Metadata, POLICY_EXTENSION_OID}, util::{Metadata, POLICY_EXTENSION_OID},
@@ -88,13 +86,17 @@ impl IdentityBuilder {
let pin_policy = self.pin_policy.unwrap_or(DEFAULT_PIN_POLICY); let pin_policy = self.pin_policy.unwrap_or(DEFAULT_PIN_POLICY);
let touch_policy = self.touch_policy.unwrap_or(DEFAULT_TOUCH_POLICY); let touch_policy = self.touch_policy.unwrap_or(DEFAULT_TOUCH_POLICY);
eprintln!("{}", fl!("builder-gen-key"));
// No need to ask for users to enter their PIN if the PIN policy requires it, // No need to ask for users to enter their PIN if the PIN policy requires it,
// because here we _always_ require them to enter their PIN in order to access the // because here we _always_ require them to enter their PIN in order to access the
// protected management key (which is necessary in order to generate identities). // protected management key (which is necessary in order to generate identities).
key::manage(yubikey)?; key::manage(yubikey)?;
if let TouchPolicy::Never = touch_policy {
// No need to touch YubiKey
} else {
eprintln!("👆 Please touch the YubiKey");
}
// Generate a new key in the selected slot. // Generate a new key in the selected slot.
let generated = yubikey_generate( let generated = yubikey_generate(
yubikey, yubikey,
@@ -107,9 +109,6 @@ impl IdentityBuilder {
let recipient = Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"); let recipient = Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey");
let stub = Stub::new(yubikey.serial(), slot, &recipient); let stub = Stub::new(yubikey.serial(), slot, &recipient);
eprintln!();
eprintln!("{}", fl!("builder-gen-cert"));
// Pick a random serial for the new self-signed certificate. // Pick a random serial for the new self-signed certificate.
let mut serial = [0; 20]; let mut serial = [0; 20];
OsRng.fill_bytes(&mut serial); OsRng.fill_bytes(&mut serial);
@@ -118,23 +117,6 @@ impl IdentityBuilder {
.name .name
.unwrap_or(format!("age identity {}", hex::encode(stub.tag))); .unwrap_or(format!("age identity {}", hex::encode(stub.tag)));
if let PinPolicy::Always = pin_policy {
// We need to enter the PIN again.
let pin = Password::new()
.with_prompt(fl!(
"plugin-enter-pin",
yubikey_serial = yubikey.serial().to_string(),
))
.report(true)
.interact()?;
yubikey.verify_pin(pin.as_bytes())?;
}
if let TouchPolicy::Never = touch_policy {
// No need to touch YubiKey
} else {
eprintln!("{}", fl!("builder-touch-yk"));
}
let cert = Certificate::generate_self_signed( let cert = Certificate::generate_self_signed(
yubikey, yubikey,
SlotId::Retired(slot), SlotId::Retired(slot),
+4 -64
View File
@@ -15,33 +15,23 @@ macro_rules! wlnfl {
pub enum Error { pub enum Error {
CustomManagementKey, CustomManagementKey,
Dialog(dialoguer::Error),
InvalidFlagCommand(String, String), InvalidFlagCommand(String, String),
InvalidFlagTui(String), InvalidFlagTui(String),
InvalidPinPolicy(String), InvalidPinPolicy(String),
InvalidSlot(u8), InvalidSlot(u8),
InvalidTouchPolicy(String), InvalidTouchPolicy(String),
Io(io::Error), Io(io::Error),
ManagementKeyAuth,
MultipleCommands, MultipleCommands,
MultipleYubiKeys, MultipleYubiKeys,
NoEmptySlots(Serial), NoEmptySlots(Serial),
NoMatchingSerial(Serial), NoMatchingSerial(Serial),
PukLocked,
SlotHasNoIdentity(RetiredSlotId), SlotHasNoIdentity(RetiredSlotId),
SlotIsNotEmpty(RetiredSlotId), SlotIsNotEmpty(RetiredSlotId),
TimedOut, TimedOut,
UseListForSingleSlot, UseListForSingleSlot,
WrongPuk(u8),
YubiKey(yubikey::Error), YubiKey(yubikey::Error),
} }
impl From<dialoguer::Error> for Error {
fn from(e: dialoguer::Error) -> Self {
Error::Dialog(e)
}
}
impl From<io::Error> for Error { impl From<io::Error> for Error {
fn from(e: io::Error) -> Self { fn from(e: io::Error) -> Self {
Error::Io(e) Error::Io(e)
@@ -58,21 +48,13 @@ impl From<yubikey::Error> for Error {
// manually to provide the error output we want. // manually to provide the error output we want.
impl fmt::Debug for Error { impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const CHANGE_MGMT_KEY_CMD: &str =
"ykman piv access change-management-key -a TDES --protect";
const CHANGE_MGMT_KEY_URL: &str = "https://developers.yubico.com/yubikey-manager/";
match self { match self {
Error::CustomManagementKey => { Error::CustomManagementKey => {
wlnfl!(f, "err-custom-mgmt-key")?; wlnfl!(f, "err-custom-mgmt-key")?;
wlnfl!( let cmd = "ykman piv access change-management-key --protect";
f, let url = "https://developers.yubico.com/yubikey-manager/";
"rec-change-mgmt-key", wlnfl!(f, "rec-custom-mgmt-key", cmd = cmd, url = url)?;
cmd = CHANGE_MGMT_KEY_CMD,
url = CHANGE_MGMT_KEY_URL
)?;
} }
Error::Dialog(e) => wlnfl!(f, "err-io-user", err = e.to_string())?,
Error::InvalidFlagCommand(flag, command) => wlnfl!( Error::InvalidFlagCommand(flag, command) => wlnfl!(
f, f,
"err-invalid-flag-command", "err-invalid-flag-command",
@@ -94,17 +76,6 @@ impl fmt::Debug for Error {
expected = "always, cached, never", expected = "always, cached, never",
)?, )?,
Error::Io(e) => wlnfl!(f, "err-io", err = e.to_string())?, Error::Io(e) => wlnfl!(f, "err-io", err = e.to_string())?,
Error::ManagementKeyAuth => {
let aes_url = "https://github.com/str4d/age-plugin-yubikey/issues/92";
wlnfl!(f, "err-mgmt-key-auth")?;
wlnfl!(f, "rec-mgmt-key-auth", aes_url = aes_url)?;
wlnfl!(
f,
"rec-change-mgmt-key",
cmd = CHANGE_MGMT_KEY_CMD,
url = CHANGE_MGMT_KEY_URL
)?;
}
Error::MultipleCommands => wlnfl!(f, "err-multiple-commands")?, Error::MultipleCommands => wlnfl!(f, "err-multiple-commands")?,
Error::MultipleYubiKeys => wlnfl!(f, "err-multiple-yubikeys")?, Error::MultipleYubiKeys => wlnfl!(f, "err-multiple-yubikeys")?,
Error::NoEmptySlots(serial) => { Error::NoEmptySlots(serial) => {
@@ -113,7 +84,6 @@ impl fmt::Debug for Error {
Error::NoMatchingSerial(serial) => { Error::NoMatchingSerial(serial) => {
wlnfl!(f, "err-no-matching-serial", serial = serial.to_string())? wlnfl!(f, "err-no-matching-serial", serial = serial.to_string())?
} }
Error::PukLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PUK")?,
Error::SlotHasNoIdentity(slot) => { Error::SlotHasNoIdentity(slot) => {
wlnfl!(f, "err-slot-has-no-identity", slot = slot_to_ui(slot))? wlnfl!(f, "err-slot-has-no-identity", slot = slot_to_ui(slot))?
} }
@@ -122,9 +92,6 @@ impl fmt::Debug for Error {
} }
Error::TimedOut => wlnfl!(f, "err-timed-out")?, Error::TimedOut => wlnfl!(f, "err-timed-out")?,
Error::UseListForSingleSlot => wlnfl!(f, "err-use-list-for-single")?, Error::UseListForSingleSlot => wlnfl!(f, "err-use-list-for-single")?,
Error::WrongPuk(tries) => {
wlnfl!(f, "err-yk-wrong-pin", pin_kind = "PUK", tries = tries)?
}
Error::YubiKey(e) => match e { Error::YubiKey(e) => match e {
yubikey::Error::NotFound => wlnfl!(f, "err-yk-not-found")?, yubikey::Error::NotFound => wlnfl!(f, "err-yk-not-found")?,
yubikey::Error::PcscError { yubikey::Error::PcscError {
@@ -138,40 +105,13 @@ impl fmt::Debug for Error {
wlnfl!(f, "err-yk-no-service-macos")?; wlnfl!(f, "err-yk-no-service-macos")?;
let url = "https://apple.stackexchange.com/a/438198"; let url = "https://apple.stackexchange.com/a/438198";
wlnfl!(f, "rec-yk-no-service-macos", url = url)?; wlnfl!(f, "rec-yk-no-service-macos", url = url)?;
} else if cfg!(target_os = "openbsd") {
wlnfl!(f, "err-yk-no-service-pcscd")?;
let pkg = "pkg_add pcsc-lite ccid";
let service_enable = "rcctl enable pcscd";
let service_start = "rcctl start pcscd";
wlnfl!(
f,
"rec-yk-no-service-pcscd-bsd",
pkg = pkg,
service_enable = service_enable,
service_start = service_start
)?;
} else if cfg!(target_os = "freebsd") {
wlnfl!(f, "err-yk-no-service-pcscd")?;
let pkg = "pkg install pcsc-lite libccid";
let service_enable = "service pcscd enable";
let service_start = "service pcscd start";
wlnfl!(
f,
"rec-yk-no-service-pcscd-bsd",
pkg = pkg,
service_enable = service_enable,
service_start = service_start
)?;
} else { } else {
wlnfl!(f, "err-yk-no-service-pcscd")?; wlnfl!(f, "err-yk-no-service-pcscd")?;
let apt = "sudo apt-get install pcscd"; let apt = "sudo apt-get install pcscd";
wlnfl!(f, "rec-yk-no-service-pcscd", apt = apt)?; wlnfl!(f, "rec-yk-no-service-pcscd", apt = apt)?;
} }
} }
yubikey::Error::PinLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PIN")?, yubikey::Error::WrongPin { tries } => wlnfl!(f, "err-yk-wrong-pin", tries = tries)?,
yubikey::Error::WrongPin { tries } => {
wlnfl!(f, "err-yk-wrong-pin", pin_kind = "PIN", tries = tries)?
}
e => { e => {
wlnfl!(f, "err-yk-general", err = e.to_string())?; wlnfl!(f, "err-yk-general", err = e.to_string())?;
use std::error::Error; use std::error::Error;
+13 -30
View File
@@ -1,15 +1,10 @@
use age_core::{ use age_core::{
format::{FileKey, Stanza}, format::{FileKey, Stanza},
primitives::aead_encrypt, primitives::{aead_encrypt, hkdf},
secrecy::ExposeSecret, secrecy::ExposeSecret,
}; };
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; use p256::{ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoint};
use p256::{
ecdh::EphemeralSecret,
elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint},
};
use rand::rngs::OsRng; use rand::rngs::OsRng;
use sha2::Sha256;
use crate::{p256::Recipient, STANZA_TAG}; use crate::{p256::Recipient, STANZA_TAG};
@@ -27,12 +22,8 @@ pub(crate) struct EphemeralKeyBytes(p256::EncodedPoint);
impl EphemeralKeyBytes { impl EphemeralKeyBytes {
fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option<Self> { fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option<Self> {
let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?; let encoded = p256::EncodedPoint::from_bytes(&bytes).ok()?;
if encoded.is_compressed() if encoded.is_compressed() && encoded.decompress().is_some() {
&& p256::PublicKey::from_encoded_point(&encoded)
.is_some()
.into()
{
Some(EphemeralKeyBytes(encoded)) Some(EphemeralKeyBytes(encoded))
} else { } else {
None None
@@ -48,9 +39,9 @@ impl EphemeralKeyBytes {
} }
pub(crate) fn decompress(&self) -> p256::EncodedPoint { pub(crate) fn decompress(&self) -> p256::EncodedPoint {
// EphemeralKeyBytes is a valid compressed encoding by construction. self.0
let p = p256::PublicKey::from_encoded_point(&self.0).unwrap(); .decompress()
p.to_encoded_point(false) .expect("EphemeralKeyBytes is a valid compressed encoding by construction")
} }
} }
@@ -66,8 +57,8 @@ impl From<RecipientLine> for Stanza {
Stanza { Stanza {
tag: STANZA_TAG.to_owned(), tag: STANZA_TAG.to_owned(),
args: vec![ args: vec![
BASE64_STANDARD_NO_PAD.encode(r.tag), base64::encode_config(&r.tag, base64::STANDARD_NO_PAD),
BASE64_STANDARD_NO_PAD.encode(r.epk_bytes.as_bytes()), base64::encode_config(r.epk_bytes.as_bytes(), base64::STANDARD_NO_PAD),
], ],
body: r.encrypted_file_key.to_vec(), body: r.encrypted_file_key.to_vec(),
} }
@@ -85,10 +76,9 @@ impl RecipientLine {
return None; return None;
} }
BASE64_STANDARD_NO_PAD base64::decode_config_slice(arg, base64::STANDARD_NO_PAD, buf.as_mut())
.decode_slice_unchecked(arg, buf.as_mut())
.ok() .ok()
.and_then(|len| (len == buf.as_mut().len()).then_some(buf)) .map(|_| buf)
} }
let (tag, epk_bytes) = match &s.args[..] { let (tag, epk_bytes) = match &s.args[..] {
@@ -111,7 +101,7 @@ impl RecipientLine {
} }
pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self { pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self {
let esk = EphemeralSecret::random(&mut OsRng); let esk = EphemeralSecret::random(OsRng);
let epk = esk.public_key(); let epk = esk.public_key();
let epk_bytes = EphemeralKeyBytes::from_public_key(&epk); let epk_bytes = EphemeralKeyBytes::from_public_key(&epk);
@@ -121,14 +111,7 @@ impl RecipientLine {
salt.extend_from_slice(epk_bytes.as_bytes()); salt.extend_from_slice(epk_bytes.as_bytes());
salt.extend_from_slice(pk.to_encoded().as_bytes()); salt.extend_from_slice(pk.to_encoded().as_bytes());
let enc_key = { let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_bytes());
let mut okm = [0; 32];
shared_secret
.extract::<Sha256>(Some(&salt))
.expand(STANZA_KEY_LABEL, &mut okm)
.expect("okm is the correct length");
okm
};
let encrypted_file_key = { let encrypted_file_key = {
let mut key = [0; ENCRYPTED_FILE_KEY_BYTES]; let mut key = [0; ENCRYPTED_FILE_KEY_BYTES];
+46 -75
View File
@@ -15,6 +15,7 @@ use std::io;
use std::iter; use std::iter;
use std::thread::sleep; use std::thread::sleep;
use std::time::{Duration, Instant, SystemTime}; use std::time::{Duration, Instant, SystemTime};
use x509_parser::der_parser::oid::Oid;
use yubikey::{ use yubikey::{
certificate::Certificate, certificate::Certificate,
piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId}, piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
@@ -27,13 +28,16 @@ use crate::{
fl, fl,
format::{RecipientLine, STANZA_KEY_LABEL}, format::{RecipientLine, STANZA_KEY_LABEL},
p256::{Recipient, TAG_BYTES}, p256::{Recipient, TAG_BYTES},
util::{otp_serial_prefix, Metadata}, util::{otp_serial_prefix, Metadata, POLICY_EXTENSION_OID},
IDENTITY_PREFIX, IDENTITY_PREFIX,
}; };
const ONE_SECOND: Duration = Duration::from_secs(1); const ONE_SECOND: Duration = Duration::from_secs(1);
const FIFTEEN_SECONDS: Duration = Duration::from_secs(15); const FIFTEEN_SECONDS: Duration = Duration::from_secs(15);
/// The set of OIDs that we understand and use when parsing YubiKey slot certificates.
const KNOWN_OIDS: &[&[u64]] = &[POLICY_EXTENSION_OID];
pub(crate) fn is_connected(reader: Reader) -> bool { pub(crate) fn is_connected(reader: Reader) -> bool {
filter_connected(&reader) filter_connected(&reader)
} }
@@ -49,24 +53,7 @@ pub(crate) fn filter_connected(reader: &Reader) -> bool {
); );
false false
} }
Err(yubikey::Error::AppletNotFound { applet_name }) => { _ => true,
warn!(
"{}",
fl!(
"warn-yk-missing-applet",
yubikey_name = reader.name(),
applet_name = applet_name,
),
);
false
}
Err(_) => true,
Ok(yubikey) => {
// We only connected as a side-effect of confirming that we can connect, so
// avoid resetting the YubiKey.
disconnect_without_reset(yubikey);
true
}
} }
} }
@@ -198,9 +185,6 @@ fn open_by_serial(serial: Serial) -> Result<YubiKey, yubikey::Error> {
if serial == yubikey.serial() { if serial == yubikey.serial() {
return Ok(yubikey); return Ok(yubikey);
} else {
// We didn't want this YubiKey; don't reset it.
disconnect_without_reset(yubikey);
} }
} }
@@ -257,27 +241,10 @@ pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
Ok(yubikey) Ok(yubikey)
} }
/// Disconnect from the YubiKey without resetting it. fn request_pin<E>(
/// mut prompt: impl FnMut(Option<String>) -> io::Result<Result<SecretString, E>>,
/// This can be used to preserve the YubiKey's PIN and touch caches. There are two cases
/// where we want to do this:
///
/// - We connected to this YubiKey in a read-only context, so we have not made any changes
/// to the YubiKey's state. However, we might have asked an agent to release the YubiKey
/// in `key::open_connection`, and we want to allow any state it may have left behind
/// (such as cached PINs or touches) to persist beyond our execution, for usability.
/// - We opened this connection in a decryption context, so the only changes to the
/// YubiKey's state were to potentially cache the PIN and/or touch (depending on the
/// policies of the slot). We want to allow these to persist beyond our execution, for
/// usability.
pub(crate) fn disconnect_without_reset(yubikey: YubiKey) {
let _ = yubikey.disconnect(pcsc::Disposition::LeaveCard);
}
fn request_pin<E, E2>(
mut prompt: impl FnMut(Option<String>) -> Result<Result<SecretString, E>, E2>,
serial: Serial, serial: Serial,
) -> Result<Result<SecretString, E>, E2> { ) -> io::Result<Result<SecretString, E>> {
let mut prev_error = None; let mut prev_error = None;
loop { loop {
prev_error = Some(match prompt(prev_error)? { prev_error = Some(match prompt(prev_error)? {
@@ -310,7 +277,6 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
yubikey_serial = yubikey.serial().to_string(), yubikey_serial = yubikey.serial().to_string(),
default_pin = DEFAULT_PIN, default_pin = DEFAULT_PIN,
)) ))
.report(true)
.interact()?; .interact()?;
yubikey.verify_pin(pin.as_bytes())?; yubikey.verify_pin(pin.as_bytes())?;
@@ -326,7 +292,7 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
let pin = request_pin( let pin = request_pin(
|prev_error| { |prev_error| {
if let Some(err) = prev_error { if let Some(err) = prev_error {
eprintln!("{err}"); eprintln!("{}", err);
} }
Password::new() Password::new()
.with_prompt(fl!("mgr-choose-new-pin")) .with_prompt(fl!("mgr-choose-new-pin"))
@@ -344,23 +310,13 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
} }
}; };
let new_pin = new_pin.expose_secret(); let new_pin = new_pin.expose_secret();
yubikey yubikey.change_puk(current_puk.as_bytes(), new_pin.as_bytes())?;
.change_puk(current_puk.as_bytes(), new_pin.as_bytes())
.map_err(|e| match e {
yubikey::Error::PinLocked => Error::PukLocked,
yubikey::Error::WrongPin { tries } => Error::WrongPuk(tries),
_ => Error::YubiKey(e),
})?;
yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?; yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?;
} }
match MgmKey::get_protected(yubikey) { if let Ok(mgm_key) = MgmKey::get_protected(yubikey) {
Ok(mgm_key) => yubikey.authenticate(mgm_key).map_err(|e| match e { yubikey.authenticate(mgm_key)?;
yubikey::Error::AuthenticationError => Error::ManagementKeyAuth, } else {
_ => e.into(),
})?,
Err(yubikey::Error::AuthenticationError) => Err(Error::ManagementKeyAuth)?,
_ => {
// Try to authenticate with the default management key. // Try to authenticate with the default management key.
yubikey yubikey
.authenticate(MgmKey::default()) .authenticate(MgmKey::default())
@@ -383,11 +339,34 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
})?; })?;
eprintln!("{}", fl!("mgr-changing-mgmt-key-success")); eprintln!("{}", fl!("mgr-changing-mgmt-key-success"));
} }
}
Ok(()) Ok(())
} }
/// Parses the certificate to identify the preferred recipient type it corresponds to.
pub(crate) fn identify_recipient(cert: &Certificate) -> Option<Recipient> {
let known_oids = KNOWN_OIDS
.iter()
.map(|oid| Oid::from(oid).unwrap())
.collect::<Vec<_>>();
// If the certificate contains any unrecognised critical extensions, reject it: we
// don't know how to correctly use the identity. In particular, some identities store
// parts of their private key material in certificate extensions to work around
// hardware limitations. Not understanding these extensions could lead to encrypting
// with the wrong protocol and violating security assumptions.
let (_, c) = x509_parser::parse_x509_certificate(cert.as_ref()).ok()?;
if c.tbs_certificate
.extensions()
.iter()
.any(|ext| ext.critical && !known_oids.contains(&ext.oid))
{
return None;
}
Recipient::from_certificate(cert)
}
/// Returns an iterator of keys that are occupying plugin-compatible slots, along with the /// Returns an iterator of keys that are occupying plugin-compatible slots, along with the
/// corresponding recipient if the key is compatible with this plugin. /// corresponding recipient if the key is compatible with this plugin.
pub(crate) fn list_slots( pub(crate) fn list_slots(
@@ -397,8 +376,7 @@ pub(crate) fn list_slots(
// We only use the retired slots. // We only use the retired slots.
match key.slot() { match key.slot() {
SlotId::Retired(slot) => { SlotId::Retired(slot) => {
// Only P-256 keys are compatible with us. let recipient = identify_recipient(key.certificate());
let recipient = Recipient::from_certificate(key.certificate());
Some((key, slot, recipient)) Some((key, slot, recipient))
} }
_ => None, _ => None,
@@ -605,9 +583,9 @@ impl Stub {
let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot)) let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
.ok() .ok()
.and_then(|cert| { .and_then(|cert| {
Recipient::from_certificate(&cert) identify_recipient(&cert)
.filter(|pk| pk.tag() == self.tag) .filter(|recipient| recipient.tag() == self.tag)
.map(|pk| (cert, pk)) .map(|r| (cert, r))
}) { }) {
Some(pk) => pk, Some(pk) => pk,
None => { None => {
@@ -664,13 +642,13 @@ impl Connection {
metadata => metadata, metadata => metadata,
}; };
} }
match self.cached_metadata.as_ref().and_then(|m| m.pin_policy) { if let Some(PinPolicy::Never) = self.cached_metadata.as_ref().and_then(|m| m.pin_policy) {
Some(PinPolicy::Never) => return Ok(Ok(())), return Ok(Ok(()));
Some(PinPolicy::Once) if self.yubikey.verify_pin(&[]).is_ok() => return Ok(Ok(())),
_ => (),
} }
// The policy requires a PIN, so request it. // The policy requires a PIN, so request it.
// Note that we can't distinguish between PinPolicy::Once and PinPolicy::Always
// because this plugin is ephemeral, so we always request the PIN.
let pin = match request_pin( let pin = match request_pin(
|prev_error| { |prev_error| {
callbacks.request_secret(&format!( callbacks.request_secret(&format!(
@@ -754,13 +732,6 @@ impl Connection {
Err(_) => Err(()), Err(_) => Err(()),
} }
} }
/// Close this connection without resetting the YubiKey.
///
/// This can be used to preserve the YubiKey's PIN and touch caches.
pub(crate) fn disconnect_without_reset(self) {
disconnect_without_reset(self.yubikey);
}
} }
#[cfg(test)] #[cfg(test)]
+16 -64
View File
@@ -181,13 +181,6 @@ fn generate(flags: PluginFlags) -> Result<(), Error> {
util::print_identity(stub, recipient, metadata); util::print_identity(stub, recipient, metadata);
// We have written to the YubiKey, which means we've authenticated with the management
// key. Out of an abundance of caution, we let the YubiKey be reset on disconnect,
// which will clear its PIN and touch caches. This has as small negative UX effect,
// but identity generation is a relatively infrequent occurrence, and users are more
// likely to see their cached PINs reset due to switching applets (e.g. from PIV to
// FIDO2).
Ok(()) Ok(())
} }
@@ -207,8 +200,6 @@ fn print_single(
printer(stub, recipient, metadata); printer(stub, recipient, metadata);
key::disconnect_without_reset(yubikey);
Ok(()) Ok(())
} }
@@ -242,8 +233,6 @@ fn print_multiple(
println!(); println!();
} }
println!(); println!();
key::disconnect_without_reset(yubikey);
} }
if printed > 1 { if printed > 1 {
eprintln!("{}", fl!("printed-multiple", kind = kind, count = printed)); eprintln!("{}", fl!("printed-multiple", kind = kind, count = printed));
@@ -296,8 +285,8 @@ fn list(flags: PluginFlags, all: bool) -> Result<(), Error> {
flags, flags,
all, all,
|_, recipient, metadata| { |_, recipient, metadata| {
println!("{metadata}"); println!("{}", metadata);
println!("{recipient}"); println!("{}", recipient.to_string());
}, },
) )
} }
@@ -329,8 +318,8 @@ fn main() -> Result<(), Error> {
if let Some(state_machine) = opts.age_plugin { if let Some(state_machine) = opts.age_plugin {
run_state_machine( run_state_machine(
&state_machine, &state_machine,
Some(plugin::RecipientPlugin::default), plugin::RecipientPlugin::default,
Some(plugin::IdentityPlugin::default), plugin::IdentityPlugin::default,
)?; )?;
Ok(()) Ok(())
} else if opts.version { } else if opts.version {
@@ -371,13 +360,11 @@ fn main() -> Result<(), Error> {
.iter() .iter()
.map(|reader| { .map(|reader| {
key::open_connection(reader).map(|yk| { key::open_connection(reader).map(|yk| {
let name = fl!( fl!(
"cli-setup-yk-name", "cli-setup-yk-name",
yubikey_name = reader.name(), yubikey_name = reader.name(),
yubikey_serial = yk.serial().to_string(), yubikey_serial = yk.serial().to_string(),
); )
key::disconnect_without_reset(yk);
name
}) })
}) })
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@@ -385,7 +372,6 @@ fn main() -> Result<(), Error> {
.with_prompt(fl!("cli-setup-select-yk")) .with_prompt(fl!("cli-setup-select-yk"))
.items(&reader_names) .items(&reader_names)
.default(0) .default(0)
.report(true)
.interact_opt()? .interact_opt()?
{ {
Some(yk) => readers_list[yk].open()?, Some(yk) => readers_list[yk].open()?,
@@ -407,13 +393,9 @@ fn main() -> Result<(), Error> {
x509_parser::parse_x509_certificate(key.certificate().as_ref()) x509_parser::parse_x509_certificate(key.certificate().as_ref())
.unwrap(); .unwrap();
let (name, _) = util::extract_name(&cert, true).unwrap(); let (name, _) = util::extract_name(&cert, true).unwrap();
let created = cert let created = cert.validity().not_before.to_rfc2822();
.validity()
.not_before
.to_rfc2822()
.unwrap_or_else(|e| format!("Invalid date: {e}"));
format!("{name}, created: {created}") format!("{}, created: {}", name, created)
}) })
}) })
}) })
@@ -444,7 +426,6 @@ fn main() -> Result<(), Error> {
.with_prompt(fl!("cli-setup-select-slot")) .with_prompt(fl!("cli-setup-select-slot"))
.items(&slots) .items(&slots)
.default(0) .default(0)
.report(true)
.interact_opt()? .interact_opt()?
{ {
Some(slot) => { Some(slot) => {
@@ -462,7 +443,6 @@ fn main() -> Result<(), Error> {
if Confirm::new() if Confirm::new()
.with_prompt(fl!("cli-setup-use-existing", slot_index = slot_index)) .with_prompt(fl!("cli-setup-use-existing", slot_index = slot_index))
.report(true)
.interact()? .interact()?
{ {
let stub = key::Stub::new(yubikey.serial(), slot, &recipient); let stub = key::Stub::new(yubikey.serial(), slot, &recipient);
@@ -470,10 +450,8 @@ fn main() -> Result<(), Error> {
util::Metadata::extract(&mut yubikey, slot, key.certificate(), true) util::Metadata::extract(&mut yubikey, slot, key.certificate(), true)
.unwrap(); .unwrap();
key::disconnect_without_reset(yubikey);
((stub, recipient, metadata), false) ((stub, recipient, metadata), false)
} else { } else {
key::disconnect_without_reset(yubikey);
return Ok(()); return Ok(());
} }
} else { } else {
@@ -484,11 +462,8 @@ fn main() -> Result<(), Error> {
flags.name.as_deref().unwrap_or("age identity TAG_HEX") flags.name.as_deref().unwrap_or("age identity TAG_HEX")
)) ))
.allow_empty(true) .allow_empty(true)
.report(true)
.interact_text()?; .interact_text()?;
let mut displayed_yk4_warning = false;
let pin_policy = loop {
let pin_policy = match Select::new() let pin_policy = match Select::new()
.with_prompt(fl!("cli-setup-select-pin-policy")) .with_prompt(fl!("cli-setup-select-pin-policy"))
.items(&[ .items(&[
@@ -504,7 +479,6 @@ fn main() -> Result<(), Error> {
}) })
.unwrap(), .unwrap(),
) )
.report(true)
.interact_opt()? .interact_opt()?
{ {
Some(0) => PinPolicy::Always, Some(0) => PinPolicy::Always,
@@ -514,29 +488,6 @@ fn main() -> Result<(), Error> {
None => return Ok(()), None => return Ok(()),
}; };
// We can't preserve the PIN cache for YubiKey 4 series, because to
// retrieve the serial we switch to the OTP applet.
match (pin_policy, yubikey.version().major) {
(PinPolicy::Once, 4) => {
if !displayed_yk4_warning {
eprintln!();
eprintln!("{}", fl!("cli-setup-yk4-pin-policy"));
eprintln!();
displayed_yk4_warning = true;
}
if Confirm::new()
.with_prompt(fl!("cli-setup-yk4-pin-policy-confirm"))
.report(true)
.interact()?
{
break pin_policy;
}
}
_ => break pin_policy,
}
};
let touch_policy = match Select::new() let touch_policy = match Select::new()
.with_prompt(fl!("cli-setup-select-touch-policy")) .with_prompt(fl!("cli-setup-select-touch-policy"))
.items(&[ .items(&[
@@ -552,7 +503,6 @@ fn main() -> Result<(), Error> {
}) })
.unwrap(), .unwrap(),
) )
.report(true)
.interact_opt()? .interact_opt()?
{ {
Some(0) => TouchPolicy::Always, Some(0) => TouchPolicy::Always,
@@ -564,7 +514,6 @@ fn main() -> Result<(), Error> {
if Confirm::new() if Confirm::new()
.with_prompt(fl!("cli-setup-generate-new", slot_index = slot_index)) .with_prompt(fl!("cli-setup-generate-new", slot_index = slot_index))
.report(true)
.interact()? .interact()?
{ {
eprintln!(); eprintln!();
@@ -580,7 +529,6 @@ fn main() -> Result<(), Error> {
true, true,
) )
} else { } else {
key::disconnect_without_reset(yubikey);
return Ok(()); return Ok(());
} }
} }
@@ -593,7 +541,6 @@ fn main() -> Result<(), Error> {
"age-yubikey-identity-{}.txt", "age-yubikey-identity-{}.txt",
hex::encode(stub.tag) hex::encode(stub.tag)
)) ))
.report(true)
.interact_text()?; .interact_text()?;
let mut file = match OpenOptions::new() let mut file = match OpenOptions::new()
@@ -605,7 +552,6 @@ fn main() -> Result<(), Error> {
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
if Confirm::new() if Confirm::new()
.with_prompt(fl!("cli-setup-identity-file-exists")) .with_prompt(fl!("cli-setup-identity-file-exists"))
.report(true)
.interact()? .interact()?
{ {
File::create(&file_name)? File::create(&file_name)?
@@ -631,8 +577,14 @@ fn main() -> Result<(), Error> {
// If `rage` binary is installed, use it in examples. Otherwise default to `age`. // If `rage` binary is installed, use it in examples. Otherwise default to `age`.
let age_binary = which::which("rage").map(|_| "rage").unwrap_or("age"); let age_binary = which::which("rage").map(|_| "rage").unwrap_or("age");
let encrypt_usage = format!("$ cat foo.txt | {age_binary} -r {recipient} -o foo.txt.age"); let encrypt_usage = format!(
let decrypt_usage = format!("$ cat foo.txt.age | {age_binary} -d -i {file_name} > foo.txt"); "$ cat foo.txt | {} -r {} -o foo.txt.age",
age_binary, recipient
);
let decrypt_usage = format!(
"$ cat foo.txt.age | {} -d -i {} > foo.txt",
age_binary, file_name
);
let identity_usage = format!( let identity_usage = format!(
"$ age-plugin-yubikey -i --serial {} --slot {} > {}", "$ age-plugin-yubikey -i --serial {} --slot {} > {}",
stub.serial, stub.serial,
+1 -1
View File
@@ -60,7 +60,7 @@ impl Recipient {
/// This accepts both compressed (as used by the plugin) and uncompressed (as used in /// This accepts both compressed (as used by the plugin) and uncompressed (as used in
/// the YubiKey certificate) encodings. /// the YubiKey certificate) encodings.
fn from_encoded(encoded: &p256::EncodedPoint) -> Option<Self> { fn from_encoded(encoded: &p256::EncodedPoint) -> Option<Self> {
Option::from(p256::PublicKey::from_encoded_point(encoded)).map(Recipient) p256::PublicKey::from_encoded_point(encoded).map(Recipient)
} }
/// Returns the compressed SEC-1 encoding of this recipient. /// Returns the compressed SEC-1 encoding of this recipient.
-2
View File
@@ -249,8 +249,6 @@ impl IdentityPluginV1 for IdentityPlugin {
} }
} }
} }
conn.disconnect_without_reset();
} }
Ok(file_keys) Ok(file_keys)
} }
+2 -9
View File
@@ -122,10 +122,7 @@ impl Metadata {
// https://developers.yubico.com/PIV/Introduction/PIV_attestation.html // https://developers.yubico.com/PIV/Introduction/PIV_attestation.html
let policies = |c: &X509Certificate| { let policies = |c: &X509Certificate| {
c.tbs_certificate c.tbs_certificate
.get_extension_unique(&Oid::from(POLICY_EXTENSION_OID).unwrap()) .find_extension(&Oid::from(POLICY_EXTENSION_OID).unwrap())
// If the extension is duplicated, we assume it is invalid.
.ok()
.flatten()
// If the encoded extension doesn't have 2 bytes, we assume it is invalid. // If the encoded extension doesn't have 2 bytes, we assume it is invalid.
.filter(|policy| policy.value.len() >= 2) .filter(|policy| policy.value.len() >= 2)
.map(|policy| { .map(|policy| {
@@ -173,11 +170,7 @@ impl Metadata {
serial: yubikey.serial(), serial: yubikey.serial(),
slot, slot,
name, name,
created: cert created: cert.validity().not_before.to_rfc2822(),
.validity()
.not_before
.to_rfc2822()
.unwrap_or_else(|e| format!("Invalid date: {e}")),
pin_policy, pin_policy,
touch_policy, touch_policy,
}) })
-125
View File
@@ -1,125 +0,0 @@
use std::env;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
const PLUGIN_BIN: &str = env!("CARGO_BIN_EXE_age-plugin-yubikey");
#[test_with::env(YUBIKEY_SERIAL, YUBIKEY_SLOT)]
#[cfg_attr(all(unix, not(target_os = "macos")), test_with::executable(pcscd))]
#[test]
fn recipient_and_identity_match() {
let recipient = Command::new(PLUGIN_BIN)
.arg("--list")
.arg("--serial")
.arg(env::var("YUBIKEY_SERIAL").unwrap())
.arg("--slot")
.arg(env::var("YUBIKEY_SLOT").unwrap())
.output()
.unwrap();
assert_eq!(recipient.status.code(), Some(0));
let identity = Command::new(PLUGIN_BIN)
.arg("--identity")
.arg("--serial")
.arg(env::var("YUBIKEY_SERIAL").unwrap())
.arg("--slot")
.arg(env::var("YUBIKEY_SLOT").unwrap())
.output()
.unwrap();
assert_eq!(identity.status.code(), Some(0));
let recipient_file = String::from_utf8_lossy(&recipient.stdout);
let recipient = recipient_file.lines().last().unwrap();
let identity = String::from_utf8_lossy(&identity.stdout);
assert!(identity.contains(recipient));
}
#[test_with::executable(rage)]
#[test]
fn plugin_encrypt() {
let enc_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
let mut process = Command::new(which::which("rage").unwrap())
.arg("-r")
.arg("age1yubikey1q2w7u3vpya839jxxuq8g0sedh3d740d4xvn639sqhr95ejj8vu3hyfumptt")
.arg("-o")
.arg(enc_file.path())
.stdin(Stdio::piped())
.env("PATH", Path::new(PLUGIN_BIN).parent().unwrap())
.spawn()
.unwrap();
// Scope to ensure stdin is closed.
{
let mut stdin = process.stdin.take().unwrap();
stdin.write_all(b"Testing YubiKey encryption").unwrap();
stdin.flush().unwrap();
}
let status = process.wait().unwrap();
assert_eq!(status.code(), Some(0));
}
#[test_with::env(YUBIKEY_SERIAL, YUBIKEY_SLOT)]
#[test_with::executable(rage)]
#[cfg_attr(all(unix, not(target_os = "macos")), test_with::executable(pcscd))]
#[test]
fn plugin_decrypt() {
let mut identity_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
let enc_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
let plaintext = "Testing YubiKey encryption";
// Write an identity file corresponding to this YubiKey slot.
let identity = Command::new(PLUGIN_BIN)
.arg("--identity")
.arg("--serial")
.arg(env::var("YUBIKEY_SERIAL").unwrap())
.arg("--slot")
.arg(env::var("YUBIKEY_SLOT").unwrap())
.output()
.unwrap();
assert_eq!(identity.status.code(), Some(0));
identity_file.write_all(&identity.stdout).unwrap();
identity_file.flush().unwrap();
// Encrypt to the YubiKey slot.
let mut enc_process = Command::new(which::which("rage").unwrap())
.arg("-e")
.arg("-i")
.arg(identity_file.path())
.arg("-o")
.arg(enc_file.path())
.stdin(Stdio::piped())
.env("PATH", Path::new(PLUGIN_BIN).parent().unwrap())
.spawn()
.unwrap();
// Scope to ensure stdin is closed.
{
let mut stdin = enc_process.stdin.take().unwrap();
stdin.write_all(plaintext.as_bytes()).unwrap();
stdin.flush().unwrap();
}
let enc_status = enc_process.wait().unwrap();
assert_eq!(enc_status.code(), Some(0));
// Decrypt with the YubiKey.
let dec_process = Command::new(which::which("rage").unwrap())
.arg("-d")
.arg("-i")
.arg(identity_file.path())
.arg(enc_file.path())
.stdin(Stdio::piped())
.env("PATH", Path::new(PLUGIN_BIN).parent().unwrap())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&dec_process.stderr);
if !stderr.is_empty() {
assert!(stderr.contains("age-plugin-yubikey"));
assert!(stderr.ends_with("...\n"));
}
assert_eq!(String::from_utf8_lossy(&dec_process.stdout), plaintext);
}