Refactors for reusability across supported recipients

This commit is contained in:
Jack Grigg
2025-12-08 02:34:04 +00:00
parent 1f1f257ede
commit 5b44faec44
8 changed files with 135 additions and 62 deletions
+5 -3
View File
@@ -11,9 +11,9 @@ use crate::{
error::Error,
fl,
key::{self, Stub},
p256::Recipient,
piv_p256,
util::{Metadata, POLICY_EXTENSION_OID},
BINARY_NAME, USABLE_SLOTS,
Recipient, BINARY_NAME, USABLE_SLOTS,
};
pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once;
@@ -104,7 +104,9 @@ impl IdentityBuilder {
touch_policy,
)?;
let recipient = Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey");
let recipient = Recipient::PivP256(
piv_p256::Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"),
);
let stub = Stub::new(yubikey.serial(), slot, &recipient);
eprintln!();
+8 -12
View File
@@ -20,11 +20,10 @@ use yubikey::{
use crate::{
error::Error,
fl,
p256::{Recipient, TAG_BYTES},
piv_p256,
fl, piv_p256,
recipient::TAG_BYTES,
util::{otp_serial_prefix, Metadata},
IDENTITY_PREFIX,
Recipient, IDENTITY_PREFIX,
};
const ONE_SECOND: Duration = Duration::from_secs(1);
@@ -394,7 +393,8 @@ pub(crate) fn list_slots(
match key.slot() {
SlotId::Retired(slot) => {
// Only P-256 keys are compatible with us.
let recipient = Recipient::from_certificate(key.certificate());
let recipient = piv_p256::Recipient::from_certificate(key.certificate())
.map(Recipient::PivP256);
Some((key, slot, recipient))
}
_ => None,
@@ -449,7 +449,7 @@ impl Stub {
Stub {
serial,
slot,
tag: recipient.tag(),
tag: recipient.static_tag(),
identity_index: 0,
}
}
@@ -476,10 +476,6 @@ impl Stub {
bytes
}
pub(crate) fn matches(&self, line: &piv_p256::RecipientLine) -> bool {
self.tag == line.tag
}
/// Returns:
/// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey.
/// - `Ok(Ok(None))` if the user told us to skip this YubiKey.
@@ -601,9 +597,9 @@ impl Stub {
let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
.ok()
.and_then(|cert| {
Recipient::from_certificate(&cert)
piv_p256::Recipient::from_certificate(&cert)
.filter(|pk| pk.tag() == self.tag)
.map(|pk| (cert, pk))
.map(|pk| (cert, Recipient::PivP256(pk)))
}) {
Some(pk) => pk,
None => {
+6 -5
View File
@@ -17,16 +17,17 @@ use yubikey::{piv::RetiredSlotId, reader::Context, PinPolicy, Serial, TouchPolic
mod builder;
mod error;
mod key;
mod p256;
mod piv_p256;
mod plugin;
mod util;
mod recipient;
use recipient::Recipient;
use error::Error;
const PLUGIN_NAME: &str = "yubikey";
const BINARY_NAME: &str = "age-plugin-yubikey";
const RECIPIENT_PREFIX: &str = "age1yubikey";
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
const USABLE_SLOTS: [RetiredSlotId; 20] = [
@@ -193,7 +194,7 @@ fn generate(flags: PluginFlags) -> Result<(), Error> {
fn print_single(
serial: Option<Serial>,
slot: RetiredSlotId,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> {
let mut yubikey = key::open(serial)?;
@@ -215,7 +216,7 @@ fn print_multiple(
kind: &str,
serial: Option<Serial>,
all: bool,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> {
let mut readers = Context::open()?;
@@ -255,7 +256,7 @@ fn print_details(
kind: &str,
flags: PluginFlags,
all: bool,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> {
if let Some(slot) = flags.slot {
print_single(flags.serial, slot, printer)
+16 -20
View File
@@ -11,12 +11,14 @@ use p256::{
use rand::rngs::OsRng;
use sha2::Sha256;
use crate::{key::Connection, p256::Recipient};
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";
const TAG_BYTES: usize = 4;
const EPK_BYTES: usize = 33;
const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
@@ -81,17 +83,6 @@ impl RecipientLine {
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[..] {
[tag, epk_bytes] => (
base64_arg(tag, [0; TAG_BYTES]),
@@ -110,15 +101,17 @@ impl RecipientLine {
_ => 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 epk = esk.public_key();
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 salt = salt(&epk_bytes, pk);
let salt = salt(&epk_bytes, self);
let enc_key = {
let mut okm = [0; 32];
@@ -136,21 +129,24 @@ impl RecipientLine {
};
RecipientLine {
tag: pk.tag(),
tag: self.tag(),
epk_bytes,
encrypted_file_key,
}
}
}
impl RecipientLine {
pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result<FileKey, ()> {
assert_eq!(self.tag, conn.recipient().tag());
let crate::recipient::Recipient::PivP256(recipient) = conn.recipient();
assert_eq!(self.tag, recipient.tag());
let salt = salt(&self.epk_bytes, recipient);
// 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 salt = salt(&self.epk_bytes, conn.recipient());
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
+3 -5
View File
@@ -1,13 +1,12 @@
use bech32::{ToBase32, Variant};
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
use sha2::{Digest, Sha256};
use yubikey::{certificate::PublicKeyInfo, Certificate};
use std::fmt;
use crate::RECIPIENT_PREFIX;
use crate::recipient::{static_tag, TAG_BYTES};
pub(crate) const TAG_BYTES: usize = 4;
const RECIPIENT_PREFIX: &str = "age1yubikey";
/// Wrapper around a compressed secp256r1 curve point.
#[derive(Clone)]
@@ -69,8 +68,7 @@ impl Recipient {
}
pub(crate) fn tag(&self) -> [u8; TAG_BYTES] {
let tag = Sha256::digest(self.to_encoded().as_bytes());
(&tag[0..TAG_BYTES]).try_into().expect("length is correct")
static_tag(self.to_encoded().as_bytes())
}
/// Exposes the wrapped public key.
+31 -13
View File
@@ -7,7 +7,7 @@ use age_plugin::{
use std::collections::{HashMap, HashSet};
use std::io;
use crate::{fl, key, p256::Recipient, piv_p256, PLUGIN_NAME};
use crate::{fl, key, piv_p256, Recipient, PLUGIN_NAME};
pub(crate) struct Handler;
@@ -37,11 +37,7 @@ impl RecipientPluginV1 for RecipientPlugin {
plugin_name: &str,
bytes: &[u8],
) -> Result<(), recipient::Error> {
if let Some(pk) = if plugin_name == PLUGIN_NAME {
Recipient::from_bytes(bytes)
} else {
None
} {
if let Some(pk) = Recipient::from_bytes(plugin_name, bytes) {
self.recipients.push(pk);
Ok(())
} else {
@@ -114,7 +110,7 @@ impl RecipientPluginV1 for RecipientPlugin {
self.recipients
.iter()
.chain(yk_recipients.iter())
.map(|pk| piv_p256::RecipientLine::wrap_file_key(&file_key, pk).into())
.map(|pk| pk.wrap_file_key(&file_key))
.collect()
})
.collect())
@@ -159,16 +155,16 @@ impl IdentityPluginV1 for IdentityPlugin {
let mut file_keys = HashMap::with_capacity(files.len());
// Filter to files / stanzas for which we have matching YubiKeys
let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<piv_p256::RecipientLine>>)> =
self.yubikeys
let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<SupportedStanza>>)> = self
.yubikeys
.iter()
.map(|stub| (stub, HashMap::new()))
.collect();
for (file, stanzas) in files.iter().enumerate() {
for (stanza_index, stanza) in stanzas.iter().enumerate() {
for (file, stanzas) in files.into_iter().enumerate() {
for (stanza_index, stanza) in stanzas.into_iter().enumerate() {
match (
piv_p256::RecipientLine::from_stanza(stanza).map(|res| {
SupportedStanza::parse(stanza).map(|res| {
res.map_err(|_| identity::Error::Stanza {
file_index: file,
stanza_index,
@@ -182,7 +178,7 @@ impl IdentityPluginV1 for IdentityPlugin {
// A line will match at most one YubiKey.
if let Some(files) =
candidate_stanzas.iter_mut().find_map(|(stub, files)| {
if stub.matches(&line) {
if line.matches_stub(stub) {
Some(files)
} else {
None
@@ -274,3 +270,25 @@ impl IdentityPluginV1 for IdentityPlugin {
Ok(file_keys)
}
}
enum SupportedStanza {
PivP256(piv_p256::RecipientLine),
}
impl SupportedStanza {
fn parse(stanza: Stanza) -> Option<Result<Self, ()>> {
piv_p256::RecipientLine::from_stanza(&stanza).map(|res| res.map(Self::PivP256))
}
pub(crate) fn matches_stub(&self, stub: &key::Stub) -> bool {
match self {
SupportedStanza::PivP256(line) => stub.tag == line.tag,
}
}
pub(crate) fn unwrap_file_key(&self, conn: &mut key::Connection) -> Result<FileKey, ()> {
match self {
SupportedStanza::PivP256(line) => line.unwrap_file_key(conn),
}
}
}
+50
View File
@@ -0,0 +1,50 @@
use std::fmt;
use age_core::format::{FileKey, Stanza};
use sha2::{Digest, Sha256};
use crate::{piv_p256, PLUGIN_NAME};
pub(crate) const TAG_BYTES: usize = 4;
#[derive(Clone, Debug)]
pub(crate) enum Recipient {
PivP256(piv_p256::Recipient),
}
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Recipient::PivP256(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),
_ => None,
}
}
/// Returns the static tag for this recipient.
pub(crate) fn static_tag(&self) -> [u8; TAG_BYTES] {
match self {
Recipient::PivP256(recipient) => recipient.tag(),
}
}
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> Stanza {
match self {
Recipient::PivP256(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")
}
+13 -1
View File
@@ -1,6 +1,7 @@
use std::fmt;
use std::iter;
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid};
use yubikey::{
piv::{RetiredSlotId, SlotId},
@@ -8,7 +9,7 @@ use yubikey::{
};
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];
@@ -219,3 +220,14 @@ pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadat
)
);
}
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))
}