use anyhow::{anyhow, ensure};
use rcgen::{CrlDistributionPoint, CustomExtension};
use simple_asn1::{ASN1Block, ASN1Class, BigUint, OID};
use tracing::info;
use url::Url;
use crate::config::LocalCaConfig;
mod oids {
use simple_asn1::{OID, oid};
pub(super) const AUTHORITY_INFO_ACCESS: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 1, 1];
pub(super) fn ca_issuers() -> OID {
oid!(1, 3, 6, 1, 5, 5, 7, 48, 2)
}
}
#[derive(Debug, Clone, Default)]
pub(super) struct LeafPolicy {
crl_distribution_points: Vec<CrlDistributionPoint>,
authority_info_access: Option<CustomExtension>,
}
impl LeafPolicy {
pub(super) fn from_config(cfg: &LocalCaConfig) -> anyhow::Result<Self> {
let crl_urls = check_urls(
"signer.local_ca.crl_distribution_points",
&cfg.crl_distribution_points,
)?;
let issuer_urls = check_urls("signer.local_ca.ca_issuer_urls", &cfg.ca_issuer_urls)?;
if !crl_urls.is_empty() || !issuer_urls.is_empty() {
info!(
event = "local_ca_leaf_policy_configured",
outcome = "success",
crl_distribution_points = ?crl_urls,
ca_issuer_urls = ?issuer_urls,
);
}
Ok(Self {
crl_distribution_points: if crl_urls.is_empty() {
Vec::new()
} else {
vec![CrlDistributionPoint { uris: crl_urls }]
},
authority_info_access: authority_info_access(&issuer_urls)?,
})
}
pub(super) fn crl_distribution_points(&self) -> Vec<CrlDistributionPoint> {
self.crl_distribution_points.clone()
}
pub(super) fn custom_extensions(&self) -> Vec<CustomExtension> {
self.authority_info_access.iter().cloned().collect()
}
}
fn check_urls(key: &str, values: &[String]) -> anyhow::Result<Vec<String>> {
let mut checked = Vec::with_capacity(values.len());
for value in values {
ensure!(!value.is_empty(), "{key} holds an empty entry");
let url = Url::parse(value)
.map_err(|error| anyhow!("{key} entry `{value}` is not a valid URL: {error}"))?;
ensure!(
matches!(url.scheme(), "http" | "https"),
"{key} entry `{value}` must be http:// or https://: this server publishes \
the CRL and its own certificate over HTTP and populates no directory, \
so nothing would answer an ldap:// or ftp:// pointer"
);
ensure!(
url.username().is_empty() && url.password().is_none(),
"{key} entry `{value}` carries credentials: they would be published \
in every certificate this CA issues, for the life of each one"
);
ensure!(
url.as_str() == value,
"{key} entry `{value}` is not in normalized form: write it as `{url}`"
);
checked.push(value.clone());
}
Ok(checked)
}
fn authority_info_access(urls: &[String]) -> anyhow::Result<Option<CustomExtension>> {
if urls.is_empty() {
return Ok(None);
}
let descriptions = urls
.iter()
.map(|url| access_description(oids::ca_issuers(), url))
.collect();
let der = simple_asn1::to_der(&ASN1Block::Sequence(0, descriptions))
.map_err(|error| anyhow!("encoding the authorityInfoAccess extension: {error}"))?;
let mut extension = CustomExtension::from_oid_content(oids::AUTHORITY_INFO_ACCESS, der);
extension.set_criticality(false);
Ok(Some(extension))
}
fn access_description(method: OID, uri: &str) -> ASN1Block {
ASN1Block::Sequence(
0,
vec![
ASN1Block::ObjectIdentifier(0, method),
ASN1Block::Unknown(
ASN1Class::ContextSpecific,
false,
0,
BigUint::from(6u8),
uri.as_bytes().to_vec(),
),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(crl: &[&str], issuers: &[&str]) -> LocalCaConfig {
LocalCaConfig {
crl_distribution_points: crl.iter().map(|s| s.to_string()).collect(),
ca_issuer_urls: issuers.iter().map(|s| s.to_string()).collect(),
..LocalCaConfig::default()
}
}
fn error(crl: &[&str], issuers: &[&str]) -> String {
LeafPolicy::from_config(&config(crl, issuers))
.expect_err("configuration should be refused")
.to_string()
}
#[test]
fn the_default_configuration_says_nothing_at_all() {
let policy = LeafPolicy::from_config(&LocalCaConfig::default()).unwrap();
assert!(policy.crl_distribution_points().is_empty());
assert!(
policy.custom_extensions().is_empty(),
"an empty list must emit no extension, not an empty SEQUENCE"
);
}
#[test]
fn every_crl_url_lands_in_one_distribution_point() {
let policy = LeafPolicy::from_config(&config(
&["http://ca.example/ca.crl", "http://mirror.example/ca.crl"],
&[],
))
.unwrap();
let points = policy.crl_distribution_points();
assert_eq!(
points.len(),
1,
"several URIs are one CRL reachable in several places, \
not several different CRLs"
);
assert_eq!(
points[0].uris,
vec!["http://ca.example/ca.crl", "http://mirror.example/ca.crl"]
);
assert!(policy.custom_extensions().is_empty());
}
#[test]
fn one_ca_issuer_url_encodes_to_the_expected_der() {
let policy = LeafPolicy::from_config(&config(&[], &["http://ca.example/ca.crt"])).unwrap();
let extensions = policy.custom_extensions();
assert_eq!(extensions.len(), 1);
let extension = &extensions[0];
assert!(!extension.criticality(), "RFC 5280 §4.2.2.1: non-critical");
assert_eq!(
extension.oid_components().collect::<Vec<_>>(),
vec![1, 3, 6, 1, 5, 5, 7, 1, 1]
);
let uri = b"http://ca.example/ca.crt";
let mut expected = vec![
0x30, 0x26, 0x30, 0x24, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02, 0x86, 0x18, ];
expected.extend_from_slice(uri);
assert_eq!(extension.content(), expected.as_slice());
}
#[test]
fn several_ca_issuer_urls_encode_with_long_form_lengths() {
let first = format!("http://ca.example/{}/ca.crt", "a".repeat(50));
let second = format!("http://ca.example/{}/ca.crt", "b".repeat(50));
let policy =
LeafPolicy::from_config(&config(&[], &[first.as_str(), second.as_str()])).unwrap();
let extensions = policy.custom_extensions();
let content = extensions[0].content();
let description_len = 2 + 10 + 2 + first.len();
let total = 2 * description_len;
assert!(total > 127, "the vector must exercise the long form");
assert_eq!(
&content[..3],
&[0x30, 0x81, total as u8],
"an outer SEQUENCE over 127 bytes takes a long-form length"
);
let uri_header = [0x86, first.len() as u8];
assert_eq!(
content.windows(2).filter(|w| *w == uri_header).count(),
2,
"each accessLocation is a primitive [6] tag carrying the URI"
);
assert!(content.windows(first.len()).any(|w| w == first.as_bytes()));
assert!(
content
.windows(second.len())
.any(|w| w == second.as_bytes())
);
}
#[test]
fn an_empty_entry_is_refused() {
assert!(error(&[""], &[]).contains("holds an empty entry"));
}
#[test]
fn an_unparsable_url_is_refused_naming_the_key_and_the_value() {
let message = error(&["not a url"], &[]);
assert!(
message.contains("signer.local_ca.crl_distribution_points"),
"{message}"
);
assert!(message.contains("not a url"), "{message}");
assert!(message.contains("not a valid URL"), "{message}");
}
#[test]
fn a_non_http_scheme_is_refused_saying_why() {
let message = error(&[], &["ldap://ca.example/cn=ca"]);
assert!(
message.contains("signer.local_ca.ca_issuer_urls"),
"{message}"
);
assert!(message.contains("must be http:// or https://"), "{message}");
assert!(message.contains("populates no directory"), "{message}");
}
#[test]
fn a_url_carrying_credentials_is_refused() {
let message = error(&["https://operator:hunter2@ca.example/ca.crl"], &[]);
assert!(message.contains("carries credentials"), "{message}");
assert!(
message.contains("every certificate this CA issues"),
"{message}"
);
}
#[test]
fn an_unnormalized_url_is_refused_quoting_the_normalized_form() {
let message = error(&[" http://ca.example/ca.crl"], &[]);
assert!(message.contains("not in normalized form"), "{message}");
assert!(
message.contains("write it as `http://ca.example/ca.crl`"),
"{message}"
);
let message = error(&[], &["https://ca.example"]);
assert!(
message.contains("write it as `https://ca.example/`"),
"{message}"
);
}
#[test]
fn a_non_ascii_url_is_refused_by_the_normalization_rule() {
let message = error(&["http://ca.example/café.crl"], &[]);
assert!(message.contains("not in normalized form"), "{message}");
}
}