diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index eeaf41c5..acef9518 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -37,6 +37,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install independent EST interoperability client + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + cache: false + + - name: Build pinned GlobalSign EST client + run: | + go install github.com/globalsign/est/cmd/estclient@v1.0.7 + echo "ATOM_EST_CLIENT=$(go env GOPATH)/bin/estclient" >> "$GITHUB_ENV" + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy diff --git a/Cargo.lock b/Cargo.lock index 9ccac8e7..9653fdd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "cms", "const-oid", "der", "dotenvy", @@ -445,8 +446,8 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "sqlx", "spki", + "sqlx", "thiserror 1.0.69", "time", "tokio", @@ -463,6 +464,7 @@ dependencies = [ "x509-cert", "x509-ocsp", "x509-parser", + "yasna", "zeroize", ] @@ -4340,6 +4342,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.52.3" @@ -5440,6 +5463,7 @@ dependencies = [ "const-oid", "der", "spki", + "tls_codec", ] [[package]] @@ -5552,6 +5576,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index b8fb914f..13699595 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ serde_json = "1" jsonschema = { version = "0.18", default-features = false } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } +cms = "0.2.3" const-oid = { version = "0.9", features = ["db"] } der = { version = "0.7", features = ["alloc", "derive", "oid"] } jsonwebtoken = "9" diff --git a/README.md b/README.md index 4122b87e..7be5af31 100644 --- a/README.md +++ b/README.md @@ -624,7 +624,7 @@ Generic application mapping: | `ATOM_GRPC_TLS_CERT_PATH` | *(unset)* | PEM server certificate chain; set with `ATOM_GRPC_TLS_KEY_PATH` to enable gRPC TLS | | `ATOM_GRPC_TLS_KEY_PATH` | *(unset)* | PEM server private key; setting only one TLS cert/key path fails startup | | `ATOM_GRPC_TLS_CLIENT_CA_PATH` | *(unset)* | PEM client CA bundle; requires server cert/key and enables mandatory mTLS | -| `ATOM_PKI_ENROLLMENT_ENABLED` | `false` | Enables the dedicated native enrollment TLS listener | +| `ATOM_PKI_ENROLLMENT_ENABLED` | `false` | Enables the dedicated native and RFC 7030 EST enrollment TLS listener | | `ATOM_PKI_ENROLLMENT_LISTEN_ADDR` | `0.0.0.0:8443` | Enrollment bind address; this is a public machine-facing surface | | `ATOM_PKI_ENROLLMENT_TLS_CERT_PATH` / `ATOM_PKI_ENROLLMENT_TLS_KEY_PATH` | *(unset)* | Required server cert/key pair when enrollment is enabled; TLS terminates in Atom | | `ATOM_PKI_ENROLLMENT_ENTITY_RATE_LIMIT` / `ATOM_PKI_ENROLLMENT_ENTITY_RATE_WINDOW_SECS` | `10` / `60` | Durable per-entity enrollment limit and fixed-window seconds | diff --git a/apidocs/pki-enrollment.md b/apidocs/pki-enrollment.md index 04cfe58d..f7365cdb 100644 --- a/apidocs/pki-enrollment.md +++ b/apidocs/pki-enrollment.md @@ -1,6 +1,6 @@ -# Native PKI enrollment API +# PKI enrollment APIs -The native enrollment API is served only on the dedicated TLS listener +The native and RFC 7030 EST enrollment APIs are served only on the dedicated TLS listener configured by `ATOM_PKI_ENROLLMENT_LISTEN_ADDR`. It is not mounted on Atom's main HTTP port. @@ -60,3 +60,24 @@ include `Retry-After`. Expired, revoked, unknown, or inactive certificate subjects cannot use re-enrollment. Recover by calling first enrollment with an active non-certificate Atom credential. + +## RFC 7030 EST adapter + +The same listener exposes the standard EST paths below. EST is a wire adapter +over the native enrollment service; the authenticated subject still determines +the tenant, client profile, and active issuer. Atom does not implement an EST +additional path segment, and no selector field is accepted. + +| Operation | Authentication | Request | Response | +| --- | --- | --- | --- | +| `GET /.well-known/est/cacerts` | none | empty | base64 certs-only PKCS#7 matching `/certs/trust-bundle.pem` | +| `POST /.well-known/est/simpleenroll` | HTTP Basic Atom password credential, or Bearer token | base64 DER PKCS#10; `application/pkcs10` | base64 certs-only PKCS#7 | +| `POST /.well-known/est/simplereenroll` | certificate being replaced in the TLS handshake | base64 DER PKCS#10; `application/pkcs10` | base64 certs-only PKCS#7 | +| `POST /.well-known/est/serverkeygen` | HTTP Basic Atom password credential, or Bearer token | base64 DER PKCS#10; `application/pkcs10` | `multipart/mixed` containing a one-time PKCS#8 key and certs-only PKCS#7 | +| `GET /.well-known/est/csrattrs` | HTTP Basic Atom password credential, or Bearer token | empty | base64 `application/csrattrs` derived from the applicable client profile | + +For HTTP Basic authentication, use the entity UUID as the username and its +Atom password credential as the password. Atom derives scope from the +credential; URL segments, query parameters, and headers cannot choose another +tenant, issuer, entity, or profile. The generated private key returned by +`serverkeygen` is zeroized after the response and is never persisted or logged. diff --git a/docs/content/docs/authentication/certificates.mdx b/docs/content/docs/authentication/certificates.mdx index 5f992f15..eff8da38 100644 --- a/docs/content/docs/authentication/certificates.mdx +++ b/docs/content/docs/authentication/certificates.mdx @@ -488,6 +488,30 @@ exact `renewal_threshold_seconds`, and `idempotent_replay`. Keep the CSR private key locally; it never enters Atom. Exact retries return the same certificate. The old certificate remains active for the normal renewal overlap window. +The listener also exposes RFC 7030 EST for standards-based firmware and tools: + +```text +GET /.well-known/est/cacerts +GET /.well-known/est/csrattrs +POST /.well-known/est/simpleenroll +POST /.well-known/est/simplereenroll +POST /.well-known/est/serverkeygen +``` + +EST requests and responses use the RFC media types and base64 transfer +encoding. `simpleenroll`, `serverkeygen`, and `csrattrs` accept HTTP Basic with +the entity UUID and its Atom password credential (or an Atom Bearer token). +`simplereenroll` accepts only the certificate being replaced in the TLS +handshake. `serverkeygen` returns a multipart PKCS#8 key and certs-only PKCS#7; +the key is delivered once and never persisted. `cacerts` is the PKCS#7 +representation of the same database trust bundle as +`/certs/trust-bundle.pem`. + +EST adds no subject selectors: an additional path segment is not mounted, and +query parameters or headers cannot select an entity, tenant, issuer, or +profile. Those values are resolved by the same subject-driven enrollment +service used by the native adapter. + Enable and bind the listener with: ```text diff --git a/product-docs/12-certificates.md b/product-docs/12-certificates.md index 0b9193e5..0e6b9afe 100644 --- a/product-docs/12-certificates.md +++ b/product-docs/12-certificates.md @@ -218,6 +218,15 @@ The listener is opt-in with `ATOM_PKI_ENROLLMENT_ENABLED=true` and requires unknown, or otherwise inactive certificate subjects must recover through first enrollment with a still-active non-certificate credential. +PR-014b attaches RFC 7030 EST to that same listener and service boundary. +`/.well-known/est/simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, +and `cacerts` translate only EST authentication and wire encodings. HTTP Basic +or Bearer authentication bootstraps first enrollment; the exact verified TLS +leaf authorizes re-enrollment. The authenticated subject remains the sole +source of entity, tenant, profile, and issuer scope. `cacerts` encodes the +PR-003 database trust bundle as certs-only PKCS#7, and `serverkeygen` delivers a +non-persisted PKCS#8 key once in a multipart response. + --- ## Interfaces @@ -244,6 +253,14 @@ The dedicated TLS enrollment listener exposes authenticated native operations: - `POST /pki/enroll` - `POST /pki/reenroll` +It also exposes RFC 7030 operations: + +- `GET /.well-known/est/cacerts` +- `GET /.well-known/est/csrattrs` +- `POST /.well-known/est/simpleenroll` +- `POST /.well-known/est/simplereenroll` +- `POST /.well-known/est/serverkeygen` + Runtime services use Atom gRPC: - `CertificateService.ResolveCertificateV2` diff --git a/src/certs/enrollment/est.rs b/src/certs/enrollment/est.rs new file mode 100644 index 00000000..8f1da1b1 --- /dev/null +++ b/src/certs/enrollment/est.rs @@ -0,0 +1,637 @@ +//! RFC 7030 Enrollment over Secure Transport adapter. +//! +//! This module owns only HTTP authentication and EST wire encodings. Subject, +//! scope, profile, issuer, lifecycle, rate-limit, and audit decisions remain in +//! the enrollment and certificate services. + +use std::io::Cursor; + +use axum::{ + body::Bytes, + extract::State, + http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Extension, Router, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use cms::content_info::ContentInfo; +use der::{Decode, Encode}; +use ring::digest; +use x509_cert::Certificate; +use x509_parser::{certification_request::X509CertificationRequest, prelude::FromDer as _}; +use yasna::models::ObjectIdentifier; + +use crate::{ + audit, + auth::{self, AuthContext}, + certs::{ + authority::provisioning, + profile::{KeyAlgorithm, KeyAlgorithmRule}, + }, + error::AppError, + identity::service as identity_service, + state::AppState, +}; + +use super::{service, tls::VerifiedPeerCertificate}; + +const PKCS10_MEDIA_TYPE: &str = "application/pkcs10"; +const PKCS7_CERTS_ONLY_MEDIA_TYPE: &str = "application/pkcs7-mime; smime-type=certs-only"; +const CSR_ATTRS_MEDIA_TYPE: &str = "application/csrattrs"; +const PKCS8_MEDIA_TYPE: &str = "application/pkcs8"; +const TRANSFER_ENCODING_BASE64: &str = "base64"; +const SERVER_KEYGEN_BOUNDARY: &str = "atom-est-serverkeygen-boundary"; +const BASIC_CHALLENGE: &str = "Basic realm=\"Atom EST\""; +const CSR_PEM_HEADER: &str = "-----BEGIN CERTIFICATE REQUEST-----\n"; +const CSR_PEM_FOOTER: &str = "-----END CERTIFICATE REQUEST-----\n"; + +pub fn routes() -> Router { + Router::new() + .route("/.well-known/est/cacerts", get(cacerts)) + .route("/.well-known/est/simpleenroll", post(simple_enroll)) + .route("/.well-known/est/simplereenroll", post(simple_reenroll)) + .route("/.well-known/est/serverkeygen", post(server_keygen)) + .route("/.well-known/est/csrattrs", get(csr_attrs)) +} + +async fn cacerts(State(state): State) -> Result { + let bundle = provisioning::trust_bundle(&state.pool).await?; + base64_response( + PKCS7_CERTS_ONLY_MEDIA_TYPE, + certs_only_der(&bundle.pem)?, + "public, max-age=60, stale-while-revalidate=300", + ) +} + +async fn simple_enroll( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + require_media_type(&headers, PKCS10_MEDIA_TYPE)?; + let csr_der = decode_request_body( + maximum_der_csr_bytes(state.config.enrollment.max_csr_bytes), + &body, + )?; + validate_csr(&csr_der)?; + let auth = authenticate_http(&state, &headers).await?; + let result = service::enroll( + &state, + auth.clone(), + service::EnrollmentInput { + csr_pem: csr_pem(&csr_der), + ttl_secs: None, + idempotency_key: idempotency_key("simpleenroll", &auth, &csr_der), + }, + ) + .await; + if let Err(ref error) = result { + audit::observe_error( + &state.pool, + state.config.events.enabled(), + &audit::AuditMeta { + actor_entity_id: Some(auth.entity_id), + tenant_id: auth.tenant_id, + target_kind: "credential", + target_id: None, + event: "certificate.enroll", + }, + &serde_json::json!({"mode": "first", "transport": "est"}), + error, + ) + .await; + } + let response = result?; + enrollment_response(&response) +} + +async fn simple_reenroll( + State(state): State, + peer: Option>, + headers: HeaderMap, + body: Bytes, +) -> Result { + require_media_type(&headers, PKCS10_MEDIA_TYPE)?; + let peer = peer + .map(|Extension(peer)| peer) + .ok_or_else(|| AppError::unauthorized("a verified client certificate is required"))?; + let csr_der = decode_request_body( + maximum_der_csr_bytes(state.config.enrollment.max_csr_bytes), + &body, + )?; + validate_csr(&csr_der)?; + let peer_fingerprint = digest::digest(&digest::SHA256, peer.as_der()); + let result = service::re_enroll( + &state, + peer, + service::EnrollmentInput { + csr_pem: csr_pem(&csr_der), + ttl_secs: None, + idempotency_key: idempotency_key_with_subject( + "simplereenroll", + peer_fingerprint.as_ref(), + &csr_der, + ), + }, + ) + .await; + if let Err(ref error) = result { + audit::observe_error( + &state.pool, + state.config.events.enabled(), + &audit::AuditMeta { + actor_entity_id: None, + tenant_id: None, + target_kind: "credential", + target_id: None, + event: "certificate.reenroll", + }, + &serde_json::json!({ + "mode": "reenroll", + "peer_fingerprint_sha256": hex::encode(peer_fingerprint.as_ref()), + "transport": "est", + }), + error, + ) + .await; + } + let response = result?; + enrollment_response(&response) +} + +async fn server_keygen( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + require_media_type(&headers, PKCS10_MEDIA_TYPE)?; + let csr_der = decode_request_body( + maximum_der_csr_bytes(state.config.enrollment.max_csr_bytes), + &body, + )?; + validate_csr(&csr_der)?; + let auth = authenticate_http(&state, &headers).await?; + + let result = service::enroll_generated(&state, auth.clone()).await; + if let Err(ref error) = result { + audit::observe_error( + &state.pool, + state.config.events.enabled(), + &audit::AuditMeta { + actor_entity_id: Some(auth.entity_id), + tenant_id: auth.tenant_id, + target_kind: "credential", + target_id: None, + event: "certificate.enroll", + }, + &serde_json::json!({"mode": "serverkeygen", "transport": "est"}), + error, + ) + .await; + } + let generated = result?; + let key_der = private_key_info_der(generated.private_key_pem.expose())?; + let certs_der = certs_only_der(&generated.enrollment.certificate_pem)?; + let body = format!( + "--{SERVER_KEYGEN_BOUNDARY}\r\n\ + Content-Type: {PKCS8_MEDIA_TYPE}\r\n\ + Content-Transfer-Encoding: {TRANSFER_ENCODING_BASE64}\r\n\r\n\ + {}\r\n\ + --{SERVER_KEYGEN_BOUNDARY}\r\n\ + Content-Type: {PKCS7_CERTS_ONLY_MEDIA_TYPE}\r\n\ + Content-Transfer-Encoding: {TRANSFER_ENCODING_BASE64}\r\n\r\n\ + {}\r\n\ + --{SERVER_KEYGEN_BOUNDARY}--\r\n", + STANDARD.encode(key_der), + STANDARD.encode(certs_der), + ); + response_with_headers( + body, + &[ + ( + header::CONTENT_TYPE, + HeaderValue::from_str(&format!( + "multipart/mixed; boundary=\"{SERVER_KEYGEN_BOUNDARY}\"" + )) + .map_err(internal_encoding_error)?, + ), + (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), + ], + ) +} + +async fn csr_attrs( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = authenticate_http(&state, &headers).await?; + let requirements = service::csr_requirements(&state, &auth).await?; + let der = encode_csr_attributes(&requirements)?; + base64_response(CSR_ATTRS_MEDIA_TYPE, der, "private, no-store") +} + +async fn authenticate_http(state: &AppState, headers: &HeaderMap) -> Result { + let value = headers + .get(header::AUTHORIZATION) + .ok_or_else(|| AppError::unauthorized("missing HTTP authentication"))? + .to_str() + .map_err(|_| AppError::unauthorized("invalid Authorization header"))?; + + let (scheme, credential) = value + .split_once(' ') + .filter(|(_, credential)| !credential.is_empty()) + .ok_or_else(|| AppError::unauthorized("invalid HTTP authentication"))?; + if scheme.eq_ignore_ascii_case("bearer") { + return auth::authenticate_token(state, credential).await; + } + if !scheme.eq_ignore_ascii_case("basic") { + return Err(AppError::unauthorized( + "HTTP Basic or Bearer authentication is required", + )); + } + + let encoded = credential; + let decoded = STANDARD + .decode(encoded) + .map_err(|_| AppError::unauthorized("invalid HTTP Basic authentication"))?; + let decoded = std::str::from_utf8(&decoded) + .map_err(|_| AppError::unauthorized("invalid HTTP Basic authentication"))?; + let (identifier, secret) = decoded + .split_once(':') + .filter(|(identifier, secret)| !identifier.is_empty() && !secret.is_empty()) + .ok_or_else(|| AppError::unauthorized("invalid HTTP Basic authentication"))?; + let authenticated = identity_service::authenticate_password_credential_in_tenant( + &state.pool, + &state.config, + identifier, + secret, + None, + ) + .await?; + Ok(AuthContext { + entity_id: authenticated.entity_id, + tenant_id: authenticated.tenant_id, + ..Default::default() + }) +} + +fn require_media_type(headers: &HeaderMap, expected: &'static str) -> Result<(), EstError> { + let actual = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim); + if actual.is_some_and(|value| value.eq_ignore_ascii_case(expected)) { + Ok(()) + } else { + Err(EstError::unsupported_media_type(format!( + "Content-Type must be {expected}" + ))) + } +} + +fn decode_request_body(max_der: usize, body: &[u8]) -> Result, AppError> { + let max_base64 = max_der + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4); + if body.len() > max_base64.saturating_add(8 * 1024) { + return Err(AppError::payload_too_large("EST request body is too large")); + } + let compact = body + .iter() + .copied() + .filter(|byte| !byte.is_ascii_whitespace()) + .collect::>(); + if compact.is_empty() { + return Err(AppError::bad_request("EST request body is empty")); + } + if compact.len() > max_base64 { + return Err(AppError::payload_too_large("EST request body is too large")); + } + let der = STANDARD + .decode(compact) + .map_err(|_| AppError::bad_request("EST request body is not valid base64"))?; + if der.len() > max_der { + return Err(AppError::payload_too_large("EST request body is too large")); + } + Ok(der) +} + +/// Derive the largest DER request whose canonical PEM representation still +/// fits the enrollment service's representation-independent CSR limit. +pub(super) fn maximum_der_csr_bytes(max_pem_bytes: usize) -> usize { + let mut low = 0usize; + let mut high = max_pem_bytes; + while low < high { + let midpoint = low + (high - low) / 2 + 1; + if csr_pem_encoded_len(midpoint) <= max_pem_bytes { + low = midpoint; + } else { + high = midpoint - 1; + } + } + low +} + +fn csr_pem_encoded_len(der_len: usize) -> usize { + let base64_len = der_len + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4); + let line_breaks = base64_len.saturating_add(63).saturating_div(64); + CSR_PEM_HEADER + .len() + .saturating_add(base64_len) + .saturating_add(line_breaks) + .saturating_add(CSR_PEM_FOOTER.len()) +} + +fn validate_csr(der: &[u8]) -> Result<(), AppError> { + let (remaining, _) = X509CertificationRequest::from_der(der) + .map_err(|_| AppError::bad_request("malformed certificate signing request"))?; + if !remaining.is_empty() { + return Err(AppError::bad_request( + "certificate signing request contains trailing data", + )); + } + Ok(()) +} + +fn csr_pem(der: &[u8]) -> String { + let encoded = STANDARD.encode(der); + let mut pem = String::from(CSR_PEM_HEADER); + for chunk in encoded.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).expect("base64 is ASCII")); + pem.push('\n'); + } + pem.push_str(CSR_PEM_FOOTER); + pem +} + +fn idempotency_key(operation: &str, auth: &AuthContext, csr_der: &[u8]) -> String { + idempotency_key_with_subject(operation, auth.entity_id.as_bytes(), csr_der) +} + +fn idempotency_key_with_subject(operation: &str, subject: &[u8], csr_der: &[u8]) -> String { + let mut context = digest::Context::new(&digest::SHA256); + context.update(b"atom:est:idempotency:v1\0"); + context.update(operation.as_bytes()); + context.update(&[0]); + context.update(subject); + context.update(csr_der); + format!("est:{}", hex::encode(context.finish())) +} + +fn enrollment_response(response: &service::EnrollmentResponse) -> Result { + certs_only_response(&response.certificate_pem) +} + +fn certs_only_response(certificates_pem: &str) -> Result { + base64_response( + PKCS7_CERTS_ONLY_MEDIA_TYPE, + certs_only_der(certificates_pem)?, + "no-store", + ) +} + +fn certs_only_der(certificates_pem: &str) -> Result, AppError> { + let certificates = rustls_pemfile::certs(&mut Cursor::new(certificates_pem.as_bytes())) + .map(|result| { + let der = result.map_err(|_| { + AppError::Internal(anyhow::anyhow!("failed to parse certificate PEM")) + })?; + Certificate::from_der(der.as_ref()).map_err(internal_encoding_error) + }) + .collect::, _>>()?; + if certificates.is_empty() { + return Err(AppError::Internal(anyhow::anyhow!( + "certificate response is empty" + ))); + } + ContentInfo::try_from(certificates) + .and_then(|content| content.to_der()) + .map_err(internal_encoding_error) +} + +fn private_key_info_der(private_key_pem: &str) -> Result, AppError> { + let mut lines = private_key_pem.lines(); + if lines.next() != Some("-----BEGIN PRIVATE KEY-----") { + return Err(AppError::Internal(anyhow::anyhow!( + "generated key is not PKCS#8 PrivateKeyInfo" + ))); + } + let mut encoded = String::new(); + let mut found_end = false; + for line in &mut lines { + if line == "-----END PRIVATE KEY-----" { + found_end = true; + break; + } + encoded.push_str(line); + } + if !found_end || lines.any(|line| !line.trim().is_empty()) { + return Err(AppError::Internal(anyhow::anyhow!( + "generated PKCS#8 key PEM is malformed" + ))); + } + STANDARD + .decode(encoded) + .map_err(|_| AppError::Internal(anyhow::anyhow!("generated PKCS#8 key is malformed"))) +} + +fn encode_csr_attributes(requirements: &[KeyAlgorithmRule]) -> Result, AppError> { + for requirement in requirements { + match requirement.algorithm { + KeyAlgorithm::Ecdsa + if requirement + .sizes + .iter() + .all(|size| matches!(size, 256 | 384)) => {} + KeyAlgorithm::Rsa if requirement.sizes.iter().all(|size| *size >= 2048) => {} + KeyAlgorithm::Ed25519 if requirement.sizes.as_slice() == [255] => {} + _ => { + return Err(AppError::Internal(anyhow::anyhow!( + "client certificate profile has unsupported EST key requirements" + ))) + } + } + } + + Ok(yasna::construct_der(|writer| { + writer.write_sequence(|writer| { + for requirement in requirements { + match requirement.algorithm { + KeyAlgorithm::Ecdsa => { + writer.next().write_sequence(|writer| { + writer.next().write_oid(&oid(&[1, 2, 840, 10045, 2, 1])); + writer.next().write_set(|writer| { + for size in &requirement.sizes { + let curve = match size { + 256 => &[1, 2, 840, 10045, 3, 1, 7][..], + 384 => &[1, 3, 132, 0, 34][..], + _ => unreachable!("requirements were validated"), + }; + writer.next().write_oid(&oid(curve)); + } + }); + }); + for size in &requirement.sizes { + let signature = match size { + 256 => &[1, 2, 840, 10045, 4, 3, 2][..], + 384 => &[1, 2, 840, 10045, 4, 3, 3][..], + _ => unreachable!("requirements were validated"), + }; + writer.next().write_oid(&oid(signature)); + } + } + KeyAlgorithm::Rsa => { + writer.next().write_sequence(|writer| { + writer.next().write_oid(&oid(&[1, 2, 840, 113549, 1, 1, 1])); + writer.next().write_set(|writer| { + for size in &requirement.sizes { + writer.next().write_u64(u64::from(*size)); + } + }); + }); + writer + .next() + .write_oid(&oid(&[1, 2, 840, 113549, 1, 1, 11])); + } + KeyAlgorithm::Ed25519 => { + writer.next().write_oid(&oid(&[1, 3, 101, 112])); + } + } + } + }); + })) +} + +fn oid(arcs: &[u64]) -> ObjectIdentifier { + ObjectIdentifier::from_slice(arcs) +} + +fn base64_response( + media_type: &str, + der: Vec, + cache_control: &'static str, +) -> Result { + response_with_headers( + STANDARD.encode(der), + &[ + ( + header::CONTENT_TYPE, + HeaderValue::from_str(media_type).map_err(internal_encoding_error)?, + ), + ( + HeaderName::from_static("content-transfer-encoding"), + HeaderValue::from_static(TRANSFER_ENCODING_BASE64), + ), + ( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ), + ], + ) +} + +fn response_with_headers( + body: String, + headers: &[(HeaderName, HeaderValue)], +) -> Result { + let mut response = (StatusCode::OK, body).into_response(); + for (name, value) in headers { + response.headers_mut().insert(name.clone(), value.clone()); + } + Ok(response) +} + +fn internal_encoding_error(error: impl std::fmt::Display) -> AppError { + AppError::Internal(anyhow::anyhow!("EST encoding failed: {error}")) +} + +struct EstError { + source: AppError, + status_override: Option, +} + +impl EstError { + fn unsupported_media_type(message: String) -> Self { + Self { + source: AppError::bad_request(message), + status_override: Some(StatusCode::UNSUPPORTED_MEDIA_TYPE), + } + } +} + +impl From for EstError { + fn from(source: AppError) -> Self { + Self { + source, + status_override: None, + } + } +} + +impl IntoResponse for EstError { + fn into_response(self) -> Response { + let mut response = self.source.into_response(); + if let Some(status) = self.status_override { + *response.status_mut() = status; + } + if response.status() == StatusCode::UNAUTHORIZED { + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static(BASIC_CHALLENGE), + ); + } + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn p256_csr_attributes_match_the_rfc7030_shape() { + let der = encode_csr_attributes(&[KeyAlgorithmRule { + algorithm: KeyAlgorithm::Ecdsa, + sizes: vec![256], + }]) + .unwrap(); + assert_eq!( + STANDARD.encode(der), + "MCEwFQYHKoZIzj0CATEKBggqhkjOPQMBBwYIKoZIzj0EAwI=" + ); + } + + #[test] + fn request_decoder_rejects_invalid_base64_before_crypto_parsing() { + assert!(matches!( + decode_request_body(64 * 1024, b"%%%"), + Err(AppError::BadRequest(_)) + )); + } + + #[test] + fn der_limit_accounts_for_pem_envelope_and_line_wrapping() { + let configured_limit = 1024; + let maximum_der = maximum_der_csr_bytes(configured_limit); + assert!(csr_pem(&vec![0; maximum_der]).len() <= configured_limit); + assert!(csr_pem(&vec![0; maximum_der + 1]).len() > configured_limit); + + let accepted = vec![0; maximum_der]; + let accepted_body = STANDARD.encode(&accepted); + assert_eq!( + decode_request_body(maximum_der, accepted_body.as_bytes()).unwrap(), + accepted + ); + let rejected_body = STANDARD.encode(vec![0; maximum_der + 1]); + assert!(matches!( + decode_request_body(maximum_der, rejected_body.as_bytes()), + Err(AppError::PayloadTooLarge(_)) + )); + } +} diff --git a/src/certs/enrollment/http.rs b/src/certs/enrollment/http.rs index 950e2079..a4d03c0b 100644 --- a/src/certs/enrollment/http.rs +++ b/src/certs/enrollment/http.rs @@ -11,6 +11,7 @@ use tower_http::trace::TraceLayer; use crate::{auth::AuthContext, error::AppError, state::AppState}; use super::{ + est, service::{self, EnrollmentInput, EnrollmentResponse}, tls::VerifiedPeerCertificate, }; @@ -37,15 +38,21 @@ impl From for EnrollmentInput { pub fn create_router(state: AppState) -> Router { // The JSON envelope needs a small fixed allowance around the independently // checked CSR. Axum rejects larger bodies before allocating the full input. - let body_limit = state + let native_body_limit = state .config .enrollment .max_csr_bytes .saturating_add(16 * 1024); + let est_body_limit = est::maximum_der_csr_bytes(state.config.enrollment.max_csr_bytes) + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4) + .saturating_add(16 * 1024); Router::new() .route("/pki/enroll", post(first_enrollment)) .route("/pki/reenroll", post(re_enrollment)) - .layer(DefaultBodyLimit::max(body_limit)) + .merge(est::routes()) + .layer(DefaultBodyLimit::max(native_body_limit.max(est_body_limit))) .layer(TraceLayer::new_for_http()) .with_state(state) } diff --git a/src/certs/enrollment/mod.rs b/src/certs/enrollment/mod.rs index bc5c2fac..dd50430c 100644 --- a/src/certs/enrollment/mod.rs +++ b/src/certs/enrollment/mod.rs @@ -1,8 +1,10 @@ //! Subject-driven certificate enrollment (PR-014). //! //! [`service`] owns every enrollment decision. [`http`] is the native protocol -//! adapter, while [`tls`] supplies the only trusted peer-certificate assertion. +//! adapter, [`est`] is the RFC 7030 adapter, and [`tls`] supplies the only +//! trusted peer-certificate assertion. +pub mod est; pub mod http; pub mod repo; pub mod service; diff --git a/src/certs/enrollment/service.rs b/src/certs/enrollment/service.rs index f48b2768..d6564b2c 100644 --- a/src/certs/enrollment/service.rs +++ b/src/certs/enrollment/service.rs @@ -5,9 +5,11 @@ use uuid::Uuid; use crate::{ audit, auth::AuthContext, + certs::profile::{self, KeyAlgorithmRule}, certs::service::{ self as certificates, CertificateRenewalAuthorization, IssueCertificateFromCsrV2, - RenewCertificateV2, RenewalKeySource, ResolveCertificateV2, + IssueGeneratedCertificateV2, OneTimePrivateKey, RenewCertificateV2, RenewalKeySource, + ResolveCertificateV2, }, error::AppError, models::enums::AuditOutcome, @@ -44,6 +46,12 @@ pub struct EnrollmentResponse { pub idempotent_replay: bool, } +#[derive(Debug)] +pub struct GeneratedEnrollmentResponse { + pub enrollment: EnrollmentResponse, + pub private_key_pem: OneTimePrivateKey, +} + #[derive(Debug, Clone, Copy)] struct Subject { entity_id: Uuid, @@ -71,6 +79,89 @@ pub async fn enroll( result } +/// First enrollment with a server-generated key. Authentication and scope use +/// the same subject boundary as CSR enrollment; the returned private key is a +/// redacted, zeroizing one-time value and is never persisted by Atom. +pub async fn enroll_generated( + state: &AppState, + auth: AuthContext, +) -> Result { + let subject = Subject { + entity_id: auth.entity_id, + tenant_id: auth.tenant_id, + }; + let result = enroll_generated_inner(state, subject).await; + crate::metrics::record_pki_enrollment("serverkeygen", outcome(&result)); + crate::metrics::record_pki_lifecycle_operation("enrollment", lifecycle_outcome(&result)); + result +} + +async fn enroll_generated_inner( + state: &AppState, + subject: Subject, +) -> Result { + enforce_rate_limits(state, subject).await?; + + let mut tx = state.pool.begin().await.map_err(AppError::Database)?; + let mut issued = certificates::issue_generated_certificate_v2_in_tx( + &mut tx, + &state.config, + subject.tenant_id, + IssueGeneratedCertificateV2 { + entity_id: subject.entity_id, + ttl_secs: None, + }, + ) + .await?; + let enrollment = response_from_issued(&issued)?; + let private_key_pem = issued.private_key_pem.take().ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "generated enrollment did not return a one-time private key" + )) + })?; + let details = serde_json::json!({ + "mode": "serverkeygen", + "credential_id": enrollment.credential_id, + "serial_number": enrollment.serial_number, + "issuer_id": enrollment.issuer_id, + "profile_id": enrollment.profile_id, + "renewal_due_at": enrollment.renewal_due_at, + "generated_key": true, + }); + commit_with_mode_audit( + state, + tx, + subject, + enrollment.credential_id, + false, + "certificate.enroll", + "certificate.enroll_replayed", + details, + ) + .await?; + Ok(GeneratedEnrollmentResponse { + enrollment, + private_key_pem, + }) +} + +/// Resolve only the authenticated subject's applicable client profile and +/// return its CSR key requirements. The EST adapter owns the ASN.1 transport; +/// profile selection remains in this service boundary. +pub async fn csr_requirements( + state: &AppState, + auth: &AuthContext, +) -> Result, AppError> { + let subject = profile::load_subject(&state.pool, auth.entity_id).await?; + if subject.tenant_id() != auth.tenant_id { + return Err(AppError::unauthorized( + "authenticated subject scope does not match the stored entity", + )); + } + let profile = profile::resolve_for_subject(&state.pool, &subject, "client").await?; + Ok(profile.permitted_key_algorithms().to_vec()) +} + async fn enroll_inner( state: &AppState, subject: Subject, @@ -208,6 +299,10 @@ async fn enforce_input_and_rate_limits( ))); } + enforce_rate_limits(state, subject).await +} + +async fn enforce_rate_limits(state: &AppState, subject: Subject) -> Result<(), AppError> { let mut tx = state.pool.begin().await.map_err(AppError::Database)?; let entity = repo::consume_rate_limit( &mut tx, @@ -342,7 +437,7 @@ fn hide_peer_resolution_error(error: AppError) -> AppError { } } -fn outcome(result: &Result) -> &'static str { +fn outcome(result: &Result) -> &'static str { match result { Ok(_) => "success", Err(AppError::RateLimited { .. }) => "rate_limited", @@ -351,7 +446,7 @@ fn outcome(result: &Result) -> &'static str { } } -fn lifecycle_outcome(result: &Result) -> &'static str { +fn lifecycle_outcome(result: &Result) -> &'static str { if result.is_ok() { "success" } else { diff --git a/src/certs/profile.rs b/src/certs/profile.rs index 0a6949e4..e2bccc09 100644 --- a/src/certs/profile.rs +++ b/src/certs/profile.rs @@ -155,6 +155,10 @@ impl CertificateProfile { self.renewal_threshold_seconds } + pub fn permitted_key_algorithms(&self) -> &[KeyAlgorithmRule] { + &self.permitted_key_algorithms + } + pub fn extended_key_usages(&self) -> &[ExtendedKeyUsage] { &self.extended_key_usages } diff --git a/tests/m41_pki_est.rs b/tests/m41_pki_est.rs new file mode 100644 index 00000000..1be4cb2e --- /dev/null +++ b/tests/m41_pki_est.rs @@ -0,0 +1,957 @@ +//! PR-014b RFC 7030 interoperability and isolation coverage. +//! +//! Requires PostgreSQL, OpenSSL, and the independently maintained GlobalSign +//! EST client named by `ATOM_EST_CLIENT`. CI provisions all three and runs this +//! ignored binary against a freshly migrated database. + +mod common; + +use std::{ + env, fs, + io::Cursor, + net::SocketAddr, + path::{Path, PathBuf}, + process::{Command as StdCommand, Output}, + sync::Arc, +}; + +use atom::{ + auth::AuthContext, + certs::{ + authority::provisioning, + enrollment::{service as enrollment, tls as enrollment_tls}, + profile::{KeyAlgorithm, KeyAlgorithmRule}, + service as certificate_service, + }, + identity::service as identity_service, + keys, + state::AppState, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use rcgen::{CertificateParams, KeyPair}; +use ring::digest; +use rustls::{pki_types::ServerName, ClientConfig, RootCertStore}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + process::Command, + time::{timeout, Duration}, +}; +use tokio_rustls::TlsConnector; +use uuid::Uuid; +use x509_parser::{extensions::GeneralName, pem::parse_x509_pem}; + +const PASSWORD: &str = "est-interoperability-secret"; + +#[tokio::test] +#[ignore] +async fn est_adapter_interoperates_and_enforces_the_pr014b_contract() { + let estclient = PathBuf::from( + env::var_os("ATOM_EST_CLIENT") + .expect("ATOM_EST_CLIENT must name the independent GlobalSign EST client"), + ); + let pool = common::pool().await; + let root = common::pki::test_root("PR-014b Offline Root"); + let tenant = common::pki::create_tenant(&pool, "pki-est").await; + let other_tenant = common::pki::create_tenant(&pool, "pki-est-other").await; + + let directory = env::temp_dir().join(format!("atom-est-{}", Uuid::new_v4())); + fs::create_dir_all(&directory).unwrap(); + let server = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let server_cert_path = directory.join("server.pem"); + let server_key_path = directory.join("server-key.pem"); + fs::write(&server_cert_path, server.cert.pem()).unwrap(); + fs::write(&server_key_path, server.signing_key.serialize_pem()).unwrap(); + + let mut config = common::pki::managed_config(true, true); + config.certs_enabled = true; + config.enrollment.enabled = true; + config.enrollment.listen_addr = "127.0.0.1:0".into(); + config.enrollment.tls = Some(atom::config::EnrollmentTlsConfig { + cert_path: server_cert_path.to_string_lossy().into_owned(), + key_path: server_key_path.to_string_lossy().into_owned(), + }); + config.enrollment.entity_rate_limit.max_requests = 100; + config.enrollment.tenant_rate_limit.max_requests = 1_000; + + let issuer = common::pki::provision_tenant_issuer(&pool, &config, &root, tenant).await; + let other_issuer = { + let mut tx = pool.begin().await.unwrap(); + let provisioned = provisioning::provision_tenant_automatically_in_tx( + &mut tx, + &config.pki_ca_keys, + other_tenant, + ) + .await + .unwrap(); + assert!( + provisioned.succeeded(), + "{:?}", + provisioned.validation_error + ); + tx.commit().await.unwrap(); + sqlx::query( + r#"UPDATE pki_authorities + SET ocsp_url = $2, ca_issuers_url = $3, + crl_distribution_point_url = $4 + WHERE id = $1"#, + ) + .bind(provisioned.authority.id) + .bind(common::pki::OCSP_URL) + .bind(common::pki::CA_ISSUERS_URL) + .bind(common::pki::CRL_URL) + .execute(&pool) + .await + .unwrap(); + provisioned.authority + }; + + keys::bootstrap_if_needed(&pool, &config.signing_keys) + .await + .unwrap(); + let active_keys = keys::load_active_keys(&pool, &config.signing_keys) + .await + .unwrap(); + let state = AppState::new(pool.clone(), config.clone(), active_keys, None); + let prepared = enrollment_tls::prepare(&state) + .await + .unwrap() + .expect("enrollment enabled"); + let address = prepared.local_addr().unwrap(); + let server_name = format!("localhost:{}", address.port()); + let server_cert_pem = server.cert.pem(); + let server_task = tokio::spawn(enrollment_tls::serve(prepared, state.clone())); + + let entity = common::pki::create_entity(&pool, tenant, "est-client").await; + identity_service::create_password(&pool, entity, PASSWORD) + .await + .unwrap(); + let other_entity = common::pki::create_entity(&pool, other_tenant, "est-other").await; + identity_service::create_password(&pool, other_entity, PASSWORD) + .await + .unwrap(); + + // An independent implementation parses the maintained-library CMS output + // from /cacerts. Fingerprint sets must match PR-003's PEM source exactly. + let cacerts_path = directory.join("cacerts.pem"); + assert_client_success( + "cacerts", + run_estclient( + &estclient, + vec![ + "cacerts".into(), + "-server".into(), + server_name.clone(), + "-insecure".into(), + "-out".into(), + path_arg(&cacerts_path), + ], + ) + .await, + ); + let trust_bundle = provisioning::trust_bundle(&pool).await.unwrap(); + assert_eq!( + certificate_fingerprints(&fs::read_to_string(&cacerts_path).unwrap()), + certificate_fingerprints(&trust_bundle.pem) + ); + + // csrattrs is the RFC 7030 AttrOrOID sequence for the profile selected by + // the authenticated subject. The default client profile is precisely P-256. + let basic = basic_authorization(entity, PASSWORD); + let attrs = est_request( + address, + &server_cert_pem, + None, + "GET", + "/.well-known/est/csrattrs", + Some(&basic), + None, + &[], + &[], + ) + .await + .unwrap(); + assert_eq!(attrs.status, 200, "{}", attrs.body); + assert!(attrs.headers.contains("content-type: application/csrattrs")); + assert!(attrs.headers.contains("content-transfer-encoding: base64")); + let requirements = enrollment::csr_requirements( + &state, + &AuthContext { + entity_id: entity, + tenant_id: Some(tenant), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + requirements, + vec![KeyAlgorithmRule { + algorithm: KeyAlgorithm::Ecdsa, + sizes: vec![256], + }] + ); + assert_eq!( + STANDARD.decode(attrs.body.trim()).unwrap(), + STANDARD + .decode("MCEwFQYHKoZIzj0CATEKBggqhkjOPQMBBwYIKoZIzj0EAwI=") + .unwrap() + ); + + // The same independent client performs first enrollment using ordinary + // HTTP Basic authentication backed by an Atom password credential. + let (first_csr, first_key) = csr_and_key(); + let first_csr_path = directory.join("first.csr"); + let first_key_path = directory.join("first-key.pem"); + let first_cert_path = directory.join("first-cert.pem"); + fs::write(&first_csr_path, &first_csr).unwrap(); + fs::write(&first_key_path, &first_key).unwrap(); + assert_client_success( + "simpleenroll", + run_estclient( + &estclient, + authenticated_args( + "enroll", + &server_name, + entity, + &[ + ("-csr", path_arg(&first_csr_path)), + ("-out", path_arg(&first_cert_path)), + ], + ), + ) + .await, + ); + assert_key_matches_certificate(&first_key_path, &first_cert_path); + let first_id = latest_certificate_id(&pool, entity).await; + let first = certificate_service::certificate_by_id(&pool, first_id) + .await + .unwrap(); + assert_eq!(first.entity_id, entity); + assert_eq!(first.tenant_id, Some(tenant)); + assert_eq!(first.issuer_id, Some(issuer.id)); + assert_eq!(first.profile_name.as_deref(), Some("client")); + assert_eq!( + certificate_fingerprints(&first.certificate_pem), + certificate_fingerprints(&fs::read_to_string(&first_cert_path).unwrap()) + ); + + // Native and EST adapters reach the identical policy and issuer pipeline + // for the same subject. Identity, profile, issuer, and encoded extensions + // therefore agree even though serials and public keys necessarily differ. + let native = enrollment::enroll( + &state, + AuthContext { + entity_id: entity, + tenant_id: Some(tenant), + ..Default::default() + }, + enrollment::EnrollmentInput { + csr_pem: csr_and_key().0, + ttl_secs: None, + idempotency_key: "est-native-parity".into(), + }, + ) + .await + .unwrap(); + assert_eq!(native.issuer_id, first.issuer_id.unwrap()); + assert_eq!(native.profile_id, first.profile_id.unwrap()); + assert_eq!(native.profile_name, first.profile_name.as_deref().unwrap()); + assert_eq!(native.identity_uri, first.identity_uri.as_deref().unwrap()); + assert_eq!( + profile_shape(&native.certificate_pem), + profile_shape(&first.certificate_pem) + ); + + // Re-enrollment uses only the exact certificate being replaced at the TLS + // layer. No bearer or Basic credential is supplied to this command. + let first_chain_path = directory.join("first-chain.pem"); + fs::write( + &first_chain_path, + format!( + "{}{}", + fs::read_to_string(&first_cert_path).unwrap(), + first.chain_pem.as_deref().unwrap() + ), + ) + .unwrap(); + let (renewed_csr, renewed_key) = csr_and_key(); + let renewed_csr_path = directory.join("renewed.csr"); + let renewed_key_path = directory.join("renewed-key.pem"); + let renewed_cert_path = directory.join("renewed-cert.pem"); + fs::write(&renewed_csr_path, &renewed_csr).unwrap(); + fs::write(&renewed_key_path, &renewed_key).unwrap(); + assert_client_success( + "simplereenroll", + run_estclient( + &estclient, + vec![ + "reenroll".into(), + "-server".into(), + server_name.clone(), + "-insecure".into(), + "-certs".into(), + path_arg(&first_chain_path), + "-key".into(), + path_arg(&first_key_path), + "-csr".into(), + path_arg(&renewed_csr_path), + "-out".into(), + path_arg(&renewed_cert_path), + ], + ) + .await, + ); + assert_key_matches_certificate(&renewed_key_path, &renewed_cert_path); + let renewed_id = latest_certificate_id(&pool, entity).await; + let renewed = certificate_service::certificate_by_id(&pool, renewed_id) + .await + .unwrap(); + assert_eq!(renewed.renewed_from_credential_id, Some(first_id)); + assert_eq!(renewed.issuer_id, first.issuer_id); + assert_eq!(renewed.profile_id, first.profile_id); + assert_eq!(renewed.identity_uri, first.identity_uri); + + // A request cannot acquire selectors through query parameters, headers, or + // an EST additional path segment. Authentication always determines scope. + let (other_csr, _) = csr_and_key(); + let selectors = vec![ + ("x-atom-tenant".into(), tenant.to_string()), + ("x-atom-issuer".into(), issuer.id.to_string()), + ("x-atom-profile".into(), "server".into()), + ]; + let other_basic = basic_authorization(other_entity, PASSWORD); + let other_reply = est_request( + address, + &server_cert_pem, + None, + "POST", + &format!( + "/.well-known/est/simpleenroll?tenant_id={tenant}&issuer_id={}&profile=server", + issuer.id + ), + Some(&other_basic), + Some("application/pkcs10"), + STANDARD.encode(csr_der(&other_csr)).as_bytes(), + &selectors, + ) + .await + .unwrap(); + assert_eq!(other_reply.status, 200, "{}", other_reply.body); + let other = certificate_service::certificate_by_id( + &pool, + latest_certificate_id(&pool, other_entity).await, + ) + .await + .unwrap(); + assert_eq!(other.tenant_id, Some(other_tenant)); + assert_eq!(other.issuer_id, Some(other_issuer.id)); + assert_eq!(other.profile_name.as_deref(), Some("client")); + let selected_path = est_request( + address, + &server_cert_pem, + None, + "POST", + &format!("/.well-known/est/{tenant}/simpleenroll"), + Some(&basic), + Some("application/pkcs10"), + STANDARD.encode(csr_der(&other_csr)).as_bytes(), + &[], + ) + .await + .unwrap(); + assert_eq!(selected_path.status, 404, "{}", selected_path.body); + + // Protocol errors are rejected before certificate issuance. A missing + // client certificate cannot be replaced by proxy-controlled headers. + assert_est_error( + address, + &server_cert_pem, + &basic, + "application/pkcs10", + b"%%%", + 400, + ) + .await; + assert_est_error( + address, + &server_cert_pem, + &basic, + "application/pkcs10", + STANDARD.encode(b"not a PKCS#10 request").as_bytes(), + 400, + ) + .await; + assert_est_error( + address, + &server_cert_pem, + &basic, + "application/octet-stream", + STANDARD.encode(csr_der(&first_csr)).as_bytes(), + 415, + ) + .await; + let oversized = vec![b'A'; config.enrollment.max_csr_bytes * 4 / 3 + 20 * 1024]; + assert_est_error( + address, + &server_cert_pem, + &basic, + "application/pkcs10", + &oversized, + 413, + ) + .await; + let no_peer = est_request( + address, + &server_cert_pem, + None, + "POST", + "/.well-known/est/simplereenroll", + None, + Some("application/pkcs10"), + STANDARD.encode(csr_der(&first_csr)).as_bytes(), + &[("x-forwarded-client-cert".into(), "forged".into())], + ) + .await + .unwrap(); + assert_eq!(no_peer.status, 401, "{}", no_peer.body); + assert!( + no_peer + .headers + .to_ascii_lowercase() + .contains("www-authenticate: basic"), + "{}", + no_peer.headers + ); + + // The independent client also parses serverkeygen's multipart response. + // Each invocation returns a different PKCS#8 key exactly once, and no key + // material is retained in the certificate credential metadata. + let generated_cert_a = directory.join("generated-a.pem"); + let generated_key_a = directory.join("generated-a-key.pem"); + let generated_cert_b = directory.join("generated-b.pem"); + let generated_key_b = directory.join("generated-b-key.pem"); + for (label, cert_path, key_path) in [ + ("serverkeygen-a", &generated_cert_a, &generated_key_a), + ("serverkeygen-b", &generated_cert_b, &generated_key_b), + ] { + assert_client_success( + label, + run_estclient( + &estclient, + authenticated_args( + "serverkeygen", + &server_name, + entity, + &[ + ("-cn", "ignored-by-atom".into()), + ("-out", path_arg(cert_path)), + ("-keyout", path_arg(key_path)), + ], + ), + ) + .await, + ); + assert_key_matches_certificate(key_path, cert_path); + } + let generated_key_a_pem = fs::read_to_string(&generated_key_a).unwrap(); + let generated_key_b_pem = fs::read_to_string(&generated_key_b).unwrap(); + assert_ne!(generated_key_a_pem, generated_key_b_pem); + assert_ne!( + fs::read_to_string(&generated_cert_a).unwrap(), + fs::read_to_string(&generated_cert_b).unwrap() + ); + let stored_metadata: Vec = sqlx::query_scalar( + "SELECT metadata::text FROM credentials WHERE entity_id = $1 AND kind = 'certificate'", + ) + .bind(entity) + .fetch_all(&pool) + .await + .unwrap(); + for key in [&generated_key_a_pem, &generated_key_b_pem] { + let marker = private_key_marker(key); + assert!( + stored_metadata + .iter() + .all(|metadata| !metadata.contains(&marker)), + "one-time private key material was persisted" + ); + } + + // Lifecycle state in Atom remains authoritative after the TLS certificate + // has passed cryptographic verification. + certificate_service::revoke_certificate_v2( + &pool, + certificate_service::RevokeCertificateV2 { + selector: certificate_service::CertificateRevocationSelector::CredentialId(first_id), + reason: Some("superseded".into()), + actor_entity_id: Some(entity), + expected_entity_id: entity, + expected_tenant_id: Some(tenant), + }, + ) + .await + .unwrap(); + let revoked_attempt = directory.join("revoked-attempt.pem"); + assert_client_failure( + "revoked simplereenroll", + run_estclient( + &estclient, + vec![ + "reenroll".into(), + "-server".into(), + server_name.clone(), + "-insecure".into(), + "-certs".into(), + path_arg(&first_chain_path), + "-key".into(), + path_arg(&first_key_path), + "-csr".into(), + path_arg(&renewed_csr_path), + "-out".into(), + path_arg(&revoked_attempt), + ], + ) + .await, + ); + + let renewed_chain_path = directory.join("renewed-chain.pem"); + fs::write( + &renewed_chain_path, + format!( + "{}{}", + fs::read_to_string(&renewed_cert_path).unwrap(), + renewed.chain_pem.as_deref().unwrap() + ), + ) + .unwrap(); + sqlx::query("UPDATE credentials SET expires_at = now() - interval '1 second' WHERE id = $1") + .bind(renewed_id) + .execute(&pool) + .await + .unwrap(); + let expired_attempt = directory.join("expired-attempt.pem"); + assert_client_failure( + "expired simplereenroll", + run_estclient( + &estclient, + vec![ + "reenroll".into(), + "-server".into(), + server_name, + "-insecure".into(), + "-certs".into(), + path_arg(&renewed_chain_path), + "-key".into(), + path_arg(&renewed_key_path), + "-csr".into(), + path_arg(&first_csr_path), + "-out".into(), + path_arg(&expired_attempt), + ], + ) + .await, + ); + + // Saturate this subject's persisted rate window without changing the + // service configuration, then prove both remaining mutation adapters emit + // structured failure observations on their service-error branches. + sqlx::query( + r#"UPDATE pki_enrollment_rate_windows + SET request_count = $2 + WHERE scope_kind = 'entity' AND scope_id = $1"#, + ) + .bind(entity) + .bind(i64::from(config.enrollment.entity_rate_limit.max_requests)) + .execute(&pool) + .await + .unwrap(); + let encoded_csr = STANDARD.encode(csr_der(&first_csr)); + for operation in ["simpleenroll", "serverkeygen"] { + let response = est_request( + address, + &server_cert_pem, + None, + "POST", + &format!("/.well-known/est/{operation}"), + Some(&basic), + Some("application/pkcs10"), + encoded_csr.as_bytes(), + &[], + ) + .await + .unwrap(); + assert_eq!(response.status, 429, "{}", response.body); + } + + for (event, mode, outcome, minimum) in [ + ("certificate.enroll", "first", "error", 1_i64), + ("certificate.enroll", "serverkeygen", "error", 1_i64), + ("certificate.reenroll", "reenroll", "deny", 2_i64), + ] { + let observed: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM event_outbox + WHERE event = $1 + AND payload->>'outcome' = $3 + AND payload->'details'->>'transport' = 'est' + AND payload->'details'->>'mode' = $2"#, + ) + .bind(event) + .bind(mode) + .bind(outcome) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + observed >= minimum, + "EST {mode} mutation failures must be observed" + ); + } + + server_task.abort(); + let _ = server_task.await; + fs::remove_dir_all(directory).ok(); +} + +fn authenticated_args( + command: &str, + server: &str, + entity_id: Uuid, + options: &[(&str, String)], +) -> Vec { + let mut arguments = vec![ + command.into(), + "-server".into(), + server.into(), + "-insecure".into(), + "-user".into(), + entity_id.to_string(), + "-pass".into(), + PASSWORD.into(), + ]; + for (flag, value) in options { + arguments.push((*flag).into()); + arguments.push(value.clone()); + } + arguments +} + +async fn run_estclient(binary: &Path, arguments: Vec) -> Output { + timeout( + Duration::from_secs(30), + Command::new(binary).args(arguments).output(), + ) + .await + .expect("independent EST client timed out") + .expect("failed to start independent EST client") +} + +fn assert_client_success(operation: &str, output: Output) -> Output { + assert!( + output.status.success(), + "{operation} failed (status {}): stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn assert_client_failure(operation: &str, output: Output) { + assert!( + !output.status.success(), + "{operation} unexpectedly succeeded: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn path_arg(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +async fn latest_certificate_id(pool: &sqlx::PgPool, entity_id: Uuid) -> Uuid { + sqlx::query_scalar( + r#"SELECT id + FROM credentials + WHERE entity_id = $1 AND kind = 'certificate' + ORDER BY created_at DESC, id DESC + LIMIT 1"#, + ) + .bind(entity_id) + .fetch_one(pool) + .await + .unwrap() +} + +fn assert_key_matches_certificate(key: &Path, certificate: &Path) { + let certificate_public_key = assert_client_success( + "extract certificate public key", + StdCommand::new("openssl") + .args(["x509", "-pubkey", "-noout", "-in"]) + .arg(certificate) + .output() + .unwrap(), + ); + let private_public_key = assert_client_success( + "extract private-key public key", + StdCommand::new("openssl") + .args(["pkey", "-pubout", "-in"]) + .arg(key) + .output() + .unwrap(), + ); + assert_eq!(certificate_public_key.stdout, private_public_key.stdout); +} + +fn certificate_fingerprints(pem: &str) -> Vec { + let mut fingerprints = rustls_pemfile::certs(&mut Cursor::new(pem.as_bytes())) + .map(|certificate| { + let certificate = certificate.unwrap(); + hex::encode(digest::digest(&digest::SHA256, certificate.as_ref())) + }) + .collect::>(); + fingerprints.sort(); + fingerprints +} + +fn private_key_marker(pem: &str) -> String { + let encoded = pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect::(); + let start = encoded.len().saturating_sub(48); + encoded[start..].to_string() +} + +fn csr_and_key() -> (String, String) { + let key = KeyPair::generate().unwrap(); + let csr = CertificateParams::default() + .serialize_request(&key) + .unwrap() + .pem() + .unwrap(); + (csr, key.serialize_pem()) +} + +fn csr_der(csr_pem: &str) -> Vec { + let (remaining, pem) = parse_x509_pem(csr_pem.as_bytes()).unwrap(); + assert!(remaining.iter().all(|byte| byte.is_ascii_whitespace())); + assert_eq!(pem.label, "CERTIFICATE REQUEST"); + pem.contents +} + +fn basic_authorization(entity_id: Uuid, password: &str) -> String { + format!( + "Basic {}", + STANDARD.encode(format!("{entity_id}:{password}")) + ) +} + +async fn assert_est_error( + address: SocketAddr, + server_certificate: &str, + authorization: &str, + content_type: &str, + body: &[u8], + expected_status: u16, +) { + let response = est_request( + address, + server_certificate, + None, + "POST", + "/.well-known/est/simpleenroll", + Some(authorization), + Some(content_type), + body, + &[], + ) + .await + .unwrap(); + assert_eq!(response.status, expected_status, "{}", response.body); +} + +#[derive(Clone)] +struct ClientIdentity { + certificate_pem: String, + private_key_pem: String, +} + +struct HttpReply { + status: u16, + headers: String, + body: String, +} + +#[allow(clippy::too_many_arguments)] +async fn est_request( + address: SocketAddr, + server_certificate: &str, + client_identity: Option<&ClientIdentity>, + method: &str, + path: &str, + authorization: Option<&str>, + content_type: Option<&str>, + body: &[u8], + extra_headers: &[(String, String)], +) -> Result { + let mut roots = RootCertStore::empty(); + for certificate in rustls_pemfile::certs(&mut Cursor::new(server_certificate.as_bytes())) { + roots + .add(certificate.map_err(|error| error.to_string())?) + .map_err(|error| error.to_string())?; + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|error| error.to_string())? + .with_root_certificates(roots); + let client_config = match client_identity { + Some(identity) => { + let certificates = + rustls_pemfile::certs(&mut Cursor::new(identity.certificate_pem.as_bytes())) + .collect::, _>>() + .map_err(|error| error.to_string())?; + let key = + rustls_pemfile::private_key(&mut Cursor::new(identity.private_key_pem.as_bytes())) + .map_err(|error| error.to_string())? + .ok_or_else(|| "client key missing".to_string())?; + builder + .with_client_auth_cert(certificates, key) + .map_err(|error| error.to_string())? + } + None => builder.with_no_client_auth(), + }; + + let tcp = TcpStream::connect(address) + .await + .map_err(|error| error.to_string())?; + let connector = TlsConnector::from(Arc::new(client_config)); + let server_name = ServerName::try_from("localhost") + .map_err(|error| error.to_string())? + .to_owned(); + let mut stream = timeout(Duration::from_secs(5), connector.connect(server_name, tcp)) + .await + .map_err(|_| "TLS handshake timed out".to_string())? + .map_err(|error| error.to_string())?; + + let mut request = format!( + "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + if let Some(authorization) = authorization { + request.push_str("Authorization: "); + request.push_str(authorization); + request.push_str("\r\n"); + } + if let Some(content_type) = content_type { + request.push_str("Content-Type: "); + request.push_str(content_type); + request.push_str("\r\n"); + } + for (name, value) in extra_headers { + request.push_str(name); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream + .write_all(request.as_bytes()) + .await + .map_err(|error| error.to_string())?; + stream + .write_all(body) + .await + .map_err(|error| error.to_string())?; + let mut response = Vec::new(); + timeout(Duration::from_secs(5), stream.read_to_end(&mut response)) + .await + .map_err(|_| "HTTP response timed out".to_string())? + .map_err(|error| error.to_string())?; + parse_http_response(&response) +} + +fn parse_http_response(response: &[u8]) -> Result { + let marker = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| "HTTP headers missing".to_string())?; + let headers = String::from_utf8(response[..marker].to_vec()).map_err(|e| e.to_string())?; + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|status| status.parse().ok()) + .ok_or_else(|| "HTTP status missing".to_string())?; + let raw_body = &response[marker + 4..]; + let body = if headers + .to_ascii_lowercase() + .contains("transfer-encoding: chunked") + { + decode_chunked(raw_body)? + } else { + raw_body.to_vec() + }; + Ok(HttpReply { + status, + headers: headers.to_ascii_lowercase(), + body: String::from_utf8(body).map_err(|error| error.to_string())?, + }) +} + +fn decode_chunked(mut input: &[u8]) -> Result, String> { + let mut output = Vec::new(); + loop { + let line_end = input + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| "chunk length missing".to_string())?; + let size_text = std::str::from_utf8(&input[..line_end]).map_err(|e| e.to_string())?; + let size = usize::from_str_radix(size_text.split(';').next().unwrap(), 16) + .map_err(|error| error.to_string())?; + input = &input[line_end + 2..]; + if size == 0 { + break; + } + if input.len() < size + 2 || &input[size..size + 2] != b"\r\n" { + return Err("malformed chunk".into()); + } + output.extend_from_slice(&input[..size]); + input = &input[size + 2..]; + } + Ok(output) +} + +fn parse_certificate(pem: &str) -> x509_parser::certificate::X509Certificate<'static> { + let (_, pem) = parse_x509_pem(pem.as_bytes()).unwrap(); + let der: &'static [u8] = Box::leak(pem.contents.into_boxed_slice()); + x509_parser::parse_x509_certificate(der).unwrap().1 +} + +fn profile_shape(pem: &str) -> (bool, bool, bool, Vec) { + let certificate = parse_certificate(pem); + let key_usage = certificate.key_usage().unwrap().unwrap(); + let extended = certificate.extended_key_usage().unwrap().unwrap(); + let basic = certificate.basic_constraints().unwrap().unwrap(); + let identity_uris = certificate + .subject_alternative_name() + .unwrap() + .unwrap() + .value + .general_names + .iter() + .filter_map(|name| match name { + GeneralName::URI(uri) if uri.starts_with("urn:atom:") => Some((*uri).to_string()), + _ => None, + }) + .collect(); + ( + key_usage.value.digital_signature(), + extended.value.client_auth, + basic.value.ca, + identity_uris, + ) +}