From 16335f5481bd58eaa59117c069925063bd94d951 Mon Sep 17 00:00:00 2001 From: sehkone Date: Thu, 29 Jan 2026 17:35:48 +0900 Subject: [PATCH] Implement ACME trust verification controls Closes #150 --- .gitignore | 1 + Cargo.lock | 4 + Cargo.toml | 2 + agent.toml.compose | 3 + agent.toml.example | 2 + docs/en/cli-examples.md | 8 +- docs/en/configuration.md | 69 +++++++++- docs/en/installation.md | 12 ++ docs/en/operations.md | 4 + docs/en/troubleshooting.md | 5 +- docs/ko/cli-examples.md | 7 +- docs/ko/configuration.md | 75 +++++++++-- docs/ko/installation.md | 13 ++ docs/ko/operations.md | 4 + docs/ko/troubleshooting.md | 5 +- src/acme/client.rs | 269 ++++++++++++++++++++++++++++++++++--- src/acme/flow.rs | 2 +- src/agent_args.rs | 10 +- src/cli/output.rs | 1 + src/config.rs | 65 ++++++++- src/config/defaults.rs | 8 +- state.json | 16 --- tests/acme_trust.rs | 207 ++++++++++++++++++++++++++++ 23 files changed, 731 insertions(+), 61 deletions(-) delete mode 100644 state.json create mode 100644 tests/acme_trust.rs diff --git a/.gitignore b/.gitignore index 51ddba1..c7f7d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ secrets.bak/ site/ site-pdf-*/ tmp/ +state.json diff --git a/Cargo.lock b/Cargo.lock index d6841e0..49c7b7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,12 +196,14 @@ dependencies = [ "rcgen", "reqwest", "ring", + "rustls", "serde", "serde_json", "tempfile", "thiserror 2.0.17", "time", "tokio", + "tokio-rustls", "tracing", "tracing-subscriber", "wiremock", @@ -1658,7 +1660,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", diff --git a/Cargo.toml b/Cargo.toml index d675e87..b03dd1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ reqwest = { version = "0.13", default-features = false, features = [ "rustls", ] } ring = "0.17" +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" @@ -28,6 +29,7 @@ x509-parser = "0.18" [dev-dependencies] tempfile = "3" +tokio-rustls = "0.26" wiremock = "0.6" [lints.clippy] diff --git a/agent.toml.compose b/agent.toml.compose index f818513..e8b23e5 100644 --- a/agent.toml.compose +++ b/agent.toml.compose @@ -6,6 +6,9 @@ server = "https://bootroot-ca:9000/acme/acme/directory" # Format: ... domain = "trusted.domain" +[trust] +verify_certificates = false + [scheduler] max_concurrent_issuances = 1 diff --git a/agent.toml.example b/agent.toml.example index 6354f91..241ee1d 100644 --- a/agent.toml.example +++ b/agent.toml.example @@ -33,6 +33,8 @@ http_responder_token_ttl_secs = 300 # Trust settings for CA bundle verification/storage [trust] +# Toggle TLS certificate verification for ACME server connections +verify_certificates = true # Path to save the CA bundle (intermediate/root) ca_bundle_path = "certs/ca-bundle.pem" # SHA-256 fingerprints of trusted CA certs (hex) diff --git a/docs/en/cli-examples.md b/docs/en/cli-examples.md index 58bc113..18be229 100644 --- a/docs/en/cli-examples.md +++ b/docs/en/cli-examples.md @@ -276,9 +276,13 @@ Issuing certificate for 001.web-app.web-01.trusted.domain Certificate issued successfully. ``` -## 7) Renewal/rotation (examples) +## 7) Secret rotation (examples) -bootroot provides rotation commands. Example that rotates all secrets: +bootroot provides rotation commands for **secrets** (step-ca password, EAB, +DB credentials, HMAC, AppRole). **Certificate renewal** is handled by +`bootroot-agent`. + +Example that rotates all secrets: ```bash bootroot rotate stepca-password diff --git a/docs/en/configuration.md b/docs/en/configuration.md index 2662f47..5d355dd 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -140,14 +140,30 @@ Controls HTTP-01 responder settings and retry behavior for ACME operations. ```toml [trust] +verify_certificates = true ca_bundle_path = "/etc/bootroot/ca-bundle.pem" trusted_ca_sha256 = [""] ``` -Controls CA bundle storage and verification for mTLS trust. +Controls mTLS trust and **ACME server TLS verification**. +Concepts: + +- **mTLS trust**: the trust bundle used by services to verify peer + certificates. bootroot-agent writes the chain (intermediate/root) to + `ca_bundle_path`, and services use that file as their trust store. +- **ACME server TLS verification**: bootroot-agent verifies the step-ca + server certificate when communicating with the ACME endpoint. This is + separate from mTLS trust. + +Settings: + +- `verify_certificates`: enables **ACME server TLS verification**. + This is not an mTLS setting; it only affects how bootroot-agent validates + the step-ca server certificate. - `ca_bundle_path`: path where bootroot-agent writes the CA bundle - (intermediate/root only). + (intermediate/root only). When `verify_certificates = true`, setting this + also makes bootroot-agent trust **this bundle** for the ACME server. - `trusted_ca_sha256`: list of trusted CA cert fingerprints (SHA-256 hex). `trusted_ca_sha256` must match **real CA certificate fingerprints** (not @@ -155,6 +171,27 @@ arbitrary values). `bootroot init` stores CA fingerprints in OpenBao, and `bootroot app add` includes the trusted list in the agent.toml snippet. In the common workflow, you should use the values printed by app add. +If `verify_certificates = true` and `ca_bundle_path` is **not** set, +bootroot-agent uses the **system CA store** to validate the ACME server. + +Default: `verify_certificates = false` for backward compatibility. For +production, enable verification and provide a trusted CA source. + +Operational notes: + +- On the **first** connection to step-ca, a trusted bundle must already exist + at `ca_bundle_path` (manual install). If that is inconvenient, you can + **temporarily** use `bootroot-agent --insecure` to obtain the first cert. +- After issuance succeeds, bootroot-agent writes the chain to `ca_bundle_path`. + For **renewals** (cron/daemon), do **not** use `--insecure`; keep + verification enabled. +- This workflow lets you keep `verify_certificates = true` in `agent.toml` and + use CLI flags only as a temporary bypass. + +Note: in a single step-ca deployment, it is practical to reuse the same +`ca_bundle_path` for both mTLS trust and ACME server verification. The two +concepts are still different, so keep them distinct in your mental model. + If the snippet does not include `trusted_ca_sha256`, check: - `secrets/certs/root_ca.crt` and `secrets/certs/intermediate_ca.crt` exist @@ -185,6 +222,34 @@ hmac = "your-hmac-key" EAB can also be passed via CLI (`--eab-kid`, `--eab-hmac`, or `--eab-file`). For production, prefer injecting EAB values via OpenBao. +### Command-line options + +`bootroot-agent` can only override a subset of settings. +Apply order is `agent.toml` → environment variables → CLI options, with CLI +being applied last. + +Example: even if `agent.toml` has `email = "admin@example.com"`, +running `bootroot-agent --email ops@example.com` uses `ops@example.com`. + +Options: + +- `--config `: config file path (default `agent.toml`) +- `--email `: support email address +- `--ca-url `: ACME directory URL +- `--http-responder-url `: HTTP-01 responder URL + (env `BOOTROOT_HTTP_RESPONDER_URL`) +- `--http-responder-hmac `: HTTP-01 responder HMAC + (env `BOOTROOT_HTTP_RESPONDER_HMAC`) +- `--eab-kid `: EAB Key ID +- `--eab-hmac `: EAB HMAC key +- `--eab-file `: EAB JSON file path +- `--oneshot`: issue once and exit (disable daemon loop) +- `--verify-certificates`: force ACME server TLS verification +- `--insecure`: disable ACME server TLS verification + +All other settings (profiles, retry, scheduler, hooks, CA bundle paths, etc.) +must be defined in `agent.toml`. + ### Profiles Each profile represents one daemon instance (one certificate identity). diff --git a/docs/en/installation.md b/docs/en/installation.md index b501027..2a0f880 100644 --- a/docs/en/installation.md +++ b/docs/en/installation.md @@ -265,6 +265,11 @@ cargo build --release `--oneshot` issues once and exits. For daemon mode, omit it. See **Configuration** for details. +TLS verification override: + +- `--verify-certificates` forces ACME server TLS verification on. +- `--insecure` disables verification (**insecure**, overrides config). + #### CA bundle consumer permissions Services using mTLS must be able to read the CA bundle written to @@ -281,6 +286,13 @@ docker compose up --build -d bootroot-agent The agent reads `agent.toml.compose` by default in the container. +The image runs the agent in **daemon mode by default** (no `--oneshot`). +For a sidecar, use a restart policy such as `restart: unless-stopped` to keep +the container running (the current compose example does **not** set a restart +policy for bootroot-agent by default). Also ensure Docker/Compose itself is +managed by systemd (or an equivalent service manager) so it survives host +reboots. + ## HTTP-01 responder ### Docker diff --git a/docs/en/operations.md b/docs/en/operations.md index 9ddc95a..1e6842e 100644 --- a/docs/en/operations.md +++ b/docs/en/operations.md @@ -81,6 +81,10 @@ to `ca_bundle_path`. This bundle is used for mTLS peer verification. - If `trust.trusted_ca_sha256` is set, the response chain **must pass fingerprint verification** or issuance fails. - If no chain is present, the CA bundle is not written (logged). +- With `trust.verify_certificates = true`, bootroot-agent verifies the ACME + server TLS certificate. If `ca_bundle_path` is set, it uses that bundle; + otherwise it uses the system CA store. +- CLI override: `bootroot-agent --verify-certificates` or `--insecure`. Permissions/ownership: diff --git a/docs/en/troubleshooting.md b/docs/en/troubleshooting.md index 5ebbaf6..832b74e 100644 --- a/docs/en/troubleshooting.md +++ b/docs/en/troubleshooting.md @@ -63,7 +63,10 @@ files are missing, init fails. ## ACME directory fetch retries - Confirm step-ca is up and reachable -- Check TLS trust for the CA endpoint +- Check TLS trust for the CA endpoint: + - If using system trust, ensure the CA is installed in the OS store + - If using `trust.ca_bundle_path`, ensure the bundle exists and is readable + - For temporary diagnosis, use `bootroot-agent --insecure` (not for prod) - Verify `server` URL in `agent.toml` ## Hook execution errors diff --git a/docs/ko/cli-examples.md b/docs/ko/cli-examples.md index 7b2fa24..3a8946a 100644 --- a/docs/ko/cli-examples.md +++ b/docs/ko/cli-examples.md @@ -280,9 +280,12 @@ Issuing certificate for 001.web-app.web-01.trusted.domain Certificate issued successfully. ``` -## 7) 인증서 갱신/회전(예시) +## 7) 시크릿 회전(예시) -bootroot는 회전 명령을 제공합니다. 모든 시크릿을 갱신하는 예: +bootroot의 `rotate`는 **시크릿 회전**(step-ca 비밀번호, EAB, DB, HMAC, AppRole)을 +수행합니다. **인증서 갱신**은 `bootroot-agent`가 처리합니다. + +모든 시크릿을 갱신하는 예: ```bash bootroot rotate stepca-password diff --git a/docs/ko/configuration.md b/docs/ko/configuration.md index 8d38bf2..27fd2a1 100644 --- a/docs/ko/configuration.md +++ b/docs/ko/configuration.md @@ -142,13 +142,29 @@ HTTP-01 리스폰더와 ACME 재시도 동작을 제어합니다. ```toml [trust] +verify_certificates = true ca_bundle_path = "/etc/bootroot/ca-bundle.pem" trusted_ca_sha256 = [""] ``` -mTLS 신뢰를 위해 CA 번들을 저장하고 검증하는 설정입니다. +mTLS 신뢰와 **ACME 서버 TLS 검증**을 제어하는 설정입니다. -- `ca_bundle_path`: bootroot-agent가 CA 번들(중간/루트)을 **저장할 경로**입니다. +개념 정리: + +- **mTLS 신뢰**: 서비스가 서로의 인증서를 검증할 때 사용할 **신뢰 번들**입니다. + bootroot-agent가 발급 응답의 체인(중간/루트)을 `ca_bundle_path`에 저장하면, + 이 파일을 서비스의 trust store로 사용합니다. +- **ACME 서버 TLS 검증**: bootroot-agent가 step-ca(ACME 서버)와 통신할 때 + **서버 인증서를 검증하는 동작**입니다. 이는 mTLS와 별개의 개념입니다. + +설정 항목: + +- `verify_certificates`: **ACME 서버 TLS 검증** 여부입니다. + mTLS 신뢰 설정이 아니라, step-ca와 통신할 때 bootroot-agent가 + 서버 인증서를 검증할지 결정합니다. +- `ca_bundle_path`: bootroot-agent가 **CA 번들(중간/루트)을 저장할 경로**입니다. + `verify_certificates = true`일 때 이 값을 설정하면 **이 번들을 ACME 서버 + 신뢰 번들로도 사용**합니다. - `trusted_ca_sha256`: 신뢰할 CA 인증서 지문 목록(SHA-256 hex)입니다. `trusted_ca_sha256`는 **임의 값이 아니라 실제 CA 인증서 지문**입니다. @@ -156,6 +172,28 @@ mTLS 신뢰를 위해 CA 번들을 저장하고 검증하는 설정입니다. `bootroot app add` 출력의 agent.toml 스니펫에 **신뢰 지문 목록이 포함**됩니다. 따라서 일반적인 운영 흐름에서는 app add가 제시한 값을 그대로 사용하면 됩니다. +`verify_certificates = true`인데 `ca_bundle_path`가 없으면, +bootroot-agent는 **시스템 CA 저장소**로 ACME 서버를 검증합니다. + +기본값: `verify_certificates = false` (호환성 유지 목적). 운영 환경에서는 +검증을 활성화하고 신뢰할 CA 소스를 제공하는 것을 권장합니다. + +운영 팁: + +- bootroot-agent가 **처음** step-ca와 통신할 때는 `ca_bundle_path`에 + 신뢰할 CA 번들이 **미리 존재해야** 합니다(수동 설치 필요). + 이 과정이 번거롭다면, **일시적으로** `bootroot-agent --insecure`로 + 발급을 진행할 수 있습니다. +- 한 번 발급이 성공하면 bootroot-agent가 체인을 `ca_bundle_path`에 + 저장합니다. 이후 **정기 갱신(cron/daemon)** 단계에서는 + `--insecure`를 사용하지 말고 **검증을 유지**하는 것이 안전합니다. +- 위 흐름을 사용하면 `agent.toml`의 `verify_certificates = true`는 + 그대로 두고, 필요 시에만 CLI로 임시 우회할 수 있습니다. + +참고: 단일 step-ca를 사용하는 환경에서는 mTLS 신뢰 번들과 +ACME 서버 검증에 **같은 `ca_bundle_path`를 재사용하는 운영**이 가능합니다. +다만 두 개념은 목적이 다르므로 필요 시 분리할 수 있다는 점은 기억하세요. + 만약 스니펫에 `trusted_ca_sha256`가 나오지 않는다면 다음을 확인하세요. - step-ca 초기화로 `secrets/certs/root_ca.crt`, @@ -250,20 +288,35 @@ backoff_secs = [5, 10, 30] - `max_output_bytes`: stdout/stderr 제한 - `on_failure`: `continue` 또는 `stop` -### CLI 재정의 +### 명령행 옵션 -```bash -bootroot-agent --config agent.toml --oneshot -bootroot-agent --config agent.toml --email admin@example.com -bootroot-agent --config agent.toml --eab-kid X --eab-hmac Y -``` +`bootroot-agent`는 아래 옵션만 설정을 덮어쓸 수 있습니다. +적용 순서는 `agent.toml` → 환경변수 → CLI 옵션이며, 마지막에 적용되는 CLI가 +가장 우선입니다. -CLI 값이 제공되면 파일 설정을 덮어씁니다. -우선순위는 `agent.toml` → 환경변수 → CLI 옵션이며, CLI가 가장 우선입니다. -예를 들어 `agent.toml`에 `email = "admin@example.com"`이 있어도 +예: `agent.toml`에 `email = "admin@example.com"`이 있어도 `bootroot-agent --email ops@example.com`으로 실행하면 실제로는 `ops@example.com`이 사용됩니다. +옵션 목록: + +- `--config `: 설정 파일 경로(기본 `agent.toml`) +- `--email `: 지원 이메일 +- `--ca-url `: ACME 디렉터리 URL +- `--http-responder-url `: HTTP-01 리스폰더 URL + (env `BOOTROOT_HTTP_RESPONDER_URL`) +- `--http-responder-hmac `: HTTP-01 리스폰더 HMAC + (env `BOOTROOT_HTTP_RESPONDER_HMAC`) +- `--eab-kid `: EAB Key ID +- `--eab-hmac `: EAB HMAC Key +- `--eab-file `: EAB JSON 파일 경로 +- `--oneshot`: 1회 발급 후 종료(데몬 루프 비활성화) +- `--verify-certificates`: ACME 서버 TLS 검증 강제 +- `--insecure`: ACME 서버 TLS 검증 비활성화 + +그 외 설정(프로필, 재시도, 스케줄러, 훅, CA 번들 경로 등)은 +`agent.toml`에 정의해야 합니다. + ## HTTP-01 리스폰더 (responder.toml) 리스폰더는 `responder.toml`(또는 `BOOTROOT_RESPONDER__*` 환경변수)을 읽습니다. diff --git a/docs/ko/installation.md b/docs/ko/installation.md index 44a36b1..6033564 100644 --- a/docs/ko/installation.md +++ b/docs/ko/installation.md @@ -278,6 +278,13 @@ cargo build --release `--oneshot`은 인증서를 **한 번만 발급**하고 종료하는 옵션입니다. 데몬 모드로 주기적 갱신을 하려면 이 옵션을 빼고 실행합니다. 자세한 설정 방법은 **설정** 섹션을 참고하세요. +bootroot-agent가 중단 없이 실행되도록 하려면 systemd 등 서비스 관리자에 +등록해 상시 실행되게 구성하세요. + +TLS 검증 오버라이드: + +- `--verify-certificates`: ACME 서버 TLS 검증 강제 +- `--insecure`: ACME 서버 TLS 검증 비활성화 #### CA 번들 소비 서비스 권한 @@ -293,6 +300,12 @@ docker compose up --build -d bootroot-agent 컨테이너는 기본으로 `agent.toml.compose`를 사용합니다. +이미지는 기본적으로 **데몬 모드**(no `--oneshot`)로 실행됩니다. +사이드카로 운용할 때는 `restart: unless-stopped` 같은 재시작 정책을 +설정해 컨테이너가 계속 돌도록 하세요(현재 compose 예시는 +bootroot-agent에 restart 정책이 기본으로 들어 있지 않습니다). 또한 호스트 재부팅에도 유지되도록 +Docker/Compose 서비스 자체를 systemd 등으로 관리해야 합니다. + ## HTTP-01 리스폰더 ### Docker diff --git a/docs/ko/operations.md b/docs/ko/operations.md index c78c652..60d2a59 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -80,6 +80,10 @@ WantedBy=timers.target - `trust.trusted_ca_sha256`를 지정하면 응답의 체인이 **지문 검증을 통과해야** 저장됩니다. 불일치 시 발급이 실패합니다. - 체인이 없는 응답이라면 CA 번들을 저장하지 않습니다(로그에 남습니다). +- `trust.verify_certificates = true`이면 bootroot-agent가 ACME 서버 TLS + 인증서를 검증합니다. `ca_bundle_path`가 있으면 그 번들을 사용하고, + 없으면 시스템 CA 저장소를 사용합니다. +- CLI 오버라이드: `bootroot-agent --verify-certificates` 또는 `--insecure`. 권한/소유권: diff --git a/docs/ko/troubleshooting.md b/docs/ko/troubleshooting.md index a44886e..af19ddc 100644 --- a/docs/ko/troubleshooting.md +++ b/docs/ko/troubleshooting.md @@ -63,7 +63,10 @@ step-ca 프로비저너 정책과 요청 DNS SAN을 확인하세요. ## ACME 디렉터리 재시도 반복 - step-ca 기동 여부 확인 -- TLS 신뢰 설정 확인 +- TLS 신뢰 설정 확인: + - 시스템 CA를 쓰는 경우 OS 저장소에 CA가 설치됐는지 확인 + - `trust.ca_bundle_path`를 쓰는 경우 파일 존재/읽기 권한 확인 + - 임시 진단 용도로는 `bootroot-agent --insecure` 사용 가능 (운영 비권장) - `server` URL 확인 ## 훅 실행 오류 diff --git a/src/acme/client.rs b/src/acme/client.rs index 379dc9a..3019e03 100644 --- a/src/acme/client.rs +++ b/src/acme/client.rs @@ -1,3 +1,6 @@ +use std::collections::HashSet; +use std::sync::Arc; + use anyhow::{Context, Result}; use base64::Engine; use reqwest::Client; @@ -5,11 +8,16 @@ use ring::digest::{Context as DigestContext, SHA256}; use ring::hmac; use ring::rand::SystemRandom; use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair}; +use rustls::ClientConfig; +use rustls::client::WebPkiServerVerifier; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; +use x509_parser::pem::parse_x509_pem; use crate::acme::types::{Authorization, Order}; -use crate::config::AcmeSettings; +use crate::config::{AcmeSettings, TrustSettings}; use crate::eab::EabCredentials; const ALG_ES256: &str = "ES256"; @@ -45,18 +53,21 @@ impl AcmeClient { /// /// # Errors /// Returns error if account key generation fails or HTTP client build fails. - pub fn new(directory_url: String, settings: &AcmeSettings) -> Result { + pub fn new( + directory_url: String, + settings: &AcmeSettings, + trust: &TrustSettings, + ) -> Result { let rng = ring::rand::SystemRandom::new(); let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng) .map_err(|_| anyhow::anyhow!("Failed to generate account key"))?; let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng) .map_err(|_| anyhow::anyhow!("Failed to parse generated key pair"))?; + let client = build_http_client(trust)?; Ok(Self { - client: Client::builder() - .danger_accept_invalid_certs(true) - .build()?, + client, directory_url, directory: None, key_pair, @@ -492,6 +503,174 @@ impl AcmeClient { } } +fn build_http_client(trust: &TrustSettings) -> Result { + install_crypto_provider(); + if !trust.verify_certificates { + return Client::builder() + .danger_accept_invalid_certs(true) + .build() + .context("Failed to build insecure HTTP client"); + } + + let Some(bundle_path) = trust.ca_bundle_path.as_ref() else { + if !trust.trusted_ca_sha256.is_empty() { + anyhow::bail!("trust.ca_bundle_path must be set when trust is configured"); + } + return Client::builder() + .build() + .context("Failed to build HTTP client"); + }; + + let (root_store, pins) = load_ca_bundle(bundle_path, &trust.trusted_ca_sha256)?; + let verifier = WebPkiServerVerifier::builder(Arc::new(root_store.clone())) + .build() + .context("Failed to build TLS verifier")?; + let verifier: Arc = if pins.is_empty() { + verifier + } else { + Arc::new(PinnedCertVerifier::new(verifier, pins)) + }; + + let mut config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + if !trust.trusted_ca_sha256.is_empty() { + config.dangerous().set_certificate_verifier(verifier); + } + + Client::builder() + .use_preconfigured_tls(config) + .build() + .context("Failed to build trusted HTTP client") +} + +fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +fn load_ca_bundle( + path: &std::path::Path, + pins: &[String], +) -> Result<(rustls::RootCertStore, HashSet)> { + let contents = std::fs::read(path) + .with_context(|| format!("Failed to read CA bundle at {}", path.display()))?; + let mut remaining = contents.as_slice(); + let mut certs = Vec::new(); + while !remaining.is_empty() { + if remaining.iter().all(u8::is_ascii_whitespace) { + break; + } + let (rest, pem) = + parse_x509_pem(remaining).map_err(|_| anyhow::anyhow!("Failed to parse CA bundle"))?; + if pem.label == "CERTIFICATE" { + certs.push(pem.contents); + } + remaining = rest; + } + if certs.is_empty() { + anyhow::bail!("CA bundle contained no certificates"); + } + let mut root_store = rustls::RootCertStore::empty(); + for cert in certs { + root_store + .add(CertificateDer::from(cert)) + .context("Failed to add CA certificate")?; + } + let pins = pins + .iter() + .map(|value| value.to_ascii_lowercase()) + .collect::>(); + Ok((root_store, pins)) +} + +#[derive(Debug)] +struct PinnedCertVerifier { + inner: Arc, + allowed: HashSet, +} + +impl PinnedCertVerifier { + fn new(inner: Arc, allowed: HashSet) -> Self { + Self { inner, allowed } + } + + fn check_pins( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + ) -> Result<(), rustls::Error> { + let mut matches = false; + matches |= self.allowed.contains(&sha256_hex(end_entity.as_ref())); + for cert in intermediates { + if self.allowed.contains(&sha256_hex(cert.as_ref())) { + matches = true; + break; + } + } + if matches { + Ok(()) + } else { + Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )) + } + } +} + +impl ServerCertVerifier for PinnedCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + )?; + self.check_pins(end_entity, intermediates)?; + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, bytes); + let mut output = String::with_capacity(digest.as_ref().len() * 2); + for byte in digest.as_ref() { + use std::fmt::Write; + write!(output, "{byte:02x}").expect("writing to string should not fail"); + } + output +} + fn decode_eab_key(encoded: &str) -> Result> { base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(encoded) @@ -544,15 +723,28 @@ mod tests { } } + fn test_trust() -> TrustSettings { + TrustSettings::default() + } + #[test] fn test_client_initialization() { - let client = AcmeClient::new("http://example.com".to_string(), &test_settings()); + let client = AcmeClient::new( + "http://example.com".to_string(), + &test_settings(), + &test_trust(), + ); assert!(client.is_ok()); } #[test] fn test_compute_key_authorization() { - let client = AcmeClient::new("http://example.com".to_string(), &test_settings()).unwrap(); + let client = AcmeClient::new( + "http://example.com".to_string(), + &test_settings(), + &test_trust(), + ) + .unwrap(); let token = "test_token_123_xyz"; let ka = client.compute_key_authorization(token).unwrap(); assert!(ka.starts_with(token)); @@ -571,7 +763,12 @@ mod tests { #[test] fn test_external_account_binding_structure() { - let client = AcmeClient::new("http://example.com".to_string(), &test_settings()).unwrap(); + let client = AcmeClient::new( + "http://example.com".to_string(), + &test_settings(), + &test_trust(), + ) + .unwrap(); let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"test-secret"); let creds = EabCredentials { kid: "kid-123".to_string(), @@ -641,8 +838,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); client .create_order(&["example.internal".to_string(), "192.0.2.10".to_string()]) .await @@ -720,8 +921,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); client.fetch_directory().await.unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 3); @@ -748,8 +953,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let nonce = client.get_nonce().await.unwrap(); assert_eq!(nonce, "nonce-123"); @@ -792,8 +1001,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let order = client .poll_order(&format!("{}/order/1", server.uri())) .await @@ -816,8 +1029,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let err = client.fetch_directory().await.unwrap_err(); assert_eq!(calls.load(Ordering::SeqCst), 3); @@ -845,8 +1062,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let err = client.get_nonce().await.unwrap_err(); assert!(err.to_string().contains("Missing Replay-Nonce header")); @@ -879,8 +1100,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let err = client .poll_order(&format!("{}/order/2", server.uri())) .await diff --git a/src/acme/flow.rs b/src/acme/flow.rs index 19831c0..0965eef 100644 --- a/src/acme/flow.rs +++ b/src/acme/flow.rs @@ -258,7 +258,7 @@ pub async fn issue_certificate( profile: &crate::config::DaemonProfileSettings, eab_creds: Option, ) -> Result<()> { - let mut client = AcmeClient::new(settings.server.clone(), &settings.acme)?; + let mut client = AcmeClient::new(settings.server.clone(), &settings.acme, &settings.trust)?; client.fetch_directory().await?; tracing::debug!("Directory loaded."); diff --git a/src/agent_args.rs b/src/agent_args.rs index 8023a2b..d82767f 100644 --- a/src/agent_args.rs +++ b/src/agent_args.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::Parser; +use clap::{ArgAction, Parser}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -40,4 +40,12 @@ pub struct Args { /// Run once and exit (disable daemon loop) #[arg(long)] pub oneshot: bool, + + /// Verify ACME server TLS certificates + #[arg(long, action = ArgAction::SetTrue, conflicts_with = "insecure")] + pub verify_certificates: bool, + + /// Disable TLS certificate verification (INSECURE) + #[arg(long, action = ArgAction::SetTrue, conflicts_with = "verify_certificates")] + pub insecure: bool, } diff --git a/src/cli/output.rs b/src/cli/output.rs index b4abfcb..48bbffc 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -298,6 +298,7 @@ fn print_trust_snippet(entry: &AppEntry, trusted: &[String], messages: &Messages let bundle_path = cert_dir.join("ca-bundle.pem"); println!("{}", messages.app_snippet_trust_title()); println!("[trust]"); + println!("verify_certificates = true"); println!("ca_bundle_path = \"{}\"", bundle_path.display()); println!( "trusted_ca_sha256 = [{}]", diff --git a/src/config.rs b/src/config.rs index 82beeee..c364c58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,14 +87,26 @@ pub struct RetrySettings { pub backoff_secs: Vec, } -#[derive(Debug, Deserialize, Clone, Default)] +#[derive(Debug, Deserialize, Clone)] pub struct TrustSettings { + #[serde(default = "defaults::default_verify_certificates")] + pub verify_certificates: bool, #[serde(default)] pub ca_bundle_path: Option, #[serde(default)] pub trusted_ca_sha256: Vec, } +impl Default for TrustSettings { + fn default() -> Self { + Self { + verify_certificates: defaults::default_verify_certificates(), + ca_bundle_path: None, + trusted_ca_sha256: Vec::new(), + } + } +} + #[derive(Debug, Deserialize, Clone, Default)] pub struct SchedulerSettings { #[serde(default = "defaults::default_max_concurrent_issuances")] @@ -212,6 +224,12 @@ impl Settings { if let Some(responder_hmac) = &args.http_responder_hmac { responder_hmac.clone_into(&mut self.acme.http_responder_hmac); } + if args.verify_certificates { + self.trust.verify_certificates = true; + } + if args.insecure { + self.trust.verify_certificates = false; + } } /// Validates configuration values for correctness. @@ -276,6 +294,7 @@ mod tests { assert_eq!(settings.acme.poll_interval_secs, 2); assert_eq!(settings.retry.backoff_secs, vec![5, 10, 30]); assert_eq!(settings.scheduler.max_concurrent_issuances, 3); + assert!(!settings.trust.verify_certificates); let profile = &settings.profiles[0]; assert_eq!(profile.daemon.check_interval, Duration::from_secs(60 * 60)); @@ -366,6 +385,8 @@ mod tests { eab_hmac: None, eab_file: None, oneshot: false, + verify_certificates: false, + insecure: false, }; settings.merge_with_args(&args); @@ -379,6 +400,48 @@ mod tests { ); } + #[test] + fn test_merge_with_args_trust_override() { + let mut file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap(); + write_minimal_profile_config(&mut file); + let mut settings = Settings::new(Some(file.path().to_path_buf())).unwrap(); + settings.trust.verify_certificates = false; + + let args = crate::Args { + config: None, + email: None, + ca_url: None, + http_responder_url: None, + http_responder_hmac: None, + eab_kid: None, + eab_hmac: None, + eab_file: None, + oneshot: false, + verify_certificates: true, + insecure: false, + }; + + settings.merge_with_args(&args); + assert!(settings.trust.verify_certificates); + + let args = crate::Args { + config: None, + email: None, + ca_url: None, + http_responder_url: None, + http_responder_hmac: None, + eab_kid: None, + eab_hmac: None, + eab_file: None, + oneshot: false, + verify_certificates: false, + insecure: true, + }; + + settings.merge_with_args(&args); + assert!(!settings.trust.verify_certificates); + } + #[test] fn test_validate_rejects_invalid_acme_settings() { let mut file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap(); diff --git a/src/config/defaults.rs b/src/config/defaults.rs index b96ea63..2fb7f06 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -20,6 +20,7 @@ const DEFAULT_POLL_INTERVAL_SECS: u64 = 2; const DEFAULT_RETRY_BACKOFF_SECS: [u64; 3] = [5, 10, 30]; const DEFAULT_HOOK_TIMEOUT_SECS: u64 = 30; const DEFAULT_MAX_CONCURRENT_ISSUANCES: u64 = 3; +const DEFAULT_VERIFY_CERTIFICATES: bool = false; pub(crate) fn apply_defaults( builder: ConfigBuilder, @@ -56,7 +57,8 @@ pub(crate) fn apply_defaults( .set_default( "scheduler.max_concurrent_issuances", DEFAULT_MAX_CONCURRENT_ISSUANCES, - ) + )? + .set_default("trust.verify_certificates", DEFAULT_VERIFY_CERTIFICATES) } pub(crate) fn default_hook_timeout_secs() -> u64 { @@ -78,3 +80,7 @@ pub(crate) fn default_check_jitter() -> Duration { pub(crate) fn default_max_concurrent_issuances() -> u64 { DEFAULT_MAX_CONCURRENT_ISSUANCES } + +pub(crate) fn default_verify_certificates() -> bool { + DEFAULT_VERIFY_CERTIFICATES +} diff --git a/state.json b/state.json deleted file mode 100644 index 983b944..0000000 --- a/state.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "openbao_url": "http://localhost:8200", - "kv_mount": "secret", - "secrets_dir": "secrets", - "policies": { - "bootroot_agent": "bootroot-agent", - "responder": "bootroot-responder", - "stepca": "bootroot-stepca" - }, - "approles": { - "bootroot_agent": "bootroot-agent-role", - "responder": "bootroot-responder-role", - "stepca": "bootroot-stepca-role" - }, - "apps": {} -} \ No newline at end of file diff --git a/tests/acme_trust.rs b/tests/acme_trust.rs new file mode 100644 index 0000000..90c5a61 --- /dev/null +++ b/tests/acme_trust.rs @@ -0,0 +1,207 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use bootroot::acme::client::AcmeClient; +use bootroot::config::{AcmeSettings, TrustSettings}; +use rcgen::generate_simple_self_signed; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +struct TlsTestServer { + addr: SocketAddr, + cert_der: Vec, + cert_pem: String, + handle: JoinHandle<()>, +} + +impl TlsTestServer { + fn url(&self) -> String { + format!("https://localhost:{}", self.addr.port()) + } +} + +async fn start_tls_server() -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let rcgen::CertifiedKey { cert, signing_key } = + generate_simple_self_signed(vec!["localhost".to_string()]) + .context("generate self-signed cert")?; + let cert_der = cert.der().to_vec(); + let cert_pem = cert.pem(); + let key_der = signing_key.serialize_der(); + + let config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from(cert_der.clone())], + rustls::pki_types::PrivateKeyDer::from(rustls::pki_types::PrivatePkcs8KeyDer::from( + key_der, + )), + ) + .context("build tls config")?; + + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind tcp")?; + let addr = listener.local_addr().context("local addr")?; + let acceptor = TlsAcceptor::from(Arc::new(config)); + + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(stream).await else { + return; + }; + let mut buffer = [0u8; 4096]; + let read = match stream.read(&mut buffer).await { + Ok(0) | Err(_) => return, + Ok(value) => value, + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let body = if path == "/directory" { + let base = format!("https://localhost:{}", addr.port()); + format!( + r#"{{"newNonce":"{base}/nonce","newAccount":"{base}/account","newOrder":"{base}/order"}}"# + ) + } else { + String::new() + }; + let status = if path == "/directory" { + "200 OK" + } else { + "404 Not Found" + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + + Ok(TlsTestServer { + addr, + cert_der, + cert_pem, + handle, + }) +} + +fn test_settings() -> AcmeSettings { + AcmeSettings { + directory_fetch_attempts: 1, + directory_fetch_base_delay_secs: 0, + directory_fetch_max_delay_secs: 0, + poll_attempts: 1, + poll_interval_secs: 1, + http_responder_url: "http://localhost:8080".to_string(), + http_responder_hmac: "dev-hmac".to_string(), + http_responder_timeout_secs: 5, + http_responder_token_ttl_secs: 300, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, bytes); + let mut output = String::with_capacity(digest.as_ref().len() * 2); + for byte in digest.as_ref() { + use std::fmt::Write; + write!(output, "{byte:02x}").expect("hex write"); + } + output +} + +fn write_ca_bundle(cert_pem: &str, dir: &tempfile::TempDir) -> Result { + let path = dir.path().join("ca-bundle.pem"); + std::fs::write(&path, cert_pem).context("write bundle")?; + Ok(path) +} + +#[tokio::test] +async fn acme_trust_allows_insecure_when_disabled() -> Result<()> { + let server = start_tls_server().await?; + let trust = TrustSettings { + verify_certificates: false, + ..TrustSettings::default() + }; + let mut client = AcmeClient::new( + format!("{}/directory", server.url()), + &test_settings(), + &trust, + )?; + client.fetch_directory().await?; + server.handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn acme_trust_rejects_self_signed_without_trust() -> Result<()> { + let server = start_tls_server().await?; + let trust = TrustSettings { + verify_certificates: true, + ..TrustSettings::default() + }; + let mut client = AcmeClient::new( + format!("{}/directory", server.url()), + &test_settings(), + &trust, + )?; + assert!(client.fetch_directory().await.is_err()); + server.handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn acme_trust_accepts_bundle_and_pin() -> Result<()> { + let server = start_tls_server().await?; + let dir = tempfile::tempdir().context("tempdir")?; + let bundle_path = write_ca_bundle(&server.cert_pem, &dir)?; + let trust = TrustSettings { + verify_certificates: true, + ca_bundle_path: Some(bundle_path), + trusted_ca_sha256: vec![sha256_hex(&server.cert_der)], + }; + + let mut client = AcmeClient::new( + format!("{}/directory", server.url()), + &test_settings(), + &trust, + )?; + client.fetch_directory().await?; + server.handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn acme_trust_rejects_pin_mismatch() -> Result<()> { + let server = start_tls_server().await?; + let dir = tempfile::tempdir().context("tempdir")?; + let bundle_path = write_ca_bundle(&server.cert_pem, &dir)?; + let trust = TrustSettings { + verify_certificates: true, + ca_bundle_path: Some(bundle_path), + trusted_ca_sha256: vec!["00".repeat(32)], + }; + + let mut client = AcmeClient::new( + format!("{}/directory", server.url()), + &test_settings(), + &trust, + )?; + assert!(client.fetch_directory().await.is_err()); + server.handle.abort(); + Ok(()) +}