Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ pub struct Problem {
pub detail: Option<String>,
/// The HTTP status code returned for this response
pub status: Option<u16>,
/// One or more subproblems associated with specific identifiers
///
/// See <https://www.rfc-editor.org/rfc/rfc8555#section-6.7.1>
#[serde(default)]
pub subproblems: Vec<Subproblem>,
}

impl Problem {
Expand Down Expand Up @@ -159,12 +164,56 @@ impl fmt::Display for Problem {
write!(f, " ({})", r#type)?;
}

if !self.subproblems.is_empty() {
let count = self.subproblems.len();
write!(f, ": {count} subproblems: ")?;
for (i, subproblem) in self.subproblems.iter().enumerate() {
write!(f, "{subproblem}")?;
if i != count - 1 {
f.write_str(", ")?;
}
}
}

Ok(())
}
}

impl std::error::Error for Problem {}

/// An RFC 8555 subproblem document contained within a problem returned by the ACME server
///
/// See <https://www.rfc-editor.org/rfc/rfc8555#section-6.7.1>
#[derive(Clone, Debug, Deserialize)]
pub struct Subproblem {
/// The identifier associated with this problem
pub identifier: Option<Identifier>,
/// One of an enumerated list of problem types
///
/// See <https://datatracker.ietf.org/doc/html/rfc8555#section-6.7>
pub r#type: Option<String>,
/// A human-readable explanation of the problem
pub detail: Option<String>,
}

impl fmt::Display for Subproblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(identifier) = &self.identifier {
write!(f, r#"for "{identifier}""#)?;
}

if let Some(detail) = &self.detail {
write!(f, ": {detail}")?;
}

if let Some(r#type) = &self.r#type {
write!(f, " ({})", r#type)?;
}

Ok(())
}
}

#[derive(Debug, Serialize)]
pub(crate) struct FinalizeRequest {
csr: String,
Expand Down Expand Up @@ -456,6 +505,14 @@ pub enum Identifier {
Dns(String),
}

impl fmt::Display for Identifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dns(domain) => write!(f, "{domain}"),
}
}
}

/// The challenge type
#[allow(missing_docs)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
Expand Down Expand Up @@ -636,5 +693,81 @@ mod tests {
obj.detail,
Some("No authorization provided for name example.org".into())
);
assert!(obj.subproblems.is_empty());
}

// https://www.rfc-editor.org/rfc/rfc8555#section-6.7.1
#[test]
fn subproblems() {
const PROBLEM: &str = r#"{
"type": "urn:ietf:params:acme:error:malformed",
"detail": "Some of the identifiers requested were rejected",
"subproblems": [
{
"type": "urn:ietf:params:acme:error:malformed",
"detail": "Invalid underscore in DNS name \"_example.org\"",
"identifier": {
"type": "dns",
"value": "_example.org"
}
},
{
"type": "urn:ietf:params:acme:error:rejectedIdentifier",
"detail": "This CA will not issue for \"example.net\"",
"identifier": {
"type": "dns",
"value": "example.net"
}
}
]
}"#;

let obj = serde_json::from_str::<Problem>(PROBLEM).unwrap();
assert_eq!(
obj.r#type,
Some("urn:ietf:params:acme:error:malformed".into())
);
assert_eq!(
obj.detail,
Some("Some of the identifiers requested were rejected".into())
);

let subproblems = &obj.subproblems;
assert_eq!(subproblems.len(), 2);

let first_subproblem = subproblems.first().unwrap();
assert_eq!(
first_subproblem.identifier,
Some(Identifier::Dns("_example.org".into()))
);
assert_eq!(
first_subproblem.r#type,
Some("urn:ietf:params:acme:error:malformed".into())
);
assert_eq!(
first_subproblem.detail,
Some(r#"Invalid underscore in DNS name "_example.org""#.into())
);

let second_subproblem = subproblems.get(1).unwrap();
assert_eq!(
second_subproblem.identifier,
Some(Identifier::Dns("example.net".into()))
);
assert_eq!(
second_subproblem.r#type,
Some("urn:ietf:params:acme:error:rejectedIdentifier".into())
);
assert_eq!(
second_subproblem.detail,
Some(r#"This CA will not issue for "example.net""#.into())
);

let expected_display = "\
API error: Some of the identifiers requested were rejected (urn:ietf:params:acme:error:malformed): \
2 subproblems: \
for \"_example.org\": Invalid underscore in DNS name \"_example.org\" (urn:ietf:params:acme:error:malformed), \
for \"example.net\": This CA will not issue for \"example.net\" (urn:ietf:params:acme:error:rejectedIdentifier)";
assert_eq!(format!("{obj}"), expected_display);
}
}
44 changes: 43 additions & 1 deletion tests/pebble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use hyper_util::client::legacy::Client as HyperClient;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use instant_acme::{
Account, AuthorizationStatus, Challenge, ChallengeType, Identifier, KeyAuthorization,
Account, AuthorizationStatus, Challenge, ChallengeType, Error, Identifier, KeyAuthorization,
NewAccount, NewOrder, Order, OrderStatus,
};
use rcgen::{CertificateParams, DistinguishedName, KeyPair};
Expand Down Expand Up @@ -88,6 +88,48 @@ async fn tls_alpn_01() -> Result<(), Box<dyn StdError>> {
.await
}

/// Test subproblem handling by trying to issue for a forbidden identifier
#[tokio::test]
#[ignore]
async fn forbidden_identifier() -> Result<(), Box<dyn StdError>> {
let _ = tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.try_init();

debug!("starting Pebble CA environment");
let config = EnvironmentConfig::default();
let forbidden_name = config.pebble.domain_blocklist.first().unwrap();
let err = Environment::new(EnvironmentConfig::default())
.await?
.test::<Http01>(&["valid.example.com", forbidden_name])
.await
.expect_err("issuing for blocked domain name should fail");

let Error::Api(problem) = *err.downcast::<Error>()? else {
panic!("unexpected error result");
};

assert_eq!(
problem.r#type.as_deref(),
Some("urn:ietf:params:acme:error:rejectedIdentifier")
);
let subproblems = problem.subproblems;
assert_eq!(subproblems.len(), 1);

let first_subproblem = subproblems.first().unwrap();
assert_eq!(
first_subproblem.identifier,
Some(Identifier::Dns(forbidden_name.to_string()))
);
assert_eq!(
problem.r#type.as_deref(),
Some("urn:ietf:params:acme:error:rejectedIdentifier")
);

Ok(())
}

/// A test environment running Pebble and a challenge test server
///
/// Subprocesses are torn down cleanly on drop to avoid leaving
Expand Down