use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::json;
use tracing::{info, warn};
use crate::config::RelayConfig;
use super::client::{AccountKey, AcmeClient, Signer, UpstreamError};
use super::eab;
pub(super) fn kid_path(account_key_path: &str) -> PathBuf {
Path::new(account_key_path).with_extension("kid")
}
pub(super) async fn provision(
cfg: &RelayConfig,
outbound: crate::http_client::Outbound,
timeout: Duration,
) -> anyhow::Result<(AcmeClient, AccountKey, String)> {
let account = load_or_generate_key(&cfg.account_key_path)?;
let client = AcmeClient::discover(&cfg.directory_url, outbound, timeout).await?;
if let Some(kid) = stored_kid(cfg) {
info!(event = "upstream_account_loaded", outcome = "success", kid = %kid);
warn_if_eab_secret_still_configured(cfg, &kid);
return Ok((client, account, kid));
}
let config_eab = configured_eab(cfg)?;
let kid = match register(
&client,
&account,
cfg,
config_eab
.as_ref()
.map(|(kid, secret)| (kid.as_str(), secret.as_slice())),
)
.await
{
Ok(kid) => kid,
Err(error) if error.is_external_account_required() => {
if config_eab.is_some() {
anyhow::bail!(
"the upstream rejected signer.relay.eab as External Account Binding \
(a wrong kid/hmac_key, or a credential that has already been consumed). Fix \
the configured credential, or clear it and run \
`acme-proxy upstream register --eab-kid <kid>` once instead."
);
}
anyhow::bail!(
"the upstream requires External Account Binding. Set signer.relay.eab.kid \
and signer.relay.eab.hmac_key in configuration, or run \
`acme-proxy upstream register --eab-kid <kid>` once, then start the server again."
);
}
Err(error) => return Err(error.into()),
};
write_kid(cfg, &kid)?;
info!(event = "upstream_account_registered", outcome = "success", kid = %kid);
warn_if_eab_secret_still_configured(cfg, &kid);
Ok((client, account, kid))
}
fn configured_eab(cfg: &RelayConfig) -> anyhow::Result<Option<(String, Vec<u8>)>> {
let kid = cfg.eab.kid.as_str();
let hmac_key = cfg.eab.hmac_key.as_str();
match (kid.is_empty(), hmac_key.is_empty()) {
(true, true) => Ok(None),
(false, true) => {
anyhow::bail!("signer.relay.eab.kid is set but signer.relay.eab.hmac_key is not")
}
(true, false) => {
anyhow::bail!("signer.relay.eab.hmac_key is set but signer.relay.eab.kid is not")
}
(false, false) => {
let secret = eab::decode_secret(hmac_key)
.ok_or_else(|| anyhow::anyhow!("signer.relay.eab.hmac_key is not valid base64"))?;
Ok(Some((kid.to_string(), secret)))
}
}
}
fn warn_if_eab_secret_still_configured(cfg: &RelayConfig, kid: &str) {
if !cfg.eab.hmac_key.is_empty() {
warn!(
event = "signer_relay_eab_secret_in_config",
outcome = "advisory",
kid = %kid,
"signer.relay.eab.hmac_key is set but this proxy is already registered \
upstream; the credential is no longer needed and does nothing now except sit on \
disk as a live secret — clear signer.relay.eab.hmac_key from configuration"
);
}
}
pub fn stored_kid(cfg: &RelayConfig) -> Option<String> {
std::fs::read_to_string(kid_path(&cfg.account_key_path))
.ok()
.map(|kid| kid.trim().to_string())
.filter(|kid| !kid.is_empty())
}
fn write_kid(cfg: &RelayConfig, kid: &str) -> anyhow::Result<()> {
crate::pemfile::write_atomic(&kid_path(&cfg.account_key_path), kid.as_bytes(), 0o600)?;
Ok(())
}
pub(super) async fn register(
client: &AcmeClient,
account: &AccountKey,
cfg: &RelayConfig,
eab: Option<(&str, &[u8])>,
) -> Result<String, UpstreamError> {
let new_account_url = client.directory().new_account.clone();
let mut payload = json!({ "termsOfServiceAgreed": true });
if !cfg.contact.is_empty() {
payload["contact"] = json!(cfg.contact);
}
if let Some((kid, secret)) = eab {
payload["externalAccountBinding"] =
serde_json::to_value(eab::build(kid, secret, &account.jwk(), &new_account_url))
.map_err(|error| UpstreamError::Jws(error.to_string()))?;
}
let response = client
.post(account, &Signer::Jwk, &new_account_url, Some(&payload))
.await?;
response.location.clone().ok_or_else(|| {
UpstreamError::Protocol("newAccount succeeded but returned no Location header".to_string())
})
}
pub async fn register_upstream_account(
cfg: &RelayConfig,
outbound: crate::http_client::Outbound,
eab: Option<(&str, &[u8])>,
) -> anyhow::Result<String> {
let account = load_or_generate_key(&cfg.account_key_path)?;
let client = AcmeClient::discover(
&cfg.directory_url,
outbound,
Duration::from_secs(cfg.poll_timeout_secs),
)
.await?;
let kid = register(&client, &account, cfg, eab).await?;
write_kid(cfg, &kid)?;
info!(event = "upstream_account_registered_by_cli", outcome = "success", kid = %kid);
Ok(kid)
}
pub(super) fn load_or_generate_key(path: &str) -> anyhow::Result<AccountKey> {
let path = Path::new(path);
if path.exists() {
crate::pemfile::warn_if_key_is_readable("upstream_account_key_permissive", path);
let key = crate::pemfile::read_private_key(path)?;
return Ok(AccountKey::from_pkcs8(key.secret_der())?);
}
let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
crate::pemfile::write_private_key(path, &key_pair.serialize_pem())?;
info!(event = "upstream_account_key_generated", outcome = "success", file_path = ?path);
Ok(AccountKey::from_pkcs8(&key_pair.serialize_der())?)
}