sshd_openpgp_auth/
lib.rs

1// SPDX-FileCopyrightText: 2023 David Runge <dave@sleepmap.de>
2// SPDX-FileCopyrightText: 2023 Wiktor Kwapisiewicz <wiktor@metacode.biz>
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4use std::convert::AsRef;
5use std::fs::create_dir_all;
6use std::fs::read_dir;
7use std::fs::read_to_string;
8use std::fs::File;
9use std::io::Write;
10use std::path::Path;
11use std::path::PathBuf;
12use std::str::FromStr;
13use std::string::FromUtf8Error;
14use std::time::Duration;
15use std::time::SystemTime;
16use std::time::SystemTimeError;
17
18use chrono::DateTime;
19use chrono::Utc;
20use clap::{Parser, Subcommand};
21use email_address::EmailAddress;
22use fqdn::FQDN;
23use sequoia_openpgp::cert::amalgamation::key::PrimaryKey;
24use sequoia_openpgp::cert::amalgamation::ValidAmalgamation;
25use sequoia_openpgp::cert::CertBuilder;
26use sequoia_openpgp::cert::SubkeyRevocationBuilder;
27use sequoia_openpgp::cert::ValidCert;
28use sequoia_openpgp::crypto::mpi::PublicKey;
29use sequoia_openpgp::crypto::mpi::MPI;
30use sequoia_openpgp::packet::key::PublicParts;
31use sequoia_openpgp::packet::key::SubordinateRole;
32use sequoia_openpgp::packet::prelude::Key4;
33use sequoia_openpgp::packet::signature::subpacket::NotationDataFlags;
34use sequoia_openpgp::packet::signature::SignatureBuilder;
35use sequoia_openpgp::packet::Key;
36use sequoia_openpgp::packet::Signature;
37use sequoia_openpgp::packet::UserID;
38use sequoia_openpgp::parse::Parse;
39use sequoia_openpgp::policy::StandardPolicy;
40use sequoia_openpgp::serialize::Serialize;
41use sequoia_openpgp::serialize::SerializeInto;
42use sequoia_openpgp::types::Curve;
43use sequoia_openpgp::types::Features;
44use sequoia_openpgp::types::HashAlgorithm;
45use sequoia_openpgp::types::KeyFlags;
46use sequoia_openpgp::types::PublicKeyAlgorithm;
47use sequoia_openpgp::types::ReasonForRevocation;
48use sequoia_openpgp::types::RevocationStatus;
49use sequoia_openpgp::types::SignatureType;
50use sequoia_openpgp::Cert;
51use sequoia_openpgp::Fingerprint;
52use sequoia_openpgp::Packet;
53use ssh_key::known_hosts::KnownHosts;
54use ssh_key::public::EcdsaPublicKey;
55use ssh_key::public::KeyData;
56use strum::IntoEnumIterator;
57
58/// The default expiry threshold in days
59const EXPIRY_THRESHOLD_DAYS: u64 = 250;
60/// The default expiration period in days
61const EXPIRY_PERIOD_DAYS: u64 = 365;
62/// The seconds in a day
63pub const SECONDS_IN_A_DAY: u64 = 24 * 60 * 60;
64/// The default threshold (counted from reference time) above which a certificate should be extended
65pub const EXPIRY_THRESHOLD: Duration = Duration::new(EXPIRY_THRESHOLD_DAYS * SECONDS_IN_A_DAY, 0);
66/// The default expiration period (counted from reference time) up until which a certificate's expiration time is extended
67pub const EXPIRY_PERIOD: Duration = Duration::new(EXPIRY_PERIOD_DAYS * SECONDS_IN_A_DAY, 0);
68/// The default location for certificates on the system
69pub const CERT_LOCATION: &str = "/var/lib/sshd-openpgp-auth/";
70/// The default location for SSH host keys on the system
71pub const SSH_HOST_KEY_LOCATION: &str = "/etc/ssh/";
72/// The default reason for revocation
73pub const REVOCATION_REASON: KeyRevocationType = KeyRevocationType::Superseded;
74/// The default WKD export type
75pub const WKD_TYPE: WkdType = WkdType::Advanced;
76/// The default WKD export location
77pub const WKD_OUTPUT_DIR: &str = "wkd";
78
79#[derive(Debug, Parser)]
80#[command(about, author, version)]
81pub enum Commands {
82    Add(AddCommand),
83    Export(ExportCommand),
84    Extend(ExtendCommand),
85    Init(InitCommand),
86    List(ListCommand),
87    Merge(MergeCommand),
88    Revoke(RevokeCommand),
89    #[command(subcommand)]
90    Proof(ProofCommand),
91}
92
93#[derive(Debug, Subcommand)]
94#[command(
95    about = "Manages Keyoxide proofs",
96    long_about = "Manages Keyoxide proofs
97
98With this command one can add proofs to the host key."
99)]
100pub enum ProofCommand {
101    #[command(subcommand)]
102    Dns(DnsCommand),
103}
104
105#[derive(Debug, Subcommand)]
106#[command(
107    about = "Manages DNS proofs",
108    long_about = "Manages DNS Keyoxide proofs
109
110With this command one can add DNS proofs to the host key."
111)]
112pub enum DnsCommand {
113    Add(AddDnsProofCommand),
114}
115
116#[derive(Debug, Parser)]
117#[command(
118    about = "Add DNS proof to an OpenPGP certificate",
119    long_about = "Add DNS proof to an OpenPGP certificate
120
121This proof, when accompanied with a respective DNS TXT record connects the OpenPGP certificate with the DNS zone."
122)]
123pub struct AddDnsProofCommand {
124    #[arg(
125        env = "SOA_OPENPGP_DIR",
126        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
127        long,
128        short,
129        value_name = "DIR",
130    )]
131    pub openpgp_dir: Option<PathBuf>,
132    #[arg(
133        env = "SOA_FINGERPRINT",
134        help = "An OpenPGP fingerprint to identify a specific certificate",
135        long,
136        short
137    )]
138    pub fingerprint: Option<Fingerprint>,
139    #[arg(
140        help = "Output the OpenPGP certificate to stdout instead of a file",
141        long,
142        short
143    )]
144    pub stdout: bool,
145    #[arg(
146        env = "SOA_TIME",
147        help = "A custom reference time formatted as an RFC3339 string (defaults to now)",
148        long,
149        short
150    )]
151    pub time: Option<DateTime<Utc>>,
152}
153
154#[derive(Debug, Parser)]
155#[command(
156    about = "Add public SSH host keys as authentication subkeys to an OpenPGP certificate",
157    long_about = format!(
158"Add public SSH host keys as authentication subkeys to an OpenPGP certificate
159
160By default this command adds SSH host keys found in \"{}\" as authentication subkeys to an OpenPGP certificate in \"{}\".
161Custom locations for SSH public keys as well as OpenPGP certificates can be provided.
162If more than one OpenPGP certificate is found in the target directory, an OpenPGP fingerprint must be specified.
163When adding from SSH host key files the subkey creation time is derived from the file creation timestamp of the respective files.
164
165It is possible to add subkeys by piping a known_hosts formatted string to this command when using the \"--known-hosts\" option.
166When adding from stdin the current time is used for the subkey creation time.
167
168A custom creation time can be provided.
169",
170        SSH_HOST_KEY_LOCATION,
171        CERT_LOCATION)
172)]
173pub struct AddCommand {
174    #[arg(
175        env = "SOA_FINGERPRINT",
176        help = "An OpenPGP fingerprint to identify a specific certificate",
177        long,
178        short
179    )]
180    pub fingerprint: Option<Fingerprint>,
181    #[arg(
182        conflicts_with = "ssh_dir",
183        help = "Read the SSH public keys in known_hosts format from stdin instead of from a directory",
184        long,
185        short
186    )]
187    pub known_hosts: bool,
188    #[arg(
189        env = "SOA_OPENPGP_DIR",
190        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
191        long,
192        short,
193        value_name = "DIR",
194    )]
195    pub openpgp_dir: Option<PathBuf>,
196    #[arg(
197        env = "SOA_SSH_DIR",
198        help = format!("A custom directory in which to look for SSH public keys (defaults to \"{}\")", SSH_HOST_KEY_LOCATION),
199        long,
200        short = 'S',
201        value_name = "DIR",
202    )]
203    pub ssh_dir: Option<PathBuf>,
204    #[arg(
205        help = "Output the OpenPGP certificate to stdout instead of a file",
206        long,
207        short
208    )]
209    pub stdout: bool,
210    #[arg(
211        env = "SOA_TIME",
212        help = "A custom reference time formatted as an RFC3339 string (defaults to now)",
213        long,
214        short
215    )]
216    pub time: Option<DateTime<Utc>>,
217}
218
219#[derive(Debug, Parser)]
220#[command(
221    about = "Export OpenPGP certificates to Web Key Directory (WKD)",
222    long_about = format!(
223"Export OpenPGP certificates to Web Key Directory (WKD)
224
225By default this command exports all valid OpenPGP certificates, that match a hostname, to a Web Key Directory (WKD) structure in \"{}\".
226Optionally, a different WKD export type can be selected and a custom reference time be chosen.",
227    WKD_OUTPUT_DIR)
228)]
229pub struct ExportCommand {
230    #[arg(help = "The hostname, as fully qualified domain name (FQDN), for which to export")]
231    pub hostname: FQDN,
232    #[arg(
233        env = "SOA_OPENPGP_DIR",
234        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
235        long,
236        short,
237        value_name = "DIR",
238    )]
239    pub openpgp_dir: Option<PathBuf>,
240    #[arg(
241        env = "SOA_WKD_OUTPUT_DIR",
242        help = format!("A custom output directory (defaults to \"{}\")", WKD_OUTPUT_DIR),
243        long,
244        short = 'O',
245        value_name = "DIR",
246    )]
247    pub output_dir: Option<PathBuf>,
248    #[arg(
249        env = "SOA_WKD_TYPE",
250        help = format!("A custom WKD type to export to (defaults to \"{}\")", WKD_TYPE),
251        long,
252        long_help = format!(
253            "A custom WKD type to export for (defaults to \"{}\").\nChoose one of {:?}.",
254            WKD_TYPE,
255            WkdType::iter().map(|wkd_type| wkd_type.to_string()).collect::<Vec<String>>()
256        ),
257        short,
258    )]
259    pub wkd_type: Option<WkdType>,
260    #[arg(
261        env = "SOA_TIME",
262        help = "A custom reference time formatted as an RFC3339 string (defaults to now)",
263        long,
264        short
265    )]
266    pub time: Option<DateTime<Utc>>,
267}
268
269#[derive(Debug, Parser)]
270#[command(
271    about = "Extend the expiration period of an OpenPGP certificate",
272    long_about = format!(
273"Extend the expiration period of an OpenPGP certificate
274
275By default this command extends the expiration period of an OpenPGP certificate by {} days from now, if the certificate would expire within the next {} days.
276If more than one OpenPGP certificate is found in the target directory, an OpenPGP fingerprint must be specified.
277Optionally, the reference time, expiration period and threshold may be provided.
278Additionally, the certificate may be written to stdout instead of a file.",
279        EXPIRY_PERIOD_DAYS,
280        EXPIRY_THRESHOLD_DAYS)
281)]
282pub struct ExtendCommand {
283    #[arg(
284        env = "SOA_EXPIRY",
285        help = format!("The expiry period in days from reference time (defaults to {})", EXPIRY_PERIOD_DAYS),
286        long,
287        short
288    )]
289    pub expiry: Option<u64>,
290    #[arg(
291        env = "SOA_FINGERPRINT",
292        help = "An OpenPGP fingerprint to identify a specific certificate",
293        long,
294        short
295    )]
296    pub fingerprint: Option<Fingerprint>,
297    #[arg(
298        env = "SOA_OPENPGP_DIR",
299        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
300        long,
301        short,
302        value_name = "DIR",
303    )]
304    pub openpgp_dir: Option<PathBuf>,
305    #[arg(
306        help = "Output the OpenPGP certificate to stdout instead of a file",
307        long,
308        short
309    )]
310    pub stdout: bool,
311    #[arg(
312        env = "SOA_THRESHOLD",
313        help = format!("A custom threshold in days from reference time, after which expiry period is extended (defaults to {})", EXPIRY_THRESHOLD_DAYS),
314        long,
315        short = 'T',
316    )]
317    pub threshold: Option<u64>,
318    #[arg(
319        env = "SOA_TIME",
320        help = "A custom reference time formatted as an RFC3339 string (defaults to now)",
321        long,
322        short
323    )]
324    pub time: Option<DateTime<Utc>>,
325}
326
327#[derive(Debug, Parser)]
328#[command(
329    about = "Initialize a new OpenPGP certificate, that serves as trust anchor for public SSH host keys",
330    long_about = format!("Initialize a new OpenPGP certificate, that serves as trust anchor for public SSH host keys
331
332By default this function creates an OpenPGP certificate for a hostname, that is valid from now for the next {} days and writes it to a file in \"{}\".
333The validity period, as well as the point in time from which the certificate is valid can be adjusted.
334Additionally, the certificate may be written to stdout instead of a file.",
335    EXPIRY_PERIOD_DAYS,
336    CERT_LOCATION)
337)]
338pub struct InitCommand {
339    #[arg(
340        env = "SOA_EXPIRY",
341        help = format!("The expiry period in days from reference time (defaults to {})", EXPIRY_PERIOD_DAYS),
342        long,
343        short,
344    )]
345    pub expiry: Option<u64>,
346    #[arg(
347        help = "The hostname, as fully qualified domain name (FQDN), for which a certificate is created"
348    )]
349    pub host: FQDN,
350    #[arg(
351        env = "SOA_OPENPGP_DIR",
352        conflicts_with = "stdout",
353        help = format!("A custom directory into which the OpenPGP certificate is written (defaults to \"{}\")", CERT_LOCATION),
354        long,
355        value_name = "DIR",
356        short,
357    )]
358    pub openpgp_dir: Option<PathBuf>,
359    #[arg(
360        help = "Output the OpenPGP certificate to stdout instead of a file",
361        long,
362        short
363    )]
364    pub stdout: bool,
365    #[arg(
366        env = "SOA_TIME",
367        help = format!("A custom reference time formatted as an RFC3339 string (defaults to now)"),
368        long,
369        short,
370    )]
371    pub time: Option<DateTime<Utc>>,
372}
373
374#[derive(Debug, Parser)]
375#[command(
376    about = "List local OpenPGP certificates that serve as trust anchor",
377    long_about = "List local OpenPGP certificates that serve as trust anchor
378
379By default this command lists all OpenPGP certificates in a directory, that are currently valid.
380Optionally, the certificates can be filtered by a hostname.
381Additionally, a custom reference time may be provided to show valid certificates at a different point in time."
382)]
383pub struct ListCommand {
384    #[arg(
385        env = "SOA_FILTER",
386        help = "A hostname, as fully qualified domain name (FQDN), by which to filter",
387        long,
388        short,
389        value_name = "HOSTNAME"
390    )]
391    pub filter: Option<FQDN>,
392    #[arg(
393        env = "SOA_OPENPGP_DIR",
394        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
395        long,
396        short,
397        value_name = "DIR",
398    )]
399    pub openpgp_dir: Option<PathBuf>,
400    #[arg(
401        env = "SOA_TIME",
402        help = "A custom reference time formatted as an RFC3339 string (defaults to now)",
403        long,
404        short
405    )]
406    pub time: Option<DateTime<Utc>>,
407}
408
409#[derive(Debug, Parser)]
410#[command(
411    about = "Merge an OpenPGP certificate with a local trust anchor",
412    long_about = "Merge an OpenPGP certificate with a local trust anchor
413
414This command is used to merge an OpenPGP certificate with a matching primary key fingerprint and primary User ID into a local trust anchor.
415This action is particularly useful when adding thirdparty certifications that should be maintained in the trust anchor."
416)]
417pub struct MergeCommand {
418    #[arg(help = "The OpenPGP certificate to merge")]
419    pub certificate: PathBuf,
420    #[arg(
421        env = "SOA_FINGERPRINT",
422        help = "An OpenPGP fingerprint to identify a specific certificate",
423        long,
424        short
425    )]
426    pub fingerprint: Option<Fingerprint>,
427    #[arg(
428        env = "SOA_OPENPGP_DIR",
429        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
430        long,
431        short,
432        value_name = "DIR",
433    )]
434    pub openpgp_dir: Option<PathBuf>,
435    #[arg(
436        help = "Output the OpenPGP certificate to stdout instead of a file",
437        long,
438        short
439    )]
440    pub stdout: bool,
441}
442
443#[derive(Debug, Parser)]
444#[command(
445    about = "Revoke subkeys of an OpenPGP certificate",
446    long_about = format!("
447Revoke subkeys of an OpenPGP certificate
448
449By default this command revokes the subkeys of an OpenPGP certificate in {}.
450If more than one OpenPGP certificate is found in the target directory, an OpenPGP fingerprint must be specified.
451        ",
452        CERT_LOCATION,
453    ),
454)]
455pub struct RevokeCommand {
456    #[arg(
457        help = "Revoke all subkeys of the chosen OpenPGP certificate",
458        long,
459        short
460    )]
461    pub all: bool,
462    #[arg(
463        env = "SOA_FINGERPRINT",
464        help = "An OpenPGP fingerprint to identify a specific certificate",
465        long,
466        short
467    )]
468    pub fingerprint: Option<Fingerprint>,
469    #[arg(
470        env = "SOA_REVOCATION_MESSAGE",
471        help = "An optional message for the revocation",
472        long,
473        short
474    )]
475    pub message: Option<String>,
476    #[arg(
477        env = "SOA_OPENPGP_DIR",
478        help = format!("A custom directory in which to look for OpenPGP certificates (defaults to \"{}\")", CERT_LOCATION),
479        long,
480        short,
481        value_name = "DIR",
482    )]
483    pub openpgp_dir: Option<PathBuf>,
484    #[arg(
485        env = "SOA_REVOCATION_REASON",
486        help = format!("A custom revocation reason (defaults to \"{}\")", KeyRevocationType::Superseded),
487        long,
488        long_help = format!(
489            "A custom revocation reason (defaults to \"{}\").\nOne of {}.",
490            KeyRevocationType::Superseded,
491            KeyRevocationType::iter()
492                .map(|rev_type| format!("\"{}\" ({:?})", rev_type, ReasonForRevocation::from(rev_type).revocation_type()))
493                .collect::<Vec<String>>().join(", ")
494        ),
495        short,
496    )]
497    pub reason: Option<KeyRevocationType>,
498    #[arg(
499        help = "Output the OpenPGP certificate to stdout instead of a file",
500        long,
501        short
502    )]
503    pub stdout: bool,
504    #[arg(
505        conflicts_with = "all",
506        env = "SOA_SUBKEY_FINGERPRINT",
507        help = "An OpenPGP fingerprint to identify a specific subkey",
508        long,
509        long_help = "An OpenPGP fingerprint to identify a specific subkey.\nThis option can be provided more than once",
510        short = 'S'
511    )]
512    pub subkey_fingerprint: Vec<Fingerprint>,
513    #[arg(
514        env = "SOA_TIME",
515        help = "A custom reference time formatted as an RFC3339 string (defaults to now)",
516        long,
517        short
518    )]
519    pub time: Option<DateTime<Utc>>,
520}
521
522#[derive(Debug, thiserror::Error)]
523pub enum Error {
524    #[error("OpenPGP Error: {0}")]
525    OpenPGP(#[from] anyhow::Error),
526    #[error("I/O Error: {0}")]
527    IO(#[from] std::io::Error),
528    #[error("SSH Key Error: {0}")]
529    SshKey(#[from] ssh_key::Error),
530    #[error("Unsupported algorithm: {0}")]
531    UnsupportedAlgorithm(String),
532    #[error("No primary key was found")]
533    PrimaryKeyNotFound,
534    #[error("No subkey found matching the fingerprint {0}")]
535    SubkeyNotFound(Fingerprint),
536    #[error("Multiple subkeys found matching the fingerprint {0}")]
537    MultipleSubkeysFound(Fingerprint),
538    #[error("Time Error: {0}")]
539    TimeError(#[from] SystemTimeError),
540    #[error("Unable to extend expiry to {0}s from reference time {1:?}s.")]
541    UnableToExtendExpiry(u64, SystemTime),
542    #[error("Unable to convert string: {0}")]
543    StringError(#[from] FromUtf8Error),
544    #[error("There are no certificates in {0}. Create one first!")]
545    NoCertificates(PathBuf),
546    #[error("There are multiple certificates in {0}. Provide a fingerprint!")]
547    MultipleCertificates(PathBuf),
548    #[error("The subkey with the fingerprint {0} has already been added!")]
549    SubkeyAlreadyAdded(Fingerprint),
550    #[error("Multiple subkeys found for the certificate with the fingerprint {0}")]
551    MultipleSubkeys(Fingerprint),
552    #[error("Subkey fingerprints can not be found: {0}")]
553    SubkeyFingerprintNotFound(String),
554    #[error("E-mail parsing error: {0}")]
555    Email(#[from] email_address::Error),
556    #[error("Not a Transferable Secret Key: {0}")]
557    NotATsk(Fingerprint),
558    #[error("Certificate fingerprints do not match: {tsk_fingerprint} != {cert_fingerprint}")]
559    MergeFingerprintMismatch {
560        tsk_fingerprint: Fingerprint,
561        cert_fingerprint: Fingerprint,
562    },
563    #[error("Certificate primary User IDs do not match: {tsk_userid} != {cert_userid}")]
564    MergeUserIdMismatch {
565        tsk_userid: UserID,
566        cert_userid: UserID,
567    },
568}
569
570/// Enum of understood key revocation types
571///
572/// This enum only matches a narrow subset of [`ReasonForRevocation`], as we are targeting keys and do not support "unspecified" revocations.
573#[derive(Debug, Clone, Copy, strum::Display, strum::EnumIter, strum::EnumString)]
574pub enum KeyRevocationType {
575    /// A *hard* revocation, that indicates the private key material is compromised
576    #[strum(ascii_case_insensitive, to_string = "compromised")]
577    Compromised,
578    /// A *soft* revocation, that indicates the key is retired
579    #[strum(ascii_case_insensitive, to_string = "retired")]
580    Retired,
581    /// A *soft* revocation, that indicates the key is superseded by another one
582    #[strum(ascii_case_insensitive, to_string = "superseded")]
583    Superseded,
584}
585
586impl From<KeyRevocationType> for ReasonForRevocation {
587    fn from(from: KeyRevocationType) -> Self {
588        match from {
589            KeyRevocationType::Compromised => ReasonForRevocation::KeyCompromised,
590            KeyRevocationType::Retired => ReasonForRevocation::KeyRetired,
591            KeyRevocationType::Superseded => ReasonForRevocation::KeySuperseded,
592        }
593    }
594}
595
596/// Enum to construct valid Web Key Directory (WKD) types from string
597#[derive(Debug, Clone, Copy, strum::Display, strum::EnumIter, strum::EnumString)]
598pub enum WkdType {
599    #[strum(ascii_case_insensitive, to_string = "advanced")]
600    Advanced,
601    #[strum(ascii_case_insensitive, to_string = "direct")]
602    Direct,
603}
604
605/// Create an OpenPGP certificate, which serves as trust anchor
606///
607/// Optionally the `validity` for the primary key can be provided, which defaults to [`EXPIRY_PERIOD`].
608pub fn create_trust_anchor(
609    host: &FQDN,
610    creation_time: Option<SystemTime>,
611    validity_period: Option<Duration>,
612) -> Result<Cert, Error> {
613    let validity_period = Some(validity_period.unwrap_or(EXPIRY_PERIOD));
614    let cert = CertBuilder::new()
615        .set_creation_time(creation_time)
616        .set_validity_period(validity_period)
617        .add_userid(UserID::from(format!("<ssh-openpgp-auth@{}>", host)))
618        .generate()?
619        .0;
620    Ok(cert)
621}
622
623/// Write Transferable Secret Key (TSK) to stdout
624pub fn write_tsk_to_stdout(cert: &Cert) -> Result<(), Error> {
625    let writer = &mut std::io::stdout().lock();
626    writer.write_all(&cert.as_tsk().armored().to_vec()?)?;
627    Ok(())
628}
629
630/// Write Transferable Secret Key (TSK) to a location
631pub fn write_tsk(cert: &Cert, output_dir: Option<&Path>) -> Result<(), Error> {
632    let output_dir = output_dir.unwrap_or(Path::new(CERT_LOCATION));
633    if !output_dir.exists() {
634        create_dir_all(output_dir)?;
635    }
636
637    let mut file = File::create(output_dir.join(format!("{}.tsk", cert.fingerprint())))?;
638    file.write_all(&cert.as_tsk().armored().to_vec()?)?;
639    Ok(())
640}
641
642/// Get available public SSH host keys from the SSH config directory
643///
644/// Using the `ssh_config_dir` parameter it is possible to provide an SSH config directory other than the default `/etc/ssh/`.
645pub fn get_public_ssh_host_keys(ssh_config_dir: Option<&Path>) -> Result<Vec<PathBuf>, Error> {
646    Ok(
647        read_dir(ssh_config_dir.unwrap_or(Path::new(SSH_HOST_KEY_LOCATION).as_ref()))?
648            .filter_map(|x| {
649                x.as_ref()
650                    .is_ok_and(|y| {
651                        y.path().is_file() && y.path().extension().is_some_and(|e| e == "pub")
652                    })
653                    .then_some(x.unwrap().path())
654            })
655            .collect(),
656    )
657}
658
659/// Create an OpenPGP subkey from an SSH public key file
660///
661/// Optionally, a specific creation time may be provided, else the creation time of the provided file is used when calling [`create_openpgp_subkey_from_ssh_public_key`].
662pub fn create_openpgp_subkey_from_ssh_public_key_file(
663    file: &Path,
664    creation_time: Option<SystemTime>,
665) -> Result<Key<PublicParts, SubordinateRole>, Error> {
666    let file_creation_time = if let Ok(metadata) = file.metadata() {
667        if let Ok(creation_time) = metadata.created() {
668            Some(creation_time)
669        } else {
670            None
671        }
672    } else {
673        None
674    };
675
676    create_openpgp_subkey_from_ssh_public_key(
677        ssh_key::PublicKey::from_openssh(&read_to_string(file)?)?,
678        match (creation_time, file_creation_time) {
679            (Some(creation_time), None) | (Some(creation_time), Some(_)) => Some(creation_time),
680            (None, Some(file_creation_time)) => Some(file_creation_time),
681            (None, None) => None,
682        },
683    )
684}
685
686/// Create an OpenPGP subkey from an SSH public key
687///
688/// The creation time of the OpenPGP subkey can optionally be provided with `creation_time` (if [`Option::None`] is provided, the underlying call defaults to [`SystemTime::now()`]).
689pub fn create_openpgp_subkey_from_ssh_public_key(
690    public_key: ssh_key::PublicKey,
691    creation_time: Option<SystemTime>,
692) -> Result<Key<PublicParts, SubordinateRole>, Error> {
693    Ok(match public_key.key_data() {
694        KeyData::Rsa(pubkey) => {
695            Key4::import_public_rsa(pubkey.e.as_bytes(), pubkey.n.as_bytes(), creation_time)?.into()
696        }
697        KeyData::Ed25519(pubkey) => Key4::import_public_ed25519(&pubkey.0, creation_time)?.into(),
698        KeyData::Ecdsa(EcdsaPublicKey::NistP256(pubkey)) => Key4::new(
699            creation_time.unwrap_or(SystemTime::now()),
700            PublicKeyAlgorithm::ECDSA,
701            PublicKey::ECDSA {
702                curve: Curve::NistP256,
703                q: MPI::from(pubkey.as_bytes().to_vec()),
704            },
705        )?
706        .into(),
707        KeyData::Ecdsa(EcdsaPublicKey::NistP384(pubkey)) => Key4::new(
708            creation_time.unwrap_or(SystemTime::now()),
709            PublicKeyAlgorithm::ECDSA,
710            PublicKey::ECDSA {
711                curve: Curve::NistP384,
712                q: MPI::from(pubkey.as_bytes().to_vec()),
713            },
714        )?
715        .into(),
716        KeyData::Ecdsa(EcdsaPublicKey::NistP521(pubkey)) => Key4::new(
717            creation_time.unwrap_or(SystemTime::now()),
718            PublicKeyAlgorithm::ECDSA,
719            PublicKey::ECDSA {
720                curve: Curve::NistP521,
721                q: MPI::from(pubkey.as_bytes().to_vec()),
722            },
723        )?
724        .into(),
725        _ => {
726            return Err(Error::UnsupportedAlgorithm(
727                public_key.algorithm().to_string(),
728            ))
729        }
730    })
731}
732
733/// Add DNS proof to the certificate.
734pub fn add_dns_proof(cert: Cert) -> Result<Cert, Error> {
735    let policy = StandardPolicy::new();
736    let mut signer = cert
737        .primary_key()
738        .key()
739        .clone()
740        .parts_into_secret()?
741        .into_keypair()?;
742    let vc = cert.with_policy(&policy, None)?;
743    let primary_uid = vc.primary_userid()?;
744    let template = primary_uid.binding_signature();
745    let address = EmailAddress::from_str(primary_uid.email2()?.expect("E-mail must be present"))?;
746    let sig = SignatureBuilder::from(template.clone())
747        .set_signature_creation_time(SystemTime::now())?
748        .add_notation(
749            "proof@ariadne.id",
750            format!("dns:{}?type=TXT", address.domain()),
751            NotationDataFlags::empty().set_human_readable(),
752            false,
753        )?
754        .sign_userid_binding(&mut signer, None, &primary_uid)?;
755
756    Ok(cert.insert_packets2(sig)?.0)
757}
758
759/// Attach a vector of OpenPGP subkeys to an OpenPGP certificate
760pub fn attach_subkeys_to_cert(
761    cert: Cert,
762    subkeys: Vec<Key<PublicParts, SubordinateRole>>,
763) -> Result<Cert, Error> {
764    let new_fingerprints = subkeys
765        .iter()
766        .map(|subkey| subkey.fingerprint())
767        .collect::<Vec<Fingerprint>>();
768    let current_fingerprints = cert
769        .keys()
770        .subkeys()
771        .map(|subkey| subkey.fingerprint())
772        .collect::<Vec<Fingerprint>>();
773
774    for new_fingerprint in new_fingerprints {
775        if current_fingerprints.contains(&new_fingerprint) {
776            return Err(Error::SubkeyAlreadyAdded(new_fingerprint));
777        }
778    }
779
780    let key = cert
781        .clone()
782        .keys()
783        .unencrypted_secret()
784        .next()
785        .unwrap()
786        .key()
787        .clone();
788    let mut signer = key.into_keypair()?;
789
790    let signature_builder = SignatureBuilder::new(SignatureType::SubkeyBinding)
791        .set_signature_creation_time(SystemTime::now())?
792        .set_preferred_hash_algorithms(vec![HashAlgorithm::SHA512])?
793        .set_hash_algo(HashAlgorithm::SHA512)
794        .set_features(Features::sequoia())?
795        .set_key_flags(KeyFlags::empty().set_authentication())?;
796    let signatures: Vec<Signature> = subkeys
797        .iter()
798        .filter_map(|x| x.bind(&mut signer, &cert, signature_builder.clone()).ok())
799        .collect();
800    let packets: Vec<Packet> = signatures.iter().map(|x| x.clone().into()).collect();
801
802    Ok(cert.insert_packets(packets)?.insert_packets(subkeys)?)
803}
804
805/// Revoke an OpenPGP subkey of an OpenPGP certificate
806///
807/// The `fingerprints` ([`Vec<Fingerprint>`]) is used to match a subkey in `cert`.
808/// Optionally, a specific `creation_time` for the revocation signature can be provided (else `SystemTime::now()` is used).
809/// Additionally, a [`ReasonForRevocation`] and a reason message can be provided (they otherwise default to [`ReasonForRevocation::KeySuperseded`] and `""`).
810/// A modified OpenPGP certificate, which includes a revocation signature for the targeted subkey is returned.
811///
812/// This function returns an [`sshd_openpgp_auth::Error`] if no or more than one subkey is found which matches `fingerprint`, or if any of the operations related to creating the revocation signature fail.
813pub fn revoke_subkey_of_cert(
814    cert: Cert,
815    fingerprints: Vec<Fingerprint>,
816    creation_time: Option<SystemTime>,
817    reason_type: Option<KeyRevocationType>,
818    reason_msg: Option<&str>,
819) -> Result<Cert, Error> {
820    let creation_time = creation_time.unwrap_or(SystemTime::now());
821    let reason_type = reason_type.unwrap_or(REVOCATION_REASON);
822    let reason_msg = reason_msg.unwrap_or_default();
823
824    let current_fingerprints = cert
825        .keys()
826        .subkeys()
827        .map(|subkey| subkey.fingerprint())
828        .collect::<Vec<Fingerprint>>();
829
830    let non_matching = fingerprints
831        .iter()
832        .filter_map(|fingerprint| {
833            if !current_fingerprints.contains(fingerprint) {
834                Some(fingerprint.clone())
835            } else {
836                None
837            }
838        })
839        .collect::<Vec<Fingerprint>>();
840    if !non_matching.is_empty() {
841        return Err(Error::SubkeyFingerprintNotFound(
842            non_matching.iter().fold(String::new(), |s, fingerprint| {
843                s + &format!("{} ", fingerprint)
844            }),
845        ));
846    }
847
848    let key = cert
849        .keys()
850        .unencrypted_secret()
851        .filter_map(|x| {
852            if x.primary() {
853                Some(x.key().clone())
854            } else {
855                None
856            }
857        })
858        .last()
859        .ok_or(Error::PrimaryKeyNotFound)?;
860    let mut signer = key.into_keypair()?;
861
862    let subkeys = cert
863        .keys()
864        .subkeys()
865        .filter_map(|subkey| {
866            if fingerprints.contains(&subkey.fingerprint()) {
867                Some(subkey.key().clone())
868            } else {
869                None
870            }
871        })
872        .collect::<Vec<_>>();
873
874    let mut cert_packets = cert.as_tsk().into_packets().collect::<Vec<Packet>>();
875    for subkey in subkeys {
876        cert_packets.push(Packet::Signature(
877            SubkeyRevocationBuilder::new()
878                .set_reason_for_revocation(reason_type.into(), reason_msg.as_bytes())?
879                .set_signature_creation_time(creation_time)?
880                .build(&mut signer, &cert, &subkey, None)?,
881        ));
882    }
883
884    Ok(Cert::from_packets(cert_packets.into_iter())?)
885}
886
887/// Extend the expiry of an OpenPGP primary key in a certificate if a threshold is met
888///
889/// The certificate's expiry period is set on top of a reference time.
890/// This function only extends the expiry if the certificate's expiry time is below `expiry_threshold` at `reference_time` (or if there is no expiration time).
891/// The default expiry threshold is defined in [`EXPIRY_THRESHOLD`] and the default expiry period in [`EXPIRY_PERIOD`].
892/// By default a the reference time is [`SystemTime::now()`].
893pub fn extend_expiry_of_cert(
894    cert: Cert,
895    expiry_threshold: Option<Duration>,
896    expiry_period: Option<Duration>,
897    reference_time: Option<SystemTime>,
898) -> Result<Cert, Error> {
899    let reference_time = reference_time.unwrap_or(SystemTime::now());
900    let expiry_threshold = expiry_threshold.unwrap_or(EXPIRY_THRESHOLD);
901    let expiry_period = expiry_period.unwrap_or(EXPIRY_PERIOD);
902    let policy = StandardPolicy::new();
903
904    let expiration_time = cert
905        .with_policy(&policy, reference_time)?
906        .primary_key()
907        .key_expiration_time()
908        .unwrap_or(SystemTime::now());
909
910    if expiration_time
911        .duration_since(reference_time)
912        .is_ok_and(|reference_time| reference_time > expiry_threshold)
913    {
914        return Ok(cert);
915    }
916
917    let mut keypair = cert
918        .primary_key()
919        .key()
920        .clone()
921        .parts_into_secret()?
922        .into_keypair()?;
923
924    let signature = cert.set_expiration_time(
925        &policy,
926        None,
927        &mut keypair,
928        Some(
929            reference_time
930                .checked_add(expiry_period)
931                .ok_or(Error::UnableToExtendExpiry(
932                    expiry_period.as_secs(),
933                    reference_time,
934                ))?,
935        ),
936    )?;
937    Ok(cert.insert_packets(signature)?)
938}
939
940/// Evaluate whether a ValidCert has at least one UserID with an e-mail address that matches a domain
941fn cert_has_userid_with_domain(cert: &ValidCert, fqdn: &FQDN) -> bool {
942    let userids = cert
943        .userids()
944        .filter_map(|x| {
945            let email = if let Ok(Some(email)) = x.userid().email2() {
946                email
947            } else {
948                return None;
949            };
950            if email.ends_with(&fqdn.to_string()) {
951                Some(x.userid().clone())
952            } else {
953                None
954            }
955        })
956        .collect::<Vec<UserID>>();
957    !userids.is_empty()
958}
959
960/// Export certificates to a Web Key Directory (WKD)
961///
962/// The certificates are filtered by FQDN, so that only certificates with matching User IDs are exported.
963/// The output directory as well as the WKD type (advanced or direct) have to be chosen.
964/// Optionally, the reference time for the certificate validation may be provided (defaults to [`SystemTime::now()`]).
965///
966/// ## Errors
967///
968/// If one of the certificates can not be inserted.
969pub fn export_certs_to_wkd(
970    certs: Vec<Cert>,
971    fqdn: FQDN,
972    wkd_type: WkdType,
973    output_dir: &Path,
974    reference_time: Option<SystemTime>,
975) -> Result<(), Error> {
976    let reference_time = Some(reference_time.unwrap_or(SystemTime::now()));
977    let policy = StandardPolicy::new();
978
979    let valid_certs: Vec<ValidCert> = certs
980        .iter()
981        .filter_map(|cert| {
982            match cert.with_policy(&policy, reference_time) {
983                Ok(valid_cert) => {
984                    if cert_has_userid_with_domain(&valid_cert, &fqdn) {
985                        Some(valid_cert)
986                    } else {
987                        eprintln!("Skipping certificate with fingerprint {}, as it is missing a User ID with FQDN {}.", valid_cert.fingerprint(), fqdn);
988                        None
989                    }
990                }
991                Err(_) => {
992                    eprintln!("Skipping certificate with fingerprint {} as it is invalid.", cert.fingerprint());
993                    None
994                }
995            }
996        })
997        .collect();
998
999    for cert in valid_certs {
1000        let (domain_dir, cert_dir) = match wkd_type {
1001            WkdType::Advanced => {
1002                let domain_dir = output_dir.join(format!(".well-known/openpgpkey/{}", fqdn));
1003                let cert_dir = domain_dir.join("hu");
1004                (domain_dir, cert_dir)
1005            }
1006            WkdType::Direct => {
1007                let domain_dir = output_dir.join(".well-known/openpgpkey");
1008                let cert_dir = domain_dir.join("hu");
1009                (domain_dir, cert_dir)
1010            }
1011        };
1012
1013        create_dir_all(&cert_dir)?;
1014
1015        let file_path = &cert_dir.join("w1bjhwjfd8nqsw4ug3kn81sny45zimkq");
1016        let mut file = File::create(file_path)?;
1017        cert.export(&mut file)?;
1018
1019        let policy_path = domain_dir.join("policy");
1020        File::create(&policy_path)?;
1021    }
1022
1023    Ok(())
1024}
1025
1026/// Create a list of SSH public keys by parsing a known_hosts string
1027///
1028/// The string is expected to provide SSH public keys in `known_hosts` format.
1029pub fn parse_known_hosts(input: &str) -> Result<Vec<ssh_key::PublicKey>, Error> {
1030    Ok(KnownHosts::new(input)
1031        .filter_map(|x| x.ok())
1032        .map(|x| x.into())
1033        .collect::<Vec<ssh_key::PublicKey>>())
1034}
1035
1036/// Read all valid Transferable Secret Keys (TSKs) in a directory and show information about them
1037///
1038/// The certificates can be filtered by FQDNs in the primary User ID.
1039/// Additionally, the reference time at which their validity is checked can be provided.
1040pub fn show_tsks_in_dir(
1041    dir: Option<&Path>,
1042    filter: Option<FQDN>,
1043    reference_time: Option<SystemTime>,
1044) -> Result<(), Error> {
1045    let dir = dir.unwrap_or(Path::new(CERT_LOCATION));
1046    let filter = filter.map_or_else(String::new, |fqdn| format!("{}>", fqdn));
1047    let reference_time = reference_time.unwrap_or(SystemTime::now());
1048
1049    let tsk_files = read_dir(dir)?
1050        .filter_map(|path| {
1051            if let Ok(path) = path.as_ref() {
1052                let path = path.path();
1053                if path.is_file() && path.extension().is_some_and(|e| e == "tsk") {
1054                    Some(path)
1055                } else {
1056                    None
1057                }
1058            } else {
1059                None
1060            }
1061        })
1062        .collect::<Vec<PathBuf>>();
1063
1064    if !tsk_files.is_empty() {
1065        let policy = StandardPolicy::new();
1066
1067        for tsk_file in tsk_files.iter() {
1068            if let Ok(cert) = Cert::from_file(tsk_file) {
1069                if cert.is_tsk() {
1070                    if let Ok(valid_cert) = cert.with_policy(&policy, reference_time) {
1071                        if let Ok(primary_uid) = valid_cert.primary_userid() {
1072                            let user_id = primary_uid.userid();
1073                            if filter.is_empty() && !format!("{}", user_id).contains(&filter) {
1074                                continue;
1075                            }
1076
1077                            println!("{}:", tsk_file.to_str().unwrap_or(""));
1078                            println!(
1079                                "🔑️ {} ({}): {} - {}",
1080                                cert.fingerprint(),
1081                                primary_uid.userid(),
1082                                DateTime::<Utc>::from(cert.primary_key().creation_time()),
1083                                DateTime::<Utc>::from(
1084                                    valid_cert
1085                                        .primary_key()
1086                                        .key_expiration_time()
1087                                        .unwrap_or(SystemTime::now())
1088                                )
1089                            );
1090                            for subkey in cert.keys().subkeys() {
1091                                println!(
1092                                    "\t{} {}",
1093                                    match subkey.revocation_status(&policy, SystemTime::now()) {
1094                                        RevocationStatus::NotAsFarAsWeKnow => "✅️",
1095                                        _ => "❌️",
1096                                    },
1097                                    subkey.fingerprint()
1098                                );
1099                            }
1100                        }
1101                    }
1102                }
1103            }
1104        }
1105    }
1106    Ok(())
1107}
1108
1109/// Get a single Cert from a directory
1110///
1111/// Optionally provide a Fingerprint.
1112pub fn get_single_cert_from_dir(
1113    dir: Option<&Path>,
1114    fingerprint: Option<Fingerprint>,
1115) -> Result<Cert, Error> {
1116    let dir = dir.unwrap_or(Path::new(CERT_LOCATION));
1117
1118    if let Some(fingerprint) = fingerprint {
1119        Ok(Cert::from_file(dir.join(format!("{}.tsk", fingerprint)))?)
1120    } else {
1121        let tsk_files = read_dir(dir)?
1122            .filter_map(|path| {
1123                if let Ok(path) = path.as_ref() {
1124                    let path = path.path();
1125                    if path.is_file() && path.extension().is_some_and(|e| e == "tsk") {
1126                        Some(path)
1127                    } else {
1128                        None
1129                    }
1130                } else {
1131                    None
1132                }
1133            })
1134            .collect::<Vec<PathBuf>>();
1135        match tsk_files.len() {
1136            0 => Err(Error::NoCertificates(dir.into())),
1137            1 => Ok(Cert::from_file(
1138                tsk_files
1139                    .first()
1140                    .expect("We should have exactly one certificate!"),
1141            )?),
1142            _ => Err(Error::MultipleCertificates(dir.into())),
1143        }
1144    }
1145}
1146
1147/// Read all certificates in a directory
1148pub fn read_all_certs(dir: Option<&Path>) -> Result<Vec<Cert>, Error> {
1149    let dir = dir.unwrap_or(Path::new(CERT_LOCATION));
1150
1151    Ok(read_dir(dir)?
1152        .filter_map(|path| {
1153            if let Ok(path) = path.as_ref() {
1154                let path = path.path();
1155                if path.is_file() && path.extension().is_some_and(|e| e == "tsk") {
1156                    Some(path)
1157                } else {
1158                    None
1159                }
1160            } else {
1161                None
1162            }
1163        })
1164        .filter_map(|tsk_file| Cert::from_file(tsk_file).ok())
1165        .collect::<Vec<Cert>>())
1166}
1167
1168/// Merge a certificate into a Transferable Secret Key and return the updated TSK
1169///
1170/// # Errors
1171///
1172/// If `tsk` is not a Transferable Secret Key an [`Error::NotATSK`] is returned.
1173/// If primary key fingerprint of `tsk` and `cert` do not match an [`Error::MergeFingerprintMismatch`] is returned.
1174/// If primary User ID of `tsk` and `cert` do not match an [`Error::MergeUserIdMismatch`] is returned.
1175pub fn merge_public_cert(tsk: Cert, cert: Cert) -> Result<Cert, Error> {
1176    let policy = StandardPolicy::new();
1177    let tsk_userid: UserID = tsk
1178        .with_policy(&policy, None)?
1179        .primary_userid()?
1180        .userid()
1181        .clone();
1182    let tsk_fingerprint = tsk.primary_key().fingerprint();
1183
1184    if !tsk.is_tsk() {
1185        return Err(Error::NotATsk(tsk_fingerprint));
1186    }
1187
1188    let cert_userid: UserID = cert
1189        .with_policy(&policy, None)?
1190        .primary_userid()?
1191        .userid()
1192        .clone();
1193    let cert_fingerprint = cert.primary_key().fingerprint();
1194
1195    Ok(if cert_fingerprint == tsk_fingerprint {
1196        if cert_userid == tsk_userid {
1197            tsk.merge_public(cert)?
1198        } else {
1199            return Err(Error::MergeUserIdMismatch {
1200                tsk_userid,
1201                cert_userid,
1202            });
1203        }
1204    } else {
1205        return Err(Error::MergeFingerprintMismatch {
1206            tsk_fingerprint,
1207            cert_fingerprint,
1208        });
1209    })
1210}