From 2e293d4484b35fa0d460c90b7079a832f8702f64 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 13 Sep 2026 13:14:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(ca):=20d=C3=A9p=C3=B4t=20public=20des=20ce?= =?UTF-8?q?rtificats=20+=20marquage=20STAGING=20du=20staging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ca-server sert désormais une page HTML lisible sur GET / : sujet, émetteur, numéro de série, validité et empreinte SHA-256 de chaque certificat de la hiérarchie chargée, plus les liens vers les ressources déjà servies en brut (.cer, .crl, chaîne PEM, matrice de conformité). Aucune mention 'staging' codée en dur : c'est le Subject réel des certificats qui identifie l'environnement. - values-staging.yaml fixe désormais des noms explicites ('Open eIDAS STAGING Root CA - NE PAS UTILISER EN PRODUCTION', etc.) pour root/issuing/TSU/OCSP — jusqu'ici identiques à ce qu'une vraie CA de production porterait. N'a d'effet que sur une prochaine cérémonie de clé : la hiérarchie déjà amorcée sur le staging actuel n'est pas renommée rétroactivement. --- Cargo.lock | 1 + bin/ca-server/Cargo.toml | 1 + bin/ca-server/src/http.rs | 133 ++++++++++++++++++++- bin/ca-server/tests/crl_publication.rs | 34 ++++++ deploy/helm/open-eidas/values-staging.yaml | 14 +++ 5 files changed, 181 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 536bd2e..7829e32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "time", "tokio", "tower", diff --git a/bin/ca-server/Cargo.toml b/bin/ca-server/Cargo.toml index 971c5f9..75aaa18 100644 --- a/bin/ca-server/Cargo.toml +++ b/bin/ca-server/Cargo.toml @@ -34,6 +34,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" hex = "0.4" base64 = "0.22" +sha2 = "0.10" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } [dev-dependencies] diff --git a/bin/ca-server/src/http.rs b/bin/ca-server/src/http.rs index 5ec1fb9..cdf06f0 100644 --- a/bin/ca-server/src/http.rs +++ b/bin/ca-server/src/http.rs @@ -29,6 +29,11 @@ pub struct Server { crl_path: String, ca_path: String, cache: RwLock, + /// Page HTML publique du dépôt (subjects, validités, empreintes des + /// certificats réellement chargés) : générée une fois au démarrage, + /// jamais reconstruite par requête — son contenu ne change qu'au + /// redémarrage du service. + repository_html: String, } impl Server { @@ -45,18 +50,22 @@ impl Server { ca_pem.push_str(&pem_block("CERTIFICATE", &der)); } } + let ca_path = format!("/download/{name}.cer"); + let crl_path = format!("/download/{name}.crl"); + let repository_html = render_repository_html(&issuer, &ca_path, &crl_path); Server { issuer, flow, version, ca_der, ca_pem, - crl_path: format!("/download/{name}.crl"), - ca_path: format!("/download/{name}.cer"), + crl_path, + ca_path, cache: RwLock::new(CrlCache { crl: None, err: None, }), + repository_html, } } @@ -141,6 +150,115 @@ impl Server { } } +fn sha256_fingerprint(der: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(der) + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") +} + +fn x509_time_to_offset(t: &x509_cert::time::Time) -> time::OffsetDateTime { + let secs = t.to_date_time().unix_duration().as_secs(); + time::OffsetDateTime::from_unix_timestamp(secs as i64) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH) +} + +fn format_time(t: &x509_cert::time::Time) -> String { + x509_time_to_offset(t) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Page HTML publique du dépôt de l'autorité : subject, validité et +/// empreinte SHA-256 de chaque certificat de `full_chain()` (l'autorité +/// émettrice, celle qui a réellement produit les certificats servis, puis +/// sa racine), sans rien affirmer de plus que ce que ces certificats +/// contiennent déjà — aucune mention de statut « staging » codée en dur +/// ici : c'est le Subject réel des certificats chargés qui en fait foi. +fn render_repository_html(issuer: &oe_ca_core::Issuer, ca_path: &str, crl_path: &str) -> String { + let mut certs_html = String::new(); + for (i, cert) in issuer.full_chain().iter().enumerate() { + let role = if i == 0 { + "Autorité émettrice (signe les certificats publiés par ce service)" + } else { + "Autorité racine" + }; + let der = cert.to_der().unwrap_or_default(); + certs_html.push_str(&format!( + r#"
+

{role}

+
+
Sujet
{subject}
+
Émetteur
{issuer_dn}
+
Numéro de série
{serial}
+
Validité
{not_before} → {not_after}
+
Empreinte SHA-256
{fingerprint}
+
+
+"#, + role = role, + subject = html_escape(&cert.tbs_certificate().subject().to_string()), + issuer_dn = html_escape(&cert.tbs_certificate().issuer().to_string()), + serial = hex::encode_upper(cert.tbs_certificate().serial_number().as_bytes()), + not_before = format_time(&cert.tbs_certificate().validity().not_before), + not_after = format_time(&cert.tbs_certificate().validity().not_after), + fingerprint = sha256_fingerprint(&der), + )); + } + + format!( + r#" + + + + +Dépôt de l'autorité de certification + + + +

Dépôt public de l'autorité de certification

+

+ Vérifiez le Sujet de chaque certificat ci-dessous avant de lui + faire confiance : son nom identifie sans ambiguïté l'environnement qui l'a émis + (production certifiée, ou un environnement de test/démonstration). +

+ {certs_html} +

Téléchargements

+ + + +"#, + certs_html = certs_html, + ca_path = ca_path, + crl_path = crl_path, + ) +} + fn common_name(cert: &x509_cert::Certificate) -> String { const OID_CN: &str = "2.5.4.3"; let cn_oid = der::asn1::ObjectIdentifier::new(OID_CN).expect("OID constant invalide"); @@ -168,6 +286,7 @@ pub fn router(server: Arc, max_request_bytes: usize) -> Router { let ca_path = server.ca_path.clone(); let crl_path = server.crl_path.clone(); Router::new() + .route("/", get(handle_repository)) .route("/api/v1/enroll", axum::routing::post(handle_enroll)) .route("/api/v1/ca.pem", get(handle_ca_pem)) .route("/api/v1/conformance", get(handle_conformance)) @@ -293,6 +412,16 @@ fn request_state_str(s: oe_castore::RequestState) -> &'static str { } } +/// Dépôt public, lisible par un humain : subject/validité/empreinte de +/// chaque certificat de la hiérarchie, et les liens vers les mêmes +/// ressources que les autres routes servent déjà en brut. +async fn handle_repository(State(server): State>) -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + server.repository_html.clone(), + ) +} + async fn handle_ca_pem(State(server): State>) -> impl IntoResponse { ( [(header::CONTENT_TYPE, "application/x-pem-file")], diff --git a/bin/ca-server/tests/crl_publication.rs b/bin/ca-server/tests/crl_publication.rs index e53285b..2035f2d 100644 --- a/bin/ca-server/tests/crl_publication.rs +++ b/bin/ca-server/tests/crl_publication.rs @@ -132,3 +132,37 @@ async fn healthz_degrades_when_the_published_crl_is_stale() { ); assert_eq!(body["statut"], "degrade"); } + +/// Le dépôt public (`GET /`) doit lister le sujet réel de chaque +/// certificat de la hiérarchie — c'est ce Subject, pas une mention codée +/// en dur dans le service, qui distingue un environnement de test d'une +/// production certifiée. +#[tokio::test] +async fn repository_page_lists_the_real_hierarchy() { + let server = build_server(time::Duration::hours(24)).await; + let app = http::router(server.clone(), 64 * 1024); + let request = axum::http::Request::builder() + .uri("/") + .body(axum::body::Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/html; charset=utf-8" + ); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let html = String::from_utf8(body.to_vec()).expect("la page doit être de l'UTF-8 valide"); + assert!( + html.contains("Test Issuing CA"), + "sujet de la CA émettrice absent" + ); + assert!( + html.contains("Test Root CA"), + "sujet de la CA racine absent" + ); + assert!( + html.contains("/api/v1/ca.pem"), + "lien de téléchargement absent" + ); +} diff --git a/deploy/helm/open-eidas/values-staging.yaml b/deploy/helm/open-eidas/values-staging.yaml index 79fe345..ab2a502 100644 --- a/deploy/helm/open-eidas/values-staging.yaml +++ b/deploy/helm/open-eidas/values-staging.yaml @@ -47,6 +47,9 @@ tsa: # Démo web officielle (open-eidas/demo, GitHub Pages) — seule origine # autorisée à appeler cette API de staging depuis un navigateur. corsAllowedOrigin: "https://demo.open-eidas.eu" + # Voir le commentaire de ca.rootCommonName plus bas. + subject: + commonName: "Open eIDAS STAGING Time-Stamping Unit 1" ca: # Gravée dans les points CRL et AIA des certificats émis : doit correspondre @@ -56,6 +59,14 @@ ca: gateway: enabled: true host: pki.staging.open-eidas.eu + # Marquage explicite « STAGING » : sans ça, rien ne distingue au premier + # coup d'œil cette hiérarchie de démonstration d'une vraie CA de + # production (voir GET / sur ce service, qui affiche ces Subject tels + # quels). Gravé dans les certificats à la cérémonie de clé — un + # changement ici n'a AUCUN effet sur une hiérarchie déjà amorcée, seule + # une nouvelle cérémonie (tokens/état effacés) en tient compte. + rootCommonName: "Open eIDAS STAGING Root CA - NE PAS UTILISER EN PRODUCTION" + issuingCommonName: "Open eIDAS STAGING Issuing CA - NE PAS UTILISER EN PRODUCTION" ocsp: # Gravée dans l'extension AIA (champ "ocsp") du certificat TSU. @@ -63,3 +74,6 @@ ocsp: gateway: enabled: true host: ocsp.staging.open-eidas.eu + # Voir le commentaire de ca.rootCommonName. + subject: + commonName: "Open eIDAS STAGING OCSP Responder 1"