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}