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(
long = "cert-file",
help = "Import certificate from CERT_FILE and declare it as introducer.",
value_name = "CERT_FILE",
help = "Import certificate from <FILE> and declare it as introducer.",
value_name = "FILE",
conflicts_with = "cert"
)]
pub cert_file: Option<String>,
@@ -159,8 +159,9 @@ pub enum LocalsSubcommand {
)]
pub struct LocalsAddCommand {
#[clap(
long = "cert",
help = "Certificate to add."
long,
help = "Import a certificate from <FILE>.",
value_name = "FILE",
)]
pub cert_file: String,
}
@@ -168,12 +169,12 @@ pub struct LocalsAddCommand {
#[derive(Parser, Debug)]
#[clap(
name = "locals_remove",
about = "Certificate to remove.",
about = "Disable a Certificate.",
)]
pub struct LocalsRemoveCommand {
#[clap(
long = "cert",
help = "Certificate to removed."
help = "Certificate to be removed."
)]
pub cert: String,
}
+1
View File
@@ -1,3 +1,4 @@
//! introducer subcommand
use anyhow::Result;
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(_)) {
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());
}
Introducer::create(&context, &vc, cmd.domains)?;
} else if let Some(cert) = cmd.cert {
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()))?;
let certs: Vec<ValidCert> = certs.iter()
@@ -48,7 +48,7 @@ pub async fn dispatch(cmd: IntroducerAddCommand, config: HuskConfigContainer) ->
.collect();
if let Some(cert) = certs.first() {
Introducer::create(&context, &cert, cmd.domains)?;
Introducer::create(&context, cert, cmd.domains)?;
} else {
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
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()))?;
// Filter out valid certificates. If the certificate is not
@@ -35,8 +35,6 @@ pub async fn dispatch(cmd: IntroducerRemoveCommand, config: HuskConfigContainer)
} else {
return Err(CommandError::CertNotUsable(fpr).into());
}
Ok(())
}
+3 -2
View File
@@ -1,3 +1,4 @@
//! locals subcommand
use anyhow::Result;
use crate::cli::cli_args::{LocalsCommand, LocalsSubcommand};
@@ -11,10 +12,10 @@ pub mod remove;
pub async fn dispatch(cmd: LocalsCommand, config: HuskConfigContainer) -> Result<()> {
match cmd.subcommand {
LocalsSubcommand::Add(add_cmd) => {
add::dispatch(add_cmd, &config).await?;
add::dispatch(add_cmd, config).await?;
},
LocalsSubcommand::Remove(rm_cmd) => {
remove::dispatch(rm_cmd, &config).await?;
remove::dispatch(rm_cmd, config).await?;
},
LocalsSubcommand::List => {
let context = HuskContext::new(&config.into())?;
+37 -4
View File
@@ -1,12 +1,45 @@
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::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!(" {:?}", 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 crate::cli::cli_args::LocalsRemoveCommand;
use crate::config::HuskConfigContainer;
use sequoia_openpgp::Fingerprint;
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!(" {:?}", 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 {
let cert_synopsis = p.target().clone();
match set.entry(cert_synopsis.fingerprint()) {
Vacant(_) => {
// Only consider non introduced certificates.
if !p.certifications().any(|c|
introducers.contains(&c.issuer().fingerprint())) {
if let Vacant(_) = set.entry(cert_synopsis.fingerprint()) {
// Only consider non introduced certificates.
if !p.certifications().any(|c|
introducers.contains(&c.issuer().fingerprint())) {
set.insert(cert_synopsis.fingerprint(), cert_synopsis);
}
},
// The target is already known, do nothing.
_ => {}
set.insert(cert_synopsis.fingerprint(), cert_synopsis);
}
}
}
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.
///
/// If `activate` is true, the certificate becomes an introducer for
/// `domains`. Passing `None` or an empty list in this case is an error.
/// If `activate` is false, create a certifiaction with minimal trust_roots
/// 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<()> {
let policy = &context.policy;
@@ -465,27 +468,37 @@ pub fn authenticate(context: &HuskContext, cert: &ValidCert, activate: bool, dom
.set_revocable(true)?;
// set no expiration date
// Add domains
if activate {
// set the trust level and depth for an active introducer.
builder = builder.set_trust_signature(1, 120)?;
// set the trust level and depth for an active local/introducer.
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 {
for domain in domains {
if !mail::is_domain(domain.as_str()) {
return Err(HuskError::InvalidDomainName(domain).into());
// Add domains
if let Some(domains) = domains {
if domains.is_empty() {
// An introducer without domains would be unlimited.
return Err(HuskError::UnlimitedIntroducer.into());
}
builder = builder.add_regular_expression(format!("<[^>]+[@.]{}>$",
escape::regex_ctrl_escape(domain.as_str())?
))?;
for domain in domains {
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 {
// deactive the introducer
// deactive the certificate
builder = builder.set_trust_signature(0, 1)?;
}
@@ -526,7 +539,8 @@ pub fn authenticate(context: &HuskContext, cert: &ValidCert, activate: bool, dom
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()
.for_certification()
.alive()
@@ -535,6 +549,17 @@ pub fn has_certification_capability(cert: &ValidCert) -> bool {
.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)]
pub mod tests {
-1
View File
@@ -1,6 +1,5 @@
//! Husk milter
use anyhow;
use std::process;
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 {
let policy = StandardPolicy::new();
@@ -66,6 +67,7 @@ impl Introducer {
result
}
/// Create an introducer.
pub fn create(context: &HuskContext, cert: &ValidCert, domains: Vec<String>)
-> Result<()> {
@@ -75,9 +77,15 @@ impl Introducer {
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)
-> Result<()> {
@@ -87,7 +95,12 @@ impl Introducer {
return Err(IntroducerError::UnsuitableCertificate.into());
}
crypto::authenticate(context, cert, false, None)
crypto::authenticate(
context,
cert,
crypto::Role::Introducer,
false,
None)
}
}