diff --git a/CHANGELOG.md b/CHANGELOG.md index b268f3d..0be8da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,20 @@ to 0.3.0 are beta releases. ## [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.5.0] - 2024-08-04 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index fda1308..41d8438 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,16 +27,42 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "age-core" version = "0.11.0" -source = "git+https://github.com/str4d/rage.git?rev=5e530a3a6aad9e189e26903bc8114e2da526b4b5#5e530a3a6aad9e189e26903bc8114e2da526b4b5" +source = "git+https://github.com/str4d/rage.git?rev=e08c450aa5d7b1cc5706094080c0042ddd60aaf7#e08c450aa5d7b1cc5706094080c0042ddd60aaf7" dependencies = [ "base64 0.22.1", "bech32", "chacha20poly1305", "cookie-factory", "hkdf", + "hpke", "io_tee", "nom 8.0.0", "rand", @@ -48,7 +74,7 @@ dependencies = [ [[package]] name = "age-plugin" version = "0.6.1" -source = "git+https://github.com/str4d/rage.git?rev=5e530a3a6aad9e189e26903bc8114e2da526b4b5#5e530a3a6aad9e189e26903bc8114e2da526b4b5" +source = "git+https://github.com/str4d/rage.git?rev=e08c450aa5d7b1cc5706094080c0042ddd60aaf7#e08c450aa5d7b1cc5706094080c0042ddd60aaf7" dependencies = [ "age-core", "base64 0.22.1", @@ -70,6 +96,8 @@ dependencies = [ "flate2", "gumdrop", "hex", + "hkdf", + "hpke", "i18n-embed", "i18n-embed-fl", "lazy_static", @@ -449,9 +477,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -855,6 +893,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -968,6 +1016,26 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest", + "generic-array", + "hkdf", + "hmac", + "p256", + "rand_core", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "http" version = "0.2.12" @@ -1710,6 +1778,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3087,3 +3167,17 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.86", +] diff --git a/Cargo.toml b/Cargo.toml index 23599e5..141b4fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ dialoguer = { version = "0.11", default-features = false, features = ["password" env_logger = "0.10" gumdrop = "0.8" hex = "0.4" +hkdf = "0.12" +hpke = { version = "0.12", default-features = false, features = ["alloc", "p256"] } log = "0.4" p256 = { version = "0.13", features = ["ecdh"] } pcsc = "2.4" @@ -58,5 +60,5 @@ test-with = "0.11" which = "5" [patch.crates-io] -age-core = { git = "https://github.com/str4d/rage.git", rev = "5e530a3a6aad9e189e26903bc8114e2da526b4b5" } -age-plugin = { git = "https://github.com/str4d/rage.git", rev = "5e530a3a6aad9e189e26903bc8114e2da526b4b5" } +age-core = { git = "https://github.com/str4d/rage.git", rev = "e08c450aa5d7b1cc5706094080c0042ddd60aaf7" } +age-plugin = { git = "https://github.com/str4d/rage.git", rev = "e08c450aa5d7b1cc5706094080c0042ddd60aaf7" } diff --git a/README.md b/README.md index 3a7a103..5235051 100644 --- a/README.md +++ b/README.md @@ -108,15 +108,17 @@ standard output: $ age-plugin-yubikey --list ``` -To encrypt files to these YubiKey recipients, ensure that `age-plugin-yubikey` -is accessible in your `PATH`, and then use the recipients with an age client as -normal (e.g. `rage -r age1yubikey1...`). +To encrypt files to these YubiKey recipients, ensure you have a recent version +of an age client, and then use the recipients with it as normal (e.g. +`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 all recipients (e.g. `age -R filename.txt`). -To decrypt files encrypted to a YubiKey identity, pass the identity file to the -age client as normal (e.g. `rage -d -i yubikey-identity.txt`). +To decrypt files encrypted to a YubiKey identity, ensure that +`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 diff --git a/i18n/en-US/age_plugin_yubikey.ftl b/i18n/en-US/age_plugin_yubikey.ftl index 2174b6c..0baf801 100644 --- a/i18n/en-US/age_plugin_yubikey.ftl +++ b/i18n/en-US/age_plugin_yubikey.ftl @@ -43,6 +43,8 @@ yubikey-metadata = # Created: {$created} # PIN policy: {$pin_policy} # Touch policy: {$touch_policy} +yubikey-legacy-recipient = + # Legacy recipient: {$recipient} yubikey-identity = {$yubikey_metadata} # Recipient: {$recipient} diff --git a/src/builder.rs b/src/builder.rs index 8ff96ba..aa4a534 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -11,7 +11,7 @@ use crate::{ error::Error, fl, key::{self, Stub}, - piv_p256, + native::p256tag, util::{Metadata, POLICY_EXTENSION_OID}, Recipient, BINARY_NAME, USABLE_SLOTS, }; @@ -104,8 +104,8 @@ impl IdentityBuilder { touch_policy, )?; - let recipient = Recipient::PivP256( - piv_p256::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); diff --git a/src/key.rs b/src/key.rs index db716f8..e1b6277 100644 --- a/src/key.rs +++ b/src/key.rs @@ -20,7 +20,8 @@ use yubikey::{ use crate::{ error::Error, - fl, piv_p256, + fl, + native::p256tag, recipient::TAG_BYTES, util::{otp_serial_prefix, Metadata}, Recipient, IDENTITY_PREFIX, @@ -393,8 +394,8 @@ pub(crate) fn list_slots( match key.slot() { SlotId::Retired(slot) => { // Only P-256 keys are compatible with us. - let recipient = piv_p256::Recipient::from_certificate(key.certificate()) - .map(Recipient::PivP256); + let recipient = + p256tag::Recipient::from_certificate(key.certificate()).map(Recipient::P256Tag); Some((key, slot, recipient)) } _ => None, @@ -592,9 +593,10 @@ impl Stub { let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot)) .ok() .and_then(|cert| { - piv_p256::Recipient::from_certificate(&cert) - .filter(|pk| pk.tag() == self.tag) - .map(|pk| (cert, Recipient::PivP256(pk))) + // Parse as the preferred recipient for each identity type. + p256tag::Recipient::from_certificate(&cert) + .filter(|pk| pk.static_tag() == self.tag) + .map(|pk| (cert, Recipient::P256Tag(pk))) }) { Some(pk) => pk, None => { @@ -628,10 +630,15 @@ pub(crate) struct Connection { } impl Connection { + /// Returns the preferred recipient for encrypting to this identity. pub(crate) fn recipient(&self) -> &Recipient { &self.pk } + pub(crate) fn stub(&self) -> Stub { + Stub::new(self.yubikey.serial(), self.slot, &self.pk) + } + pub(crate) fn request_pin_if_necessary( &mut self, callbacks: &mut dyn Callbacks, diff --git a/src/main.rs b/src/main.rs index 69f2840..24d7c0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use yubikey::{piv::RetiredSlotId, reader::Context, PinPolicy, Serial, TouchPolic mod builder; mod error; mod key; +mod native; mod piv_p256; mod plugin; mod util; @@ -297,6 +298,12 @@ fn list(flags: PluginFlags, all: bool) -> Result<(), Error> { all, |_, recipient, metadata| { println!("{metadata}"); + if let Some(legacy_recipient) = recipient.legacy_recipient(&metadata) { + println!( + "{}", + fl!("yubikey-legacy-recipient", recipient = legacy_recipient) + ); + } println!("{recipient}"); }, ) @@ -402,7 +409,7 @@ fn main() -> Result<(), Error> { let (_, cert) = x509_parser::parse_x509_certificate(key.certificate().as_ref()) .unwrap(); - let (name, _) = util::extract_name(&cert, true).unwrap(); + let (name, _) = util::extract_name_and_version(&cert, true).unwrap(); let created = cert .validity() .not_before @@ -612,6 +619,15 @@ fn main() -> Result<(), Error> { 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!( file, "{}", @@ -619,7 +635,7 @@ fn main() -> Result<(), Error> { "yubikey-identity", yubikey_metadata = metadata.to_string(), recipient = recipient.to_string(), - identity = stub.to_string(), + identity = identity, ) )?; file.sync_data()?; diff --git a/src/native.rs b/src/native.rs new file mode 100644 index 0000000..f7484a2 --- /dev/null +++ b/src/native.rs @@ -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::::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>, + _kem: PhantomData, +} + +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 = ::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 { + unreachable!("Never called") + } +} diff --git a/src/native/p256tag.rs b/src/native/p256tag.rs new file mode 100644 index 0000000..ad94675 --- /dev/null +++ b/src/native/p256tag.rs @@ -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: ::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 { + 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 = + ::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::from_spki(cert.subject_pki()) + } + + pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option { + let encoded = match spki { + PublicKeyInfo::EcP256(pubkey) => Some(pubkey), + _ => None, + }?; + + // Check that the certificate encoding is uncompressed. + let pk_recip = ::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::( + &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: &::EncappedKey, static_tag: [u8; TAG_BYTES]) -> [u8; TAG_BYTES] { + let ikm = enc + .to_bytes() + .into_iter() + .chain(static_tag) + .collect::>(); + + stanza_tag(&ikm, P256TAG_SALT) +} + +pub(crate) struct RecipientLine { + tag: [u8; TAG_BYTES], + enc: ::EncappedKey, + ct: Vec, +} + +impl From 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> { + 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| ::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 { + // > 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::( + &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 = ::PublicKey; + type PrivateKey = YubiKeyKemPrivateKey<'a, Kem>; + + fn sk_to_pk(_: &Self::PrivateKey) -> Self::PublicKey { + unreachable!("Never called") + } + + type EncappedKey = ::EncappedKey; + type NSecret = ::NSecret; + const KEM_ID: u16 = ::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::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::>(); + + // 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 = as Default>::default(); + hpke::kdf::extract_and_expand::( + &kex_res_eph, + suite_id, + &kem_context, + &mut shared_secret.0, + ) + .expect("shared secret is way too big"); + Ok(shared_secret) + } + + fn encap( + _: &Self::PublicKey, + _: Option<(&Self::PrivateKey, &Self::PublicKey)>, + _: &mut R, + ) -> Result<(hpke::kem::SharedSecret, Self::EncappedKey), hpke::HpkeError> { + unreachable!("Never called") + } +} diff --git a/src/piv_p256.rs b/src/piv_p256.rs index f1b1750..26873d4 100644 --- a/src/piv_p256.rs +++ b/src/piv_p256.rs @@ -111,7 +111,7 @@ impl Recipient { let shared_secret = esk.diffie_hellman(self.public_key()); - let salt = salt(&epk_bytes, self); + let salt = salt(&epk_bytes, self.to_encoded()); let enc_key = { let mut okm = [0; 32]; @@ -138,10 +138,17 @@ impl Recipient { impl RecipientLine { pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result { - let crate::recipient::Recipient::PivP256(recipient) = conn.recipient(); - assert_eq!(self.tag, recipient.tag()); + 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, recipient); + let salt = salt(&self.epk_bytes, pk); // The YubiKey API for performing scalar multiplication takes the point in its // uncompressed SEC-1 encoding. @@ -162,9 +169,10 @@ impl RecipientLine { } } -fn salt(epk_bytes: &EphemeralKeyBytes, pk: &Recipient) -> Vec { +fn salt(epk_bytes: &EphemeralKeyBytes, pk: p256::EncodedPoint) -> Vec { + assert!(pk.is_compressed()); let mut salt = vec![]; salt.extend_from_slice(epk_bytes.as_bytes()); - salt.extend_from_slice(pk.to_encoded().as_bytes()); + salt.extend_from_slice(pk.as_bytes()); salt } diff --git a/src/piv_p256/recipient.rs b/src/piv_p256/recipient.rs index a0a661b..0b41efe 100644 --- a/src/piv_p256/recipient.rs +++ b/src/piv_p256/recipient.rs @@ -1,6 +1,5 @@ use age_core::primitives::bech32_encode_to_fmt; use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; -use yubikey::{certificate::PublicKeyInfo, Certificate}; use std::fmt; @@ -35,17 +34,6 @@ impl Recipient { } } - pub(crate) fn from_certificate(cert: &Certificate) -> Option { - Self::from_spki(cert.subject_pki()) - } - - pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option { - match spki { - PublicKeyInfo::EcP256(pubkey) => Self::from_encoded(pubkey), - _ => None, - } - } - /// 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 diff --git a/src/plugin.rs b/src/plugin.rs index 97cdd1c..910a554 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -7,7 +7,7 @@ use age_plugin::{ use std::collections::{HashMap, HashSet}; use std::io; -use crate::{fl, key, piv_p256, Recipient, PLUGIN_NAME}; +use crate::{fl, key, native::p256tag, piv_p256, Recipient, PLUGIN_NAME}; pub(crate) struct Handler; @@ -273,22 +273,29 @@ impl IdentityPluginV1 for IdentityPlugin { enum SupportedStanza { PivP256(piv_p256::RecipientLine), + P256Tag(p256tag::RecipientLine), } impl SupportedStanza { fn parse(stanza: Stanza) -> Option> { - piv_p256::RecipientLine::from_stanza(&stanza).map(|res| res.map(Self::PivP256)) + 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 { match self { SupportedStanza::PivP256(line) => line.unwrap_file_key(conn), + SupportedStanza::P256Tag(line) => line.unwrap_file_key(conn), } } } diff --git a/src/recipient.rs b/src/recipient.rs index f846635..2333ddc 100644 --- a/src/recipient.rs +++ b/src/recipient.rs @@ -3,19 +3,21 @@ use std::fmt; use age_core::format::{FileKey, Stanza}; use sha2::{Digest, Sha256}; -use crate::{piv_p256, PLUGIN_NAME}; +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), } } } @@ -25,20 +27,38 @@ impl Recipient { pub(crate) fn from_bytes(plugin_name: &str, bytes: &[u8]) -> Option { 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 { + 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(), } } } diff --git a/src/util.rs b/src/util.rs index 36141d7..b723ce9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -71,7 +71,10 @@ pub(crate) fn otp_serial_prefix(serial: Serial) -> String { .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)> { // Look at Subject Organization to determine if we created this. match cert.subject().iter_organization().next() { Some(org) if org.as_str() == Ok(BINARY_NAME) => { @@ -84,7 +87,16 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String, .map(|s| s.to_owned()) .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. @@ -95,7 +107,7 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String, // Display the entire subject. let name = cert.subject().to_string(); - Some((name, false)) + Some((name, None)) } } } @@ -104,6 +116,7 @@ pub(crate) struct Metadata { serial: Serial, slot: RetiredSlotId, name: String, + version: Option, created: String, pub(crate) pin_policy: Option, pub(crate) touch_policy: Option, @@ -149,31 +162,29 @@ impl Metadata { .unwrap_or((None, None)) }; - extract_name(&cert, all) - .map(|(name, ours)| { - if ours { - let (pin_policy, touch_policy) = policies(&cert); - (name, pin_policy, touch_policy) + extract_name_and_version(&cert, all) + .map(|(name, version)| { + let (pin_policy, touch_policy) = if version.is_some() { + policies(&cert) } else { // We can extract the PIN and touch policies via an attestation. This // is slow, but the user has asked for all compatible keys, so... - let (pin_policy, touch_policy) = - yubikey::piv::attest(yubikey, SlotId::Retired(slot)) - .ok() - .and_then(|buf| { - x509_parser::parse_x509_certificate(&buf) - .map(|(_, c)| policies(&c)) - .ok() - }) - .unwrap_or((None, None)); - - (name, pin_policy, touch_policy) - } + yubikey::piv::attest(yubikey, SlotId::Retired(slot)) + .ok() + .and_then(|buf| { + x509_parser::parse_x509_certificate(&buf) + .map(|(_, c)| policies(&c)) + .ok() + }) + .unwrap_or((None, None)) + }; + (name, version, pin_policy, touch_policy) }) - .map(|(name, pin_policy, touch_policy)| Metadata { + .map(|(name, version, pin_policy, touch_policy)| Metadata { serial: yubikey.serial(), slot, name, + version, created: cert .validity() .not_before @@ -183,6 +194,19 @@ impl Metadata { 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::().is_ok_and(|minor| minor < 6) + }) + } } impl fmt::Display for Metadata { @@ -204,19 +228,29 @@ impl fmt::Display for Metadata { } pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) { + let legacy_recipient = recipient.legacy_recipient(&metadata); let recipient = recipient.to_string(); if !console::user_attended() { let recipient = recipient.as_str(); 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!( "{}", fl!( "yubikey-identity", yubikey_metadata = metadata.to_string(), recipient = recipient, - identity = stub.to_string(), + identity = identity, ) ); }