use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::acme::protocol::AccountKey;
use crate::error::{AcmeError, Error, Result};
#[derive(Clone, Serialize, Deserialize)]
pub struct EabCredentials {
pub key_id: String,
pub hmac_key: String,
}
impl std::fmt::Debug for EabCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EabCredentials")
.field("key_id", &self.key_id)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct Account {
pub key: AccountKey,
pub url: String,
pub contacts: Vec<String>,
pub status: String,
pub terms_of_service_agreed: bool,
}
impl Account {
pub fn new_es256(contacts: &[String]) -> Result<Self> {
Ok(Self {
key: AccountKey::generate_es256()?,
url: String::new(),
contacts: contacts.to_vec(),
status: String::new(),
terms_of_service_agreed: false,
})
}
#[cfg(feature = "rsa")]
pub fn new_rsa2048(contacts: &[String]) -> Result<Self> {
Ok(Self {
key: AccountKey::generate_rsa2048()?,
url: String::new(),
contacts: contacts.to_vec(),
status: String::new(),
terms_of_service_agreed: false,
})
}
#[must_use]
pub fn is_registered(&self) -> bool {
!self.url.is_empty()
}
pub fn jwk(&self) -> Result<Value> {
Ok(self.key.jwk()?.value)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AccountInfo {
#[serde(default)]
pub status: String,
#[serde(default)]
pub contact: Vec<String>,
#[serde(rename = "termsOfServiceAgreed", default)]
pub terms_of_service_agreed: bool,
#[serde(default)]
pub orders: Option<String>,
}
pub fn external_account_binding(
eab: &EabCredentials,
account_key: &AccountKey,
new_account_url: &str,
) -> Result<Value> {
let hmac_key_bytes = B64URL
.decode(eab.hmac_key.as_bytes())
.map_err(|e| Error::Acme(AcmeError::Account(format!("EAB hmac key: {e}"))))?;
let jwk = account_key.jwk()?.value;
let payload = serde_json::to_vec(&jwk)
.map_err(|e| Error::Acme(AcmeError::Account(format!("jwk: {e}"))))?;
let protected = json!({
"alg": "HS256",
"kid": eab.key_id,
"url": new_account_url,
});
let protected_b64 = B64URL.encode(
serde_json::to_vec(&protected)
.map_err(|e| Error::Acme(AcmeError::Account(format!("protected: {e}"))))?,
);
let payload_b64 = B64URL.encode(&payload);
let signing_input = format!("{protected_b64}.{payload_b64}");
let signature = crate::acme::provider::hmac_sha256(&hmac_key_bytes, signing_input.as_bytes());
Ok(json!({
"protected": protected_b64,
"payload": payload_b64,
"signature": B64URL.encode(signature),
}))
}
#[must_use]
pub fn prompt_user_for_email() -> Option<String> {
use std::io::{self, IsTerminal};
if !io::stdin().is_terminal() {
return None;
}
let stdin = io::stdin();
let mut input = stdin.lock();
let mut output = io::stderr();
prompt_user_for_email_with_io(&mut input, &mut output)
.ok()
.flatten()
}
pub fn prompt_user_for_email_with_io<R, W>(
input: &mut R,
output: &mut W,
) -> std::io::Result<Option<String>>
where
R: std::io::BufRead,
W: std::io::Write,
{
write!(
output,
"Your email address (for ACME account, Let's Encrypt notifications): "
)?;
output.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
let trimmed = line.trim();
Ok((!trimmed.is_empty()).then(|| trimmed.to_owned()))
}
#[must_use]
pub fn prompt_user_agreement(tos_url: &str) -> bool {
use std::io::{self, IsTerminal};
if !io::stdin().is_terminal() {
return false;
}
let stdin = io::stdin();
let mut input = stdin.lock();
let mut output = io::stderr();
prompt_user_agreement_with_io(tos_url, &mut input, &mut output).unwrap_or(false)
}
pub fn prompt_user_agreement_with_io<R, W>(
tos_url: &str,
input: &mut R,
output: &mut W,
) -> std::io::Result<bool>
where
R: std::io::BufRead,
W: std::io::Write,
{
writeln!(output, "\nYour CA's Terms of Service:")?;
writeln!(output, " {tos_url}")?;
write!(output, "Do you agree to the Terms of Service? (y/N): ")?;
output.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
Ok(matches!(
line.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn review_eab_debug_redacts_hmac_secret() {
let credentials = EabCredentials {
key_id: "account".into(),
hmac_key: "private-hmac-secret".into(),
};
assert!(!format!("{credentials:?}").contains("private-hmac-secret"));
}
#[test]
fn eab_structure_and_signature() {
let hmac_b64 = B64URL.encode(b"secret");
let eab = EabCredentials {
key_id: "kid-123".into(),
hmac_key: hmac_b64,
};
let key = AccountKey::generate_es256().unwrap();
let url = "https://ca/new-account";
let binding = external_account_binding(&eab, &key, url).unwrap();
let protected: Value = serde_json::from_slice(
&B64URL
.decode(binding["protected"].as_str().unwrap())
.unwrap(),
)
.unwrap();
assert_eq!(protected["alg"], "HS256");
assert_eq!(protected["kid"], "kid-123");
assert_eq!(protected["url"], url);
let payload: Value =
serde_json::from_slice(&B64URL.decode(binding["payload"].as_str().unwrap()).unwrap())
.unwrap();
assert_eq!(payload, key.jwk().unwrap().value);
let signing_input = format!(
"{}.{}",
binding["protected"].as_str().unwrap(),
binding["payload"].as_str().unwrap()
);
let expected = crate::acme::provider::hmac_sha256(b"secret", signing_input.as_bytes());
assert_eq!(
binding["signature"].as_str().unwrap(),
B64URL.encode(expected)
);
}
#[test]
fn account_registration_lifecycle() {
let mut acct = Account::new_es256(&["mailto:a@b.c".into()]).unwrap();
assert!(!acct.is_registered());
acct.url = "https://ca/acct/1".into();
acct.status = "valid".into();
assert!(acct.is_registered());
assert_eq!(acct.contacts, vec!["mailto:a@b.c"]);
}
#[test]
fn email_prompt_trims_input_and_writes_to_output() {
let mut input = std::io::Cursor::new(b" admin@example.com \n".to_vec());
let mut output = Vec::new();
let email = prompt_user_for_email_with_io(&mut input, &mut output).unwrap();
assert_eq!(email.as_deref(), Some("admin@example.com"));
assert!(
String::from_utf8(output)
.unwrap()
.contains("Your email address")
);
}
#[test]
fn email_prompt_rejects_empty_or_eof_input() {
let mut input = std::io::Cursor::new(b" \n".to_vec());
let mut output = Vec::new();
assert_eq!(
prompt_user_for_email_with_io(&mut input, &mut output).unwrap(),
None
);
let mut input = std::io::Cursor::new(Vec::<u8>::new());
assert_eq!(
prompt_user_for_email_with_io(&mut input, &mut output).unwrap(),
None
);
}
#[test]
fn agreement_prompt_accepts_only_yes_answers() {
for answer in ["y\n", "Y\n", "yes\n", " YES \n"] {
let mut input = std::io::Cursor::new(answer.as_bytes().to_vec());
let mut output = Vec::new();
assert!(
prompt_user_agreement_with_io("https://ca.example/tos", &mut input, &mut output)
.unwrap()
);
}
for answer in ["\n", "n\n", "no\n", "yup\n", "1\n"] {
let mut input = std::io::Cursor::new(answer.as_bytes().to_vec());
let mut output = Vec::new();
assert!(
!prompt_user_agreement_with_io("https://ca.example/tos", &mut input, &mut output)
.unwrap()
);
}
}
#[test]
fn agreement_prompt_displays_tos_url() {
let mut input = std::io::Cursor::new(b"yes\n".to_vec());
let mut output = Vec::new();
prompt_user_agreement_with_io("https://ca.example/tos", &mut input, &mut output).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("https://ca.example/tos"));
assert!(output.contains("(y/N)"));
}
}