Merge branch 'main' into detect-critical-extensions

This commit is contained in:
Jack Grigg
2026-04-08 04:31:34 +01:00
17 changed files with 1085 additions and 418 deletions
+2 -2
View File
@@ -86,7 +86,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.9.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 }}
@@ -144,7 +144,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.9.0
with: with:
file: target/${{ matrix.target }}/debian/*.deb file: target/${{ matrix.target }}/debian/*.deb
file_glob: true file_glob: true
+15
View File
@@ -8,6 +8,21 @@ to 0.3.0 are beta releases.
## [Unreleased] ## [Unreleased]
### Added
- Support for the native non-hybrid tagged recipient type (`age1tag1..`).
- Encryption requires making the `age-plugin-yubikey` binary available on the
`PATH` as `age-plugin-tag`, or upgrading to a client version that builds in
support for this new native recipient type.
### Changed
- MSRV is now 1.70.0.
- Encryption to an identity now uses the preferred recipient type supported for
that identity.
- `age-plugin-yubikey` now prints `age1tag1..` recipients in its CLI and
identity files instead of `age1yubikey1..` recipients. The latter is now only
shown in comments for identities generated with `age-plugin-yubikey 0.5.0` or
earlier.
## [0.3.4], [0.4.1], [0.5.1] - 2026-04-08 ## [0.3.4], [0.4.1], [0.5.1] - 2026-04-08
### Fixed ### Fixed
- `age-plugin-yubikey` now completely ignores any identity that has unrecognised - `age-plugin-yubikey` now completely ignores any identity that has unrecognised
Generated
+375 -247
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -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.70" # MSRV
[package.metadata.deb] [package.metadata.deb]
extended-description = """\ extended-description = """\
@@ -22,15 +22,17 @@ assets = [
] ]
[dependencies] [dependencies]
age-core = "0.10" age-core = "0.11"
age-plugin = "0.5" age-plugin = "0.6"
base64 = "0.21" base64 = "0.22"
bech32 = "0.9" bech32 = "0.11"
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.11", default-features = false, features = ["password"] }
env_logger = "0.10" env_logger = "0.10"
gumdrop = "0.8" gumdrop = "0.8"
hex = "0.4" hex = "0.4"
hkdf = "0.12"
hpke = { version = "0.12", default-features = false, features = ["alloc", "p256"] }
log = "0.4" log = "0.4"
p256 = { version = "0.13", features = ["ecdh"] } p256 = { version = "0.13", features = ["ecdh"] }
pcsc = "2.4" pcsc = "2.4"
@@ -42,8 +44,8 @@ x509-parser = "0.14"
yubikey = { version = "=0.8.0-pre.0", features = ["untested"] } yubikey = { version = "=0.8.0-pre.0", features = ["untested"] }
# Translations # Translations
i18n-embed = { version = "0.14", features = ["desktop-requester", "fluent-system"] } i18n-embed = { version = "0.15", features = ["desktop-requester", "fluent-system"] }
i18n-embed-fl = "0.8" i18n-embed-fl = "0.9"
lazy_static = "1" lazy_static = "1"
rust-embed = "8" rust-embed = "8"
@@ -56,3 +58,7 @@ man = "0.3"
tempfile = "3" tempfile = "3"
test-with = "0.11" test-with = "0.11"
which = "5" which = "5"
[patch.crates-io]
age-core = { git = "https://github.com/str4d/rage.git", rev = "e08c450aa5d7b1cc5706094080c0042ddd60aaf7" }
age-plugin = { git = "https://github.com/str4d/rage.git", rev = "e08c450aa5d7b1cc5706094080c0042ddd60aaf7" }
+8 -6
View File
@@ -8,7 +8,7 @@ which enables files to be encrypted to age identities stored on YubiKeys.
| Environment | CLI command | | Environment | CLI command |
|-------------|-------------| |-------------|-------------|
| Cargo (Rust 1.67+) | `cargo install age-plugin-yubikey` | | Cargo (Rust 1.70+) | `cargo install age-plugin-yubikey` |
| Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` | | Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` |
| Arch Linux | `pacman -S age-plugin-yubikey` | | Arch Linux | `pacman -S age-plugin-yubikey` |
| Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) | | Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) |
@@ -108,15 +108,17 @@ standard output:
$ age-plugin-yubikey --list $ age-plugin-yubikey --list
``` ```
To encrypt files to these YubiKey recipients, ensure that `age-plugin-yubikey` To encrypt files to these YubiKey recipients, ensure you have a recent version
is accessible in your `PATH`, and then use the recipients with an age client as of an age client, and then use the recipients with it as normal (e.g.
normal (e.g. `rage -r age1yubikey1...`). `rage -r age1tag1...`). If this does not work, make `age-plugin-yubikey`
accessible in your `PATH` with the name `age-plugin-tag` and try again.
The output of the `--list` command can also be used directly to encrypt files to The output of the `--list` command can also be used directly to encrypt files to
all recipients (e.g. `age -R filename.txt`). all recipients (e.g. `age -R filename.txt`).
To decrypt files encrypted to a YubiKey identity, pass the identity file to the To decrypt files encrypted to a YubiKey identity, ensure that
age client as normal (e.g. `rage -d -i yubikey-identity.txt`). `age-plugin-yubikey` is accessible in your `PATH`, and then pass the identity
file to the age client as normal (e.g. `rage -d -i yubikey-identity.txt`).
## Advanced topics ## Advanced topics
+2
View File
@@ -43,6 +43,8 @@ yubikey-metadata =
# Created: {$created} # Created: {$created}
# PIN policy: {$pin_policy} # PIN policy: {$pin_policy}
# Touch policy: {$touch_policy} # Touch policy: {$touch_policy}
yubikey-legacy-recipient =
# Legacy recipient: {$recipient}
yubikey-identity = yubikey-identity =
{$yubikey_metadata} {$yubikey_metadata}
# Recipient: {$recipient} # Recipient: {$recipient}
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.67.0" channel = "1.70.0"
components = ["clippy", "rustfmt"] components = ["clippy", "rustfmt"]
+5 -3
View File
@@ -11,9 +11,9 @@ use crate::{
error::Error, error::Error,
fl, fl,
key::{self, Stub}, key::{self, Stub},
p256::Recipient, native::p256tag,
util::{Metadata, POLICY_EXTENSION_OID}, util::{Metadata, POLICY_EXTENSION_OID},
BINARY_NAME, USABLE_SLOTS, Recipient, BINARY_NAME, USABLE_SLOTS,
}; };
pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once; pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once;
@@ -104,7 +104,9 @@ impl IdentityBuilder {
touch_policy, touch_policy,
)?; )?;
let recipient = Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"); let recipient = Recipient::P256Tag(
p256tag::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!();
+24 -46
View File
@@ -1,12 +1,8 @@
//! Structs for handling YubiKeys. //! Structs for handling YubiKeys.
use age_core::{ use age_core::primitives::bech32_encode;
format::{FileKey, FILE_KEY_BYTES}, use age_core::secrecy::{ExposeSecret, SecretString};
primitives::{aead_decrypt, hkdf},
secrecy::{ExposeSecret, SecretString},
};
use age_plugin::{identity, Callbacks}; use age_plugin::{identity, Callbacks};
use bech32::{ToBase32, Variant};
use dialoguer::Password; use dialoguer::Password;
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::convert::Infallible; use std::convert::Infallible;
@@ -26,10 +22,10 @@ use yubikey::{
use crate::{ use crate::{
error::Error, error::Error,
fl, fl,
format::{RecipientLine, STANZA_KEY_LABEL}, native::p256tag,
p256::{Recipient, TAG_BYTES}, recipient::TAG_BYTES,
util::{otp_serial_prefix, Metadata, POLICY_EXTENSION_OID}, util::{otp_serial_prefix, Metadata, POLICY_EXTENSION_OID},
IDENTITY_PREFIX, Recipient, IDENTITY_PREFIX,
}; };
const ONE_SECOND: Duration = Duration::from_secs(1); const ONE_SECOND: Duration = Duration::from_secs(1);
@@ -336,7 +332,7 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
.with_prompt(fl!("mgr-choose-new-pin")) .with_prompt(fl!("mgr-choose-new-pin"))
.with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch")) .with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch"))
.interact() .interact()
.map(|pin| Result::<_, Infallible>::Ok(SecretString::new(pin))) .map(|pin| Result::<_, Infallible>::Ok(SecretString::from(pin)))
}, },
yubikey.serial(), yubikey.serial(),
)? )?
@@ -413,7 +409,7 @@ pub(crate) fn identify_recipient(cert: &Certificate) -> Option<Recipient> {
return None; return None;
} }
Recipient::from_certificate(cert) p256tag::Recipient::from_certificate(cert).map(Recipient::P256Tag)
} }
/// 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
@@ -453,14 +449,9 @@ pub struct Stub {
impl fmt::Display for Stub { impl fmt::Display for Stub {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str( f.write_str(
bech32::encode( bech32_encode(IDENTITY_PREFIX, &self.to_bytes())
IDENTITY_PREFIX, .to_uppercase()
self.to_bytes().to_base32(), .as_str(),
Variant::Bech32,
)
.expect("HRP is valid")
.to_uppercase()
.as_str(),
) )
} }
} }
@@ -480,7 +471,7 @@ impl Stub {
Stub { Stub {
serial, serial,
slot, slot,
tag: recipient.tag(), tag: recipient.static_tag(),
identity_index: 0, identity_index: 0,
} }
} }
@@ -507,10 +498,6 @@ impl Stub {
bytes bytes
} }
pub(crate) fn matches(&self, line: &RecipientLine) -> bool {
self.tag == line.tag
}
/// Returns: /// Returns:
/// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey. /// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey.
/// - `Ok(Ok(None))` if the user told us to skip this YubiKey. /// - `Ok(Ok(None))` if the user told us to skip this YubiKey.
@@ -632,8 +619,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| {
// Parse as the preferred recipient for each identity type.
identify_recipient(&cert) identify_recipient(&cert)
.filter(|recipient| recipient.tag() == self.tag) .filter(|recipient| recipient.static_tag() == self.tag)
.map(|r| (cert, r)) .map(|r| (cert, r))
}) { }) {
Some(pk) => pk, Some(pk) => pk,
@@ -650,7 +638,6 @@ impl Stub {
cert, cert,
pk, pk,
slot: self.slot, slot: self.slot,
tag: self.tag,
identity_index: self.identity_index, identity_index: self.identity_index,
cached_metadata: None, cached_metadata: None,
last_touch: None, last_touch: None,
@@ -663,17 +650,21 @@ pub(crate) struct Connection {
cert: Certificate, cert: Certificate,
pk: Recipient, pk: Recipient,
slot: RetiredSlotId, slot: RetiredSlotId,
tag: [u8; 4],
identity_index: usize, identity_index: usize,
cached_metadata: Option<Metadata>, cached_metadata: Option<Metadata>,
last_touch: Option<Instant>, last_touch: Option<Instant>,
} }
impl Connection { impl Connection {
/// Returns the preferred recipient for encrypting to this identity.
pub(crate) fn recipient(&self) -> &Recipient { pub(crate) fn recipient(&self) -> &Recipient {
&self.pk &self.pk
} }
pub(crate) fn stub(&self) -> Stub {
Stub::new(self.yubikey.serial(), self.slot, &self.pk)
}
pub(crate) fn request_pin_if_necessary<E>( pub(crate) fn request_pin_if_necessary<E>(
&mut self, &mut self,
callbacks: &mut dyn Callbacks<E>, callbacks: &mut dyn Callbacks<E>,
@@ -732,8 +723,10 @@ impl Connection {
Ok(Ok(())) Ok(Ok(()))
} }
pub(crate) fn unwrap_file_key(&mut self, line: &RecipientLine) -> Result<FileKey, ()> { pub(crate) fn p256_ecdh(&mut self, epk_bytes: &[u8]) -> Result<yubikey::Buffer, ()> {
assert_eq!(self.tag, line.tag); // The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
assert_eq!(epk_bytes.len(), 65);
// Check if the touch policy requires a touch. // Check if the touch policy requires a touch.
let needs_touch = match ( let needs_touch = match (
@@ -745,11 +738,9 @@ impl Connection {
_ => false, _ => false,
}; };
// The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
let shared_secret = match decrypt_data( let shared_secret = match decrypt_data(
&mut self.yubikey, &mut self.yubikey,
line.epk_bytes.decompress().as_bytes(), epk_bytes,
AlgorithmId::EccP256, AlgorithmId::EccP256,
SlotId::Retired(self.slot), SlotId::Retired(self.slot),
) { ) {
@@ -766,20 +757,7 @@ impl Connection {
} }
} }
let mut salt = vec![]; Ok(shared_secret)
salt.extend_from_slice(line.epk_bytes.as_bytes());
salt.extend_from_slice(self.pk.to_encoded().as_bytes());
let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_ref());
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
match aead_decrypt(&enc_key, FILE_KEY_BYTES, &line.encrypted_file_key) {
Ok(pt) => Ok(TryInto::<[u8; FILE_KEY_BYTES]>::try_into(&pt[..])
.unwrap()
.into()),
Err(_) => Err(()),
}
} }
/// Close this connection without resetting the YubiKey. /// Close this connection without resetting the YubiKey.
+27 -15
View File
@@ -16,19 +16,20 @@ use yubikey::{piv::RetiredSlotId, reader::Context, PinPolicy, Serial, TouchPolic
mod builder; mod builder;
mod error; mod error;
mod format;
mod key; mod key;
mod p256; mod native;
mod piv_p256;
mod plugin; mod plugin;
mod util; mod util;
mod recipient;
use recipient::Recipient;
use error::Error; use error::Error;
const PLUGIN_NAME: &str = "yubikey"; const PLUGIN_NAME: &str = "yubikey";
const BINARY_NAME: &str = "age-plugin-yubikey"; const BINARY_NAME: &str = "age-plugin-yubikey";
const RECIPIENT_PREFIX: &str = "age1yubikey"; const IDENTITY_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("AGE-PLUGIN-YUBIKEY-");
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
const STANZA_TAG: &str = "piv-p256";
const USABLE_SLOTS: [RetiredSlotId; 20] = [ const USABLE_SLOTS: [RetiredSlotId; 20] = [
RetiredSlotId::R1, RetiredSlotId::R1,
@@ -194,7 +195,7 @@ fn generate(flags: PluginFlags) -> Result<(), Error> {
fn print_single( fn print_single(
serial: Option<Serial>, serial: Option<Serial>,
slot: RetiredSlotId, slot: RetiredSlotId,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata), printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut yubikey = key::open(serial)?; let mut yubikey = key::open(serial)?;
@@ -216,7 +217,7 @@ fn print_multiple(
kind: &str, kind: &str,
serial: Option<Serial>, serial: Option<Serial>,
all: bool, all: bool,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata), printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut readers = Context::open()?; let mut readers = Context::open()?;
@@ -256,7 +257,7 @@ fn print_details(
kind: &str, kind: &str,
flags: PluginFlags, flags: PluginFlags,
all: bool, all: bool,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata), printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> { ) -> Result<(), Error> {
if let Some(slot) = flags.slot { if let Some(slot) = flags.slot {
print_single(flags.serial, slot, printer) print_single(flags.serial, slot, printer)
@@ -297,6 +298,12 @@ fn list(flags: PluginFlags, all: bool) -> Result<(), Error> {
all, all,
|_, recipient, metadata| { |_, recipient, metadata| {
println!("{metadata}"); println!("{metadata}");
if let Some(legacy_recipient) = recipient.legacy_recipient(&metadata) {
println!(
"{}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient)
);
}
println!("{recipient}"); println!("{recipient}");
}, },
) )
@@ -327,11 +334,7 @@ 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, plugin::Handler)?;
&state_machine,
Some(plugin::RecipientPlugin::default),
Some(plugin::IdentityPlugin::default),
)?;
Ok(()) Ok(())
} else if opts.version { } else if opts.version {
println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION")); println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION"));
@@ -406,7 +409,7 @@ fn main() -> Result<(), Error> {
let (_, cert) = let (_, cert) =
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_and_version(&cert, true).unwrap();
let created = cert let created = cert
.validity() .validity()
.not_before .not_before
@@ -616,6 +619,15 @@ fn main() -> Result<(), Error> {
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
let identity = if let Some(legacy_recipient) = recipient.legacy_recipient(&metadata) {
format!(
"{}\n{stub}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient),
)
} else {
stub.to_string()
};
writeln!( writeln!(
file, file,
"{}", "{}",
@@ -623,7 +635,7 @@ fn main() -> Result<(), Error> {
"yubikey-identity", "yubikey-identity",
yubikey_metadata = metadata.to_string(), yubikey_metadata = metadata.to_string(),
recipient = recipient.to_string(), recipient = recipient.to_string(),
identity = stub.to_string(), identity = identity,
) )
)?; )?;
file.sync_data()?; file.sync_data()?;
+59
View File
@@ -0,0 +1,59 @@
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::RwLock;
use hkdf::Hkdf;
use sha2::Sha256;
use crate::key::Connection;
pub(crate) mod p256tag;
/// Derives a tag for the tagged age recipient formats.
fn stanza_tag(ikm: &[u8], salt: &str) -> [u8; 4] {
let (tag, _) = Hkdf::<Sha256>::extract(Some(salt.as_bytes()), ikm);
tag[..4].try_into().expect("correct length")
}
/// Pretend that a YubiKey connection is a KEM private key.
struct YubiKeyKemPrivateKey<'a, Kem> {
conn: Rc<RwLock<&'a mut Connection>>,
_kem: PhantomData<Kem>,
}
impl<'a, Kem> YubiKeyKemPrivateKey<'a, Kem> {
fn new(conn: &'a mut Connection) -> Self {
Self {
conn: Rc::new(RwLock::new(conn)),
_kem: PhantomData::default(),
}
}
}
impl<'a, Kem> Clone for YubiKeyKemPrivateKey<'a, Kem> {
fn clone(&self) -> Self {
Self {
conn: self.conn.clone(),
_kem: PhantomData::default(),
}
}
}
impl<'a, Kem> PartialEq for YubiKeyKemPrivateKey<'a, Kem> {
fn eq(&self, other: &Self) -> bool {
self.conn.read().unwrap().stub() == other.conn.read().unwrap().stub()
}
}
impl<'a, Kem> Eq for YubiKeyKemPrivateKey<'a, Kem> {}
impl<'a, Kem: hpke::Kem> hpke::Serializable for YubiKeyKemPrivateKey<'a, Kem> {
type OutputSize = <Kem::PrivateKey as hpke::Serializable>::OutputSize;
fn write_exact(&self, _: &mut [u8]) {
unreachable!("Never called")
}
}
impl<'a, Kem: hpke::Kem> hpke::Deserializable for YubiKeyKemPrivateKey<'a, Kem> {
fn from_bytes(_: &[u8]) -> Result<Self, hpke::HpkeError> {
unreachable!("Never called")
}
}
+292
View File
@@ -0,0 +1,292 @@
use std::fmt;
use std::marker::PhantomData;
use age_core::{
format::{FileKey, Stanza},
primitives::{bech32_encode_to_fmt, hpke_open, hpke_seal},
secrecy::{zeroize::Zeroize, ExposeSecret},
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use hpke::{Deserializable, Serializable};
use p256::{
elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint},
EncodedPoint,
};
use rand::rngs::OsRng;
use yubikey::{certificate::PublicKeyInfo, Certificate};
use super::{stanza_tag, YubiKeyKemPrivateKey};
use crate::{
key::{self, Connection},
recipient::static_tag,
util::base64_arg,
};
pub(crate) const PLUGIN_NAME: &str = "tag";
const RECIPIENT_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("age1tag");
const P256TAG_RECIPIENT_TAG: &str = "p256tag";
const P256TAG_SALT: &str = "age-encryption.org/p256tag";
const TAG_BYTES: usize = 4;
/// Per [RFC 9180 section 7.1.1]:
/// > For P-256, P-384, and P-521, the `SerializePublicKey()` function of the KEM performs
/// > the uncompressed Elliptic-Curve-Point-to-Octet-String conversion according to [SECG].
///
/// [RFC 9180 section 7.1.1]: https://www.rfc-editor.org/rfc/rfc9180.html#section-7.1.1
/// [SECG]: https://secg.org/sec1-v2.pdf
const ENC_BYTES: usize = 65;
type Kem = hpke::kem::DhP256HkdfSha256;
/// The non-hybrid tagged age recipient type, designed for hardware keys where decryption
/// potentially requires user presence.
///
/// With knowledge of the recipient, it is possible to check if a stanza was addressed to
/// a specific recipient before attempting decryption. This offers less privacy than the
/// untagged recipient types.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Recipient {
/// Compressed encoding of the recipient public key.
compressed: EncodedPoint,
/// Cached in-memory representation, for HPKE.
pk_recip: <Kem as hpke::Kem>::PublicKey,
}
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
bech32_encode_to_fmt(f, RECIPIENT_PREFIX, self.compressed.as_bytes())
}
}
impl fmt::Debug for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
impl Recipient {
/// Attempts to parse a valid p256tag recipient from its compressed SEC-1 byte encoding.
pub(crate) fn from_bytes(bytes: &[u8]) -> Option<Self> {
let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?;
if !encoded.is_compressed() {
return None;
}
let point = p256::PublicKey::from_encoded_point(&encoded).into_option()?;
let pk_recip =
<Kem as hpke::Kem>::PublicKey::from_bytes(point.to_encoded_point(false).as_bytes())
.expect("valid");
Some(Self {
compressed: encoded,
pk_recip,
})
}
pub(crate) fn from_certificate(cert: &Certificate) -> Option<Self> {
Self::from_spki(cert.subject_pki())
}
pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option<Self> {
let encoded = match spki {
PublicKeyInfo::EcP256(pubkey) => Some(pubkey),
_ => None,
}?;
// Check that the certificate encoding is uncompressed.
let pk_recip = <Kem as hpke::Kem>::PublicKey::from_bytes(encoded.as_bytes()).ok()?;
let point = p256::PublicKey::from_encoded_point(encoded).into_option()?;
let compressed = point.to_encoded_point(true);
Some(Self {
compressed,
pk_recip,
})
}
/// Returns the compressed SEC-1 encoding of this recipient.
pub(crate) fn to_compressed(&self) -> p256::EncodedPoint {
self.compressed
}
pub(crate) fn static_tag(&self) -> [u8; TAG_BYTES] {
static_tag(self.compressed.as_bytes())
}
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> RecipientLine {
let (enc, ct) = hpke_seal::<Kem, _>(
&self.pk_recip,
P256TAG_SALT.as_bytes(),
file_key.expose_secret(),
&mut OsRng,
);
RecipientLine {
tag: tag(&enc, self.static_tag()),
enc,
ct,
}
}
}
fn tag(enc: &<Kem as hpke::Kem>::EncappedKey, static_tag: [u8; TAG_BYTES]) -> [u8; TAG_BYTES] {
let ikm = enc
.to_bytes()
.into_iter()
.chain(static_tag)
.collect::<Vec<u8>>();
stanza_tag(&ikm, P256TAG_SALT)
}
pub(crate) struct RecipientLine {
tag: [u8; TAG_BYTES],
enc: <Kem as hpke::Kem>::EncappedKey,
ct: Vec<u8>,
}
impl From<RecipientLine> for Stanza {
fn from(r: RecipientLine) -> Self {
Stanza {
tag: P256TAG_RECIPIENT_TAG.to_owned(),
args: vec![
BASE64_STANDARD_NO_PAD.encode(r.tag),
BASE64_STANDARD_NO_PAD.encode(r.enc.to_bytes()),
],
body: r.ct,
}
}
}
impl RecipientLine {
pub(crate) fn from_stanza(s: Stanza) -> Option<Result<Self, ()>> {
if s.tag != P256TAG_RECIPIENT_TAG {
return None;
}
let (tag, enc) = match &s.args[..] {
[encoded_tag, encoded_enc] => (
base64_arg(encoded_tag, [0; TAG_BYTES]),
base64_arg(encoded_enc, [0; ENC_BYTES])
.and_then(|bytes| <Kem as hpke::Kem>::EncappedKey::from_bytes(&bytes[..]).ok()),
),
_ => (None, None),
};
Some(match (tag, enc) {
(Some(tag), Some(epk_bytes)) => Ok(RecipientLine {
tag,
enc: epk_bytes,
ct: s.body,
}),
// Anything else indicates a structurally-invalid stanza.
_ => Err(()),
})
}
pub(crate) fn matches_stub(&self, stub: &key::Stub) -> bool {
self.tag == tag(&self.enc, stub.tag)
}
pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result<FileKey, ()> {
// > The identity implementation [...] MUST check that the body length is exactly
// > 32 bytes before attempting to decrypt it, to mitigate partitioning oracle
// > attacks.
if self.ct.len() != 32 {
return Err(());
}
let sk_recip = YubiKeyKemPrivateKey::new(conn);
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
hpke_open::<YubiKeyDhP256HkdfSha256>(
&self.enc,
&sk_recip,
P256TAG_SALT.as_bytes(),
&self.ct,
)
.map_err(|_| ())
.map(|mut pt| {
FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
})
})
}
}
/// A decap-only version of [`Kem`] where the private key is stored on a YubiKey.
struct YubiKeyDhP256HkdfSha256<'a>(PhantomData<&'a ()>);
impl<'a> hpke::Kem for YubiKeyDhP256HkdfSha256<'a> {
type PublicKey = <Kem as hpke::Kem>::PublicKey;
type PrivateKey = YubiKeyKemPrivateKey<'a, Kem>;
fn sk_to_pk(_: &Self::PrivateKey) -> Self::PublicKey {
unreachable!("Never called")
}
type EncappedKey = <Kem as hpke::Kem>::EncappedKey;
type NSecret = <Kem as hpke::Kem>::NSecret;
const KEM_ID: u16 = <Kem as hpke::Kem>::KEM_ID;
fn derive_keypair(_: &[u8]) -> (Self::PrivateKey, Self::PublicKey) {
unreachable!("Never called")
}
fn decap(
sk_recip: &Self::PrivateKey,
pk_sender_id: Option<&Self::PublicKey>,
encapped_key: &Self::EncappedKey,
) -> Result<hpke::kem::SharedSecret<Self>, hpke::HpkeError> {
let mut sk_recip = sk_recip.conn.write().unwrap();
// Put together the binding context used for all KDF operations
let suite_id = b"KEM\x00\x10";
// Compute the shared secret from the ephemeral inputs
let kex_res_eph = sk_recip
.p256_ecdh(&encapped_key.to_bytes())
.map_err(|_| hpke::HpkeError::DecapError)?;
// Compute the sender's pubkey from their privkey
let pk_recip = match sk_recip.recipient() {
crate::recipient::Recipient::P256Tag(recipient) => &recipient.pk_recip,
_ => panic!("should have been filtered out earlier"),
};
assert!(pk_sender_id.is_none());
// kem_context = encapped_key || pk_recip || pk_sender_id
let kem_context = [encapped_key.to_bytes(), pk_recip.to_bytes()]
.into_iter()
.flatten()
.collect::<Vec<_>>();
// The "unauthed shared secret" is derived from just the KEX of the ephemeral
// input with the recipient pubkey. The HKDF-Expand call only errors if the
// output values are 255x the digest size of the hash function. Since these
// values are fixed at compile time, we don't worry about it.
let mut shared_secret = <hpke::kem::SharedSecret<Self> as Default>::default();
hpke::kdf::extract_and_expand::<hpke::kdf::HkdfSha256>(
&kex_res_eph,
suite_id,
&kem_context,
&mut shared_secret.0,
)
.expect("shared secret is way too big");
Ok(shared_secret)
}
fn encap<R: rand::CryptoRng + rand::RngCore>(
_: &Self::PublicKey,
_: Option<(&Self::PrivateKey, &Self::PublicKey)>,
_: &mut R,
) -> Result<(hpke::kem::SharedSecret<Self>, Self::EncappedKey), hpke::HpkeError> {
unreachable!("Never called")
}
}
+55 -22
View File
@@ -1,7 +1,7 @@
use age_core::{ use age_core::{
format::{FileKey, Stanza}, format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::aead_encrypt, primitives::{aead_decrypt, aead_encrypt, hkdf},
secrecy::ExposeSecret, secrecy::{zeroize::Zeroize, ExposeSecret},
}; };
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use p256::{ use p256::{
@@ -11,11 +11,14 @@ use p256::{
use rand::rngs::OsRng; use rand::rngs::OsRng;
use sha2::Sha256; use sha2::Sha256;
use crate::{p256::Recipient, STANZA_TAG}; use crate::{key::Connection, recipient::TAG_BYTES, util::base64_arg};
mod recipient;
pub(crate) use recipient::Recipient;
const STANZA_TAG: &str = "piv-p256";
pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256"; pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256";
const TAG_BYTES: usize = 4;
const EPK_BYTES: usize = 33; const EPK_BYTES: usize = 33;
const ENCRYPTED_FILE_KEY_BYTES: usize = 32; const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
@@ -80,17 +83,6 @@ impl RecipientLine {
return None; return None;
} }
fn base64_arg<A: AsRef<[u8]>, B: AsMut<[u8]>>(arg: &A, mut buf: B) -> Option<B> {
if arg.as_ref().len() != ((4 * buf.as_mut().len()) + 2) / 3 {
return None;
}
BASE64_STANDARD_NO_PAD
.decode_slice_unchecked(arg, buf.as_mut())
.ok()
.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[..] {
[tag, epk_bytes] => ( [tag, epk_bytes] => (
base64_arg(tag, [0; TAG_BYTES]), base64_arg(tag, [0; TAG_BYTES]),
@@ -109,17 +101,17 @@ impl RecipientLine {
_ => Err(()), _ => Err(()),
}) })
} }
}
pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self { impl Recipient {
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> RecipientLine {
let esk = EphemeralSecret::random(&mut 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);
let shared_secret = esk.diffie_hellman(pk.public_key()); let shared_secret = esk.diffie_hellman(self.public_key());
let mut salt = vec![]; let salt = salt(&epk_bytes, self.to_encoded());
salt.extend_from_slice(epk_bytes.as_bytes());
salt.extend_from_slice(pk.to_encoded().as_bytes());
let enc_key = { let enc_key = {
let mut okm = [0; 32]; let mut okm = [0; 32];
@@ -137,9 +129,50 @@ impl RecipientLine {
}; };
RecipientLine { RecipientLine {
tag: pk.tag(), tag: self.tag(),
epk_bytes, epk_bytes,
encrypted_file_key, encrypted_file_key,
} }
} }
} }
impl RecipientLine {
pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result<FileKey, ()> {
let (static_tag, pk) = match conn.recipient() {
crate::recipient::Recipient::PivP256(recipient) => {
(recipient.tag(), recipient.to_encoded())
}
crate::recipient::Recipient::P256Tag(recipient) => {
(recipient.static_tag(), recipient.to_compressed())
}
};
assert_eq!(self.tag, static_tag);
let salt = salt(&self.epk_bytes, pk);
// The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
let shared_secret = conn.p256_ecdh(self.epk_bytes.decompress().as_bytes())?;
let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_ref());
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
aead_decrypt(&enc_key, FILE_KEY_BYTES, &self.encrypted_file_key)
.map_err(|_| ())
.map(|mut pt| {
FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
})
})
}
}
fn salt(epk_bytes: &EphemeralKeyBytes, pk: p256::EncodedPoint) -> Vec<u8> {
assert!(pk.is_compressed());
let mut salt = vec![];
salt.extend_from_slice(epk_bytes.as_bytes());
salt.extend_from_slice(pk.as_bytes());
salt
}
+5 -27
View File
@@ -1,13 +1,11 @@
use bech32::{ToBase32, Variant}; use age_core::primitives::bech32_encode_to_fmt;
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
use sha2::{Digest, Sha256};
use yubikey::{certificate::PublicKeyInfo, Certificate};
use std::fmt; use std::fmt;
use crate::RECIPIENT_PREFIX; use crate::recipient::{static_tag, TAG_BYTES};
pub(crate) const TAG_BYTES: usize = 4; const RECIPIENT_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("age1yubikey");
/// Wrapper around a compressed secp256r1 curve point. /// Wrapper around a compressed secp256r1 curve point.
#[derive(Clone)] #[derive(Clone)]
@@ -21,15 +19,7 @@ impl fmt::Debug for Recipient {
impl fmt::Display for Recipient { impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str( bech32_encode_to_fmt(f, RECIPIENT_PREFIX, self.to_encoded().as_bytes())
bech32::encode(
RECIPIENT_PREFIX,
self.to_encoded().as_bytes().to_base32(),
Variant::Bech32,
)
.expect("HRP is valid")
.as_str(),
)
} }
} }
@@ -44,17 +34,6 @@ impl Recipient {
} }
} }
pub(crate) fn from_certificate(cert: &Certificate) -> Option<Self> {
Self::from_spki(cert.subject_pki())
}
pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option<Self> {
match spki {
PublicKeyInfo::EcP256(pubkey) => Self::from_encoded(pubkey),
_ => None,
}
}
/// Attempts to parse a valid YubiKey recipient from its SEC-1 encoding. /// Attempts to parse a valid YubiKey recipient from its SEC-1 encoding.
/// ///
/// 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
@@ -69,8 +48,7 @@ impl Recipient {
} }
pub(crate) fn tag(&self) -> [u8; TAG_BYTES] { pub(crate) fn tag(&self) -> [u8; TAG_BYTES] {
let tag = Sha256::digest(self.to_encoded().as_bytes()); static_tag(self.to_encoded().as_bytes())
(&tag[0..TAG_BYTES]).try_into().expect("length is correct")
} }
/// Exposes the wrapped public key. /// Exposes the wrapped public key.
+63 -19
View File
@@ -2,12 +2,27 @@ use age_core::format::{FileKey, Stanza};
use age_plugin::{ use age_plugin::{
identity::{self, IdentityPluginV1}, identity::{self, IdentityPluginV1},
recipient::{self, RecipientPluginV1}, recipient::{self, RecipientPluginV1},
Callbacks, Callbacks, PluginHandler,
}; };
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::io; use std::io;
use crate::{fl, format, key, p256::Recipient, PLUGIN_NAME}; use crate::{fl, key, native::p256tag, piv_p256, Recipient, PLUGIN_NAME};
pub(crate) struct Handler;
impl PluginHandler for Handler {
type RecipientV1 = RecipientPlugin;
type IdentityV1 = IdentityPlugin;
fn recipient_v1(self) -> io::Result<Self::RecipientV1> {
Ok(RecipientPlugin::default())
}
fn identity_v1(self) -> io::Result<Self::IdentityV1> {
Ok(IdentityPlugin::default())
}
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct RecipientPlugin { pub(crate) struct RecipientPlugin {
@@ -22,11 +37,7 @@ impl RecipientPluginV1 for RecipientPlugin {
plugin_name: &str, plugin_name: &str,
bytes: &[u8], bytes: &[u8],
) -> Result<(), recipient::Error> { ) -> Result<(), recipient::Error> {
if let Some(pk) = if plugin_name == PLUGIN_NAME { if let Some(pk) = Recipient::from_bytes(plugin_name, bytes) {
Recipient::from_bytes(bytes)
} else {
None
} {
self.recipients.push(pk); self.recipients.push(pk);
Ok(()) Ok(())
} else { } else {
@@ -58,6 +69,10 @@ impl RecipientPluginV1 for RecipientPlugin {
} }
} }
fn labels(&mut self) -> HashSet<String> {
HashSet::new()
}
fn wrap_file_keys( fn wrap_file_keys(
&mut self, &mut self,
file_keys: Vec<FileKey>, file_keys: Vec<FileKey>,
@@ -95,7 +110,7 @@ impl RecipientPluginV1 for RecipientPlugin {
self.recipients self.recipients
.iter() .iter()
.chain(yk_recipients.iter()) .chain(yk_recipients.iter())
.map(|pk| format::RecipientLine::wrap_file_key(&file_key, pk).into()) .map(|pk| pk.wrap_file_key(&file_key))
.collect() .collect()
}) })
.collect()) .collect())
@@ -140,16 +155,16 @@ impl IdentityPluginV1 for IdentityPlugin {
let mut file_keys = HashMap::with_capacity(files.len()); let mut file_keys = HashMap::with_capacity(files.len());
// Filter to files / stanzas for which we have matching YubiKeys // Filter to files / stanzas for which we have matching YubiKeys
let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<format::RecipientLine>>)> = let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<SupportedStanza>>)> = self
self.yubikeys .yubikeys
.iter() .iter()
.map(|stub| (stub, HashMap::new())) .map(|stub| (stub, HashMap::new()))
.collect(); .collect();
for (file, stanzas) in files.iter().enumerate() { for (file, stanzas) in files.into_iter().enumerate() {
for (stanza_index, stanza) in stanzas.iter().enumerate() { for (stanza_index, stanza) in stanzas.into_iter().enumerate() {
match ( match (
format::RecipientLine::from_stanza(stanza).map(|res| { SupportedStanza::parse(stanza).map(|res| {
res.map_err(|_| identity::Error::Stanza { res.map_err(|_| identity::Error::Stanza {
file_index: file, file_index: file,
stanza_index, stanza_index,
@@ -163,7 +178,7 @@ impl IdentityPluginV1 for IdentityPlugin {
// A line will match at most one YubiKey. // A line will match at most one YubiKey.
if let Some(files) = if let Some(files) =
candidate_stanzas.iter_mut().find_map(|(stub, files)| { candidate_stanzas.iter_mut().find_map(|(stub, files)| {
if stub.matches(&line) { if line.matches_stub(stub) {
Some(files) Some(files)
} else { } else {
None None
@@ -233,7 +248,7 @@ impl IdentityPluginV1 for IdentityPlugin {
} }
for (stanza_index, line) in stanzas.iter().enumerate() { for (stanza_index, line) in stanzas.iter().enumerate() {
match conn.unwrap_file_key(line) { match line.unwrap_file_key(&mut conn) {
Ok(file_key) => { Ok(file_key) => {
// We've managed to decrypt this file! // We've managed to decrypt this file!
file_keys.entry(file_index).or_insert(Ok(file_key)); file_keys.entry(file_index).or_insert(Ok(file_key));
@@ -255,3 +270,32 @@ impl IdentityPluginV1 for IdentityPlugin {
Ok(file_keys) Ok(file_keys)
} }
} }
enum SupportedStanza {
PivP256(piv_p256::RecipientLine),
P256Tag(p256tag::RecipientLine),
}
impl SupportedStanza {
fn parse(stanza: Stanza) -> Option<Result<Self, ()>> {
piv_p256::RecipientLine::from_stanza(&stanza)
.map(|res| res.map(Self::PivP256))
.or_else(|| {
p256tag::RecipientLine::from_stanza(stanza).map(|res| res.map(Self::P256Tag))
})
}
pub(crate) fn matches_stub(&self, stub: &key::Stub) -> bool {
match self {
SupportedStanza::PivP256(line) => stub.tag == line.tag,
SupportedStanza::P256Tag(line) => line.matches_stub(stub),
}
}
pub(crate) fn unwrap_file_key(&self, conn: &mut key::Connection) -> Result<FileKey, ()> {
match self {
SupportedStanza::PivP256(line) => line.unwrap_file_key(conn),
SupportedStanza::P256Tag(line) => line.unwrap_file_key(conn),
}
}
}
+70
View File
@@ -0,0 +1,70 @@
use std::fmt;
use age_core::format::{FileKey, Stanza};
use sha2::{Digest, Sha256};
use crate::{native::p256tag, piv_p256, util::Metadata, PLUGIN_NAME};
pub(crate) const TAG_BYTES: usize = 4;
#[derive(Clone, Debug)]
pub(crate) enum Recipient {
PivP256(piv_p256::Recipient),
P256Tag(p256tag::Recipient),
}
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Recipient::PivP256(recipient) => recipient.fmt(f),
Recipient::P256Tag(recipient) => recipient.fmt(f),
}
}
}
impl Recipient {
/// Attempts to parse a supported YubiKey recipient.
pub(crate) fn from_bytes(plugin_name: &str, bytes: &[u8]) -> Option<Self> {
match plugin_name {
PLUGIN_NAME => piv_p256::Recipient::from_bytes(bytes).map(Self::PivP256),
p256tag::PLUGIN_NAME => p256tag::Recipient::from_bytes(bytes).map(Self::P256Tag),
_ => None,
}
}
/// Helper for returning the legacy encoding of this recipient, if any.
pub(crate) fn legacy_recipient(&self, metadata: &Metadata) -> Option<String> {
metadata
.is_pre_p256tag()
.then(|| match self {
Recipient::P256Tag(recipient) => Some(
piv_p256::Recipient::from_bytes(recipient.to_compressed().as_bytes())
.expect("valid")
.to_string(),
),
_ => None,
})
.flatten()
}
/// Returns the static tag for this recipient.
pub(crate) fn static_tag(&self) -> [u8; TAG_BYTES] {
match self {
Recipient::PivP256(recipient) => recipient.tag(),
Recipient::P256Tag(recipient) => recipient.static_tag(),
}
}
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> Stanza {
match self {
Recipient::PivP256(recipient) => recipient.wrap_file_key(file_key).into(),
Recipient::P256Tag(recipient) => recipient.wrap_file_key(file_key).into(),
}
}
}
pub(crate) fn static_tag(pk: &[u8]) -> [u8; TAG_BYTES] {
Sha256::digest(pk)[0..TAG_BYTES]
.try_into()
.expect("length is correct")
}
+69 -23
View File
@@ -1,6 +1,7 @@
use std::fmt; use std::fmt;
use std::iter; use std::iter;
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid}; use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid};
use yubikey::{ use yubikey::{
piv::{RetiredSlotId, SlotId}, piv::{RetiredSlotId, SlotId},
@@ -8,7 +9,7 @@ use yubikey::{
}; };
use crate::fl; use crate::fl;
use crate::{error::Error, key::Stub, p256::Recipient, BINARY_NAME, USABLE_SLOTS}; use crate::{error::Error, key::Stub, Recipient, BINARY_NAME, USABLE_SLOTS};
pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8]; pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8];
@@ -70,7 +71,10 @@ pub(crate) fn otp_serial_prefix(serial: Serial) -> String {
.collect() .collect()
} }
pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String, bool)> { pub(crate) fn extract_name_and_version(
cert: &X509Certificate,
all: bool,
) -> Option<(String, Option<String>)> {
// Look at Subject Organization to determine if we created this. // Look at Subject Organization to determine if we created this.
match cert.subject().iter_organization().next() { match cert.subject().iter_organization().next() {
Some(org) if org.as_str() == Ok(BINARY_NAME) => { Some(org) if org.as_str() == Ok(BINARY_NAME) => {
@@ -83,7 +87,16 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String,
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.unwrap_or_default(); // TODO: This should always be present. .unwrap_or_default(); // TODO: This should always be present.
Some((name, true)) // We store the binary version as an Organizational Unit attribute.
let version = cert
.subject()
.iter_organizational_unit()
.next()
.and_then(|cn| cn.as_str().ok())
.map(|s| s.to_owned())
.unwrap_or_default(); // TODO: This should always be present.
Some((name, Some(version)))
} }
_ => { _ => {
// Not one of ours, but we've already filtered for compatibility. // Not one of ours, but we've already filtered for compatibility.
@@ -94,7 +107,7 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String,
// Display the entire subject. // Display the entire subject.
let name = cert.subject().to_string(); let name = cert.subject().to_string();
Some((name, false)) Some((name, None))
} }
} }
} }
@@ -103,6 +116,7 @@ pub(crate) struct Metadata {
serial: Serial, serial: Serial,
slot: RetiredSlotId, slot: RetiredSlotId,
name: String, name: String,
version: Option<String>,
created: String, created: String,
pub(crate) pin_policy: Option<PinPolicy>, pub(crate) pin_policy: Option<PinPolicy>,
pub(crate) touch_policy: Option<TouchPolicy>, pub(crate) touch_policy: Option<TouchPolicy>,
@@ -148,31 +162,29 @@ impl Metadata {
.unwrap_or((None, None)) .unwrap_or((None, None))
}; };
extract_name(&cert, all) extract_name_and_version(&cert, all)
.map(|(name, ours)| { .map(|(name, version)| {
if ours { let (pin_policy, touch_policy) = if version.is_some() {
let (pin_policy, touch_policy) = policies(&cert); policies(&cert)
(name, pin_policy, touch_policy)
} else { } else {
// We can extract the PIN and touch policies via an attestation. This // We can extract the PIN and touch policies via an attestation. This
// is slow, but the user has asked for all compatible keys, so... // is slow, but the user has asked for all compatible keys, so...
let (pin_policy, touch_policy) = yubikey::piv::attest(yubikey, SlotId::Retired(slot))
yubikey::piv::attest(yubikey, SlotId::Retired(slot)) .ok()
.ok() .and_then(|buf| {
.and_then(|buf| { x509_parser::parse_x509_certificate(&buf)
x509_parser::parse_x509_certificate(&buf) .map(|(_, c)| policies(&c))
.map(|(_, c)| policies(&c)) .ok()
.ok() })
}) .unwrap_or((None, None))
.unwrap_or((None, None)); };
(name, version, pin_policy, touch_policy)
(name, pin_policy, touch_policy)
}
}) })
.map(|(name, pin_policy, touch_policy)| Metadata { .map(|(name, version, pin_policy, touch_policy)| Metadata {
serial: yubikey.serial(), serial: yubikey.serial(),
slot, slot,
name, name,
version,
created: cert created: cert
.validity() .validity()
.not_before .not_before
@@ -182,6 +194,19 @@ impl Metadata {
touch_policy, touch_policy,
}) })
} }
/// Returns `true` if this identity was generated with an `age-plugin-yubikey` version
/// before `p256tag` was added (and became the default).
pub(crate) fn is_pre_p256tag(&self) -> bool {
self.version
.as_ref()
.and_then(|version| version.split_once('.'))
.and_then(|(major, rest)| rest.split_once('.').map(|(minor, _)| (major, minor)))
.is_some_and(|(major, minor)| {
// `p256tag` added in v0.6.0
major == "0" && minor.parse::<u8>().is_ok_and(|minor| minor < 6)
})
}
} }
impl fmt::Display for Metadata { impl fmt::Display for Metadata {
@@ -203,19 +228,40 @@ impl fmt::Display for Metadata {
} }
pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) { pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) {
let legacy_recipient = recipient.legacy_recipient(&metadata);
let recipient = recipient.to_string(); let recipient = recipient.to_string();
if !console::user_attended() { if !console::user_attended() {
let recipient = recipient.as_str(); let recipient = recipient.as_str();
eprintln!("{}", fl!("print-recipient", recipient = recipient)); eprintln!("{}", fl!("print-recipient", recipient = recipient));
} }
let identity = if let Some(legacy_recipient) = legacy_recipient {
format!(
"{}\n{stub}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient),
)
} else {
stub.to_string()
};
println!( println!(
"{}", "{}",
fl!( fl!(
"yubikey-identity", "yubikey-identity",
yubikey_metadata = metadata.to_string(), yubikey_metadata = metadata.to_string(),
recipient = recipient, recipient = recipient,
identity = stub.to_string(), identity = identity,
) )
); );
} }
pub(crate) fn base64_arg<A: AsRef<[u8]>, B: AsMut<[u8]>>(arg: &A, mut buf: B) -> Option<B> {
if arg.as_ref().len() != ((4 * buf.as_mut().len()) + 2) / 3 {
return None;
}
BASE64_STANDARD_NO_PAD
.decode_slice_unchecked(arg, buf.as_mut())
.ok()
.and_then(|len| (len == buf.as_mut().len()).then_some(buf))
}