YubiKey plugin protocol
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
use age_core::{
|
||||
format::{FileKey, Stanza},
|
||||
primitives::{aead_encrypt, hkdf},
|
||||
};
|
||||
use p256::{ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoint};
|
||||
use rand::rngs::OsRng;
|
||||
use secrecy::ExposeSecret;
|
||||
use std::convert::TryInto;
|
||||
|
||||
use crate::{p256::Recipient, STANZA_TAG};
|
||||
|
||||
pub(crate) const STANZA_KEY_LABEL: &[u8] = b"age-encryption.org/v1/piv-p256";
|
||||
|
||||
const TAG_BYTES: usize = 4;
|
||||
const EPK_BYTES: usize = 33;
|
||||
const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
|
||||
|
||||
/// The ephemeral key bytes in a piv-p256 stanza.
|
||||
///
|
||||
/// The bytes contain a compressed SEC-1 encoding of a valid point.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct EphemeralKeyBytes(p256::EncodedPoint);
|
||||
|
||||
impl EphemeralKeyBytes {
|
||||
fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option<Self> {
|
||||
let encoded = p256::EncodedPoint::from_bytes(&bytes).ok()?;
|
||||
if encoded.is_compressed() && encoded.decompress().is_some() {
|
||||
Some(EphemeralKeyBytes(encoded))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn from_public_key(epk: &p256::PublicKey) -> Self {
|
||||
EphemeralKeyBytes(epk.to_encoded_point(true))
|
||||
}
|
||||
|
||||
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn decompress(&self) -> p256::EncodedPoint {
|
||||
self.0
|
||||
.decompress()
|
||||
.expect("EphemeralKeyBytes is a valid compressed encoding by construction")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RecipientLine {
|
||||
pub(crate) tag: [u8; TAG_BYTES],
|
||||
pub(crate) epk_bytes: EphemeralKeyBytes,
|
||||
pub(crate) encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES],
|
||||
}
|
||||
|
||||
impl From<RecipientLine> for Stanza {
|
||||
fn from(r: RecipientLine) -> Self {
|
||||
Stanza {
|
||||
tag: STANZA_TAG.to_owned(),
|
||||
args: vec![
|
||||
base64::encode_config(&r.tag, base64::STANDARD_NO_PAD),
|
||||
base64::encode_config(r.epk_bytes.as_bytes(), base64::STANDARD_NO_PAD),
|
||||
],
|
||||
body: r.encrypted_file_key.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecipientLine {
|
||||
pub(super) fn from_stanza(s: &Stanza) -> Option<Result<Self, ()>> {
|
||||
if s.tag != STANZA_TAG {
|
||||
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::decode_config_slice(arg, base64::STANDARD_NO_PAD, buf.as_mut())
|
||||
.ok()
|
||||
.map(|_| buf)
|
||||
}
|
||||
|
||||
let (tag, epk_bytes) = match &s.args[..] {
|
||||
[tag, epk_bytes] => (
|
||||
base64_arg(tag, [0; TAG_BYTES]),
|
||||
base64_arg(epk_bytes, [0; EPK_BYTES]).and_then(EphemeralKeyBytes::from_bytes),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Some(match (tag, epk_bytes, s.body[..].try_into()) {
|
||||
(Some(tag), Some(epk_bytes), Ok(encrypted_file_key)) => Ok(RecipientLine {
|
||||
tag,
|
||||
epk_bytes,
|
||||
encrypted_file_key,
|
||||
}),
|
||||
// Anything else indicates a structurally-invalid stanza.
|
||||
_ => Err(()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self {
|
||||
let esk = EphemeralSecret::random(OsRng);
|
||||
let epk = esk.public_key();
|
||||
let epk_bytes = EphemeralKeyBytes::from_public_key(&epk);
|
||||
|
||||
let shared_secret = esk.diffie_hellman(&pk.public_key());
|
||||
|
||||
let mut salt = vec![];
|
||||
salt.extend_from_slice(epk_bytes.as_bytes());
|
||||
salt.extend_from_slice(pk.to_encoded().as_bytes());
|
||||
|
||||
let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_bytes());
|
||||
|
||||
let encrypted_file_key = {
|
||||
let mut key = [0; ENCRYPTED_FILE_KEY_BYTES];
|
||||
key.copy_from_slice(&aead_encrypt(&enc_key, file_key.expose_secret()));
|
||||
key
|
||||
};
|
||||
|
||||
RecipientLine {
|
||||
tag: pk.tag(),
|
||||
epk_bytes,
|
||||
encrypted_file_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use yubikey_piv::{
|
||||
|
||||
mod builder;
|
||||
mod error;
|
||||
mod format;
|
||||
mod p256;
|
||||
mod plugin;
|
||||
mod util;
|
||||
@@ -21,6 +22,7 @@ use error::Error;
|
||||
const PLUGIN_NAME: &str = "age-plugin-yubikey";
|
||||
const RECIPIENT_PREFIX: &str = "age1yubikey";
|
||||
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
|
||||
const STANZA_TAG: &str = "piv-p256";
|
||||
|
||||
const USABLE_SLOTS: [RetiredSlotId; 20] = [
|
||||
RetiredSlotId::R1,
|
||||
|
||||
+15
@@ -33,6 +33,16 @@ impl fmt::Display for Recipient {
|
||||
}
|
||||
|
||||
impl Recipient {
|
||||
/// Attempts to parse a valid YubiKey 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() {
|
||||
Self::from_encoded(&encoded)
|
||||
} else {
|
||||
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
|
||||
@@ -50,4 +60,9 @@ impl Recipient {
|
||||
let tag = Sha256::digest(self.to_string().as_bytes());
|
||||
(&tag[0..TAG_BYTES]).try_into().expect("length is correct")
|
||||
}
|
||||
|
||||
/// Exposes the wrapped public key.
|
||||
pub(crate) fn public_key(&self) -> &p256::PublicKey {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
+241
-8
@@ -4,45 +4,171 @@ use age_plugin::{
|
||||
recipient::{self, RecipientPluginV1},
|
||||
Callbacks,
|
||||
};
|
||||
use bech32::{FromBase32, Variant};
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
|
||||
use crate::{format, p256::Recipient, yubikey, IDENTITY_PREFIX, RECIPIENT_PREFIX};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct RecipientPlugin {}
|
||||
pub(crate) struct RecipientPlugin {
|
||||
recipients: Vec<Recipient>,
|
||||
yubikeys: Vec<yubikey::Stub>,
|
||||
}
|
||||
|
||||
impl RecipientPluginV1 for RecipientPlugin {
|
||||
fn add_recipients<'a, I: Iterator<Item = &'a str>>(
|
||||
&mut self,
|
||||
recipients: I,
|
||||
) -> Result<(), Vec<recipient::Error>> {
|
||||
todo!()
|
||||
let errors: Vec<_> = recipients
|
||||
.enumerate()
|
||||
.filter_map(|(index, recipient)| {
|
||||
if let Some(pk) = bech32::decode(recipient)
|
||||
.ok()
|
||||
.and_then(|(hrp, data, variant)| {
|
||||
if hrp == RECIPIENT_PREFIX && variant == Variant::Bech32 {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.and_then(|data| Vec::from_base32(&data).ok())
|
||||
.and_then(|bytes| Recipient::from_bytes(&bytes))
|
||||
{
|
||||
self.recipients.push(pk);
|
||||
None
|
||||
} else {
|
||||
Some(recipient::Error::Recipient {
|
||||
index,
|
||||
message: "Invalid recipient".to_owned(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn add_identities<'a, I: Iterator<Item = &'a str>>(
|
||||
&mut self,
|
||||
identities: I,
|
||||
) -> Result<(), Vec<recipient::Error>> {
|
||||
todo!()
|
||||
let errors: Vec<_> = identities
|
||||
.enumerate()
|
||||
.filter_map(|(index, identity)| {
|
||||
if let Some(stub) = bech32::decode(identity)
|
||||
.ok()
|
||||
.and_then(|(hrp, data, variant)| {
|
||||
if hrp == IDENTITY_PREFIX.to_lowercase() && variant == Variant::Bech32 {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.and_then(|data| Vec::from_base32(&data).ok())
|
||||
.and_then(|bytes| yubikey::Stub::from_bytes(&bytes, index))
|
||||
{
|
||||
self.yubikeys.push(stub);
|
||||
None
|
||||
} else {
|
||||
Some(recipient::Error::Identity {
|
||||
index,
|
||||
message: "Invalid Yubikey stub".to_owned(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_file_keys(
|
||||
&mut self,
|
||||
file_keys: Vec<FileKey>,
|
||||
callbacks: impl Callbacks<recipient::Error>,
|
||||
mut callbacks: impl Callbacks<recipient::Error>,
|
||||
) -> io::Result<Result<Vec<Vec<Stanza>>, Vec<recipient::Error>>> {
|
||||
todo!()
|
||||
// Connect to any listed YubiKey identities to obtain the corresponding recipients.
|
||||
let mut yk_recipients = vec![];
|
||||
let mut yk_errors = vec![];
|
||||
for stub in &self.yubikeys {
|
||||
match stub.connect(&mut callbacks)? {
|
||||
Ok(conn) => yk_recipients.push(conn.recipient().clone()),
|
||||
Err(e) => yk_errors.push(match e {
|
||||
identity::Error::Identity { index, message } => {
|
||||
recipient::Error::Identity { index, message }
|
||||
}
|
||||
// stub.connect() only returns identity::Error::Identity
|
||||
_ => unreachable!(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// If any errors occurred while fetching recipients from YubiKeys, don't encrypt
|
||||
// the file to any of the other recipients.
|
||||
Ok(if yk_errors.is_empty() {
|
||||
Ok(file_keys
|
||||
.into_iter()
|
||||
.map(|file_key| {
|
||||
self.recipients
|
||||
.iter()
|
||||
.chain(yk_recipients.iter())
|
||||
.map(|pk| format::RecipientLine::wrap_file_key(&file_key, &pk).into())
|
||||
.collect()
|
||||
})
|
||||
.collect())
|
||||
} else {
|
||||
Err(yk_errors)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct IdentityPlugin {}
|
||||
pub(crate) struct IdentityPlugin {
|
||||
yubikeys: Vec<yubikey::Stub>,
|
||||
}
|
||||
|
||||
impl IdentityPluginV1 for IdentityPlugin {
|
||||
fn add_identities<'a, I: Iterator<Item = &'a str>>(
|
||||
&mut self,
|
||||
identities: I,
|
||||
) -> Result<(), Vec<identity::Error>> {
|
||||
todo!()
|
||||
let errors: Vec<_> = identities
|
||||
.enumerate()
|
||||
.filter_map(|(index, identity)| {
|
||||
if let Some(stub) = bech32::decode(identity)
|
||||
.ok()
|
||||
.and_then(|(hrp, data, variant)| {
|
||||
if hrp == IDENTITY_PREFIX.to_lowercase() && variant == Variant::Bech32 {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.and_then(|data| Vec::from_base32(&data).ok())
|
||||
.and_then(|bytes| yubikey::Stub::from_bytes(&bytes, index))
|
||||
{
|
||||
self.yubikeys.push(stub);
|
||||
None
|
||||
} else {
|
||||
Some(identity::Error::Identity {
|
||||
index,
|
||||
message: "Invalid Yubikey stub".to_owned(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_file_keys(
|
||||
@@ -50,6 +176,113 @@ impl IdentityPluginV1 for IdentityPlugin {
|
||||
files: Vec<Vec<Stanza>>,
|
||||
mut callbacks: impl Callbacks<identity::Error>,
|
||||
) -> io::Result<HashMap<usize, Result<FileKey, Vec<identity::Error>>>> {
|
||||
todo!()
|
||||
let mut file_keys = HashMap::with_capacity(files.len());
|
||||
|
||||
// Filter to files / stanzas for which we have matching YubiKeys
|
||||
let mut candidate_stanzas: Vec<(
|
||||
&yubikey::Stub,
|
||||
HashMap<usize, Vec<format::RecipientLine>>,
|
||||
)> = self
|
||||
.yubikeys
|
||||
.iter()
|
||||
.map(|stub| (stub, HashMap::new()))
|
||||
.collect();
|
||||
|
||||
for (file, stanzas) in files.iter().enumerate() {
|
||||
for (stanza_index, stanza) in stanzas.iter().enumerate() {
|
||||
match (
|
||||
format::RecipientLine::from_stanza(&stanza).map(|res| {
|
||||
res.map_err(|_| identity::Error::Stanza {
|
||||
file_index: file,
|
||||
stanza_index,
|
||||
message: "Invalid yubikey stanza".to_owned(),
|
||||
})
|
||||
}),
|
||||
file_keys.contains_key(&file),
|
||||
) {
|
||||
// Only record candidate stanzas for files without structural errors.
|
||||
(Some(Ok(line)), false) => {
|
||||
// A line will match at most one YubiKey.
|
||||
if let Some(files) =
|
||||
candidate_stanzas.iter_mut().find_map(|(stub, files)| {
|
||||
if stub.matches(&line) {
|
||||
Some(files)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
files.entry(file).or_default().push(line);
|
||||
}
|
||||
}
|
||||
(Some(Err(e)), _) => {
|
||||
// This is a structurally-invalid stanza, so we MUST return errors
|
||||
// and MUST NOT unwrap any stanzas in the same file. Let's collect
|
||||
// these errors to return to the client.
|
||||
match file_keys.entry(file).or_insert_with(|| Err(vec![])) {
|
||||
Err(errors) => errors.push(e),
|
||||
Ok(_) => unreachable!(),
|
||||
}
|
||||
// Drop any existing candidate stanzas from this file.
|
||||
for (_, candidates) in candidate_stanzas.iter_mut() {
|
||||
candidates.remove(&file);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by effectiveness (YubiKey that can trial-decrypt the most stanzas)
|
||||
candidate_stanzas.sort_by_key(|(_, files)| {
|
||||
files
|
||||
.iter()
|
||||
.map(|(_, stanzas)| stanzas.len())
|
||||
.sum::<usize>()
|
||||
});
|
||||
candidate_stanzas.reverse();
|
||||
// Remove any YubiKeys without stanzas.
|
||||
candidate_stanzas.retain(|(_, files)| {
|
||||
files
|
||||
.iter()
|
||||
.map(|(_, stanzas)| stanzas.len())
|
||||
.sum::<usize>()
|
||||
> 0
|
||||
});
|
||||
|
||||
for (stub, files) in candidate_stanzas.iter() {
|
||||
let mut conn = match stub.connect(&mut callbacks)? {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
callbacks.error(e)?.unwrap();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for (&file_index, stanzas) in files {
|
||||
if file_keys.contains_key(&file_index) {
|
||||
// We decrypted this file with an earlier YubiKey.
|
||||
continue;
|
||||
}
|
||||
|
||||
for (stanza_index, line) in stanzas.iter().enumerate() {
|
||||
match conn.unwrap_file_key(&line) {
|
||||
Ok(file_key) => {
|
||||
// We've managed to decrypt this file!
|
||||
file_keys.entry(file_index).or_insert(Ok(file_key));
|
||||
break;
|
||||
}
|
||||
Err(_) => callbacks
|
||||
.error(identity::Error::Stanza {
|
||||
file_index,
|
||||
stanza_index,
|
||||
message: "Failed to decrypt YubiKey stanza".to_owned(),
|
||||
})?
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(file_keys)
|
||||
}
|
||||
}
|
||||
|
||||
+204
-1
@@ -1,14 +1,28 @@
|
||||
//! Structs for handling YubiKeys.
|
||||
|
||||
use age_core::{
|
||||
format::{FileKey, FILE_KEY_BYTES},
|
||||
primitives::{aead_decrypt, hkdf},
|
||||
};
|
||||
use age_plugin::{identity, Callbacks};
|
||||
use bech32::{ToBase32, Variant};
|
||||
use dialoguer::Password;
|
||||
use secrecy::ExposeSecret;
|
||||
use std::convert::TryInto;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use yubikey_piv::{key::RetiredSlotId, yubikey::Serial, MgmKey, Readers, YubiKey};
|
||||
use yubikey_piv::{
|
||||
certificate::{Certificate, PublicKeyInfo},
|
||||
key::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
|
||||
yubikey::Serial,
|
||||
MgmKey, Readers, YubiKey,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::Error,
|
||||
format::{RecipientLine, STANZA_KEY_LABEL},
|
||||
p256::{Recipient, TAG_BYTES},
|
||||
IDENTITY_PREFIX,
|
||||
};
|
||||
@@ -145,6 +159,12 @@ impl fmt::Display for Stub {
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Stub {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.to_bytes().eq(&other.to_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
/// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple.
|
||||
///
|
||||
@@ -159,6 +179,17 @@ impl Stub {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_bytes(bytes: &[u8], identity_index: usize) -> Option<Self> {
|
||||
let serial = Serial::from(u32::from_le_bytes(bytes[0..4].try_into().unwrap()));
|
||||
let slot: RetiredSlotId = bytes[4].try_into().ok()?;
|
||||
Some(Stub {
|
||||
serial,
|
||||
slot,
|
||||
tag: bytes[5..9].try_into().unwrap(),
|
||||
identity_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(9);
|
||||
bytes.extend_from_slice(&self.serial.0.to_le_bytes());
|
||||
@@ -166,4 +197,176 @@ impl Stub {
|
||||
bytes.extend_from_slice(&self.tag);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub(crate) fn matches(&self, line: &RecipientLine) -> bool {
|
||||
self.tag == line.tag
|
||||
}
|
||||
|
||||
pub(crate) fn connect<E>(
|
||||
&self,
|
||||
callbacks: &mut dyn Callbacks<E>,
|
||||
) -> io::Result<Result<Connection, identity::Error>> {
|
||||
let mut yubikey = match YubiKey::open_by_serial(self.serial) {
|
||||
Ok(yk) => yk,
|
||||
Err(yubikey_piv::Error::NotFound) => {
|
||||
if callbacks
|
||||
.message(&format!(
|
||||
"Please insert YubiKey with serial {}",
|
||||
self.serial
|
||||
))?
|
||||
.is_err()
|
||||
{
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: format!("Could not find YubiKey with serial {}", self.serial),
|
||||
}));
|
||||
}
|
||||
|
||||
// Start a 15-second timer waiting for the YubiKey to be inserted
|
||||
let start = SystemTime::now();
|
||||
loop {
|
||||
match YubiKey::open_by_serial(self.serial) {
|
||||
Ok(yubikey) => break yubikey,
|
||||
Err(yubikey_piv::Error::NotFound) => (),
|
||||
Err(_) => {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: format!(
|
||||
"Could not open YubiKey with serial {}",
|
||||
self.serial
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
match SystemTime::now().duration_since(start) {
|
||||
Ok(end) if end >= FIFTEEN_SECONDS => {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: format!(
|
||||
"Timed out while waiting for YubiKey with serial {} to be inserted",
|
||||
self.serial
|
||||
),
|
||||
}))
|
||||
}
|
||||
_ => sleep(ONE_SECOND),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: format!("Could not open YubiKey with serial {}", self.serial),
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
// Read the pubkey from the YubiKey slot and check it still matches.
|
||||
let pk = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
|
||||
.ok()
|
||||
.and_then(|cert| match cert.subject_pki() {
|
||||
PublicKeyInfo::EcP256(pubkey) => {
|
||||
Recipient::from_encoded(pubkey).filter(|pk| pk.tag() == self.tag)
|
||||
}
|
||||
_ => None,
|
||||
}) {
|
||||
Some(pk) => pk,
|
||||
None => {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: "A YubiKey stub did not match the YubiKey".to_owned(),
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
let pin = match callbacks.request_secret(&format!(
|
||||
"Enter PIN for YubiKey with serial {}",
|
||||
self.serial
|
||||
))? {
|
||||
Ok(pin) => pin,
|
||||
Err(_) => {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: format!("A PIN is required for YubiKey with serial {}", self.serial),
|
||||
}))
|
||||
}
|
||||
};
|
||||
if yubikey.verify_pin(pin.expose_secret().as_bytes()).is_err() {
|
||||
return Ok(Err(identity::Error::Identity {
|
||||
index: self.identity_index,
|
||||
message: "Invalid YubiKey PIN".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Ok(Connection {
|
||||
yubikey,
|
||||
pk,
|
||||
slot: self.slot,
|
||||
tag: self.tag,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Connection {
|
||||
yubikey: YubiKey,
|
||||
pk: Recipient,
|
||||
slot: RetiredSlotId,
|
||||
tag: [u8; 4],
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub(crate) fn recipient(&self) -> &Recipient {
|
||||
&self.pk
|
||||
}
|
||||
|
||||
pub(crate) fn unwrap_file_key(&mut self, line: &RecipientLine) -> Result<FileKey, ()> {
|
||||
assert_eq!(self.tag, line.tag);
|
||||
|
||||
// The YubiKey API for performing scalar multiplication takes the point in its
|
||||
// uncompressed SEC-1 encoding.
|
||||
let shared_secret = match decrypt_data(
|
||||
&mut self.yubikey,
|
||||
line.epk_bytes.decompress().as_bytes(),
|
||||
AlgorithmId::EccP256,
|
||||
SlotId::Retired(self.slot),
|
||||
) {
|
||||
Ok(res) => res,
|
||||
Err(_) => return Err(()),
|
||||
};
|
||||
|
||||
let mut salt = vec![];
|
||||
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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use yubikey_piv::{key::RetiredSlotId, Serial};
|
||||
|
||||
use super::Stub;
|
||||
|
||||
#[test]
|
||||
fn stub_round_trip() {
|
||||
let stub = Stub {
|
||||
serial: Serial::from(42),
|
||||
slot: RetiredSlotId::R1,
|
||||
tag: [7; 4],
|
||||
identity_index: 0,
|
||||
};
|
||||
|
||||
let encoded = stub.to_bytes();
|
||||
assert_eq!(Stub::from_bytes(&encoded, 0), Some(stub));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user