YubiKey plugin protocol

This commit is contained in:
Jack Grigg
2021-01-04 01:05:39 +00:00
parent 12df32817c
commit 5a85a15341
7 changed files with 598 additions and 12 deletions
+129
View File
@@ -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,
}
}
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}