1use 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
58const EXPIRY_THRESHOLD_DAYS: u64 = 250;
60const EXPIRY_PERIOD_DAYS: u64 = 365;
62pub const SECONDS_IN_A_DAY: u64 = 24 * 60 * 60;
64pub const EXPIRY_THRESHOLD: Duration = Duration::new(EXPIRY_THRESHOLD_DAYS * SECONDS_IN_A_DAY, 0);
66pub const EXPIRY_PERIOD: Duration = Duration::new(EXPIRY_PERIOD_DAYS * SECONDS_IN_A_DAY, 0);
68pub const CERT_LOCATION: &str = "/var/lib/sshd-openpgp-auth/";
70pub const SSH_HOST_KEY_LOCATION: &str = "/etc/ssh/";
72pub const REVOCATION_REASON: KeyRevocationType = KeyRevocationType::Superseded;
74pub const WKD_TYPE: WkdType = WkdType::Advanced;
76pub 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#[derive(Debug, Clone, Copy, strum::Display, strum::EnumIter, strum::EnumString)]
574pub enum KeyRevocationType {
575 #[strum(ascii_case_insensitive, to_string = "compromised")]
577 Compromised,
578 #[strum(ascii_case_insensitive, to_string = "retired")]
580 Retired,
581 #[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#[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
605pub 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
623pub 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
630pub 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
642pub 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
659pub 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
686pub 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
733pub 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
759pub 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
805pub 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
887pub 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
940fn 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
960pub 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
1026pub 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
1036pub 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
1109pub 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
1147pub 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
1168pub 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}