diff --git a/CHANGELOG.md b/CHANGELOG.md index b268f3d..c2979f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ 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. 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/src/key.rs b/src/key.rs index db716f8..4e1375e 100644 --- a/src/key.rs +++ b/src/key.rs @@ -632,6 +632,10 @@ impl Connection { &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..5492493 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; 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..beb3cde --- /dev/null +++ b/src/native/p256tag.rs @@ -0,0 +1,264 @@ +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 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 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..17e05c8 100644 --- a/src/piv_p256.rs +++ b/src/piv_p256.rs @@ -138,7 +138,10 @@ impl Recipient { impl RecipientLine { pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result { - let crate::recipient::Recipient::PivP256(recipient) = conn.recipient(); + let recipient = match conn.recipient() { + crate::recipient::Recipient::PivP256(recipient) => recipient, + _ => panic!("should have been filtered out earlier"), + }; assert_eq!(self.tag, recipient.tag()); let salt = salt(&self.epk_bytes, recipient); 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..03a2223 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, 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,6 +27,7 @@ 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, } } @@ -33,12 +36,14 @@ impl 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(), } } }