Add locals add and remove subcommands

- `locals add` adds a local (unintroduced) certificate for encryption.
- `locals remove` removes/deacivates a local certificate.
- Overall cleanup
This commit is contained in:
Malte Meiboom
2026-05-19 16:07:48 +02:00
parent 14dca91d92
commit e6c6bf87d9
10 changed files with 162 additions and 51 deletions
+7 -6
View File
@@ -94,8 +94,8 @@ pub struct IntroducerAddCommand {
#[clap( #[clap(
long = "cert-file", long = "cert-file",
help = "Import certificate from CERT_FILE and declare it as introducer.", help = "Import certificate from <FILE> and declare it as introducer.",
value_name = "CERT_FILE", value_name = "FILE",
conflicts_with = "cert" conflicts_with = "cert"
)] )]
pub cert_file: Option<String>, pub cert_file: Option<String>,
@@ -159,8 +159,9 @@ pub enum LocalsSubcommand {
)] )]
pub struct LocalsAddCommand { pub struct LocalsAddCommand {
#[clap( #[clap(
long = "cert", long,
help = "Certificate to add." help = "Import a certificate from <FILE>.",
value_name = "FILE",
)] )]
pub cert_file: String, pub cert_file: String,
} }
@@ -168,12 +169,12 @@ pub struct LocalsAddCommand {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[clap( #[clap(
name = "locals_remove", name = "locals_remove",
about = "Certificate to remove.", about = "Disable a Certificate.",
)] )]
pub struct LocalsRemoveCommand { pub struct LocalsRemoveCommand {
#[clap( #[clap(
long = "cert", long = "cert",
help = "Certificate to removed." help = "Certificate to be removed."
)] )]
pub cert: String, pub cert: String,
} }
+1
View File
@@ -1,3 +1,4 @@
//! introducer subcommand
use anyhow::Result; use anyhow::Result;
use crate::cli::cli_args::{IntroducerCommand, IntroducerSubcommand}; use crate::cli::cli_args::{IntroducerCommand, IntroducerSubcommand};
+3 -3
View File
@@ -31,14 +31,14 @@ pub async fn dispatch(cmd: IntroducerAddCommand, config: HuskConfigContainer) ->
if matches!(vc.revocation_status(), RevocationStatus::Revoked(_)) { if matches!(vc.revocation_status(), RevocationStatus::Revoked(_)) {
return Err(CommandError::CertRevoked(vc.fingerprint()).into()); return Err(CommandError::CertRevoked(vc.fingerprint()).into());
} }
if !crypto::has_certification_capability(&vc) { if !crypto::can_certify(&vc) {
return Err(CommandError::CertNotUsable(vc.fingerprint()).into()); return Err(CommandError::CertNotUsable(vc.fingerprint()).into());
} }
Introducer::create(&context, &vc, cmd.domains)?; Introducer::create(&context, &vc, cmd.domains)?;
} else if let Some(cert) = cmd.cert { } else if let Some(cert) = cmd.cert {
let fpr = Fingerprint::from_hex(cert.as_str())?; let fpr = Fingerprint::from_hex(cert.as_str())?;
let certs = cert_store.lookup_by_cert(&KeyHandle::try_from(&fpr)?) let certs = cert_store.lookup_by_cert(&KeyHandle::from(&fpr))
.map_err(|_| CommandError::CertNotFound(fpr.clone()))?; .map_err(|_| CommandError::CertNotFound(fpr.clone()))?;
let certs: Vec<ValidCert> = certs.iter() let certs: Vec<ValidCert> = certs.iter()
@@ -48,7 +48,7 @@ pub async fn dispatch(cmd: IntroducerAddCommand, config: HuskConfigContainer) ->
.collect(); .collect();
if let Some(cert) = certs.first() { if let Some(cert) = certs.first() {
Introducer::create(&context, &cert, cmd.domains)?; Introducer::create(&context, cert, cmd.domains)?;
} else { } else {
return Err(CommandError::CertNotUsable(fpr).into()); return Err(CommandError::CertNotUsable(fpr).into());
} }
+1 -3
View File
@@ -19,7 +19,7 @@ pub async fn dispatch(cmd: IntroducerRemoveCommand, config: HuskConfigContainer)
// Get the certificate // Get the certificate
let fpr = Fingerprint::from_hex(&cmd.cert)?; let fpr = Fingerprint::from_hex(&cmd.cert)?;
let certs = cert_store.lookup_by_cert(&KeyHandle::try_from(&fpr)?) let certs = cert_store.lookup_by_cert(&KeyHandle::from(&fpr))
.map_err(|_| CommandError::CertNotFound(fpr.clone()))?; .map_err(|_| CommandError::CertNotFound(fpr.clone()))?;
// Filter out valid certificates. If the certificate is not // Filter out valid certificates. If the certificate is not
@@ -35,8 +35,6 @@ pub async fn dispatch(cmd: IntroducerRemoveCommand, config: HuskConfigContainer)
} else { } else {
return Err(CommandError::CertNotUsable(fpr).into()); return Err(CommandError::CertNotUsable(fpr).into());
} }
Ok(()) Ok(())
} }
+3 -2
View File
@@ -1,3 +1,4 @@
//! locals subcommand
use anyhow::Result; use anyhow::Result;
use crate::cli::cli_args::{LocalsCommand, LocalsSubcommand}; use crate::cli::cli_args::{LocalsCommand, LocalsSubcommand};
@@ -11,10 +12,10 @@ pub mod remove;
pub async fn dispatch(cmd: LocalsCommand, config: HuskConfigContainer) -> Result<()> { pub async fn dispatch(cmd: LocalsCommand, config: HuskConfigContainer) -> Result<()> {
match cmd.subcommand { match cmd.subcommand {
LocalsSubcommand::Add(add_cmd) => { LocalsSubcommand::Add(add_cmd) => {
add::dispatch(add_cmd, &config).await?; add::dispatch(add_cmd, config).await?;
}, },
LocalsSubcommand::Remove(rm_cmd) => { LocalsSubcommand::Remove(rm_cmd) => {
remove::dispatch(rm_cmd, &config).await?; remove::dispatch(rm_cmd, config).await?;
}, },
LocalsSubcommand::List => { LocalsSubcommand::List => {
let context = HuskContext::new(&config.into())?; let context = HuskContext::new(&config.into())?;
+37 -4
View File
@@ -1,12 +1,45 @@
use anyhow::Result; use anyhow::Result;
use sequoia_openpgp::cert;
use sequoia_openpgp::parse::Parse;
use sequoia_openpgp::types::RevocationStatus;
use crate::cli::cli_args::LocalsAddCommand; use crate::cli::cli_args::LocalsAddCommand;
use crate::config::HuskConfigContainer; use crate::config::HuskConfigContainer;
use crate::types::husk_context::HuskContext;
use crate::commands::CommandError;
use crate::common::crypto;
pub async fn dispatch(cmd: LocalsAddCommand, _config: &HuskConfigContainer) -> Result<()> { pub async fn dispatch(cmd: LocalsAddCommand, config: HuskConfigContainer) -> Result<()> {
println!("add"); println!("add");
println!(" {:?}", cmd.cert_file); println!(" {:?}", cmd.cert_file);
Ok(())
let context = HuskContext::new(&config.into())?;
let policy = &context.policy;
let c = cert::Cert::from_file(cmd.cert_file)?;
let vc = c.with_policy(policy, None)?;
// guards
if vc.alive().is_err() {
return Err(CommandError::CertNotAlive(vc.fingerprint()).into());
}
if matches!(vc.revocation_status(), RevocationStatus::Revoked(_)) {
return Err(CommandError::CertRevoked(vc.fingerprint()).into());
}
if !crypto::can_encrypt(&vc) {
return Err(CommandError::CertNotUsable(vc.fingerprint()).into());
}
if vc.fingerprint() == context.local_trust_root.fingerprint() {
// XXX: find a better error
return Err(CommandError::CertNotUsable(vc.fingerprint()).into());
}
crypto::authenticate(
&context,
&vc,
crypto::Role::Local,
true,
None
)
} }
+44 -4
View File
@@ -1,12 +1,52 @@
//! locals remove subcommand
use anyhow::Result; use anyhow::Result;
use crate::cli::cli_args::LocalsRemoveCommand; use sequoia_openpgp::Fingerprint;
use crate::config::HuskConfigContainer; use sequoia_openpgp::KeyHandle;
use sequoia_openpgp::cert::ValidCert;
use sequoia_cert_store::Store;
pub async fn dispatch(cmd: LocalsRemoveCommand, _config: &HuskConfigContainer) -> Result<()> { use crate::cli::cli_args::LocalsRemoveCommand;
use crate::common::crypto;
use crate::config::HuskConfigContainer;
use crate::commands::CommandError;
use crate::types::husk_context::HuskContext;
pub async fn dispatch(cmd: LocalsRemoveCommand, config: HuskConfigContainer) -> Result<()> {
println!("remove"); println!("remove");
println!(" {:?}", cmd.cert); println!(" {:?}", cmd.cert);
Ok(()) let context = HuskContext::new(&config.into())?;
let policy = &context.policy;
let cert_store = &context.cert_store;
// Get the certificate
let fpr = Fingerprint::from_hex(&cmd.cert)?;
let certs = cert_store.lookup_by_cert(&KeyHandle::from(&fpr))
.map_err(|_| CommandError::CertNotFound(fpr.clone()))?;
// Filter out valid certificates. If the certificate is not
// valid according to the policy, it is already no introducer.
let certs: Vec<ValidCert> = certs.iter()
.filter_map(|c|
c.with_policy(policy, None).ok()
)
.collect();
if let Some(cert) = certs.first() {
// filter out the trust root
if cert.fingerprint() == context.local_trust_root.fingerprint() {
return Err(CommandError::CertNotUsable(fpr).into());
}
crypto::authenticate(
&context,
cert,
crypto::Role::Local,
false,
None)
} else {
Err(CommandError::CertNotUsable(fpr).into())
}
} }
+51 -26
View File
@@ -346,17 +346,13 @@ pub fn get_locals<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, local
for p in targets { for p in targets {
let cert_synopsis = p.target().clone(); let cert_synopsis = p.target().clone();
match set.entry(cert_synopsis.fingerprint()) { if let Vacant(_) = set.entry(cert_synopsis.fingerprint()) {
Vacant(_) => { // Only consider non introduced certificates.
// Only consider non introduced certificates. if !p.certifications().any(|c|
if !p.certifications().any(|c| introducers.contains(&c.issuer().fingerprint())) {
introducers.contains(&c.issuer().fingerprint())) {
set.insert(cert_synopsis.fingerprint(), cert_synopsis); set.insert(cert_synopsis.fingerprint(), cert_synopsis);
} }
},
// The target is already known, do nothing.
_ => {}
} }
} }
set.into_values().collect() set.into_values().collect()
@@ -428,13 +424,20 @@ pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec<Recipie
} }
} }
/// Roles for authenticate()
#[derive(PartialEq)]
pub enum Role {
Local,
Introducer,
}
/// Authenticate a certificate. /// Authenticate a certificate.
/// ///
/// If `activate` is true, the certificate becomes an introducer for /// If `activate` is true, the certificate becomes an introducer for
/// `domains`. Passing `None` or an empty list in this case is an error. /// `domains`. Passing `None` or an empty list in this case is an error.
/// If `activate` is false, create a certifiaction with minimal trust_roots /// If `activate` is false, create a certifiaction with minimal trust_roots
/// amount and no depth. In this case `domains` is ignored. /// amount and no depth. In this case `domains` is ignored.
pub fn authenticate(context: &HuskContext, cert: &ValidCert, activate: bool, domains: Option<Vec<String>>) pub fn authenticate(context: &HuskContext, cert: &ValidCert, role: Role, activate: bool, domains: Option<Vec<String>>)
-> anyhow::Result<()> { -> anyhow::Result<()> {
let policy = &context.policy; let policy = &context.policy;
@@ -465,27 +468,37 @@ pub fn authenticate(context: &HuskContext, cert: &ValidCert, activate: bool, dom
.set_revocable(true)?; .set_revocable(true)?;
// set no expiration date // set no expiration date
// Add domains
if activate { if activate {
// set the trust level and depth for an active introducer. // set the trust level and depth for an active local/introducer.
builder = builder.set_trust_signature(1, 120)?; if role == Role::Local {
builder = builder.set_trust_signature(0, 120)?;
} else {
// Introducer
builder = builder.set_trust_signature(1, 120)?;
if let Some(domains) = domains { // Add domains
for domain in domains { if let Some(domains) = domains {
if !mail::is_domain(domain.as_str()) { if domains.is_empty() {
return Err(HuskError::InvalidDomainName(domain).into()); // An introducer without domains would be unlimited.
return Err(HuskError::UnlimitedIntroducer.into());
} }
builder = builder.add_regular_expression(format!("<[^>]+[@.]{}>$", for domain in domains {
escape::regex_ctrl_escape(domain.as_str())? if !mail::is_domain(domain.as_str()) {
))?; return Err(HuskError::InvalidDomainName(domain).into());
}
builder = builder.add_regular_expression(format!("<[^>]+[@.]{}>$",
escape::regex_ctrl_escape(domain.as_str())?
))?;
}
} else {
// An introducer without domains would be unlimited.
return Err(HuskError::UnlimitedIntroducer.into());
} }
} else {
// An introducer without domains would be unlimited.
return Err(HuskError::UnlimitedIntroducer.into());
} }
} else { } else {
// deactive the introducer // deactive the certificate
builder = builder.set_trust_signature(0, 1)?; builder = builder.set_trust_signature(0, 1)?;
} }
@@ -526,7 +539,8 @@ pub fn authenticate(context: &HuskContext, cert: &ValidCert, activate: bool, dom
Ok(()) Ok(())
} }
pub fn has_certification_capability(cert: &ValidCert) -> bool { /// Returns true if `cert` can be used for certifications.
pub fn can_certify(cert: &ValidCert) -> bool {
cert.keys() cert.keys()
.for_certification() .for_certification()
.alive() .alive()
@@ -535,6 +549,17 @@ pub fn has_certification_capability(cert: &ValidCert) -> bool {
.count() > 0 .count() > 0
} }
/// Returns true if `cert` can be used for encryption.
pub fn can_encrypt(cert: &ValidCert) -> bool {
cert.keys()
.for_transport_encryption()
.alive()
.supported()
.revoked(false)
.count() > 0
}
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
-1
View File
@@ -1,6 +1,5 @@
//! Husk milter //! Husk milter
use anyhow;
use std::process; use std::process;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
+15 -2
View File
@@ -42,6 +42,7 @@ impl Introducer {
} }
} }
/// Has this introducer certified cert?
pub fn is_introducing(&self, cert: &Cert) -> bool { pub fn is_introducing(&self, cert: &Cert) -> bool {
let policy = StandardPolicy::new(); let policy = StandardPolicy::new();
@@ -66,6 +67,7 @@ impl Introducer {
result result
} }
/// Create an introducer.
pub fn create(context: &HuskContext, cert: &ValidCert, domains: Vec<String>) pub fn create(context: &HuskContext, cert: &ValidCert, domains: Vec<String>)
-> Result<()> { -> Result<()> {
@@ -75,9 +77,15 @@ impl Introducer {
return Err(IntroducerError::UnsuitableCertificate.into()); return Err(IntroducerError::UnsuitableCertificate.into());
} }
crypto::authenticate(context, cert, true, Some(domains)) crypto::authenticate(
context,
cert,
crypto::Role::Introducer,
true,
Some(domains))
} }
/// Remove an introducer.
pub fn remove(context: &HuskContext, cert: &ValidCert, _demote: bool) pub fn remove(context: &HuskContext, cert: &ValidCert, _demote: bool)
-> Result<()> { -> Result<()> {
@@ -87,7 +95,12 @@ impl Introducer {
return Err(IntroducerError::UnsuitableCertificate.into()); return Err(IntroducerError::UnsuitableCertificate.into());
} }
crypto::authenticate(context, cert, false, None) crypto::authenticate(
context,
cert,
crypto::Role::Introducer,
false,
None)
} }
} }