YubiKey plugin protocol
This commit is contained in:
Generated
+3
-1
@@ -43,6 +43,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"age-core",
|
"age-core",
|
||||||
"age-plugin",
|
"age-plugin",
|
||||||
|
"base64",
|
||||||
"bech32",
|
"bech32",
|
||||||
"chrono",
|
"chrono",
|
||||||
"console",
|
"console",
|
||||||
@@ -52,7 +53,8 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"log",
|
"log",
|
||||||
"p256",
|
"p256",
|
||||||
"rand 0.8.3",
|
"rand 0.7.3",
|
||||||
|
"secrecy",
|
||||||
"sha2",
|
"sha2",
|
||||||
"x509",
|
"x509",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
|
|||||||
+4
-2
@@ -13,6 +13,7 @@ edition = "2018"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
age-core = "0.5"
|
age-core = "0.5"
|
||||||
age-plugin = "0.0"
|
age-plugin = "0.0"
|
||||||
|
base64 = "0.13"
|
||||||
bech32 = "0.8"
|
bech32 = "0.8"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
console = "0.14"
|
console = "0.14"
|
||||||
@@ -21,8 +22,9 @@ env_logger = "0.8"
|
|||||||
gumdrop = "0.8"
|
gumdrop = "0.8"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
p256 = "0.7"
|
p256 = { version = "0.7", features = ["ecdh"] }
|
||||||
rand = "0.8"
|
rand = "0.7"
|
||||||
|
secrecy = "0.7"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
x509 = "0.2"
|
x509 = "0.2"
|
||||||
x509-parser = "0.9"
|
x509-parser = "0.9"
|
||||||
|
|||||||
+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 builder;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod format;
|
||||||
mod p256;
|
mod p256;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod util;
|
mod util;
|
||||||
@@ -21,6 +22,7 @@ use error::Error;
|
|||||||
const PLUGIN_NAME: &str = "age-plugin-yubikey";
|
const PLUGIN_NAME: &str = "age-plugin-yubikey";
|
||||||
const RECIPIENT_PREFIX: &str = "age1yubikey";
|
const RECIPIENT_PREFIX: &str = "age1yubikey";
|
||||||
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
|
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
|
||||||
|
const STANZA_TAG: &str = "piv-p256";
|
||||||
|
|
||||||
const USABLE_SLOTS: [RetiredSlotId; 20] = [
|
const USABLE_SLOTS: [RetiredSlotId; 20] = [
|
||||||
RetiredSlotId::R1,
|
RetiredSlotId::R1,
|
||||||
|
|||||||
+15
@@ -33,6 +33,16 @@ impl fmt::Display for Recipient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// 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
|
/// 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());
|
let tag = Sha256::digest(self.to_string().as_bytes());
|
||||||
(&tag[0..TAG_BYTES]).try_into().expect("length is correct")
|
(&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},
|
recipient::{self, RecipientPluginV1},
|
||||||
Callbacks,
|
Callbacks,
|
||||||
};
|
};
|
||||||
|
use bech32::{FromBase32, Variant};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
use crate::{format, p256::Recipient, yubikey, IDENTITY_PREFIX, RECIPIENT_PREFIX};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct RecipientPlugin {}
|
pub(crate) struct RecipientPlugin {
|
||||||
|
recipients: Vec<Recipient>,
|
||||||
|
yubikeys: Vec<yubikey::Stub>,
|
||||||
|
}
|
||||||
|
|
||||||
impl RecipientPluginV1 for RecipientPlugin {
|
impl RecipientPluginV1 for RecipientPlugin {
|
||||||
fn add_recipients<'a, I: Iterator<Item = &'a str>>(
|
fn add_recipients<'a, I: Iterator<Item = &'a str>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
recipients: I,
|
recipients: I,
|
||||||
) -> Result<(), Vec<recipient::Error>> {
|
) -> 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>>(
|
fn add_identities<'a, I: Iterator<Item = &'a str>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
identities: I,
|
identities: I,
|
||||||
) -> Result<(), Vec<recipient::Error>> {
|
) -> 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(
|
fn wrap_file_keys(
|
||||||
&mut self,
|
&mut self,
|
||||||
file_keys: Vec<FileKey>,
|
file_keys: Vec<FileKey>,
|
||||||
callbacks: impl Callbacks<recipient::Error>,
|
mut callbacks: impl Callbacks<recipient::Error>,
|
||||||
) -> io::Result<Result<Vec<Vec<Stanza>>, Vec<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)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct IdentityPlugin {}
|
pub(crate) struct IdentityPlugin {
|
||||||
|
yubikeys: Vec<yubikey::Stub>,
|
||||||
|
}
|
||||||
|
|
||||||
impl IdentityPluginV1 for IdentityPlugin {
|
impl IdentityPluginV1 for IdentityPlugin {
|
||||||
fn add_identities<'a, I: Iterator<Item = &'a str>>(
|
fn add_identities<'a, I: Iterator<Item = &'a str>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
identities: I,
|
identities: I,
|
||||||
) -> Result<(), Vec<identity::Error>> {
|
) -> 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(
|
fn unwrap_file_keys(
|
||||||
@@ -50,6 +176,113 @@ impl IdentityPluginV1 for IdentityPlugin {
|
|||||||
files: Vec<Vec<Stanza>>,
|
files: Vec<Vec<Stanza>>,
|
||||||
mut callbacks: impl Callbacks<identity::Error>,
|
mut callbacks: impl Callbacks<identity::Error>,
|
||||||
) -> io::Result<HashMap<usize, Result<FileKey, Vec<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.
|
//! 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 bech32::{ToBase32, Variant};
|
||||||
use dialoguer::Password;
|
use dialoguer::Password;
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
use std::convert::TryInto;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::io;
|
||||||
use std::thread::sleep;
|
use std::thread::sleep;
|
||||||
use std::time::{Duration, SystemTime};
|
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::{
|
use crate::{
|
||||||
error::Error,
|
error::Error,
|
||||||
|
format::{RecipientLine, STANZA_KEY_LABEL},
|
||||||
p256::{Recipient, TAG_BYTES},
|
p256::{Recipient, TAG_BYTES},
|
||||||
IDENTITY_PREFIX,
|
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 {
|
impl Stub {
|
||||||
/// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple.
|
/// 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> {
|
fn to_bytes(&self) -> Vec<u8> {
|
||||||
let mut bytes = Vec::with_capacity(9);
|
let mut bytes = Vec::with_capacity(9);
|
||||||
bytes.extend_from_slice(&self.serial.0.to_le_bytes());
|
bytes.extend_from_slice(&self.serial.0.to_le_bytes());
|
||||||
@@ -166,4 +197,176 @@ impl Stub {
|
|||||||
bytes.extend_from_slice(&self.tag);
|
bytes.extend_from_slice(&self.tag);
|
||||||
bytes
|
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