Add support for p256tag

This commit is contained in:
Jack Grigg
2025-12-08 03:45:43 +00:00
parent 4f13e2fc27
commit 0057a1825e
10 changed files with 453 additions and 8 deletions
+6
View File
@@ -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.
Generated
+96 -2
View File
@@ -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",
]
+4 -2
View File
@@ -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" }
+4
View File
@@ -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<E>(
&mut self,
callbacks: &mut dyn Callbacks<E>,
+1
View File
@@ -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;
+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")
}
}
+264
View File
@@ -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: <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 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")
}
}
+4 -1
View File
@@ -138,7 +138,10 @@ impl Recipient {
impl RecipientLine {
pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result<FileKey, ()> {
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);
+9 -2
View File
@@ -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<Result<Self, ()>> {
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<FileKey, ()> {
match self {
SupportedStanza::PivP256(line) => line.unwrap_file_key(conn),
SupportedStanza::P256Tag(line) => line.unwrap_file_key(conn),
}
}
}
+6 -1
View File
@@ -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<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,
}
}
@@ -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(),
}
}
}