402 lines
11 KiB
Rust
402 lines
11 KiB
Rust
use std::borrow::Cow;
|
|
use std::fmt;
|
|
|
|
use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
|
|
use hyper::{Body, Response};
|
|
use ring::digest::{digest, Digest, SHA256};
|
|
use ring::signature::{EcdsaKeyPair, KeyPair};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
/// Error type for instant-acme
|
|
#[derive(Debug, Error)]
|
|
pub enum Error {
|
|
/// An JSON problem as returned by the ACME server
|
|
///
|
|
/// RFC 8555 uses problem documents as described in RFC 7807.
|
|
#[error(transparent)]
|
|
Api(#[from] Problem),
|
|
/// Failed to base64-decode data
|
|
#[error("base64 decoding failed: {0}")]
|
|
Base64(#[from] base64::DecodeError),
|
|
/// Failed from cryptographic operations
|
|
#[error("cryptographic operation failed: {0}")]
|
|
Crypto(#[from] ring::error::Unspecified),
|
|
/// Failed to instantiate a private key
|
|
#[error("invalid key bytes: {0}")]
|
|
CryptoKey(#[from] ring::error::KeyRejected),
|
|
/// HTTP request failure
|
|
#[error("HTTP request failure: {0}")]
|
|
Http(#[from] hyper::Error),
|
|
/// Invalid ACME server URL
|
|
#[error("invalid URI: {0}")]
|
|
InvalidUri(#[from] hyper::http::uri::InvalidUri),
|
|
/// Failed to (de)serialize a JSON object
|
|
#[error("failed to (de)serialize JSON: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
/// Miscellaneous errors
|
|
#[error("missing data: {0}")]
|
|
Str(&'static str),
|
|
}
|
|
|
|
impl From<&'static str> for Error {
|
|
fn from(s: &'static str) -> Self {
|
|
Error::Str(s)
|
|
}
|
|
}
|
|
|
|
/// ACME account credentials
|
|
///
|
|
/// This opaque type contains the account ID, the private key data and the
|
|
/// server URLs from the relevant ACME server. This can be used to serialize
|
|
/// the account credentials to a file or secret manager and restore the
|
|
/// account from persistent storage.
|
|
#[derive(Deserialize, Serialize)]
|
|
pub struct AccountCredentials<'a> {
|
|
pub(crate) id: Cow<'a, str>,
|
|
pub(crate) key_pkcs8: String,
|
|
pub(crate) urls: Cow<'a, DirectoryUrls>,
|
|
}
|
|
|
|
/// An RFC 7807 problem document as returned by the ACME server
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Problem {
|
|
/// One of an enumerated list of problem types
|
|
///
|
|
/// See <https://datatracker.ietf.org/doc/html/rfc8555#section-6.7>
|
|
pub r#type: String,
|
|
/// A human-readable explanation of the problem
|
|
pub detail: String,
|
|
/// The HTTP status code returned for this response
|
|
pub status: u16,
|
|
}
|
|
|
|
impl Problem {
|
|
pub(crate) async fn check<T: DeserializeOwned>(rsp: Response<Body>) -> Result<T, Error> {
|
|
Ok(serde_json::from_slice(
|
|
&hyper::body::to_bytes(Self::from_response(rsp).await?).await?,
|
|
)?)
|
|
}
|
|
|
|
pub(crate) async fn from_response(rsp: Response<Body>) -> Result<Body, Error> {
|
|
let status = rsp.status();
|
|
let body = rsp.into_body();
|
|
if status.is_informational() || status.is_success() || status.is_redirection() {
|
|
return Ok(body);
|
|
}
|
|
|
|
let body = hyper::body::to_bytes(body).await?;
|
|
Err(serde_json::from_slice::<Problem>(&body)?.into())
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Problem {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "API error: {} ({})", self.detail, self.r#type)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for Problem {}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub(crate) struct FinalizeRequest {
|
|
csr: String,
|
|
}
|
|
|
|
impl FinalizeRequest {
|
|
pub(crate) fn new(csr_der: &[u8]) -> Self {
|
|
Self {
|
|
csr: BASE64_URL_SAFE_NO_PAD.encode(csr_der),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub(crate) struct Header<'a> {
|
|
pub(crate) alg: SigningAlgorithm,
|
|
#[serde(flatten)]
|
|
pub(crate) key: KeyOrKeyId<'a>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) nonce: Option<&'a str>,
|
|
pub(crate) url: &'a str,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub(crate) enum KeyOrKeyId<'a> {
|
|
#[serde(rename = "jwk")]
|
|
Key(Jwk),
|
|
#[serde(rename = "kid")]
|
|
KeyId(&'a str),
|
|
}
|
|
|
|
impl<'a> KeyOrKeyId<'a> {
|
|
pub(crate) fn from_key(key: &EcdsaKeyPair) -> KeyOrKeyId<'static> {
|
|
KeyOrKeyId::Key(Jwk::new(key))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub(crate) struct Jwk {
|
|
alg: SigningAlgorithm,
|
|
crv: &'static str,
|
|
kty: &'static str,
|
|
r#use: &'static str,
|
|
x: String,
|
|
y: String,
|
|
}
|
|
|
|
impl Jwk {
|
|
pub(crate) fn new(key: &EcdsaKeyPair) -> Self {
|
|
let (x, y) = key.public_key().as_ref()[1..].split_at(32);
|
|
Self {
|
|
alg: SigningAlgorithm::Es256,
|
|
crv: "P-256",
|
|
kty: "EC",
|
|
r#use: "sig",
|
|
x: BASE64_URL_SAFE_NO_PAD.encode(x),
|
|
y: BASE64_URL_SAFE_NO_PAD.encode(y),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn thumb_sha256(key: &EcdsaKeyPair) -> Result<Digest, serde_json::Error> {
|
|
let jwk = Self::new(key);
|
|
Ok(digest(
|
|
&SHA256,
|
|
&serde_json::to_vec(&JwkThumb {
|
|
crv: jwk.crv,
|
|
kty: jwk.kty,
|
|
x: &jwk.x,
|
|
y: &jwk.y,
|
|
})?,
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct JwkThumb<'a> {
|
|
crv: &'a str,
|
|
kty: &'a str,
|
|
x: &'a str,
|
|
y: &'a str,
|
|
}
|
|
|
|
/// An ACME challenge as described in RFC 8555 (section 7.1.5)
|
|
///
|
|
/// <https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.5>
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Challenge {
|
|
/// Type of challenge
|
|
pub r#type: ChallengeType,
|
|
/// Challenge identifier
|
|
pub url: String,
|
|
/// Token for this challenge
|
|
pub token: String,
|
|
/// Current status
|
|
pub status: ChallengeStatus,
|
|
/// Potential error state
|
|
pub error: Option<Problem>,
|
|
}
|
|
|
|
/// Contents of an ACME order as described in RFC 8555 (section 7.1.3)
|
|
///
|
|
/// The order identity will usually be represented by an [Order](crate::Order).
|
|
///
|
|
/// <https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.3>
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OrderState {
|
|
/// Current status
|
|
pub status: OrderStatus,
|
|
/// Authorization URLs for this order
|
|
///
|
|
/// There should be one authorization per identifier in the order.
|
|
pub authorizations: Vec<String>,
|
|
/// Potential error state
|
|
pub error: Option<Problem>,
|
|
/// A finalization URL, to be used once status becomes `Ready`
|
|
pub finalize: String,
|
|
/// The certificate URL, which becomes available after finalization
|
|
pub certificate: Option<String>,
|
|
}
|
|
|
|
/// Input data for [Order](crate::Order) creation
|
|
///
|
|
/// To be passed into [Account::new_order()](crate::Account::new_order()).
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NewOrder<'a> {
|
|
/// Identifiers to be included in the order
|
|
pub identifiers: &'a [Identifier],
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct NewAccountPayload<'a> {
|
|
#[serde(flatten)]
|
|
pub(crate) new_account: &'a NewAccount<'a>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) external_account_binding: Option<JoseJson>,
|
|
}
|
|
|
|
/// Input data for [Account](crate::Account) creation
|
|
///
|
|
/// To be passed into [Account::create()](crate::Account::create()).
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NewAccount<'a> {
|
|
/// A list of contact URIs (like `mailto:info@example.com`)
|
|
pub contact: &'a [&'a str],
|
|
/// Whether you agree to the terms of service
|
|
pub terms_of_service_agreed: bool,
|
|
/// Set to `true` in order to retrieve an existing account
|
|
///
|
|
/// Setting this to `false` has not been tested.
|
|
pub only_return_existing: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct DirectoryUrls {
|
|
pub(crate) new_nonce: String,
|
|
pub(crate) new_account: String,
|
|
pub(crate) new_order: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub(crate) struct JoseJson {
|
|
pub(crate) protected: String,
|
|
pub(crate) payload: String,
|
|
pub(crate) signature: String,
|
|
}
|
|
|
|
impl JoseJson {
|
|
pub(crate) fn new(
|
|
payload: Option<&impl Serialize>,
|
|
protected: Header<'_>,
|
|
signer: &impl Signer,
|
|
) -> Result<Self, Error> {
|
|
let protected = base64(&protected)?;
|
|
let payload = match payload {
|
|
Some(data) => base64(&data)?,
|
|
None => String::new(),
|
|
};
|
|
|
|
let combined = format!("{protected}.{payload}");
|
|
let signature = signer.sign(combined.as_bytes())?;
|
|
Ok(Self {
|
|
protected,
|
|
payload,
|
|
signature: BASE64_URL_SAFE_NO_PAD.encode(signature.as_ref()),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) trait Signer {
|
|
type Signature: AsRef<[u8]>;
|
|
|
|
fn header<'n, 'u: 'n, 's: 'u>(&'s self, nonce: Option<&'n str>, url: &'u str) -> Header<'n>;
|
|
|
|
fn sign(&self, payload: &[u8]) -> Result<Self::Signature, Error>;
|
|
}
|
|
|
|
fn base64(data: &impl Serialize) -> Result<String, serde_json::Error> {
|
|
Ok(BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(data)?))
|
|
}
|
|
|
|
/// An ACME authorization as described in RFC 8555 (section 7.1.4)
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Authorization {
|
|
/// The identifier that the account is authorized to represent
|
|
pub identifier: Identifier,
|
|
/// Current state of the authorization
|
|
pub status: AuthorizationStatus,
|
|
/// Possible challenges for the authorization
|
|
pub challenges: Vec<Challenge>,
|
|
}
|
|
|
|
/// Status for an [`Authorization`]
|
|
#[allow(missing_docs)]
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum AuthorizationStatus {
|
|
Pending,
|
|
Valid,
|
|
Invalid,
|
|
Revoked,
|
|
Expired,
|
|
}
|
|
|
|
/// Represent an identifier in an ACME [Order](crate::Order)
|
|
#[allow(missing_docs)]
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
|
|
pub enum Identifier {
|
|
Dns(String),
|
|
}
|
|
|
|
/// The challenge type
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
|
|
#[allow(missing_docs)]
|
|
pub enum ChallengeType {
|
|
#[serde(rename = "http-01")]
|
|
Http01,
|
|
#[serde(rename = "dns-01")]
|
|
Dns01,
|
|
#[serde(rename = "tls-alpn-01")]
|
|
TlsAlpn01,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum ChallengeStatus {
|
|
Pending,
|
|
Processing,
|
|
Valid,
|
|
Invalid,
|
|
}
|
|
|
|
/// Status of an [Order](crate::Order)
|
|
#[allow(missing_docs)]
|
|
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum OrderStatus {
|
|
Pending,
|
|
Ready,
|
|
Processing,
|
|
Valid,
|
|
Invalid,
|
|
}
|
|
|
|
/// Helper type to reference Let's Encrypt server URLs
|
|
#[allow(missing_docs)]
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum LetsEncrypt {
|
|
Production,
|
|
Staging,
|
|
}
|
|
|
|
impl LetsEncrypt {
|
|
/// Get the directory URL for the given Let's Encrypt server
|
|
pub const fn url(&self) -> &'static str {
|
|
match self {
|
|
LetsEncrypt::Production => "https://acme-v02.api.letsencrypt.org/directory",
|
|
LetsEncrypt::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize)]
|
|
#[serde(rename_all = "UPPERCASE")]
|
|
pub(crate) enum SigningAlgorithm {
|
|
/// ECDSA using P-256 and SHA-256
|
|
Es256,
|
|
/// HMAC with SHA-256,
|
|
Hs256,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub(crate) struct Empty {}
|