Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bin/ca-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
133 changes: 131 additions & 2 deletions bin/ca-server/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ pub struct Server {
crl_path: String,
ca_path: String,
cache: RwLock<CrlCache>,
/// 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 {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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::<Vec<_>>()
.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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

/// 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#"<section class="cert">
<h2>{role}</h2>
<dl>
<dt>Sujet</dt><dd>{subject}</dd>
<dt>Émetteur</dt><dd>{issuer_dn}</dd>
<dt>Numéro de série</dt><dd><code>{serial}</code></dd>
<dt>Validité</dt><dd>{not_before} → {not_after}</dd>
<dt>Empreinte SHA-256</dt><dd><code>{fingerprint}</code></dd>
</dl>
</section>
"#,
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#"<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dépôt de l'autorité de certification</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 720px; margin: 2rem auto; padding: 0 1.25rem; line-height: 1.5; color: #0f172a; }}
h1 {{ font-size: 1.4rem; }}
section.cert {{ border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.25rem; margin: 1rem 0; }}
dl {{ display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 1rem; margin: 0; }}
dt {{ color: #64748b; }}
dd {{ margin: 0; word-break: break-all; }}
code {{ background: #f8fafc; padding: 0.1rem 0.35rem; border-radius: 4px; }}
ul {{ padding-left: 1.25rem; }}
.warn {{ background: #fffbeb; border-left: 4px solid #b45309; padding: 0.75rem 1rem; border-radius: 6px; font-size: 0.9rem; }}
</style>
</head>
<body>
<h1>Dépôt public de l'autorité de certification</h1>
<p class="warn">
Vérifiez le <strong>Sujet</strong> 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).
</p>
{certs_html}
<h2>Téléchargements</h2>
<ul>
<li><a href="{ca_path}">Certificat de l'autorité émettrice (DER)</a></li>
<li><a href="{crl_path}">Liste de révocation (CRL)</a></li>
<li><a href="/api/v1/ca.pem">Chaîne complète (PEM)</a></li>
<li><a href="/api/v1/conformance">Matrice de conformité ETSI</a></li>
</ul>
</body>
</html>
"#,
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");
Expand Down Expand Up @@ -168,6 +286,7 @@ pub fn router(server: Arc<Server>, 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))
Expand Down Expand Up @@ -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<Arc<Server>>) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
server.repository_html.clone(),
)
}

async fn handle_ca_pem(State(server): State<Arc<Server>>) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/x-pem-file")],
Expand Down
34 changes: 34 additions & 0 deletions bin/ca-server/tests/crl_publication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
14 changes: 14 additions & 0 deletions deploy/helm/open-eidas/values-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,10 +59,21 @@ 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.
publicURL: "https://ocsp.staging.open-eidas.eu"
gateway:
enabled: true
host: ocsp.staging.open-eidas.eu
# Voir le commentaire de ca.rootCommonName.
subject:
commonName: "Open eIDAS STAGING OCSP Responder 1"
Loading