Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf081835c4 | |||
| 9503f406ae | |||
| 307f5396a8 | |||
| cd03e7bda3 | |||
| 54ad666c73 | |||
| d2132b4ac2 | |||
| 80e8072624 | |||
| ff3e8e37c9 | |||
| a5178bb16e | |||
| b1710e8d69 |
@@ -8,6 +8,22 @@ to 0.3.0 are beta releases.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.4] - 2026-04-08
|
||||
### Fixed
|
||||
- `age-plugin-yubikey` now completely ignores any identity that has unrecognised
|
||||
critical extensions in its certificate, to ensure it doesn't misuse a newer
|
||||
identity type.
|
||||
|
||||
## [0.3.3] - 2023-02-11
|
||||
### Fixed
|
||||
- When `age-plugin-yubikey` assists the user in changing their PIN from the
|
||||
default PIN, it no longer tells the user that PINs shorter than 6 characters
|
||||
are allowed, and instead loops until the user enters a PIN of valid length.
|
||||
It also now prevents the user from setting their PIN to the default PIN, to
|
||||
avoid creating a cycle.
|
||||
- More kinds of SmartCard readers are ignored when they have no SmartCard
|
||||
inserted.
|
||||
|
||||
## [0.3.2] - 2023-01-01
|
||||
### Changed
|
||||
- The "sharing violation" logic now also sends SIGHUP to any `yubikey-agent`
|
||||
|
||||
Generated
+1
-1
@@ -49,7 +49,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "age-plugin-yubikey"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"age-core",
|
||||
"age-plugin",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "age-plugin-yubikey"
|
||||
description = "YubiKey plugin for age clients"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
authors = ["Jack Grigg <thestr4d@gmail.com>"]
|
||||
repository = "https://github.com/str4d/age-plugin-yubikey"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -127,13 +127,15 @@ mgr-change-default-pin =
|
||||
✨ Your {-yubikey} is using the default PIN. Let's change it!
|
||||
✨ We'll also set the PUK equal to the PIN.
|
||||
|
||||
🔐 The PIN is up to 8 numbers, letters, or symbols. Not just numbers!
|
||||
🔐 The PIN can be numbers, letters, or symbols. Not just numbers!
|
||||
📏 The PIN must be at least 6 and at most 8 characters in length.
|
||||
❌ Your keys will be lost if the PIN and PUK are locked after 3 incorrect tries.
|
||||
|
||||
mgr-enter-current-puk = Enter current PUK (default is {$default_puk})
|
||||
mgr-choose-new-pin = Choose a new PIN/PUK
|
||||
mgr-repeat-new-pin = Repeat the PIN/PUK
|
||||
mgr-pin-mismatch = PINs don't match
|
||||
mgr-nope-default-pin = You entered the default PIN again. You need to change it.
|
||||
|
||||
mgr-changing-mgmt-key =
|
||||
✨ Your {-yubikey} is using the default management key.
|
||||
@@ -180,7 +182,6 @@ rec-custom-mgmt-key =
|
||||
|
||||
err-invalid-flag-command = Flag '{$flag}' cannot be used with '{$command}'.
|
||||
err-invalid-flag-tui = Flag '{$flag}' cannot be used with the interactive interface.
|
||||
err-invalid-pin-length = The PIN needs to be 1-8 characters.
|
||||
err-invalid-pin-policy = Invalid PIN policy '{$policy}' (expected [{$expected}]).
|
||||
err-invalid-slot = Invalid slot '{$slot}' (expected number between 1 and 20).
|
||||
err-invalid-touch-policy = Invalid touch policy '{$policy}' (expected [{$expected}]).
|
||||
|
||||
@@ -17,7 +17,6 @@ pub enum Error {
|
||||
CustomManagementKey,
|
||||
InvalidFlagCommand(String, String),
|
||||
InvalidFlagTui(String),
|
||||
InvalidPinLength,
|
||||
InvalidPinPolicy(String),
|
||||
InvalidSlot(u8),
|
||||
InvalidTouchPolicy(String),
|
||||
@@ -63,7 +62,6 @@ impl fmt::Debug for Error {
|
||||
command = command.as_str(),
|
||||
)?,
|
||||
Error::InvalidFlagTui(flag) => wlnfl!(f, "err-invalid-flag-tui", flag = flag.as_str())?,
|
||||
Error::InvalidPinLength => wlnfl!(f, "err-invalid-pin-length")?,
|
||||
Error::InvalidPinPolicy(s) => wlnfl!(
|
||||
f,
|
||||
"err-invalid-pin-policy",
|
||||
|
||||
+92
-33
@@ -3,17 +3,19 @@
|
||||
use age_core::{
|
||||
format::{FileKey, FILE_KEY_BYTES},
|
||||
primitives::{aead_decrypt, hkdf},
|
||||
secrecy::ExposeSecret,
|
||||
secrecy::{ExposeSecret, SecretString},
|
||||
};
|
||||
use age_plugin::{identity, Callbacks};
|
||||
use bech32::{ToBase32, Variant};
|
||||
use dialoguer::Password;
|
||||
use log::{debug, error, warn};
|
||||
use std::convert::Infallible;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::iter;
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
use x509_parser::der_parser::oid::Oid;
|
||||
use yubikey::{
|
||||
certificate::Certificate,
|
||||
piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
|
||||
@@ -26,13 +28,16 @@ use crate::{
|
||||
fl,
|
||||
format::{RecipientLine, STANZA_KEY_LABEL},
|
||||
p256::{Recipient, TAG_BYTES},
|
||||
util::{otp_serial_prefix, Metadata},
|
||||
util::{otp_serial_prefix, Metadata, POLICY_EXTENSION_OID},
|
||||
IDENTITY_PREFIX,
|
||||
};
|
||||
|
||||
const ONE_SECOND: Duration = Duration::from_secs(1);
|
||||
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 {
|
||||
filter_connected(&reader)
|
||||
}
|
||||
@@ -40,7 +45,7 @@ pub(crate) fn is_connected(reader: Reader) -> bool {
|
||||
pub(crate) fn filter_connected(reader: &Reader) -> bool {
|
||||
match reader.open() {
|
||||
Err(yubikey::Error::PcscError {
|
||||
inner: Some(pcsc::Error::RemovedCard),
|
||||
inner: Some(pcsc::Error::NoSmartcard | pcsc::Error::RemovedCard),
|
||||
}) => {
|
||||
warn!(
|
||||
"{}",
|
||||
@@ -236,6 +241,31 @@ pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
|
||||
Ok(yubikey)
|
||||
}
|
||||
|
||||
fn request_pin<E>(
|
||||
mut prompt: impl FnMut(Option<String>) -> io::Result<Result<SecretString, E>>,
|
||||
serial: Serial,
|
||||
) -> io::Result<Result<SecretString, E>> {
|
||||
let mut prev_error = None;
|
||||
loop {
|
||||
prev_error = Some(match prompt(prev_error)? {
|
||||
Ok(pin) => match pin.expose_secret().len() {
|
||||
// A PIN must be between 6 and 8 characters.
|
||||
6..=8 => break Ok(Ok(pin)),
|
||||
// If the string is 44 bytes and starts with the YubiKey's serial
|
||||
// encoded as 12-byte modhex, the user probably touched the YubiKey
|
||||
// early and "typed" an OTP.
|
||||
44 if pin.expose_secret().starts_with(&otp_serial_prefix(serial)) => {
|
||||
fl!("plugin-err-accidental-touch")
|
||||
}
|
||||
// Otherwise, the PIN is either too short or too long.
|
||||
0..=5 => fl!("plugin-err-pin-too-short"),
|
||||
_ => fl!("plugin-err-pin-too-long"),
|
||||
},
|
||||
Err(e) => break Ok(Err(e)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
|
||||
const DEFAULT_PIN: &str = "123456";
|
||||
const DEFAULT_PUK: &str = "12345678";
|
||||
@@ -258,13 +288,28 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
|
||||
let current_puk = Password::new()
|
||||
.with_prompt(fl!("mgr-enter-current-puk", default_puk = DEFAULT_PUK))
|
||||
.interact()?;
|
||||
let new_pin = Password::new()
|
||||
let new_pin = loop {
|
||||
let pin = request_pin(
|
||||
|prev_error| {
|
||||
if let Some(err) = prev_error {
|
||||
eprintln!("{}", err);
|
||||
}
|
||||
Password::new()
|
||||
.with_prompt(fl!("mgr-choose-new-pin"))
|
||||
.with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch"))
|
||||
.interact()?;
|
||||
if new_pin.len() > 8 {
|
||||
return Err(Error::InvalidPinLength);
|
||||
.interact()
|
||||
.map(|pin| Result::<_, Infallible>::Ok(SecretString::new(pin)))
|
||||
},
|
||||
yubikey.serial(),
|
||||
)?
|
||||
.unwrap();
|
||||
if pin.expose_secret() == DEFAULT_PIN {
|
||||
eprintln!("{}", fl!("mgr-nope-default-pin"));
|
||||
} else {
|
||||
break pin;
|
||||
}
|
||||
};
|
||||
let new_pin = new_pin.expose_secret();
|
||||
yubikey.change_puk(current_puk.as_bytes(), new_pin.as_bytes())?;
|
||||
yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?;
|
||||
}
|
||||
@@ -298,6 +343,30 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
|
||||
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
|
||||
/// corresponding recipient if the key is compatible with this plugin.
|
||||
pub(crate) fn list_slots(
|
||||
@@ -307,8 +376,7 @@ pub(crate) fn list_slots(
|
||||
// We only use the retired slots.
|
||||
match key.slot() {
|
||||
SlotId::Retired(slot) => {
|
||||
// Only P-256 keys are compatible with us.
|
||||
let recipient = Recipient::from_certificate(key.certificate());
|
||||
let recipient = identify_recipient(key.certificate());
|
||||
Some((key, slot, recipient))
|
||||
}
|
||||
_ => None,
|
||||
@@ -515,9 +583,9 @@ impl Stub {
|
||||
let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
|
||||
.ok()
|
||||
.and_then(|cert| {
|
||||
Recipient::from_certificate(&cert)
|
||||
.filter(|pk| pk.tag() == self.tag)
|
||||
.map(|pk| (cert, pk))
|
||||
identify_recipient(&cert)
|
||||
.filter(|recipient| recipient.tag() == self.tag)
|
||||
.map(|r| (cert, r))
|
||||
}) {
|
||||
Some(pk) => pk,
|
||||
None => {
|
||||
@@ -581,29 +649,21 @@ impl Connection {
|
||||
// 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 enter_pin_msg = fl!(
|
||||
let pin = match request_pin(
|
||||
|prev_error| {
|
||||
callbacks.request_secret(&format!(
|
||||
"{}{}{}",
|
||||
prev_error.as_deref().unwrap_or(""),
|
||||
prev_error.as_deref().map(|_| " ").unwrap_or(""),
|
||||
fl!(
|
||||
"plugin-enter-pin",
|
||||
yubikey_serial = self.yubikey.serial().to_string(),
|
||||
);
|
||||
let mut message = enter_pin_msg.clone();
|
||||
let pin = loop {
|
||||
message = match callbacks.request_secret(&message)? {
|
||||
Ok(pin) => match pin.expose_secret().len() {
|
||||
// A PIN must be between 6 and 8 characters.
|
||||
6..=8 => break pin,
|
||||
// If the string is 44 bytes and starts with the YubiKey's serial
|
||||
// encoded as 12-byte modhex, the user probably touched the YubiKey
|
||||
// early and "typed" an OTP.
|
||||
44 if pin
|
||||
.expose_secret()
|
||||
.starts_with(&otp_serial_prefix(self.yubikey.serial())) =>
|
||||
{
|
||||
format!("{} {}", fl!("plugin-err-accidental-touch"), enter_pin_msg)
|
||||
}
|
||||
// Otherwise, the PIN is either too short or too long.
|
||||
0..=5 => format!("{} {}", fl!("plugin-err-pin-too-short"), enter_pin_msg),
|
||||
_ => format!("{} {}", fl!("plugin-err-pin-too-long"), enter_pin_msg),
|
||||
)
|
||||
))
|
||||
},
|
||||
self.yubikey.serial(),
|
||||
)? {
|
||||
Ok(pin) => pin,
|
||||
Err(_) => {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
@@ -614,7 +674,6 @@ impl Connection {
|
||||
}))
|
||||
}
|
||||
};
|
||||
};
|
||||
if let Err(e) = self.yubikey.verify_pin(pin.expose_secret().as_bytes()) {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
|
||||
Reference in New Issue
Block a user