voa_openpgp/trust/
util.rs

1//! Helper functions for VOA trust model business logic
2
3use std::{collections::HashSet, ops::Add, str::FromStr, time::SystemTime};
4
5use log::{info, warn};
6use pgp::{
7    packet::{RevocationCode, Signature, SignatureType, UserId},
8    types::SignedUser,
9};
10use voa_config::openpgp::{DomainName, OpenpgpFingerprint};
11
12use crate::{OpenpgpCert, OpenpgpSignature};
13
14/// Checks whether an [`OpenpgpCert`] matches a set of [`OpenpgpFingerprint`]s.
15///
16/// Returns `true` if `cert` is an accepted [`OpenpgpCert`], based on the fingerprint filter
17/// configuration in `fingerprints`.
18///
19/// If `fingerprints` is empty, any cert is accepted.
20///
21/// Otherwise, only certs with a matching fingerprint are accepted.
22pub(crate) fn fingerprint_matches(
23    cert: &OpenpgpCert,
24    fingerprints: &HashSet<OpenpgpFingerprint>,
25) -> bool {
26    // if we have no filter list, then we keep all certs
27    if fingerprints.is_empty() {
28        return true;
29    };
30
31    if let Ok(fp) = cert.fingerprint() {
32        if fingerprints.contains(&fp) {
33            true
34        } else {
35            info!(
36                "Ignoring certificate {:?} because it doesn't match the fingerprint_matches set",
37                fp
38            );
39            false
40        }
41    } else {
42        warn!(
43            "Skipping certificate with invalid fingerprint {:?}",
44            cert.certificate.fingerprint()
45        );
46        false
47    }
48}
49
50/// Returns the domain name in an OpenPGP User ID string, if one is found.
51///
52/// For a `userid` of the form "Foo <bar@baz.org> Bleep", we consider
53/// `bar@baz.org` the email part, and `baz.org` the domainname.
54///
55/// If there are additional `<` or `>` characters, this returns `None`.
56/// If there is more than one `@` character in the email part, this returns `None`.
57///
58/// # Note
59///
60/// This is a very naive implementation and will be replaced by a proper type in the future.
61fn extract_domain_from_user_id(userid: &str) -> Option<&str> {
62    // If `userid` only contains one occurrence of "<" and ">" respectively,
63    // then `maybe_email` will contain the substring between those two characters.
64    let (_, part) = userid.split_once('<')?;
65    let (maybe_email, rest) = part.split_once('>')?;
66
67    // If there are any additional occurrences of "<" or ">", we reject `userid` as malformed
68    if rest.contains('<') || rest.contains('>') {
69        return None;
70    }
71
72    // If there is exactly one "@" in `maybe_email`, then we split the domain part of it into
73    // `maybe_domain`
74    let (_local, maybe_domain) = maybe_email.split_once('@')?;
75
76    // If `maybe_domain` contains additional occurrences of "@", then we reject `userid` as invalid
77    if maybe_domain.contains('@') {
78        return None;
79    }
80
81    // Return the domain substring of `userid`
82    Some(maybe_domain)
83}
84
85/// Checks whether an OpenPGP User ID matches a domain name.
86///
87/// Returns `true` if `userid` has a domain part, and it matches `identity_domain_name`, `false`
88/// otherwise.
89fn user_id_matches_domain(userid: &str, identity_domain_name: &DomainName) -> bool {
90    extract_domain_from_user_id(userid)
91        .map(|domain| Some(identity_domain_name) == DomainName::from_str(domain).ok().as_ref())
92        .unwrap_or(false)
93}
94
95/// Checks whether an OpenPGP User ID matches any domain name in a set of domain names.
96///
97/// Returns `true` if `user_id` matches any of the `identity_domain_names`, `false` otherwise.
98pub(crate) fn user_id_matches_any(
99    user_id: &str,
100    identity_domain_names: &HashSet<DomainName>,
101) -> bool {
102    identity_domain_names
103        .iter()
104        .any(|dn| user_id_matches_domain(user_id, dn))
105}
106
107/// Ensures that `sigs` contains a certifying signature that is valid at `reference_time`,
108/// for self-binding an identity to `cert`.
109///
110/// This assumes `sigs` contains validated self-signatures over a user id
111/// (which is guaranteed if they come from a `Checked`).
112///
113/// The creation time of `cert` is used as a lower bound for identity validity.
114pub(crate) fn identity_bound_at(
115    cert: &OpenpgpCert,
116    sigs: &[Signature],
117    reference_time: SystemTime,
118) -> bool {
119    // No identity is valid before the creation time of `cert`
120    if reference_time < SystemTime::from(*cert.certificate.primary_creation_time()) {
121        return false;
122    }
123
124    let mut any_positive_binding = false;
125
126    for sig in sigs {
127        match is_certification_revocation(sig) {
128            RevokedCertification::Hard => return false,
129            RevokedCertification::Soft => {
130                if let Some(created) = sig.created()
131                    && SystemTime::from(*created) <= reference_time
132                {
133                    // soft revocation that is already in effect at reference_time
134                    return false;
135                }
136            }
137            RevokedCertification::None => {
138                if let Some(created) = sig.created() {
139                    if let Some(expires) = sig.signature_expiration_time() {
140                        let expired = SystemTime::from(created.add(*expires));
141                        if expired > reference_time {
142                            any_positive_binding = true;
143                        }
144                    } else {
145                        any_positive_binding = true;
146                    }
147                }
148            }
149        }
150    }
151
152    // The identity is not revoked at reference time, and there is at least one
153    // positive binding that isn't expired at reference time.
154    if any_positive_binding {
155        return true;
156    }
157
158    // we have found neither a revocation nor an explicit binding
159    false
160}
161
162/// Checks if `sig` is a certification revocation signature, and if so, if it's "hard" or "soft".
163fn is_certification_revocation(sig: &Signature) -> RevokedCertification {
164    if let Some(SignatureType::CertRevocation) = sig.typ() {
165        if is_soft_revocation_reason(sig.revocation_reason_code()) {
166            RevokedCertification::Soft
167        } else {
168            RevokedCertification::Hard
169        }
170    } else {
171        RevokedCertification::None
172    }
173}
174
175/// Models the revocation status of an OpenPGP component.
176///
177/// - `Hard` means that the component is revoked at all points in time.
178/// - `Soft` means that the component is revoked starting at one specific point in time.
179/// - `None` means that the component is not revoked.
180enum RevokedCertification {
181    None,
182    Soft,
183    Hard,
184}
185
186/// Checks for explicitly "soft" reason codes.
187/// In all other cases, we consider a revocation to be "hard".
188fn is_soft_revocation_reason(reason: Option<&RevocationCode>) -> bool {
189    matches!(
190        reason,
191        Some(RevocationCode::KeyRetired)
192            | Some(RevocationCode::CertUserIdInvalid)
193            | Some(RevocationCode::KeySuperseded)
194    )
195}
196
197/// Finds the [`SignedUser`] that matches a `user_id`.
198///
199/// This object contains the valid (cryptographically and based on rpgpie policy) self-signatures
200/// over that user id.
201pub(crate) fn find_signed_user<'c>(
202    cert: &'c OpenpgpCert,
203    user_id: &UserId,
204) -> Option<&'c SignedUser> {
205    cert.certificate
206        .user_ids()
207        .iter()
208        .find(|&su| &su.id == user_id)
209}
210
211/// Creates a representative string for logging from an [`OpenpgpSignature`].
212///
213/// The returned string is of the form "fingerprint (file)" where "fingerprint" is the
214/// list of issuer fingerprints of the signature and "file" is the optional path of a file from
215/// which the OpenPGP signature has been read.
216pub(crate) fn log_sig_data(signature: &OpenpgpSignature) -> String {
217    format!(
218        "{}{}",
219        signature
220            .detached
221            .signature
222            .issuer_fingerprint()
223            .iter()
224            .map(|fingerprint| fingerprint.to_string())
225            .collect::<Vec<String>>()
226            .join(", "),
227        if let Some(path) = signature.source() {
228            format!(" ({})", path.to_string_lossy())
229        } else {
230            "".to_string()
231        }
232    )
233}
234
235/// Creates a representative string for logging from an [`OpenpgpCert`].
236///
237/// The returned string is of the form "fingerprint (verifier)" where "fingerprint" is the
238/// primary key fingerprint of the certificate and "verifier" is a list of one or more verifier
239/// locations from which the certificate has been assembled.
240pub(crate) fn log_cert_data(cert: &OpenpgpCert) -> String {
241    format!(
242        "{} ({})",
243        cert.certificate.fingerprint(),
244        cert.sources
245            .iter()
246            .map(|verifier| verifier.canonicalized().to_string_lossy().to_string())
247            .collect::<Vec<_>>()
248            .join(",")
249    )
250}