ssh_openpgp_auth/
lib.rs

1// SPDX-FileCopyrightText: 2023 Wiktor Kwapisiewicz <wiktor@metacode.biz>
2// SPDX-FileCopyrightText: 2024 David Runge <dave@sleepmap.de>
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4use std::io::Read;
5use std::net::SocketAddr;
6use std::path::PathBuf;
7pub mod dns;
8mod key;
9use chrono::DateTime;
10use chrono::Utc;
11use clap::Parser;
12use openpgp_cert_d::CertD;
13use openpgp_cert_d::MergeResult;
14use sequoia_cert_store::store::StoreError;
15use sequoia_cert_store::CertStore;
16use sequoia_openpgp::cert::prelude::*;
17use sequoia_openpgp::crypto::mpi::PublicKey as SequoiaPublicKey;
18use sequoia_openpgp::crypto::Signer;
19use sequoia_openpgp::packet::signature::subpacket::NotationDataFlags;
20use sequoia_openpgp::packet::signature::SignatureBuilder;
21use sequoia_openpgp::packet::UserID;
22use sequoia_openpgp::parse::Parse;
23use sequoia_openpgp::policy::StandardPolicy;
24use sequoia_openpgp::serialize::SerializeInto;
25use sequoia_openpgp::types::Curve;
26use sequoia_openpgp::types::RevocationStatus;
27use sequoia_wot::store::CertStore as WotCertStore;
28use sequoia_wot::{
29    Network as WotNetwork, QueryBuilder as WotQueryBuilder, Roots as WotRoots, FULLY_TRUSTED,
30};
31
32#[derive(Debug, Parser)]
33pub struct Authenticate {
34    /// Target host to authenticate. This should be a DNS name.
35    pub host: String,
36
37    /// Certificate time. By default now.
38    /// Example: 2021-11-21T11:11:11Z
39    #[clap(short, long)]
40    pub time: Option<DateTime<Utc>>,
41
42    /// Certificate store. By default uses user's shared PGP certificate directory.
43    #[clap(short, long, env = "PGP_CERT_D")]
44    pub cert_store: Option<PathBuf>,
45
46    /// Nameserver to use for DNS lookups (if enabled).
47    #[clap(long, default_value = "8.8.8.8:53")]
48    pub nameserver: SocketAddr,
49
50    /// Verify Keyoxide DNS proofs of certificates.
51    #[clap(long)]
52    pub verify_dns_proof: bool,
53
54    /// Verify the certificates using local Web of Trust network.
55    #[clap(long)]
56    pub verify_wot: bool,
57
58    /// Print details of the verification process. Useful for debugging.
59    #[clap(long)]
60    pub verbose: bool,
61
62    /// Store verification results in the OpenPGP certificate.
63    #[clap(long)]
64    pub store_verifications: bool,
65}
66
67impl Authenticate {
68    fn verification_string(&self) -> String {
69        let mut verifications = vec![];
70        if self.verify_dns_proof {
71            verifications.push("dns");
72        }
73        if self.verify_wot {
74            verifications.push("wot");
75        }
76        verifications.join(" ")
77    }
78}
79
80impl Default for Authenticate {
81    fn default() -> Self {
82        Self {
83            host: "example.com".into(),
84            time: None,
85            cert_store: None,
86            nameserver: "8.8.8.8:53".parse().expect("static value to be parseable"),
87            verify_dns_proof: false,
88            verify_wot: false,
89            verbose: false,
90            store_verifications: false,
91        }
92    }
93}
94
95#[derive(Debug, Parser)]
96pub enum Commands {
97    /// Fetches OpenPGP host certificates, verifies it and prints host keys in SSH format on successful verification.
98    Authenticate(Authenticate),
99}
100
101#[derive(Debug, thiserror::Error)]
102pub enum Error {
103    #[error("OpenPGP error: {0}")]
104    OpenPGP(#[from] anyhow::Error),
105
106    #[error("I/O error: {0}")]
107    IO(#[from] std::io::Error),
108
109    #[error("Network error: {0}")]
110    Network(#[from] reqwest::Error),
111
112    #[error("SSH error: {0}")]
113    Ssh(#[from] ssh_key::Error),
114
115    #[error("Cert store error: {0}")]
116    Store(#[from] openpgp_cert_d::Error),
117
118    #[error("DNS error: {0}")]
119    DnsProto(#[from] hickory_client::proto::error::ProtoError),
120
121    #[error("DNS error: {0}")]
122    DnsClient(#[from] hickory_client::error::ClientError),
123
124    #[error("Cert store error: {0}")]
125    CertStore(#[from] StoreError),
126
127    #[error("Unknown curve: {0} in subkey: {1}")]
128    UnknownCurve(Curve, sequoia_openpgp::Fingerprint),
129
130    #[error("Unknown algorithm: {0:?} in subkey: {1}")]
131    UnknownAlgorithm(SequoiaPublicKey, sequoia_openpgp::Fingerprint),
132
133    #[error("Other error: {0}")]
134    Other(Box<dyn std::error::Error>),
135}
136
137fn is_cert_expired(cert: ValidCert<'_>) -> bool {
138    if let Some(expiry) = cert.primary_key().key_expiration_time() {
139        cert.time() > expiry
140    } else {
141        // key without expiration is never expired
142        false
143    }
144}
145
146pub trait Effects {
147    fn get(&mut self, url: String) -> Result<Vec<u8>, Error>;
148    fn dns_query(
149        &mut self,
150        nameserver: SocketAddr,
151        name: &str,
152    ) -> Result<Vec<String>, crate::Error>;
153}
154
155pub fn authenticate(
156    auth: Authenticate,
157    effects: &mut impl Effects,
158    writer: &mut impl std::io::Write,
159) -> Result<(), Error> {
160    let p = StandardPolicy::new();
161    let store = if let Some(cert_store) = &auth.cert_store {
162        CertD::with_base_dir(cert_store)?
163    } else {
164        CertD::new()?
165    };
166    let trust_root = if let Ok(Some((_, bytes))) = store.get("trust-root") {
167        let trust_root_fpr = Cert::from_bytes(&bytes)?.fingerprint();
168        if auth.verbose {
169            writeln!(writer, "# Found trust root: {}", trust_root_fpr)?;
170        }
171        Some(trust_root_fpr)
172    } else {
173        None
174    };
175    let email = format!("ssh-openpgp-auth@{}", auth.host);
176    let mut certs = vec![];
177    for file in store.iter_files() {
178        for cert in file.into_iter().flat_map(|(_, mut f)| {
179            let mut v = Vec::new();
180            f.read_to_end(&mut v)?;
181            Cert::from_bytes(&v)
182        }) {
183            if let Ok(valid_cert) = cert.with_policy(&p, auth.time.map(Into::into)) {
184                if valid_cert
185                    .userids()
186                    .any(|ui| ui.email2().unwrap_or_default() == Some(&email))
187                    && !is_cert_expired(valid_cert)
188                {
189                    if auth.verbose {
190                        writeln!(writer, "# Found local cert: {}", cert.fingerprint())?;
191                    }
192                    certs.push(cert);
193                }
194            }
195        }
196    }
197
198    if certs.is_empty() {
199        let f = |new: Cert, old: Option<&[u8]>| {
200            if let Some(old) = old {
201                let old = Cert::from_bytes(&old)?;
202                let merged = old.merge_public(new)?.to_vec()?;
203
204                Ok(MergeResult::Data(merged))
205            } else {
206                Ok(MergeResult::Data(new.to_vec()?))
207            }
208        };
209
210        let bytes = effects.get(format!(
211        "https://{}/.well-known/openpgpkey/hu/w1bjhwjfd8nqsw4ug3kn81sny45zimkq?l=ssh-openpgp-auth",
212        auth.host
213    ))?;
214
215        let parser = CertParser::from_bytes(&*bytes)?;
216
217        for cert in parser.flatten() {
218            let fingerprint = &cert.fingerprint().to_string();
219            if auth.verbose {
220                writeln!(writer, "# Downloaded certificate: {}", fingerprint)?;
221            }
222            store.insert(fingerprint, cert.clone(), false, f)?;
223            certs.push(cert);
224        }
225    }
226
227    if auth.verify_dns_proof {
228        let txts = effects.dns_query(auth.nameserver, &auth.host)?;
229
230        let certs_and_validations = certs.into_iter().map(|cert| {
231            (
232                txts.contains(&format!("openpgp4fpr:{:x}", cert.fingerprint())),
233                cert,
234            )
235        });
236
237        let mut ok_certs = vec![];
238
239        for (cert_in_dns, cert) in certs_and_validations {
240            if auth.verbose {
241                writeln!(
242                    writer,
243                    "# Certificate {}{} found in the DNS zone",
244                    cert.fingerprint(),
245                    if cert_in_dns { "" } else { " NOT" }
246                )?;
247            }
248            if cert_in_dns {
249                ok_certs.push(cert);
250            }
251        }
252
253        certs = ok_certs;
254    }
255
256    if auth.verify_wot {
257        let mut store = CertStore::empty();
258        if let Some(cert_store) = &auth.cert_store {
259            if auth.verbose {
260                writeln!(writer, "# Using cert store: {}", cert_store.display())?;
261            }
262            store.add_certd(cert_store)?;
263        } else {
264            if auth.verbose {
265                writeln!(writer, "# Using default cert store")?;
266            }
267            store.add_default_certd()?;
268        };
269        let store = WotCertStore::from_store(&store, &p, auth.time.map(Into::into));
270        let network = WotNetwork::new(&store)?;
271        let mut builder = WotQueryBuilder::new(&network);
272        if let Some(fingerprint) = trust_root {
273            builder.roots(WotRoots::new([fingerprint]));
274        }
275        let query = builder.build();
276        let user_id = UserID::from(format!("<ssh-openpgp-auth@{}>", auth.host));
277        let valid_certs = certs.into_iter().map(|cert| {
278            let paths = query.authenticate(&user_id, cert.fingerprint(), FULLY_TRUSTED);
279            (cert, paths.amount() > 0)
280        });
281        let mut new_certs = vec![];
282        for (cert, valid) in valid_certs {
283            if auth.verbose {
284                writeln!(
285                    writer,
286                    "# Web of Trust verification of {} {}",
287                    cert.fingerprint(),
288                    if valid { "succeeded" } else { "failed" }
289                )?;
290            }
291            if valid {
292                new_certs.push(cert);
293            }
294        }
295        certs = new_certs;
296    }
297
298    let mut persistence_cert: Option<Box<dyn Signer>> = if auth.store_verifications {
299        Some(
300            if let Some((_, bytes)) = store.get("_metacode_ssh_openpgp_auth_persistence.pgp")? {
301                Cert::from_bytes(&bytes)
302            } else {
303                Ok(create_new_certifying_key(&store)?)
304            }
305            .and_then(|cert| {
306                Ok(Box::new(
307                    cert.primary_key()
308                        .key()
309                        .clone()
310                        .parts_into_secret()?
311                        .into_keypair()?,
312                ) as Box<dyn Signer>)
313            })?,
314        )
315    } else {
316        None
317    };
318
319    for cert in certs {
320        let cert = cert.with_policy(&p, auth.time.map(Into::into))?;
321        if cert.alive().is_err() {
322            if auth.verbose {
323                writeln!(
324                    writer,
325                    "# Certificate {} is already expired at {}",
326                    cert.fingerprint(),
327                    auth.time.unwrap_or_default()
328                )?;
329            }
330        } else if cert.revocation_status() != RevocationStatus::NotAsFarAsWeKnow {
331            if auth.verbose {
332                writeln!(
333                    writer,
334                    "# Certificate {} is skipped because it is revoked",
335                    cert.fingerprint(),
336                )?;
337            }
338        } else {
339            for key in cert.keys().for_authentication() {
340                match key.revocation_status() {
341                    RevocationStatus::NotAsFarAsWeKnow => {
342                        if auth.verbose {
343                            writeln!(
344                                writer,
345                                "# Certificate {}, exporting subkey {}",
346                                cert.fingerprint(),
347                                key.fingerprint()
348                            )?;
349                        }
350                        match key::PublicKey::try_from(key.key()) {
351                            Ok(key) => writeln!(writer, "{} {}", auth.host, key)?,
352                            Err(error) => writeln!(writer, "# {}", error)?,
353                        }
354                    }
355                    RevocationStatus::Revoked(_revocations)
356                    | RevocationStatus::CouldBe(_revocations) => {
357                        if auth.verbose {
358                            writeln!(
359                                writer,
360                                "# Certificate {}, skipping subkey {} as it is revoked",
361                                cert.fingerprint(),
362                                key.fingerprint()
363                            )?;
364                        }
365                    }
366                }
367            }
368        }
369
370        if let Some(ref mut persistence_cert) = persistence_cert {
371            let signature =
372                SignatureBuilder::new(sequoia_openpgp::types::SignatureType::PositiveCertification)
373                    .add_notation(
374                        "ssh-openpgp-auth-verification@metacode.biz",
375                        auth.verification_string(),
376                        NotationDataFlags::empty().set_human_readable(),
377                        false,
378                    )?
379                    .set_exportable_certification(false)?
380                    .sign_userid_binding(
381                        persistence_cert,
382                        cert.primary_key().key(),
383                        &UserID::from(format!("<ssh-openpgp-auth@{}>", auth.host)),
384                    )?;
385
386            let new_cert = cert.cert().clone().insert_packets2(signature)?.0;
387            store.insert(
388                &cert.fingerprint().to_string(),
389                new_cert.clone(),
390                false,
391                |new, old| {
392                    Ok(match old {
393                        Some(old) => {
394                            let old = Cert::from_bytes(&old)?;
395                            MergeResult::Data(old.merge_public(new)?.as_tsk().to_vec()?)
396                        }
397                        None => MergeResult::Data(new.as_tsk().to_vec()?),
398                    })
399                },
400            )?;
401        }
402    }
403    Ok(())
404}
405
406/// Creates and inserts a new certifying key which is used to add proof
407/// verification results to host certificates.
408///
409/// This function uses CertD optimistic-locking to insert the certificate
410/// and, if it already exists, return the old one.
411fn create_new_certifying_key(store: &CertD) -> sequoia_openpgp::Result<Cert> {
412    let cert = CertBuilder::new().generate()?.0;
413    let cert_data = store.insert_special(
414        "_metacode_ssh_openpgp_auth_persistence.pgp",
415        cert.clone(),
416        true,
417        // if the certificate has already been created (before the previous check
418        // and calling this function) just take the existing one and throw
419        // away the generated one
420        |new, old| {
421            Ok(match old {
422                Some(old) => MergeResult::Data(old.into()),
423                None => MergeResult::Data(new.as_tsk().to_vec()?),
424            })
425        },
426    )?;
427    if let Some(cert_data) = cert_data.1 {
428        Cert::from_bytes(&cert_data)
429    } else {
430        Ok(cert)
431    }
432}