2025-01-30 16:07:32 +00:00
|
|
|
use keyfork_prompt::{
|
|
|
|
default_handler, prompt_validated_passphrase,
|
|
|
|
validators::{PinValidator, Validator},
|
|
|
|
};
|
|
|
|
use openpgp_card::{Error as CardError, StatusBytes};
|
|
|
|
use openpgp_card_sequoia::{state::Open, Card};
|
|
|
|
use sequoia_openpgp::{
|
|
|
|
self as openpgp,
|
|
|
|
armor::{Kind, Writer},
|
|
|
|
crypto::hash::Digest,
|
|
|
|
packet::{signature::SignatureBuilder, Packet},
|
|
|
|
parse::Parse,
|
|
|
|
serialize::Serialize as _,
|
|
|
|
types::SignatureType,
|
|
|
|
Cert, Fingerprint,
|
|
|
|
};
|
2025-01-30 19:51:53 +00:00
|
|
|
use chrono::prelude::*;
|
2025-01-30 16:07:32 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use serde_json::Value;
|
|
|
|
use std::{collections::BTreeMap, fs::File, io::Read, path::Path};
|
|
|
|
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
|
|
/// An error with a [`PayloadVerification`] policy.
|
|
|
|
#[error("{error} (policy: {policy:?})")]
|
|
|
|
pub struct Error {
|
|
|
|
error: BaseError,
|
|
|
|
policy: PayloadVerification,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
|
|
pub enum BaseError {
|
|
|
|
/// In the given certificate keyring, the provided fingerprint was not found.
|
|
|
|
#[error("fingerprint not found: {0}")]
|
|
|
|
FingerprintNotFound(Fingerprint),
|
|
|
|
|
|
|
|
/// No smartcard was found.
|
|
|
|
#[error("no smartcard found")]
|
|
|
|
NoSmartcard,
|
|
|
|
|
|
|
|
/// None of the certificates in the given certificate keyring matched any plugged-in smartcard.
|
|
|
|
#[error("no certs found matching any available smartcard")]
|
|
|
|
NoCertMatchedSmartcard,
|
|
|
|
|
|
|
|
/// The certificate was not trusted by the root of trust.
|
|
|
|
#[error("untrusted certificate: {0} has not signed {1:?}")]
|
|
|
|
UntrustedCertificates(Fingerprint, Vec<Fingerprint>),
|
|
|
|
|
|
|
|
/// No certificate in the given certificate keyring matched the signature.
|
|
|
|
#[error("no public key matched signature")]
|
|
|
|
NoPublicKeyMatchedSignature,
|
|
|
|
|
|
|
|
/// Not enough signatures matched based on the given threshold
|
|
|
|
#[error("not enough signatures: {0} < {1}")]
|
|
|
|
NotEnoughSignatures(u8, u8),
|
|
|
|
|
|
|
|
/// A Payload was provided when an inner [`serde_json::Value`] was expected.
|
|
|
|
#[error("a payload was provided when a non-payload JSON value was expected")]
|
|
|
|
UnexpectedPayloadProvided,
|
2025-01-30 19:51:53 +00:00
|
|
|
|
|
|
|
/// The JSON object is not a valid value.
|
|
|
|
#[error("the JSON object is not a valid value")]
|
|
|
|
InvalidJSONValue,
|
2025-01-30 16:07:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
impl BaseError {
|
|
|
|
fn with_policy(self, policy: &PayloadVerification) -> Error {
|
|
|
|
Error {
|
|
|
|
error: self,
|
|
|
|
policy: policy.clone(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn canonicalize(value: Value) -> Value {
|
|
|
|
match value {
|
|
|
|
Value::Array(vec) => {
|
|
|
|
let values = vec.into_iter().map(canonicalize).collect();
|
|
|
|
Value::Array(values)
|
|
|
|
}
|
|
|
|
Value::Object(map) => {
|
|
|
|
// this sorts the values
|
|
|
|
let map: BTreeMap<String, Value> =
|
|
|
|
map.into_iter().map(|(k, v)| (k, canonicalize(v))).collect();
|
|
|
|
let sorted: Vec<Value> = map
|
|
|
|
.into_iter()
|
|
|
|
.map(|(k, v)| Value::Array(vec![Value::String(k), v]))
|
|
|
|
.collect();
|
|
|
|
Value::Array(sorted)
|
|
|
|
}
|
|
|
|
value => value,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn unhashed(value: Value) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
2025-01-30 19:51:53 +00:00
|
|
|
let Value::Object(mut value) = value else {
|
|
|
|
return Err(BaseError::InvalidJSONValue.into());
|
|
|
|
};
|
|
|
|
value.remove("signatures");
|
|
|
|
let value = canonicalize(Value::Object(value));
|
2025-01-30 16:07:32 +00:00
|
|
|
let bincoded = bincode::serialize(&value)?;
|
|
|
|
Ok(bincoded)
|
|
|
|
}
|
|
|
|
|
|
|
|
fn hash(value: Value) -> Result<Box<dyn Digest>, Box<dyn std::error::Error>> {
|
2025-01-30 19:51:53 +00:00
|
|
|
let bincoded = unhashed(value)?;
|
2025-01-30 16:07:32 +00:00
|
|
|
let mut digest = openpgp::types::HashAlgorithm::SHA512.context()?;
|
|
|
|
digest.update(&bincoded);
|
|
|
|
|
|
|
|
Ok(digest)
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
|
|
pub struct Payload {
|
2025-01-30 19:51:53 +00:00
|
|
|
workflow: [String; 2],
|
2025-01-30 16:07:32 +00:00
|
|
|
values: Value,
|
2025-01-30 19:51:53 +00:00
|
|
|
datetime: DateTime<Utc>,
|
|
|
|
#[serde(default)]
|
2025-01-30 16:07:32 +00:00
|
|
|
signatures: Vec<String>,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
pub struct PayloadVerification {
|
|
|
|
threshold: u8,
|
|
|
|
error_on_invalid: bool,
|
|
|
|
error_on_missing_key: bool,
|
|
|
|
one_each: bool,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl std::default::Default for PayloadVerification {
|
|
|
|
fn default() -> Self {
|
|
|
|
Self {
|
|
|
|
threshold: 0,
|
|
|
|
error_on_invalid: true,
|
|
|
|
error_on_missing_key: true,
|
|
|
|
one_each: true,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
impl PayloadVerification {
|
|
|
|
pub fn new() -> Self {
|
|
|
|
Default::default()
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn with_one_per_key(self, one_each: bool) -> Self {
|
|
|
|
Self { one_each, ..self }
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn with_threshold(self, threshold: u8) -> Self {
|
|
|
|
Self { threshold, ..self }
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn with_any_valid(self) -> Self {
|
|
|
|
Self {
|
|
|
|
threshold: 1,
|
|
|
|
error_on_invalid: false,
|
|
|
|
..self
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn with_all_valid(self, threshold: u8) -> Self {
|
|
|
|
Self {
|
|
|
|
threshold,
|
|
|
|
error_on_invalid: true,
|
|
|
|
..self
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn ignoring_invalid_signatures(self) -> Self {
|
|
|
|
Self {
|
|
|
|
error_on_invalid: false,
|
|
|
|
..self
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn ignoring_missing_keys(self) -> Self {
|
|
|
|
Self {
|
|
|
|
error_on_missing_key: true,
|
|
|
|
..self
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Format a name from an OpenPGP card.
|
|
|
|
fn format_name(input: impl AsRef<str>) -> String {
|
|
|
|
let mut n = input
|
|
|
|
.as_ref()
|
|
|
|
.split("<<")
|
|
|
|
.take(2)
|
|
|
|
.map(|s| s.replace('<', " "))
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
n.reverse();
|
|
|
|
n.join(" ")
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Payload {
|
2025-01-30 19:51:53 +00:00
|
|
|
/// Create a new Payload, using the current system's time, in UTC.
|
|
|
|
pub fn new(values: serde_json::Value, module_name: impl AsRef<str>, workflow_name: impl AsRef<str>) -> Self {
|
|
|
|
Self {
|
|
|
|
workflow: [module_name.as_ref().to_string(), workflow_name.as_ref().to_string()],
|
|
|
|
values,
|
|
|
|
datetime: Utc::now(),
|
|
|
|
signatures: vec![],
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-01-30 16:07:32 +00:00
|
|
|
/// Load a Payload and the relevant certificates.
|
|
|
|
///
|
|
|
|
/// # Errors
|
|
|
|
///
|
|
|
|
/// The constructor may error if either file can't be read or if either file has invalid data.
|
|
|
|
pub fn load(
|
|
|
|
payload_path: impl AsRef<Path>,
|
|
|
|
keyring_path: impl AsRef<Path>,
|
|
|
|
) -> Result<(Self, Vec<Cert>), Box<dyn std::error::Error>> {
|
|
|
|
let payload_file = File::open(payload_path)?;
|
|
|
|
let cert_file = File::open(keyring_path)?;
|
|
|
|
|
|
|
|
Self::from_readers(payload_file, cert_file)
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn from_readers(
|
|
|
|
payload: impl Read,
|
|
|
|
keyring: impl Read + Send + Sync,
|
|
|
|
) -> Result<(Self, Vec<Cert>), Box<dyn std::error::Error>> {
|
|
|
|
let payload: Payload = serde_json::from_reader(payload)?;
|
|
|
|
let certs =
|
|
|
|
openpgp::cert::CertParser::from_reader(keyring)?.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
Ok((payload, certs))
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Attach a signature from an OpenPGP card.
|
|
|
|
///
|
|
|
|
/// # Errors
|
|
|
|
///
|
|
|
|
/// The method may error if a signature could not be created.
|
|
|
|
pub fn add_signature(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
2025-01-30 19:51:53 +00:00
|
|
|
let unhashed = unhashed(serde_json::to_value(&self)?)?;
|
2025-01-30 16:07:32 +00:00
|
|
|
let builder = SignatureBuilder::new(SignatureType::Binary);
|
|
|
|
let mut prompt_handler = default_handler()?;
|
|
|
|
let pin_validator = PinValidator {
|
|
|
|
min_length: Some(6),
|
|
|
|
..Default::default()
|
|
|
|
};
|
|
|
|
|
|
|
|
for backend in card_backend_pcsc::PcscBackend::cards(None)? {
|
|
|
|
let mut card = Card::<Open>::new(backend?)?;
|
|
|
|
let mut transaction = card.transaction()?;
|
|
|
|
|
|
|
|
let cardholder_name = format_name(transaction.cardholder_name()?);
|
|
|
|
let card_id = transaction.application_identifier()?.ident();
|
|
|
|
let mut pin = None;
|
|
|
|
|
|
|
|
while transaction.pw_status_bytes()?.err_count_pw1() > 0 && pin.is_none() {
|
|
|
|
transaction.reload_ard()?;
|
|
|
|
let attempts = transaction.pw_status_bytes()?.err_count_pw1();
|
|
|
|
let rpea = "Remaining PIN entry attempts";
|
|
|
|
let message = if cardholder_name.is_empty() {
|
|
|
|
format!("Unlock card {card_id}\n{rpea}: {attempts}\n\nPIN: ")
|
|
|
|
} else {
|
|
|
|
format!(
|
|
|
|
"Unlock card {card_id} ({cardholder_name})\n{rpea}: {attempts}\n\nPIN: "
|
|
|
|
)
|
|
|
|
};
|
|
|
|
|
|
|
|
let temp_pin = prompt_validated_passphrase(
|
|
|
|
&mut *prompt_handler,
|
|
|
|
&message,
|
|
|
|
3,
|
|
|
|
pin_validator.to_fn(),
|
|
|
|
)?;
|
|
|
|
|
|
|
|
let verification_status = transaction.verify_user_pin(temp_pin.as_str().trim());
|
|
|
|
match verification_status {
|
|
|
|
#[allow(clippy::ignored_unit_patterns)]
|
|
|
|
Ok(_) => {
|
|
|
|
pin.replace(temp_pin);
|
|
|
|
}
|
|
|
|
// NOTE: This should not be hit, because of the above validator.
|
|
|
|
Err(CardError::CardStatus(
|
|
|
|
StatusBytes::IncorrectParametersCommandDataField,
|
|
|
|
)) => {
|
|
|
|
prompt_handler.prompt_message(keyfork_prompt::Message::Text(
|
|
|
|
"Invalid PIN length entered.".to_string(),
|
|
|
|
))?;
|
|
|
|
}
|
|
|
|
Err(_) => {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let mut signer_card = transaction.to_signing_card(pin.expect("valid PIN").as_str())?;
|
|
|
|
// NOTE: Can't use a PromptHandler to prompt a message as it doesn't provide a way to
|
|
|
|
// cancel a prompt when in terminal mode. Just eprintln to stderr.
|
|
|
|
//
|
|
|
|
// We shouldn't be writing with a PromptHandler, so the terminal should be reset.
|
|
|
|
let mut signer =
|
|
|
|
signer_card.signer(&|| eprintln!("Touch confirmation needed for signing"))?;
|
|
|
|
let signature = builder.clone().sign_message(&mut signer, &unhashed)?;
|
|
|
|
let signature = Packet::from(signature);
|
|
|
|
|
|
|
|
let mut armored_signature = vec![];
|
|
|
|
let mut writer = Writer::new(&mut armored_signature, Kind::Signature)?;
|
|
|
|
signature.serialize(&mut writer)?;
|
|
|
|
writer.finalize()?;
|
|
|
|
|
|
|
|
self.signatures.push(String::from_utf8(armored_signature)?);
|
|
|
|
}
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Verify the keychain and certificates using either a Key ID or an OpenPGP card.
|
|
|
|
///
|
|
|
|
/// # Errors
|
|
|
|
///
|
|
|
|
/// The method may error if no certificate could be verified or if any singatures are invalid.
|
|
|
|
pub fn verify_signatures(
|
|
|
|
&self,
|
|
|
|
certs: &[Cert],
|
|
|
|
verification_policy: &PayloadVerification,
|
|
|
|
fingerprint: Option<Fingerprint>,
|
|
|
|
) -> Result<&serde_json::Value, Box<dyn std::error::Error>> {
|
|
|
|
let policy = openpgp::policy::StandardPolicy::new();
|
|
|
|
let validated_cert = find_matching_certificate(fingerprint, certs, &policy)?;
|
|
|
|
let (certs, invalid_certs) = validate_cross_signed_certs(&validated_cert, certs, &policy)?;
|
|
|
|
|
|
|
|
if !invalid_certs.is_empty() {
|
|
|
|
return Err(BaseError::UntrustedCertificates(
|
|
|
|
validated_cert.fingerprint(),
|
|
|
|
invalid_certs.iter().map(Cert::fingerprint).collect(),
|
|
|
|
))?;
|
|
|
|
}
|
|
|
|
|
2025-01-30 19:51:53 +00:00
|
|
|
let hashed = hash(serde_json::to_value(self)?)?;
|
2025-01-30 16:07:32 +00:00
|
|
|
|
|
|
|
let PayloadVerification {
|
|
|
|
mut threshold,
|
|
|
|
error_on_invalid,
|
|
|
|
error_on_missing_key,
|
|
|
|
one_each,
|
|
|
|
} = *verification_policy;
|
|
|
|
let mut matches = 0;
|
|
|
|
|
|
|
|
if one_each {
|
|
|
|
threshold = certs.len() as u8;
|
|
|
|
}
|
|
|
|
|
|
|
|
for signature in &self.signatures {
|
|
|
|
let packet = Packet::from_bytes(signature.as_bytes())?;
|
|
|
|
let Packet::Signature(signature) = packet else {
|
|
|
|
panic!("bad packet found: {}", packet.tag());
|
|
|
|
};
|
|
|
|
let mut signature_matched = false;
|
|
|
|
for issuer in signature.get_issuers() {
|
|
|
|
for cert in &certs {
|
|
|
|
match cert
|
|
|
|
.with_policy(&policy, None)?
|
|
|
|
.keys()
|
|
|
|
.alive()
|
|
|
|
.for_signing()
|
|
|
|
.key_handle(issuer.clone())
|
|
|
|
.next()
|
|
|
|
.map(|signing_key| signature.verify_hash(&signing_key, hashed.clone()))
|
|
|
|
{
|
|
|
|
Some(Ok(())) => {
|
|
|
|
// key found, signature matched
|
|
|
|
signature_matched = true;
|
|
|
|
}
|
|
|
|
Some(Err(e)) => {
|
|
|
|
if error_on_invalid {
|
|
|
|
return Err(e)?;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
// key not found, but we have more certs to go through
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if signature_matched {
|
|
|
|
matches += 1;
|
|
|
|
} else if error_on_missing_key {
|
|
|
|
return Err(
|
|
|
|
BaseError::NoPublicKeyMatchedSignature.with_policy(verification_policy)
|
|
|
|
)?;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if matches < threshold {
|
|
|
|
return Err(
|
|
|
|
BaseError::NotEnoughSignatures(matches, threshold).with_policy(verification_policy)
|
|
|
|
)?;
|
|
|
|
}
|
|
|
|
|
|
|
|
Ok(&self.values)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn find_matching_certificate(
|
|
|
|
fingerprint: Option<Fingerprint>,
|
|
|
|
certs: &[Cert],
|
|
|
|
policy: &sequoia_openpgp::policy::StandardPolicy<'_>,
|
|
|
|
) -> Result<Cert, Box<dyn std::error::Error>> {
|
|
|
|
if let Some(fingerprint) = fingerprint {
|
|
|
|
Ok(certs
|
|
|
|
.iter()
|
|
|
|
.find(|cert| cert.fingerprint() == fingerprint)
|
|
|
|
.ok_or(BaseError::FingerprintNotFound(fingerprint))?
|
|
|
|
.clone())
|
|
|
|
} else {
|
|
|
|
let mut any_smartcard = false;
|
|
|
|
for backend in card_backend_pcsc::PcscBackend::cards(None)? {
|
|
|
|
any_smartcard = true;
|
|
|
|
let mut card = Card::<Open>::new(backend?)?;
|
|
|
|
let mut transaction = card.transaction()?;
|
|
|
|
let signing_fingerprint = transaction
|
|
|
|
.fingerprint(openpgp_card::KeyType::Signing)?
|
|
|
|
.expect("smartcard signing key is unavailable");
|
|
|
|
for cert in certs {
|
|
|
|
let valid_cert = cert.with_policy(policy, None)?;
|
|
|
|
for key in valid_cert.keys().alive().for_signing() {
|
|
|
|
let fpr = key.fingerprint();
|
|
|
|
if fpr.as_bytes() == signing_fingerprint.as_bytes() {
|
|
|
|
return Ok(cert.clone());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if any_smartcard {
|
|
|
|
Err(BaseError::NoCertMatchedSmartcard.into())
|
|
|
|
} else {
|
|
|
|
Err(BaseError::NoSmartcard.into())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Validate that `certs` are signed by `validated_cert`, either by a signature directly upon the
|
|
|
|
/// primary key of that certificate, or a signature on a user ID of the certificate.
|
|
|
|
///
|
|
|
|
/// Returns a list of trusted certs and a list of untrusted certs.
|
|
|
|
fn validate_cross_signed_certs(
|
|
|
|
validated_cert: &Cert,
|
|
|
|
certs: &[Cert],
|
|
|
|
policy: &sequoia_openpgp::policy::StandardPolicy,
|
|
|
|
) -> Result<(Vec<Cert>, Vec<Cert>), Box<dyn std::error::Error>> {
|
|
|
|
let our_pkey = validated_cert.primary_key();
|
|
|
|
let mut verified_certs = vec![validated_cert.clone()];
|
|
|
|
let mut unverified_certs = vec![];
|
|
|
|
|
|
|
|
for cert in certs
|
|
|
|
.iter()
|
|
|
|
.filter(|cert| cert.fingerprint() != validated_cert.fingerprint())
|
|
|
|
{
|
|
|
|
let mut has_valid_userid_signature = false;
|
|
|
|
let cert_pkey = cert.primary_key();
|
|
|
|
|
|
|
|
// check signatures on User IDs
|
|
|
|
let userids = cert
|
|
|
|
.userids()
|
|
|
|
.map(|ua| (ua.certifications(), ua.userid().clone()));
|
|
|
|
for (signatures, userid) in userids {
|
|
|
|
for signature in signatures {
|
|
|
|
if signature
|
|
|
|
.verify_userid_binding(&our_pkey, &*cert_pkey, &userid)
|
|
|
|
.is_ok()
|
|
|
|
{
|
|
|
|
has_valid_userid_signature = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// check signatures on the primary key itself
|
|
|
|
let has_valid_direct_signature = cert_pkey
|
|
|
|
.active_certifications_by_key(policy, None, &***our_pkey.role_as_unspecified())
|
|
|
|
.next()
|
|
|
|
.is_some();
|
|
|
|
|
|
|
|
if has_valid_userid_signature || has_valid_direct_signature {
|
|
|
|
verified_certs.push(cert.clone());
|
|
|
|
} else {
|
|
|
|
unverified_certs.push(cert.clone());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Ok((verified_certs, unverified_certs))
|
|
|
|
}
|