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}