rsop-oct 0.1.9

SOP CLI tool for OpenPGP card devices based on rPGP
Documentation
use openpgp_card_rpgp::CardSlot;
use pgp::{
    composed::SignedPublicKey,
    packet::{
        PublicKey,
        PublicSubkey,
        RevocationCode,
        Signature,
        SignatureType,
        Subpacket,
        SubpacketData,
        UserId,
    },
    types::{Duration, KeyDetails, Tag, Timestamp},
};
use rpgpie::{
    certificate::{Certificate, Checked},
    merge::CertificateInfo,
};
use sop::{Password, errors::Error};

use crate::{
    Certs,
    Keys,
    RPGSOPOCT,
    card::{get_card, present_pin},
};

// TODO: remove again, when rpgp offers more time-math functionality
const SECONDS_PER_DAY: u32 = 24 * 60 * 60;

pub(crate) struct UpdateKey {
    with_key_password: Vec<Password>,
    merge: Vec<Certificate>,
}

impl UpdateKey {
    pub(crate) fn new() -> Self {
        Self {
            with_key_password: Default::default(),
            merge: Default::default(),
        }
    }
}

impl<'a> sop::ops::UpdateKey<'a, RPGSOPOCT, Certs, Keys> for UpdateKey {
    fn signing_only(
        self: Box<Self>,
    ) -> Box<dyn sop::ops::UpdateKey<'a, RPGSOPOCT, Certs, Keys> + 'a> {
        unimplemented!("signing_only")
    }

    fn no_added_capabilities(
        self: Box<Self>,
    ) -> Box<dyn sop::ops::UpdateKey<'a, RPGSOPOCT, Certs, Keys> + 'a> {
        unimplemented!("no_added_capabilities")
    }

    fn with_key_password(
        mut self: Box<Self>,
        password: Password,
    ) -> sop::Result<Box<dyn sop::ops::UpdateKey<'a, RPGSOPOCT, Certs, Keys> + 'a>> {
        self.with_key_password.push(password);
        Ok(self)
    }

    fn merge_updates(
        mut self: Box<Self>,
        updates: &Certs,
    ) -> sop::Result<Box<dyn sop::ops::UpdateKey<'a, RPGSOPOCT, Certs, Keys> + 'a>> {
        for cert in &updates.certs {
            self.merge.push(cert.clone());
        }

        Ok(self)
    }

    fn update(self: Box<Self>, keys: &Keys) -> sop::Result<Keys> {
        let now = Timestamp::now();

        let mut res = Vec::new();

        for key in &keys.keys {
            let ccert: Checked = key.clone().into();

            if ccert.revoked_at(now) {
                return Err(sop::errors::Error::PrimaryKeyBad);
            }

            // We don't check if the cert is expired.
            // We are willing to update our own expired certificate.

            let mut spk: SignedPublicKey = key.clone().into();
            let signer = &spk.primary_key;

            log::info!(
                "Trying to update this cert with signer: {:02x?}",
                signer.fingerprint()
            );

            // Set up a CardSlot object for the cert's primary key
            let mut card = get_card(&signer, openpgp_card::ocard::KeyType::Signing)
                .map_err(|_| Error::UnspecifiedFailure)?;

            let mut tx = card.transaction().expect("FIXME");

            present_pin(
                &mut tx,
                openpgp_card::ocard::KeyType::Signing,
                &self.with_key_password,
            )
            .expect("FIXME");

            let cs =
                CardSlot::init_from_card(&mut tx, openpgp_card::ocard::KeyType::Signing, &|| {
                    eprintln!("Touch confirmation required for rsop-oct update-key")
                })
                .expect("FIXME");

            // DKS
            let selfsig = filter_direct(&spk.details.direct_signatures, &spk.primary_key);

            if let Some(sig) = active_sig(&selfsig, now) {
                // we found an active direct key signature. extend the key expiration, if necessary:
                if let Some(sig) = extend_sig(sig, &cs, spk.primary_key.created_at(), None, None) {
                    // add the new signature to the DKS, if we made one
                    spk.details.direct_signatures.push(sig);
                }
            } else {
                eprintln!("no active dks, not creating a new one")
            }

            // Subkeys
            for spsk in spk.public_subkeys.iter_mut() {
                let selfsig = filter_subkey(&spsk.signatures, &spk.primary_key, &spsk.key);

                if let Some(sig) = active_sig(&selfsig, now) {
                    if let Some(sig) =
                        extend_sig(sig, &cs, spsk.key.created_at(), Some(&spsk.key), None)
                    {
                        // add new signature to the subkey, if any
                        spsk.signatures.push(sig);
                    }
                }
            }

            // User IDs
            for su in spk.details.users.iter_mut() {
                let selfsig = filter_userid(&su.signatures, &spk.primary_key, &su.id);

                if let Some(sig) = active_sig(&selfsig, now) {
                    if let Some(sig) =
                        extend_sig(sig, &cs, spk.primary_key.created_at(), None, Some(&su.id))
                    {
                        // add new signature to the user id
                        su.signatures.push(sig);
                    }
                }
            }

            // Switch to Certificate view
            let mut cert: Certificate = spk.into();

            // Merge in additional cert data, if any
            cert.merge(
                self.merge
                    .iter()
                    .map(|c| CertificateInfo::Cert(c.clone()))
                    .collect(),
            );

            // Store the (possibly modified) spk in the output list
            res.push(cert);
        }

        Ok(Keys {
            keys: res,
            source_name: None,
        })
    }
}

/// Extend the signature's key expiration time by a number of years, so that it is valid until at
/// least now + 30 days
///
/// FIXME: make a nicer API to distinguish between direct, subkey and userid binding signatures
/// FIXME: maybe integrate self-sig check into this fn?
fn extend_sig(
    sig: &Signature,
    cs: &CardSlot,
    created: Timestamp,
    subkey: Option<&PublicSubkey>,
    userid: Option<&UserId>,
) -> Option<Signature> {
    let Some(config) = sig.config() else {
        unimplemented!("FIXME")
    };

    if let Some(exp) = sig.key_expiration_time() {
        if exp.as_secs() > 0 {
            let now = Timestamp::now();
            let target = Timestamp::from_secs(now.as_secs() + 30 * SECONDS_PER_DAY);

            let valid_until = Timestamp::from_secs(created.as_secs() + exp.as_secs());

            // Signature expires in less than 30 days
            if valid_until < target {
                // How many seconds short is the current validity, compared to `target`?
                let diff = target.as_secs() - valid_until.as_secs();

                // The number of years that will put expiration beyond "target"
                // (`diff` divided by seconds per year, rounded up)
                let years = diff.div_ceil(365 * SECONDS_PER_DAY);

                let mut config_new = config.clone();
                config_new.hashed_subpackets.retain(|s| {
                    !matches!(&s.data, SubpacketData::KeyExpirationTime(_))
                        && !matches!(&s.data, SubpacketData::SignatureCreationTime(_))
                });

                // Set a new signature creation time
                config_new
                    .hashed_subpackets
                    .push(Subpacket::regular(SubpacketData::SignatureCreationTime(now)).unwrap());

                // Extend by a number of years that puts the new "key expiration" beyond "target"
                let new_exp = Duration::from_secs(exp.as_secs() + years * 365 * SECONDS_PER_DAY);
                config_new
                    .hashed_subpackets
                    .push(Subpacket::regular(SubpacketData::KeyExpirationTime(new_exp)).unwrap());

                // Produce a new signature packet based on `config_new`
                let sig = match (subkey, userid) {
                    (None, None) => {
                        eprintln!("Generating new direct key signature for primary");
                        config_new
                            .sign_key(cs, &pgp::types::Password::empty(), cs.public_key())
                            .expect("FIXME")
                    }
                    (Some(subkey), None) => {
                        eprintln!(
                            "Generating new subkey binding signature for {}",
                            subkey.fingerprint()
                        );

                        config_new
                            .sign_subkey_binding(
                                cs,
                                cs.public_key(),
                                &pgp::types::Password::empty(),
                                subkey,
                            )
                            .expect("FIXME")
                    }
                    (_, Some(userid)) => {
                        eprintln!(
                            "Generating new userid self-certification for '{}'",
                            String::from_utf8_lossy(userid.id())
                        );

                        config_new
                            .sign_certification(
                                cs,
                                cs.public_key(),
                                &pgp::types::Password::empty(),
                                Tag::UserId,
                                userid,
                            )
                            .expect("FIXME")
                    }
                };

                return Some(sig);
            }
        }
    }

    None
}

/// filter direct signatures on the primary for self-signature-ness
///
/// FIXME: this logic should be usable from rpgpie
fn filter_direct<'a>(sigs: &'a [Signature], primary: &PublicKey) -> Vec<&'a Signature> {
    sigs.iter()
        .filter(|sig| sig.verify_key(primary).is_ok())
        .collect()
}

/// filter subkey bindings for self-signature-ness
///
/// FIXME: this logic should be usable from rpgpie
fn filter_subkey<'a, 'b>(
    sigs: &'a [Signature],
    primary: &'b PublicKey,
    subkey: &'b PublicSubkey,
) -> Vec<&'a Signature> {
    sigs.iter()
        .filter(|sig| sig.verify_subkey_binding(primary, subkey).is_ok())
        .collect()
}

/// filter userid bindings for self-signature-ness
///
/// FIXME: this logic should be usable from rpgpie
fn filter_userid<'a, 'b>(
    sigs: &'a [Signature],
    primary: &'b PublicKey,
    userid: &'b UserId,
) -> Vec<&'a Signature> {
    sigs.iter()
        .filter(|sig| {
            sig.verify_certification(primary, Tag::UserId, userid)
                .is_ok()
        })
        .collect()
}

/// Get the active self-signature (that is not a revocation) in this stack, at `reference`
///
/// - None, if the stack is revoked, or there is no signature
/// - Some(sig), with the most recently created signature otherwise (while filtering out signatures
///   from the future)
///
/// FIXME: this logic should be usable from rpgpie
fn active_sig<'a>(self_sigs: &'a [&'a Signature], reference: Timestamp) -> Option<&'a Signature> {
    /// Checks for explicitly "soft" reason codes.
    /// In all other cases, we consider a revocation to be "hard".
    fn is_soft_revocation_reason(reason: Option<&RevocationCode>) -> bool {
        matches!(
            reason,
            Some(RevocationCode::KeyRetired)
                | Some(RevocationCode::CertUserIdInvalid)
                | Some(RevocationCode::KeySuperseded)
        )
    }

    // Check for revocations
    for sig in self_sigs {
        if is_revocation(sig) {
            // this is a revocation
            if !is_soft_revocation_reason(sig.revocation_reason_code()) {
                // hard revocation -> there is no active self signature
                return None;
            } else {
                // soft revocation, but is it created before reference time?
                if sig.created().expect("signature creation time") <= reference {
                    // yes -> there is no active signature
                    return None;
                }
            }
        }
    }

    self_sigs
        .iter()
        .filter(|s| !is_revocation(s))
        .filter(|s| {
            if let Some(created) = s.created() {
                created <= reference
            } else {
                false
            }
        })
        // FIXME: is this right?
        .max_by(|this, other| {
            this.created()
                .unwrap()
                .as_secs()
                .cmp(&other.created().unwrap().as_secs())
        })
        .copied()
}

/// FIXME: this logic should be usable from rpgpie
fn is_revocation(sig: &Signature) -> bool {
    let Some(typ) = sig.typ() else {
        unimplemented!("no signature type")
    };

    matches!(
        typ,
        SignatureType::KeyRevocation
            | SignatureType::CertRevocation
            | SignatureType::SubkeyRevocation
    )
}