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, Timestamp},
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 =
141                            Timestamp::from_secs(created.as_secs().add(expires.as_secs()));
142                        if SystemTime::from(expired) > reference_time {
143                            any_positive_binding = true;
144                        }
145                    } else {
146                        any_positive_binding = true;
147                    }
148                }
149            }
150        }
151    }
152
153    // The identity is not revoked at reference time, and there is at least one
154    // positive binding that isn't expired at reference time.
155    if any_positive_binding {
156        return true;
157    }
158
159    // we have found neither a revocation nor an explicit binding
160    false
161}
162
163/// Checks if `sig` is a certification revocation signature, and if so, if it's "hard" or "soft".
164fn is_certification_revocation(sig: &Signature) -> RevokedCertification {
165    if let Some(SignatureType::CertRevocation) = sig.typ() {
166        if is_soft_revocation_reason(sig.revocation_reason_code()) {
167            RevokedCertification::Soft
168        } else {
169            RevokedCertification::Hard
170        }
171    } else {
172        RevokedCertification::None
173    }
174}
175
176/// Models the revocation status of an OpenPGP component.
177///
178/// - `Hard` means that the component is revoked at all points in time.
179/// - `Soft` means that the component is revoked starting at one specific point in time.
180/// - `None` means that the component is not revoked.
181enum RevokedCertification {
182    None,
183    Soft,
184    Hard,
185}
186
187/// Checks for explicitly "soft" reason codes.
188/// In all other cases, we consider a revocation to be "hard".
189fn is_soft_revocation_reason(reason: Option<&RevocationCode>) -> bool {
190    matches!(
191        reason,
192        Some(RevocationCode::KeyRetired)
193            | Some(RevocationCode::CertUserIdInvalid)
194            | Some(RevocationCode::KeySuperseded)
195    )
196}
197
198/// Finds the [`SignedUser`] that matches a `user_id`.
199///
200/// This object contains the valid (cryptographically and based on rpgpie policy) self-signatures
201/// over that user id.
202pub(crate) fn find_signed_user<'c>(
203    cert: &'c OpenpgpCert,
204    user_id: &UserId,
205) -> Option<&'c SignedUser> {
206    cert.certificate
207        .user_ids()
208        .iter()
209        .find(|&su| &su.id == user_id)
210}
211
212/// Creates a representative string for logging from an [`OpenpgpSignature`].
213///
214/// The returned string is of the form "fingerprint (file)" where "fingerprint" is the
215/// list of issuer fingerprints of the signature and "file" is the optional path of a file from
216/// which the OpenPGP signature has been read.
217pub(crate) fn log_sig_data(signature: &OpenpgpSignature) -> String {
218    format!(
219        "{}{}",
220        signature
221            .detached
222            .signature
223            .issuer_fingerprint()
224            .iter()
225            .map(|fingerprint| fingerprint.to_string())
226            .collect::<Vec<String>>()
227            .join(", "),
228        if let Some(path) = signature.source() {
229            format!(" ({})", path.to_string_lossy())
230        } else {
231            "".to_string()
232        }
233    )
234}
235
236/// Creates a representative string for logging from an [`OpenpgpCert`].
237///
238/// The returned string is of the form "fingerprint (verifier)" where "fingerprint" is the
239/// primary key fingerprint of the certificate and "verifier" is a list of one or more verifier
240/// locations from which the certificate has been assembled.
241pub(crate) fn log_cert_data(cert: &OpenpgpCert) -> String {
242    format!(
243        "{} ({})",
244        cert.certificate.fingerprint(),
245        cert.sources
246            .iter()
247            .map(|verifier| verifier.canonicalized().to_string_lossy().to_string())
248            .collect::<Vec<_>>()
249            .join(",")
250    )
251}