Merge tag 'v0.4.0' into detect-critical-extensions

This commit is contained in:
Jack Grigg
2026-04-08 04:16:00 +01:00
20 changed files with 2112 additions and 644 deletions
+12 -94
View File
@@ -23,116 +23,42 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - 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 != ''
- name: cargo fetch - name: Install test dependencies
uses: actions-rs/cargo@v1 run: cargo install rage
with: - run: cargo fetch
command: fetch
- name: Build tests - name: Build tests
uses: actions-rs/cargo@v1 run: cargo build --verbose --tests
with:
command: build
args: --verbose --tests
- name: Run tests - name: Run tests
uses: actions-rs/cargo@v1 run: cargo test --verbose
with:
command: test
args: --verbose
clippy:
name: Clippy (1.56.0)
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.56.0
components: clippy
override: true
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Run clippy
uses: actions-rs/clippy-check@v1
with:
name: Clippy (1.56.0)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -D warnings
clippy-nightly:
name: Clippy (nightly)
timeout-minutes: 30
runs-on: ubuntu-latest
continue-on-error: true
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
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
# Use stable for this to ensure that cargo-tarpaulin can be built.
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install build dependencies - name: Install build dependencies
run: sudo apt install libpcsclite-dev run: sudo apt install libpcsclite-dev
- name: Install coverage dependencies
run: cargo install cargo-tarpaulin
- name: Generate coverage report - name: Generate coverage report
uses: actions-rs/tarpaulin@v0.1 run: cargo tarpaulin --engine llvm --all-features --release --timeout 600 --out Xml
with:
args: --release --timeout 180 --out Xml
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.1 uses: codecov/codecov-action@v3.1.1
with:
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@v3 - 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
- name: cargo fetch - run: cargo fetch
uses: actions-rs/cargo@v1 # Requires #![deny(rustdoc::broken_intra_doc_links)] in crates.
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
uses: actions-rs/cargo@v1 run: cargo doc --document-private-items
with:
command: doc
args: --document-private-items
fmt: fmt:
name: Rustfmt name: Rustfmt
@@ -140,13 +66,5 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.56.0
components: rustfmt
override: true
- name: Check formatting - name: Check formatting
uses: actions-rs/cargo@v1 run: cargo fmt -- --check
with:
command: fmt
args: -- --check
+23
View File
@@ -0,0 +1,23 @@
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)
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- 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
+20
View File
@@ -0,0 +1,20 @@
name: Stable lints
# We only run these lints on trial-merges of PRs to reduce noise.
on: pull_request
jobs:
clippy:
name: Clippy (MSRV)
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- 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
+2 -2
View File
@@ -87,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.3.0 uses: svenstaro/upload-release-action@2.5.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 }}
@@ -146,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.3.0 uses: svenstaro/upload-release-action@2.5.0
with: with:
file: target/${{ matrix.target }}/debian/*.deb file: target/${{ matrix.target }}/debian/*.deb
file_glob: true file_glob: true
+18
View File
@@ -14,6 +14,24 @@ to 0.3.0 are beta releases.
critical extensions in its certificate, to ensure it doesn't misuse a newer critical extensions in its certificate, to ensure it doesn't misuse a newer
identity type. identity type.
## [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
- When `age-plugin-yubikey` assists the user in changing their PIN from the - When `age-plugin-yubikey` assists the user in changing their PIN from the
Generated
+1521 -433
View File
File diff suppressed because it is too large Load Diff
+16 -13
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.3.4" version = "0.4.0"
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.56" # MSRV rust-version = "1.65" # MSRV
[package.metadata.deb] [package.metadata.deb]
extended-description = """\ extended-description = """\
@@ -22,24 +22,24 @@ assets = [
] ]
[dependencies] [dependencies]
age-core = "0.8" age-core = "0.9"
age-plugin = "0.3" age-plugin = "0.4"
base64 = "0.13" base64 = "0.21"
bech32 = "0.8" bech32 = "0.9"
console = { version = "0.15", default-features = false } console = { version = "0.15", default-features = false }
dialoguer = { version = "0.9", default-features = false, features = ["password"] } dialoguer = { version = "0.10", default-features = false, features = ["password"] }
env_logger = "0.9" env_logger = "0.10"
gumdrop = "0.8" gumdrop = "0.8"
hex = "0.4" hex = "0.4"
log = "0.4" log = "0.4"
p256 = { version = "0.9", features = ["ecdh"] } p256 = { version = "0.13", features = ["ecdh"] }
pcsc = "2.4" pcsc = "2.4"
rand = "0.8" rand = "0.8"
sha2 = "0.9" sha2 = "0.10"
which = "4.1" which = "4.1"
x509 = "0.2" x509 = "0.2"
x509-parser = "0.12" x509-parser = "0.14"
yubikey = { version = "0.5", features = ["untested"] } yubikey = { version = "=0.8.0-pre.0", features = ["untested"] }
# Translations # Translations
i18n-embed = { version = "0.13", features = ["desktop-requester", "fluent-system"] } i18n-embed = { version = "0.13", features = ["desktop-requester", "fluent-system"] }
@@ -48,8 +48,11 @@ lazy_static = "1"
rust-embed = "6" rust-embed = "6"
# GnuPG coexistence # GnuPG coexistence
sysinfo = ">=0.26, <0.26.4" sysinfo = "0.28"
[dev-dependencies] [dev-dependencies]
flate2 = "1" flate2 = "1"
man = "0.3" man = "0.3"
tempfile = "3"
test-with = "0.9"
which = "4"
+53 -18
View File
@@ -6,26 +6,34 @@ which enables files to be encrypted to age identities stored on YubiKeys.
## Installation ## Installation
| Environment | CLI command |
|-------------|-------------|
| Cargo (Rust 1.65+) | `cargo install age-plugin-yubikey` |
| Homebrew (macOS or Linux) | `brew install 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. On Debian or Ubuntu, you can do this with: is installed and running.
``` | Environment | CLI command |
$ sudo apt-get install pcscd |-------------|-------------|
``` | Debian or Ubuntu | `sudo apt-get install pcscd` |
| 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` |
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` |
### Windows Subsystem for Linux (WSL) ### Windows Subsystem for Linux (WSL)
@@ -44,7 +52,12 @@ YubiKey:
## Configuration ## Configuration
There are two ways to configure a YubiKey as an `age` identity. You can run the `age-plugin-yubikey` identities have two parts:
- 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:
@@ -70,6 +83,14 @@ 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
@@ -94,13 +115,27 @@ 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.
As age plugin binaries have short lifetimes (they only run while the age client It does however attempt to preserve the PIN cache by not soft-resetting the
is running), this means that YubiKey identities configured with a PIN policy of YubiKey after a decryption or read-only operation, which enables YubiKey
`once` will actually prompt for the PIN on every decryption. identities configured with a PIN policy of `once` to not prompt for the PIN on
every decryption. **This does not work for YubiKey 4 series.**
A decryption agent will most likely be implemented as a separate age plugin that The session that corresponds to the `once` policy can be ended in several ways,
interacts with [`yubikey-agent`](https://github.com/FiloSottile/yubikey-agent), not all of which are necessarily intuitive:
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
+32 -2
View File
@@ -76,6 +76,14 @@ 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}?
@@ -112,6 +120,7 @@ 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}
@@ -146,6 +155,12 @@ 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
@@ -172,8 +187,12 @@ 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-custom-mgmt-key = rec-change-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}
@@ -205,16 +224,27 @@ 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
@@ -1 +0,0 @@
1.56.0
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.65.0"
components = ["clippy", "rustfmt"]
+24 -6
View File
@@ -1,3 +1,4 @@
use dialoguer::Password;
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use x509::RelativeDistinguishedName; use x509::RelativeDistinguishedName;
use yubikey::{ use yubikey::{
@@ -8,6 +9,7 @@ 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},
@@ -86,17 +88,13 @@ 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,
@@ -109,6 +107,9 @@ 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);
@@ -117,6 +118,23 @@ 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),
+56 -4
View File
@@ -21,14 +21,17 @@ pub enum Error {
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),
} }
@@ -48,12 +51,19 @@ 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")?;
let cmd = "ykman piv access change-management-key --protect"; wlnfl!(
let url = "https://developers.yubico.com/yubikey-manager/"; f,
wlnfl!(f, "rec-custom-mgmt-key", cmd = cmd, url = url)?; "rec-change-mgmt-key",
cmd = CHANGE_MGMT_KEY_CMD,
url = CHANGE_MGMT_KEY_URL
)?;
} }
Error::InvalidFlagCommand(flag, command) => wlnfl!( Error::InvalidFlagCommand(flag, command) => wlnfl!(
f, f,
@@ -76,6 +86,17 @@ 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) => {
@@ -84,6 +105,7 @@ 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))?
} }
@@ -92,6 +114,9 @@ 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 {
@@ -105,13 +130,40 @@ 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::WrongPin { tries } => wlnfl!(f, "err-yk-wrong-pin", tries = tries)?, yubikey::Error::PinLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PIN")?,
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;
+30 -13
View File
@@ -1,10 +1,15 @@
use age_core::{ use age_core::{
format::{FileKey, Stanza}, format::{FileKey, Stanza},
primitives::{aead_encrypt, hkdf}, primitives::aead_encrypt,
secrecy::ExposeSecret, secrecy::ExposeSecret,
}; };
use p256::{ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoint}; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
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};
@@ -22,8 +27,12 @@ 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() && encoded.decompress().is_some() { if encoded.is_compressed()
&& p256::PublicKey::from_encoded_point(&encoded)
.is_some()
.into()
{
Some(EphemeralKeyBytes(encoded)) Some(EphemeralKeyBytes(encoded))
} else { } else {
None None
@@ -39,9 +48,9 @@ impl EphemeralKeyBytes {
} }
pub(crate) fn decompress(&self) -> p256::EncodedPoint { pub(crate) fn decompress(&self) -> p256::EncodedPoint {
self.0 // EphemeralKeyBytes is a valid compressed encoding by construction.
.decompress() let p = p256::PublicKey::from_encoded_point(&self.0).unwrap();
.expect("EphemeralKeyBytes is a valid compressed encoding by construction") p.to_encoded_point(false)
} }
} }
@@ -57,8 +66,8 @@ impl From<RecipientLine> for Stanza {
Stanza { Stanza {
tag: STANZA_TAG.to_owned(), tag: STANZA_TAG.to_owned(),
args: vec![ args: vec![
base64::encode_config(&r.tag, base64::STANDARD_NO_PAD), BASE64_STANDARD_NO_PAD.encode(r.tag),
base64::encode_config(r.epk_bytes.as_bytes(), base64::STANDARD_NO_PAD), BASE64_STANDARD_NO_PAD.encode(r.epk_bytes.as_bytes()),
], ],
body: r.encrypted_file_key.to_vec(), body: r.encrypted_file_key.to_vec(),
} }
@@ -76,9 +85,10 @@ impl RecipientLine {
return None; return None;
} }
base64::decode_config_slice(arg, base64::STANDARD_NO_PAD, buf.as_mut()) BASE64_STANDARD_NO_PAD
.decode_slice_unchecked(arg, buf.as_mut())
.ok() .ok()
.map(|_| buf) .and_then(|len| (len == buf.as_mut().len()).then_some(buf))
} }
let (tag, epk_bytes) = match &s.args[..] { let (tag, epk_bytes) = match &s.args[..] {
@@ -101,7 +111,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(OsRng); let esk = EphemeralSecret::random(&mut 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);
@@ -111,7 +121,14 @@ 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 = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_bytes()); let enc_key = {
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];
+85 -29
View File
@@ -53,7 +53,24 @@ pub(crate) fn filter_connected(reader: &Reader) -> bool {
); );
false false
} }
_ => true, Err(yubikey::Error::AppletNotFound { applet_name }) => {
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
}
} }
} }
@@ -185,6 +202,9 @@ 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);
} }
} }
@@ -241,6 +261,23 @@ pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
Ok(yubikey) Ok(yubikey)
} }
/// Disconnect from the YubiKey without resetting it.
///
/// 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>( fn request_pin<E>(
mut prompt: impl FnMut(Option<String>) -> io::Result<Result<SecretString, E>>, mut prompt: impl FnMut(Option<String>) -> io::Result<Result<SecretString, E>>,
serial: Serial, serial: Serial,
@@ -277,6 +314,7 @@ 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())?;
@@ -310,34 +348,45 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
} }
}; };
let new_pin = new_pin.expose_secret(); let new_pin = new_pin.expose_secret();
yubikey.change_puk(current_puk.as_bytes(), new_pin.as_bytes())?; yubikey
.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())?;
} }
if let Ok(mgm_key) = MgmKey::get_protected(yubikey) { match MgmKey::get_protected(yubikey) {
yubikey.authenticate(mgm_key)?; Ok(mgm_key) => yubikey.authenticate(mgm_key).map_err(|e| match e {
} else { yubikey::Error::AuthenticationError => Error::ManagementKeyAuth,
// Try to authenticate with the default management key. _ => e.into(),
yubikey })?,
.authenticate(MgmKey::default()) Err(yubikey::Error::AuthenticationError) => Err(Error::ManagementKeyAuth)?,
.map_err(|_| Error::CustomManagementKey)?; _ => {
// Try to authenticate with the default management key.
yubikey
.authenticate(MgmKey::default())
.map_err(|_| Error::CustomManagementKey)?;
// Migrate to a PIN-protected management key. // Migrate to a PIN-protected management key.
let mgm_key = MgmKey::generate(); let mgm_key = MgmKey::generate();
eprintln!(); eprintln!();
eprintln!("{}", fl!("mgr-changing-mgmt-key")); eprintln!("{}", fl!("mgr-changing-mgmt-key"));
eprint!("... "); eprint!("... ");
mgm_key.set_protected(yubikey).map_err(|e| { mgm_key.set_protected(yubikey).map_err(|e| {
eprintln!( eprintln!(
"{}", "{}",
fl!( fl!(
"mgr-changing-mgmt-key-error", "mgr-changing-mgmt-key-error",
management_key = hex::encode(mgm_key.as_ref()), management_key = hex::encode(mgm_key.as_ref()),
) )
); );
e e
})?; })?;
eprintln!("{}", fl!("mgr-changing-mgmt-key-success")); eprintln!("{}", fl!("mgr-changing-mgmt-key-success"));
}
} }
Ok(()) Ok(())
@@ -642,13 +691,13 @@ impl Connection {
metadata => metadata, metadata => metadata,
}; };
} }
if let Some(PinPolicy::Never) = self.cached_metadata.as_ref().and_then(|m| m.pin_policy) { match self.cached_metadata.as_ref().and_then(|m| m.pin_policy) {
return Ok(Ok(())); Some(PinPolicy::Never) => 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!(
@@ -732,6 +781,13 @@ 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)]
+80 -26
View File
@@ -181,6 +181,13 @@ 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(())
} }
@@ -200,6 +207,8 @@ fn print_single(
printer(stub, recipient, metadata); printer(stub, recipient, metadata);
key::disconnect_without_reset(yubikey);
Ok(()) Ok(())
} }
@@ -233,6 +242,8 @@ 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));
@@ -286,7 +297,7 @@ fn list(flags: PluginFlags, all: bool) -> Result<(), Error> {
all, all,
|_, recipient, metadata| { |_, recipient, metadata| {
println!("{}", metadata); println!("{}", metadata);
println!("{}", recipient.to_string()); println!("{}", recipient);
}, },
) )
} }
@@ -360,11 +371,13 @@ fn main() -> Result<(), Error> {
.iter() .iter()
.map(|reader| { .map(|reader| {
key::open_connection(reader).map(|yk| { key::open_connection(reader).map(|yk| {
fl!( let name = 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<_>, _>>()?;
@@ -372,6 +385,7 @@ 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()?,
@@ -393,7 +407,11 @@ 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.validity().not_before.to_rfc2822(); let created = cert
.validity()
.not_before
.to_rfc2822()
.unwrap_or_else(|e| format!("Invalid date: {}", e));
format!("{}, created: {}", name, created) format!("{}, created: {}", name, created)
}) })
@@ -426,6 +444,7 @@ 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) => {
@@ -443,6 +462,7 @@ 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);
@@ -450,8 +470,10 @@ 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 {
@@ -462,30 +484,57 @@ 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 pin_policy = match Select::new() let mut displayed_yk4_warning = false;
.with_prompt(fl!("cli-setup-select-pin-policy")) let pin_policy = loop {
.items(&[ let pin_policy = match Select::new()
fl!("pin-policy-always"), .with_prompt(fl!("cli-setup-select-pin-policy"))
fl!("pin-policy-once"), .items(&[
fl!("pin-policy-never"), fl!("pin-policy-always"),
]) fl!("pin-policy-once"),
.default( fl!("pin-policy-never"),
[PinPolicy::Always, PinPolicy::Once, PinPolicy::Never] ])
.iter() .default(
.position(|p| { [PinPolicy::Always, PinPolicy::Once, PinPolicy::Never]
p == &flags.pin_policy.unwrap_or(builder::DEFAULT_PIN_POLICY) .iter()
}) .position(|p| {
.unwrap(), p == &flags.pin_policy.unwrap_or(builder::DEFAULT_PIN_POLICY)
) })
.interact_opt()? .unwrap(),
{ )
Some(0) => PinPolicy::Always, .report(true)
Some(1) => PinPolicy::Once, .interact_opt()?
Some(2) => PinPolicy::Never, {
Some(_) => unreachable!(), Some(0) => PinPolicy::Always,
None => return Ok(()), Some(1) => PinPolicy::Once,
Some(2) => PinPolicy::Never,
Some(_) => unreachable!(),
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()
@@ -503,6 +552,7 @@ fn main() -> Result<(), Error> {
}) })
.unwrap(), .unwrap(),
) )
.report(true)
.interact_opt()? .interact_opt()?
{ {
Some(0) => TouchPolicy::Always, Some(0) => TouchPolicy::Always,
@@ -514,6 +564,7 @@ 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!();
@@ -529,6 +580,7 @@ fn main() -> Result<(), Error> {
true, true,
) )
} else { } else {
key::disconnect_without_reset(yubikey);
return Ok(()); return Ok(());
} }
} }
@@ -541,6 +593,7 @@ 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()
@@ -552,6 +605,7 @@ 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)?
+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> {
p256::PublicKey::from_encoded_point(encoded).map(Recipient) Option::from(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,6 +249,8 @@ impl IdentityPluginV1 for IdentityPlugin {
} }
} }
} }
conn.disconnect_without_reset();
} }
Ok(file_keys) Ok(file_keys)
} }
+9 -2
View File
@@ -122,7 +122,10 @@ 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
.find_extension(&Oid::from(POLICY_EXTENSION_OID).unwrap()) .get_extension_unique(&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| {
@@ -170,7 +173,11 @@ impl Metadata {
serial: yubikey.serial(), serial: yubikey.serial(),
slot, slot,
name, name,
created: cert.validity().not_before.to_rfc2822(), created: cert
.validity()
.not_before
.to_rfc2822()
.unwrap_or_else(|e| format!("Invalid date: {}", e)),
pin_policy, pin_policy,
touch_policy, touch_policy,
}) })
+125
View File
@@ -0,0 +1,125 @@
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);
}