diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9591347..6cac08c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,13 +50,18 @@ jobs: - name: cargo clippy run: cargo clippy --workspace --all-targets -- -D warnings - # Les tests qui exigent SoftHSM2/PostgreSQL réels (feature `postgres`, - # crates/oe-hsm/tests/pkcs11_integration.rs) sont explicitement ignorés - # par défaut ou gardés par une variable d'environnement absente en CI ; - # ce job couvre donc la logique métier, pas l'intégration matérielle — - # celle-ci est revalidée par les jobs de démonstration (kind, compose) - # plus bas, qui tournent en conditions réelles. + # SoftHSM2 pour le test du binaire tsa-server (bin/tsa-server/tests/serve.rs), + # que la matrice de conformité cite comme preuve de mise en service + # (constat D-2) : OE_REQUIRE_SOFTHSM le fait échouer, plutôt que s'ignorer, + # si SoftHSM venait à manquer. Les tests PostgreSQL restent gardés par + # OE_CASTORE_TEST_DSN, absente ici ; l'intégration complète est revalidée + # par les jobs de démonstration (kind, compose) plus bas. + - name: Installe SoftHSM2 + run: sudo apt-get update && sudo apt-get install -y softhsm2 + - name: cargo test + env: + OE_REQUIRE_SOFTHSM: "1" run: cargo test --workspace # Suites PostgreSQL, gardées par OE_CASTORE_TEST_DSN : schéma du registre, diff --git a/Cargo.lock b/Cargo.lock index 6936d54..9093fb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3401,9 +3401,12 @@ name = "tsa-server" version = "0.1.0" dependencies = [ "axum", + "base64 0.22.1", "clap", "der 0.8.2", "oe-audit", + "oe-ca-core", + "oe-castore", "oe-certs", "oe-config", "oe-conformance", @@ -3412,6 +3415,7 @@ dependencies = [ "oe-httpapi", "oe-timesource", "oe-tsa-core", + "reqwest", "serde_json", "time", "tokio", diff --git a/README.md b/README.md index d942d77..f5c6bd9 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,12 @@ Ce dépôt héberge le prototype **fonctionnel et vérifiable** du premier servi et réponses OCSP — qui délivre le certificat de l'unité d'horodatage par enrôlement automatisé ; - une **matrice de conformité ETSI** générée depuis le code - ([docs/CONFORMITE-ETSI.md](docs/CONFORMITE-ETSI.md)) : chaque exigence - applicable est portée par un mécanisme identifié et un test exécutable, ou - déclarée comme écart avec sa cible — la CI échoue si une ligne perd l'un - des deux ; + ([docs/CONFORMITE-ETSI.md](docs/CONFORMITE-ETSI.md)), qui cite la version + de chaque norme et ses identifiants d'exigence : une exigence n'y est + « couverte » que si un binaire en service l'applique (test du binaire ou + job de démonstration réelle), sinon elle est déclarée « implémentée, pas en + service » ou « écart » avec sa cible — la CI échoue si une ligne perd sa + preuve ou cite un test introuvable ou ignoré ; - un **HSM logiciel SoftHSM2** parlant le protocole d'un HSM certifié, pour que le passage en production soit un changement de configuration, pas de code ; - une **heure traçable jusqu'à UTC** : le service recoupe deux serveurs de diff --git a/bin/ca-server/src/http.rs b/bin/ca-server/src/http.rs index 31234a5..5bbde18 100644 --- a/bin/ca-server/src/http.rs +++ b/bin/ca-server/src/http.rs @@ -549,6 +549,7 @@ async fn handle_conformance(State(server): State>) -> impl IntoRespo "statut": e.status.label(), "mecanisme": e.mechanism, "test": e.test, + "en_service": e.in_service, "cible": e.target, }) }) diff --git a/bin/ca-server/tests/bootstrap_cli.rs b/bin/ca-server/tests/bootstrap_cli.rs index da4b8c9..9739516 100644 --- a/bin/ca-server/tests/bootstrap_cli.rs +++ b/bin/ca-server/tests/bootstrap_cli.rs @@ -120,3 +120,32 @@ fn commands_that_sign_still_require_the_pin_and_the_public_address() { assert!(err.contains("OPENEIDAS_PKI_PUBLIC_URL"), "{args:?} : {err}"); } } + +/// ETSI TS 119 312 §8.4 (constat D-2 : preuve sur le binaire) : une clé +/// d'autorité de moins de 3072 bits est refusée avant tout, y compris par une +/// commande qui n'ouvre aucun token. +#[test] +fn undersized_ca_keys_are_refused_by_the_binary() { + for (bits, refused) in [("2048", true), ("3072", false)] { + let out = Command::new(env!("CARGO_BIN_EXE_ca-server")) + .args(["ra", "list"]) + // Sans DSN : une clé admise échoue ensuite sur la base, ce qui + // prouve que le contrôle de longueur est passé. + .env_remove("OPENEIDAS_DB_DSN") + .env("OPENEIDAS_CA_KEY_BITS", bits) + .output() + .expect("lancement de ca-server"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{bits} : {stderr}"); + assert_eq!( + stderr.contains("OPENEIDAS_DB_DSN"), + !refused, + "{bits} : {stderr}" + ); + assert_eq!( + stderr.contains("ETSI TS 119 312 impose au moins 3072 bits"), + refused, + "{bits} : {stderr}" + ); + } +} diff --git a/bin/tsa-server/Cargo.toml b/bin/tsa-server/Cargo.toml index 498f68b..80be47f 100644 --- a/bin/tsa-server/Cargo.toml +++ b/bin/tsa-server/Cargo.toml @@ -31,3 +31,9 @@ time = { version = "0.3", features = ["std"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } tracing-subscriber = "0.3" axum = "0.8" + +[dev-dependencies] +oe-ca-core = { path = "../../crates/oe-ca-core" } +oe-castore = { path = "../../crates/oe-castore" } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +base64 = "0.22" diff --git a/bin/tsa-server/tests/serve.rs b/bin/tsa-server/tests/serve.rs new file mode 100644 index 0000000..f144577 --- /dev/null +++ b/bin/tsa-server/tests/serve.rs @@ -0,0 +1,322 @@ +//! `tsa-server serve`, lancé pour de vrai (constat D-2 de l'audit du +//! 2026-09-25) : la matrice de conformité ne déclare « couvert » que ce que le +//! binaire en service applique, pas seulement ce qu'une bibliothèque sait +//! faire. Un vrai token SoftHSM porte la clé TSU, une CA de test émet son +//! certificat, et `openssl` relit les réponses en tiers indépendant. +//! +//! Ignoré, en le disant, si `softhsm2-util` ou `openssl` manquent — sauf si +//! `OE_REQUIRE_SOFTHSM` est posée (CI) : il échoue alors. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use der::Encode; +use oe_ca_core::ceremony::{run_ceremony, CeremonyOptions}; +use oe_ca_core::{profile, Issuer, Options as CaOptions}; +use oe_hsm::testing::SoftwareToken; +use oe_hsm::SigningToken; + +const MODULE: &str = "/usr/lib/softhsm/libsofthsm2.so"; +const TOKEN: &str = "tsa-wiring"; +const KEY: &str = "tsu-key"; +const PIN: &str = "1234"; + +fn have_tools() -> bool { + let runs = |cmd: &str, arg: &str| Command::new(cmd).arg(arg).output().is_ok(); + runs("softhsm2-util", "--version") && runs("openssl", "version") && Path::new(MODULE).exists() +} + +fn pem(label: &str, der: &[u8]) -> String { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let mut out = format!("-----BEGIN {label}-----\n"); + for chunk in b64.as_bytes().chunks(64) { + out.push_str(std::str::from_utf8(chunk).unwrap()); + out.push('\n'); + } + out.push_str(&format!("-----END {label}-----\n")); + out +} + +/// Le processus est tué à la fin du test, même en cas d'échec d'assertion. +struct Server(Child); + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +struct Fixture { + dir: PathBuf, + conf: PathBuf, +} + +impl Fixture { + fn command(&self, cert: &str, policy: &str, port: u16, audit: &str) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_tsa-server")); + cmd.arg("serve") + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("SOFTHSM2_CONF", &self.conf) + .env("OPENEIDAS_PKCS11_MODULE", MODULE) + .env("OPENEIDAS_TOKEN_LABEL", TOKEN) + .env("OPENEIDAS_KEY_LABEL", KEY) + .env("OPENEIDAS_PIN", PIN) + .env("OPENEIDAS_CERT_FILE", self.dir.join(cert)) + .env("OPENEIDAS_CHAIN_FILE", self.dir.join("chain.pem")) + .env("OPENEIDAS_AUDIT_FILE", self.dir.join(audit)) + .env("OPENEIDAS_LISTEN", format!("127.0.0.1:{port}")) + .env("OPENEIDAS_TIME_POLICY", policy) + // Aucune source joignable : l'heure n'est jamais traçable. + .env("OPENEIDAS_TIME_SOURCES", "127.0.0.1:9") + .env("OPENEIDAS_TIME_MIN_SOURCES", "1") + .env("OPENEIDAS_TIME_TIMEOUT", "200ms") + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + cmd + } + + async fn serve(&self, cert: &str, policy: &str, audit: &str) -> (Server, String) { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let child = self.command(cert, policy, port, audit).spawn().unwrap(); + let server = Server(child); + for _ in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return (server, format!("http://127.0.0.1:{port}")); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("tsa-server n'écoute pas sur {port}"); + } + + fn openssl(&self, args: &[&str]) -> Output { + Command::new("openssl") + .args(args) + .current_dir(&self.dir) + .output() + .unwrap() + } + + /// Soumet une requête RFC 3161 fabriquée par openssl, rend le texte de la + /// réponse tel qu'openssl le relit. + async fn timestamp(&self, base: &str, digest: &str, name: &str) -> String { + let query = format!("{name}.tsq"); + let reply = format!("{name}.tsr"); + let out = self.openssl(&[ + "ts", "-query", "-data", "data.txt", digest, "-cert", "-out", &query, + ]); + assert!(out.status.success(), "{out:?}"); + let body = std::fs::read(self.dir.join(&query)).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{base}/tsa")) + .header("Content-Type", "application/timestamp-query") + .body(body) + .send() + .await + .unwrap(); + std::fs::write(self.dir.join(&reply), resp.bytes().await.unwrap()).unwrap(); + let text = self.openssl(&["ts", "-reply", "-in", &reply, "-text"]); + String::from_utf8_lossy(&text.stdout).to_string() + } +} + +#[tokio::test] +async fn the_binary_applies_what_the_matrix_declares() { + if !have_tools() { + // En CI, cette preuve de mise en service ne doit jamais sauter en silence. + assert!( + std::env::var_os("OE_REQUIRE_SOFTHSM").is_none(), + "OE_REQUIRE_SOFTHSM est posée mais softhsm2-util ou openssl manque" + ); + eprintln!("softhsm2-util ou openssl absent : test du binaire tsa-server ignoré"); + return; + } + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("tsa-serve-{nanos}")); + std::fs::create_dir_all(dir.join("tokens")).unwrap(); + let conf = dir.join("softhsm.conf"); + std::fs::write( + &conf, + format!( + "directories.tokendir = {}\nobjectstore.backend = file\nlog.level = ERROR\n", + dir.join("tokens").display() + ), + ) + .unwrap(); + let init = Command::new("softhsm2-util") + .args([ + "--init-token", + "--free", + "--label", + TOKEN, + "--pin", + PIN, + "--so-pin", + "5678", + ]) + .env("SOFTHSM2_CONF", &conf) + .output() + .unwrap(); + assert!(init.status.success(), "{init:?}"); + + // La clé TSU est générée dans le token, comme en service. Ce fichier de + // test ne contient qu'un test : la variable ne fuit vers aucun autre. + std::env::set_var("SOFTHSM2_CONF", &conf); + let spki = { + let token = oe_hsm::Pkcs11Token::open(&oe_hsm::Options { + module_path: MODULE.to_string(), + token_label: TOKEN.to_string(), + key_label: KEY.to_string(), + pin: PIN.to_string(), + }) + .unwrap(); + token.generate_rsa_key(3072).unwrap(); + token.public_key_der().unwrap() + }; + + // Une CA de test émet, pour cette clé, un certificat au profil TSU et un + // autre au profil du répondeur OCSP. + let store = Arc::new(oe_castore::Memory::new()); + let issuing_signer = Arc::new(SoftwareToken::generate(3072)); + let hierarchy = run_ceremony(CeremonyOptions { + root_signer: Arc::new(SoftwareToken::generate(3072)), + issuing_signer: issuing_signer.clone(), + root_cn: "Test Root CA".to_string(), + issuing_cn: "Test Issuing CA".to_string(), + organization: "Open eIDAS Test".to_string(), + country: "FR".to_string(), + root_validity: time::Duration::days(20 * 365), + issuing_validity: time::Duration::days(10 * 365), + root_token_label: "root".to_string(), + root_key_label: "root-key".to_string(), + issuing_token_label: "issuing".to_string(), + issuing_key_label: "issuing-key".to_string(), + store: store.clone(), + operator: "test-operator".to_string(), + public_url: "https://ca.example.test".to_string(), + recorder: None, + }) + .await + .unwrap(); + let issuer = Issuer::new(CaOptions { + signer: issuing_signer, + certificate: hierarchy.issuing.clone(), + chain: vec![hierarchy.root.clone()], + store, + public_url: "https://ca.example.test".to_string(), + ocsp_url: None, + crl_validity: time::Duration::hours(24), + crl_grace: time::Duration::hours(1), + recorder: None, + }) + .unwrap(); + let tsu = issuer + .issue(&spki, "tsu.example.test", &profile::tsa_signer(), "tx-tsu") + .await + .unwrap(); + let ocsp = issuer + .issue( + &spki, + "ocsp.example.test", + &profile::ocsp_responder(), + "tx-ocsp", + ) + .await + .unwrap(); + std::fs::write( + dir.join("tsu.pem"), + pem("CERTIFICATE", &tsu.to_der().unwrap()), + ) + .unwrap(); + std::fs::write( + dir.join("ocsp.pem"), + pem("CERTIFICATE", &ocsp.to_der().unwrap()), + ) + .unwrap(); + let chain = [&hierarchy.issuing, &hierarchy.root] + .iter() + .map(|c| pem("CERTIFICATE", &c.to_der().unwrap())) + .collect::(); + std::fs::write(dir.join("chain.pem"), &chain).unwrap(); + std::fs::write(dir.join("data.txt"), "facture de test\n").unwrap(); + + let fx = Fixture { + dir: dir.clone(), + conf, + }; + + // EN 319 422 §6 : un certificat qui n'est pas au profil TSU est refusé au + // démarrage (oe_conformance::check_tsu_certificate, via Authority::new). + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let refused = fx + .command("ocsp.pem", "monitor", port, "audit-refused.log") + .output() + .unwrap(); + assert!(!refused.status.success(), "{refused:?}"); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!(stderr.contains("certificat TSU"), "{stderr}"); + + // EN 319 421 TIS-7.7.1-07 : politique enforce et heure non traçable → la + // TSU cesse d'émettre (timeNotAvailable) et /healthz le dit. + { + let (_server, base) = fx.serve("tsu.pem", "enforce", "audit-enforce.log").await; + let health = reqwest::get(format!("{base}/healthz")).await.unwrap(); + assert_eq!(health.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + let text = fx.timestamp(&base, "-sha256", "enforce").await; + assert!(text.contains("Status: Rejected"), "{text}"); + assert!(text.contains("time source is not available"), "{text}"); + } + + // Politique monitor : le jeton est émis, et openssl le vérifie (EN 319 422 + // §7, clé dans le token : EN 319 401 REQ-7.5-01). TS 119 312 §5.1 : une + // empreinte SHA-1 est refusée (badAlg). + { + let (_server, base) = fx.serve("tsu.pem", "monitor", "audit-monitor.log").await; + let text = fx.timestamp(&base, "-sha256", "granted").await; + assert!(text.contains("Status: Granted"), "{text}"); + let verify = fx.openssl(&[ + "ts", + "-verify", + "-in", + "granted.tsr", + "-queryfile", + "granted.tsq", + "-CAfile", + "chain.pem", + ]); + assert!( + verify.status.success(), + "{}{}", + String::from_utf8_lossy(&verify.stdout), + String::from_utf8_lossy(&verify.stderr) + ); + + let text = fx.timestamp(&base, "-sha1", "sha1").await; + assert!(text.contains("Status: Rejected"), "{text}"); + assert!( + text.to_lowercase() + .contains("unrecognized or unsupported algorithm"), + "{text}" + ); + } + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/oe-conformance/src/lib.rs b/crates/oe-conformance/src/lib.rs index f60fa53..d8d374f 100644 --- a/crates/oe-conformance/src/lib.rs +++ b/crates/oe-conformance/src/lib.rs @@ -128,12 +128,16 @@ impl Findings { } } -/// L'état d'une exigence pour l'ensemble du système. Trois valeurs +/// L'état d'une exigence pour l'ensemble du système. Quatre valeurs /// seulement, pour qu'aucune zone grise ne puisse s'y loger. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Status { - /// L'exigence est appliquée par du code de ce dépôt et vérifiée par un test. + /// L'exigence est appliquée par un binaire en service, vérifiée par un + /// test, et la mise en service est elle-même prouvée ([`Entry::in_service`]). Covered, + /// Le mécanisme existe et il est testé, mais aucun binaire ne l'appelle + /// (constat D-2 de l'audit du 2026-09-25) : l'exigence n'est pas satisfaite. + Implemented, /// Écart connu et assumé, avec une mesure compensatoire et une cible. Gap, /// Exigence organisationnelle, qu'aucun logiciel ne peut satisfaire seul. @@ -144,6 +148,7 @@ impl Status { pub fn label(self) -> &'static str { match self { Status::Covered => "couvert", + Status::Implemented => "implémenté, pas en service", Status::Gap => "écart documenté", Status::OutOfScope => "hors périmètre logiciel", } @@ -159,6 +164,11 @@ pub struct Entry { pub mechanism: &'static str, /// Nomme le test qui vérifie le mécanisme. Vide hors périmètre logiciel. pub test: &'static str, + /// Preuve que le mécanisme est en service (constat D-2) : un test qui + /// exerce un binaire (`bin/…`) ou une étape d'un job de démonstration + /// réelle (`.github/workflows/…`). Obligatoire pour une ligne couverte ; + /// chaque référence est résolue par `tests/references.rs`. + pub in_service: &'static str, /// Ce qui reste à faire pour lever un écart. Vide si couvert. pub target: &'static str, } @@ -171,6 +181,7 @@ impl Matrix { pub fn counts(&self) -> BTreeMap { let mut out = BTreeMap::new(); out.insert(Status::Covered, 0); + out.insert(Status::Implemented, 0); out.insert(Status::Gap, 0); out.insert(Status::OutOfScope, 0); for e in &self.0 { @@ -236,6 +247,24 @@ impl Matrix { if e.test.is_empty() { problems.push(format!("{key}: couvert mais aucun test nommé")); } + // Constat D-2 : un test de bibliothèque prouve que le + // mécanisme existe, pas qu'un binaire l'applique. + if !e + .in_service + .split(", ") + .any(|r| r.starts_with("bin/") || r.starts_with(".github/workflows/")) + { + problems.push(format!( + "{key}: couvert sans preuve de mise en service (test d'un binaire ou job de démonstration)" + )); + } + } + Status::Implemented => { + if e.mechanism.is_empty() || e.test.is_empty() || e.target.is_empty() { + problems.push(format!( + "{key}: implémenté sans mécanisme, test ou cible de mise en service" + )); + } } Status::Gap => { if e.mechanism.is_empty() { @@ -282,15 +311,17 @@ pub fn render_markdown(m: &Matrix) -> String { let counts = m.counts(); b.push_str(&format!( - "**{} exigences** — {} couvertes, {} écarts documentés, {} hors périmètre logiciel.\n\n", + "**{} exigences** — {} couvertes, {} implémentée(s) sans être en service, {} écarts documentés, {} hors périmètre logiciel.\n\n", m.0.len(), counts[&Status::Covered], + counts[&Status::Implemented], counts[&Status::Gap], counts[&Status::OutOfScope], )); - b.push_str("Trois statuts seulement, pour qu'aucune zone grise ne puisse s'y loger :\n\n"); - b.push_str("- **couvert** — l'exigence est appliquée par du code de ce dépôt et vérifiée par un test nommé ci-dessous ;\n"); + b.push_str("Quatre statuts seulement, pour qu'aucune zone grise ne puisse s'y loger :\n\n"); + b.push_str("- **couvert** — l'exigence est appliquée par un binaire en service : le test nommé vérifie le mécanisme, la preuve de mise en service (test du binaire ou job de démonstration réelle) montre qu'il est appelé ;\n"); + b.push_str("- **implémenté, pas en service** — le mécanisme existe et il est testé, mais aucun binaire ne l'appelle : l'exigence n'est pas satisfaite ;\n"); b.push_str("- **écart documenté** — l'exigence n'est pas satisfaite en l'état ; la mesure compensatoire en place et la cible sont indiquées ;\n"); b.push_str("- **hors périmètre logiciel** — exigence organisationnelle, qu'aucun code ne peut établir seul.\n\n"); @@ -314,7 +345,7 @@ pub fn render_markdown(m: &Matrix) -> String { let last = if e.status != Status::Covered { format!("**Cible :** {}", e.target) } else { - e.test.to_string() + format!("{} ; **en service :** {}", e.test, e.in_service) }; let clause = if e.requirement.ids.is_empty() { cell(e.requirement.clause) @@ -699,7 +730,8 @@ pub fn system_matrix() -> Matrix { requirement: Requirement { standard: "ETSI EN 319 401", version: "V3.2.1 (2026-01)", clause: "§7.5", ids: &["REQ-7.5-01"], title: "Gestion des clés du prestataire dans un module cryptographique" }, status: Status::Covered, mechanism: "Toutes les clés vivent dans un token PKCS#11 et n'en sortent jamais : oe-hsm::Pkcs11Token, validé contre un vrai token SoftHSM2 (crates/oe-hsm/tests/pkcs11_integration.rs).", - test: "crates/oe-hsm/tests/pkcs11_integration.rs", + test: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares)", + in_service: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares), .github/workflows/ci.yml (Horodate un fichier et vérifie le jeton (openssl ts))", target: "", }, Entry { @@ -707,6 +739,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Journal JSON Lines chaîné par SHA-256 (oe-audit), écrit avant chaque émission dans les deux services ; durée de conservation contrôlée à la configuration par oe_conformance::check_audit_retention, mais côté CA seulement (bin/ca-server::Config::load).", test: "crates/oe-audit/src/lib.rs (deux_ecrivains_partagent_la_meme_chaine), crates/oe-conformance/src/lib.rs (check_audit_retention_accepts_the_minimum, check_audit_retention_rejects_unconfigured_and_short_durations)", + in_service: "", target: "Constat J-3 (PR #52) : consigner la série du jeton et l'empreinte soumise ; contrôler aussi la durée de conservation au démarrage de tsa-server (docs/CPS.md B.3).", }, Entry { @@ -714,13 +747,15 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Chaînage par hachage vérifié intégralement à l'ouverture ; verrou de fichier partagé entre plusieurs écrivains : oe-audit::Log. Le chaînage n'a pas de clé : une réécriture complète du fichier avec recalcul de la chaîne n'est pas détectée tant que la tête n'est pas ancrée hors du système.", test: "crates/oe-audit/src/lib.rs (deux_ecrivains_partagent_la_meme_chaine, verify_detects_modified_record, verify_detects_truncated_and_rewritten_tail)", + in_service: "", target: "Constat J-1 : sceller périodiquement la tête de chaîne (TSU et TSA tierce) et en déposer une copie hors site, depuis les binaires en service.", }, Entry { requirement: Requirement { standard: "ETSI EN 319 401", version: "V3.2.1 (2026-01)", clause: "§7.11.1, §7.11.2", ids: &["REQ-7.11.1-01", "REQ-7.11.2-01"], title: "Copies de sauvegarde et plan de sauvegarde" }, - status: Status::Gap, + status: Status::Implemented, mechanism: "Contreseing du journal par une TSA tierce (oe-crosstsa) et réplication WebDAV hors site (oe-replicate) écrits et testés contre un vrai serveur, mais appelés par aucun binaire : l'intégrité et la survie du journal reposent sur le contrôle d'accès et la sauvegarde de son volume.", test: "crates/oe-crosstsa/tests/against_local_server.rs (seals_a_digest_against_a_real_rfc3161_server), crates/oe-replicate/tests/against_local_server.rs (replicates_content_via_webdav_put)", + in_service: "", target: "Constat J-1 : câbler dans tsa-server serve puis ca-server serve le scellement périodique (log.sealed), le contreseing tiers (log.cross_sealed) et la copie hors site (PR #49, #50 pour le stockage S3), testés de bout en bout sur le binaire ; dégrader /healthz sur échec prolongé.", }, Entry { @@ -728,6 +763,7 @@ pub fn system_matrix() -> Matrix { status: Status::OutOfScope, mechanism: "Procédure organisationnelle décrite dans docs/CA.md, indépendante du langage d'implémentation.", test: "", + in_service: "", target: "Engagement juridique de l'association, dépôt auprès de l'organe de contrôle, séquestre des journaux.", }, Entry { @@ -735,6 +771,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "docs/CPS.md porte un brouillon structuré, déjà indépendant du langage d'implémentation du service.", test: "", + in_service: "", target: "Adoption formelle de docs/CPS.md par l'association (organisationnel, non affecté par le portage Rust).", }, Entry { @@ -742,6 +779,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "Cérémonie produisant une racine et une CA émettrice au profil contrôlé (CA:TRUE critique, keyCertSign+cRLSign, SKI/AKI) : oe_ca_core::ceremony::run_ceremony, chaîne revérifiée par openssl.", test: "crates/oe-ca-core/tests/issuance.rs (ceremony_is_idempotent, ceremony_rejects_mismatched_signer_on_replay, openssl_accepts_the_chain_and_honors_revocation)", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -749,6 +787,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "Profils définis en structures Rust compilées, pas en configuration interprétée : oe_ca_core::profile. Contrôle de criticité (basicConstraints, keyUsage, EKU) posé à la main, vérifié par openssl.", test: "crates/oe-ca-core/tests/issuance.rs (openssl_accepts_the_chain_and_honors_revocation)", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -756,6 +795,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Aucune transition vers Approved n'existe sans identité d'opérateur : oe_raflow::Decider::approve/reject. L'identité est consignée en base et au journal d'audit.", test: "crates/oe-raflow/tests/flow.rs (decide_without_operator_identity_is_refused, approve_then_resubmit_issues_a_certificate_signed_by_the_issuing_key)", + in_service: "", target: "Constat R-2 (PR #57) : approbation automatique désactivée par défaut dans le chart Helm, refusée avec production: true.", }, Entry { @@ -763,6 +803,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "HMAC-SHA256 sur la CSR DER, vérifié en temps constant, et vérification de l'auto-signature de la CSR (preuve de possession) : oe_raflow::Flow::submit.", test: "crates/oe-raflow/tests/flow.rs (submit_without_valid_hmac_is_unauthenticated, submit_opens_a_pending_request_idempotently)", + in_service: "", target: "Constat R-3 : un secret, ou mieux une identité mTLS, par demandeur, liée aux profils qu'il peut demander ; ne révoquer au renouvellement que les certificats du même demandeur.", }, Entry { @@ -770,6 +811,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "Numéro de série de 128 bits tiré sur rand::thread_rng et réservé de façon atomique (contrainte d'unicité en base) : oe_ca_core::Issuer::reserve_serial, oe_castore::Store::reserve_serial.", test: "crates/oe-castore/src/lib.rs (reserve_serial_twice_conflicts), crates/oe-castore/tests/postgres.rs (reserve_serial_twice_conflicts)", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -777,6 +819,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "Motif RFC 5280 obligatoire à la révocation (Issuer::revoke), persisté et repris dans chaque entrée de CRL avec son extension cRLReason.", test: "crates/oe-ca-core/tests/issuance.rs (revoke_is_idempotent_and_keeps_first_reason, revoke_then_publish_crl_lists_the_certificate)", + in_service: "bin/ca-server/tests/revocation_action.rs (a_ca_operator_revokes_a_certificate_and_the_crl_carries_it)", target: "", }, Entry { @@ -784,6 +827,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "oe_ca_core::Issuer::publish_crl produit une CRL signée, republiable même vide ; bin/ca-server::http::Server republie à intervalle régulier et dégrade /healthz (503) dès que la CRL servie est périmée, plutôt que de se déclarer sain sans pouvoir dire ce qui est révoqué.", test: "crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate), bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically, healthz_degrades_when_the_published_crl_is_stale)", + in_service: "bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically, healthz_degrades_when_the_published_crl_is_stale)", target: "", }, Entry { @@ -791,6 +835,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Aucune : la CA émettrice ne porte ni CDP ni AIA vers la racine, et la racine ne publie aucune ARL.", test: "", + in_service: "", target: "Constat C-1 (PR #56) : CDP/AIA vers la racine, `ca-server authority revoke|publish-arl`, ARL servie, émission refusée par une émettrice révoquée.", }, Entry { @@ -798,6 +843,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Le répondeur OCSP ne connaît que la CRL : un numéro de série absent de la CRL est déclaré good, y compris jamais émis.", test: "", + in_service: "", target: "Constat O-1 (PR #55) : publier les numéros émis avec la CRL et répondre unknown pour un numéro absent.", }, Entry { @@ -805,6 +851,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Cérémonie scriptée et idempotente (`ca-server ceremony`), produisant un procès-verbal consigné au journal d'audit (empreintes de clés, opérateur, date) : oe_ca_core::ceremony.", test: "crates/oe-ca-core/tests/issuance.rs (every_authority_decision_is_recorded)", + in_service: "", target: "Cérémonie en double contrôle, sous témoin indépendant, sur HSM certifié, avec procès-verbal contresigné — écart organisationnel, pas seulement logiciel.", }, Entry { @@ -812,6 +859,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Actions d'opérateur signées par WebAuthn (clé attestée, rôle lu dans le registre, quorum pour la révocation) : oe-actions, derrière le lien interne mTLS de ca-server. Le CLI de secours (ra approve|reject, revoke) reste ouvert sans second facteur.", test: "", + in_service: "", target: "Constats R-1/R-2 : relayer les actions signées depuis ra-console (docs/WEBUI.md §15, étape 3) ; tracer et revoir la voie de secours CLI (PR #58, #59) ; approbation automatique désactivée par défaut (PR #57).", }, Entry { @@ -819,6 +867,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "Surveillance NTP multi-sources avec quorum, seuil de dérive (MaxOffset) et péremption (MaxAge) ; la politique enforce fait refuser chaque demande avec timeNotAvailable : oe_timesource::Monitor.", test: "crates/oe-timesource/src/lib.rs (now_refuses_untraceable_time_in_enforce_mode, now_allows_untraceable_time_in_monitor_mode, new_rejects_quorum_larger_than_source_count), crates/oe-tsa-core/src/lib.rs (test_timestamp_refuses_when_time_is_not_traceable)", + in_service: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares)", target: "", }, Entry { @@ -826,6 +875,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "genTime est tronqué à la seconde alors que la dérive tolérée atteint OPENEIDAS_TIME_MAX_OFFSET : l'écart réel peut dépasser l'exactitude annoncée.", test: "", + in_service: "", target: "Constat T-1 (PR #53) : genTime à la milliseconde, et refus de démarrer si accuracy < max_offset + résolution.", }, Entry { @@ -833,6 +883,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Plusieurs sources UTC(k) avec quorum et seuil de dérive (oe_timesource::Monitor), mais client NTP sans contrôle d'origine, de l'indicateur de seconde intercalaire ni NTS.", test: "", + in_service: "", target: "Constat T-2 : contrôler l'origine et le LI des réponses NTP, envisager NTS (RFC 8915).", }, Entry { @@ -840,13 +891,15 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Aucune : la seconde intercalaire n'est ni détectée ni consignée.", test: "", + in_service: "", target: "Constat T-4 : détecter l'annonce (LI), consigner l'instant du changement au journal.", }, Entry { requirement: Requirement { standard: "ETSI EN 319 421", version: "V1.3.1 (2025-07)", clause: "§7.6.2", ids: &["TIS-7.6.2-03"], title: "Génération de la clé TSU dans le module cryptographique" }, status: Status::Gap, mechanism: "La bi-clé est générée dans le token PKCS#11 (oe_hsm::Pkcs11Token::generate_rsa_key) et ne manipule qu'un SigningToken ; la clé privée n'est jamais extraite.", - test: "crates/oe-hsm/tests/pkcs11_integration.rs", + test: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares)", + in_service: "", target: "Constat H-1 : vérifier au chargement que la clé a été générée dans le module (CKA_LOCAL, CKA_NEVER_EXTRACTABLE).", }, Entry { @@ -854,6 +907,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "oe_conformance::check_certificate_lifetime relit la validité du certificat réellement signé et la compare à un plafond indépendant du profil (MAX_END_ENTITY_LIFETIME/MAX_OCSP_LIFETIME) ; appelé via le champ Profile::check de oe_ca_core::Issuer::issue, comme profile.Check (Go).", test: "crates/oe-conformance/src/lib.rs (check_certificate_lifetime_accepts_within_the_ceiling, check_certificate_lifetime_rejects_beyond_the_ceiling), crates/oe-conformance/tests/tsu_certificate.rs", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -861,6 +915,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Validité du certificat TSU contrôlée au démarrage seulement ; aucune date d'expiration de clé définie.", test: "", + in_service: "", target: "Constat T-3 (PR #54) : privateKeyUsagePeriod posé à l'émission, contrôlé à chaque signature ; nouvelle clé à chaque renouvellement.", }, Entry { @@ -868,6 +923,7 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "Le journal consigne genTime et la série du certificat TSU, pas celle du jeton : les jetons émis ne sont pas identifiables un par un.", test: "", + in_service: "", target: "Constat J-3 (PR #52) : consigner la série du jeton, l'empreinte soumise et l'état de l'horloge.", }, Entry { @@ -875,6 +931,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "oe_conformance::check_tsu_certificate (id-kp-timeStamping seul et critique, CA:FALSE, keyUsage restreint, durée de vie plafonnée) est appliquée à l'émission (Profile::check) ET re-contrôlée au démarrage de tsa-server (oe_tsa_core::Authority::new) — un certificat chargé depuis le disque peut venir d'ailleurs.", test: "crates/oe-conformance/tests/tsu_certificate.rs (accepts_a_certificate_issued_with_the_tsa_signer_profile, rejects_a_certificate_issued_with_the_ocsp_responder_profile)", + in_service: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares), .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -882,13 +939,15 @@ pub fn system_matrix() -> Matrix { status: Status::Gap, mechanism: "TSTInfo complet (politique, imprint, série, genTime UTC, précision), assemblé en CMS SignedData signé par le token : oe_rfc3161_asn1, oe_tsa_core::Authority::timestamp.", test: "crates/oe-tsa-core/tests/end_to_end.rs (produces_tokens_accepted_by_openssl_for_every_granted_case_in_the_corpus)", + in_service: "", target: "Constat T-1 (PR #53) : genTime avec fraction de seconde (§5.2.2), cohérent avec la précision annoncée.", }, Entry { requirement: Requirement { standard: "ETSI EN 319 422", version: "V1.1.1 (2016-03)", clause: "§7", ids: &[], title: "Protocole d'horodatage RFC 3161 sur HTTP" }, status: Status::Covered, mechanism: "Endpoint /tsa acceptant application/timestamp-query, refus protocolaires rendus en TimeStampResp valides : oe_httpapi, bin/tsa-server.", - test: "crates/oe-httpapi/tests/end_to_end.rs (serves_a_verifiable_token_over_http, vérification croisée openssl ts -verify)", + test: "crates/oe-httpapi/tests/end_to_end.rs (serves_a_verifiable_token_over_http)", + in_service: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares), .github/workflows/ci.yml (Horodate un fichier et vérifie le jeton (openssl ts))", target: "", }, Entry { @@ -896,6 +955,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "oe-config et bin/ca-server/src/config.rs imposent OPENEIDAS_KEY_BITS >= 3072 à la configuration (clé des autorités elles-mêmes) ; oe_raflow::parse_and_verify_csr applique la même exigence à la clé publique portée par une CSR soumise à l'enrôlement.", test: "crates/oe-config/src/lib.rs (load_fails_on_undersized_key_bits), crates/oe-raflow/tests/flow.rs (submit_rejects_a_csr_with_an_undersized_key)", + in_service: "bin/ca-server/tests/bootstrap_cli.rs (undersized_ca_keys_are_refused_by_the_binary)", target: "", }, Entry { @@ -903,6 +963,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "oe_conformance::check_signature_algorithm vérifie explicitement l'OID de signature d'un certificat contre la liste des suites admises (SHA-256/384/512 avec RSA), appelée via Profile::check à l'émission et à la re-vérification.", test: "crates/oe-conformance/src/lib.rs (check_signature_algorithm_accepts_sha256_with_rsa, check_signature_algorithm_rejects_sha1)", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -910,6 +971,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "oe_hsm::DigestAlg restreint la signature à SHA-256/384/512 ; une empreinte SHA-1 est refusée avec le failureInfo RFC 3161 badAlg : oe_tsa_core::Authority::timestamp.", test: "crates/oe-tsa-core/src/lib.rs (test_timestamp_rejects_sha1)", + in_service: "bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares)", target: "", }, Entry { @@ -917,13 +979,15 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "Répondeur OCSP RFC 6960 s'appuyant sur la CRL publiée par la CA : oe_ocsp_core::Responder.", test: "crates/oe-ocsp-core/tests/against_real_crl.rs (reports_good_status_for_a_non_revoked_certificate, reports_revoked_status_for_a_revoked_certificate)", + in_service: ".github/workflows/ci.yml (Interroge le répondeur OCSP pour le certificat TSU)", target: "", }, Entry { requirement: Requirement { standard: "RFC 6960", version: "", clause: "§4.2.2.2", ids: &[], title: "Profil du certificat de signature du répondeur OCSP" }, status: Status::Covered, mechanism: "Profil ocsp_responder (id-pkix-ocsp-nocheck, pas de CDP/AIA, durée de vie courte) appliqué à l'émission, re-contrôlé après signature par oe_conformance::check_ocsp_responder_certificate (Profile::check).", - test: "crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate, qui émet avec ce profil), crates/oe-conformance/tests/tsu_certificate.rs (rejects_a_certificate_issued_with_the_ocsp_responder_profile, qui prouve que check_ocsp_responder_certificate distingue bien ce profil de tsa_signer)", + test: "crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate), crates/oe-conformance/tests/tsu_certificate.rs (rejects_a_certificate_issued_with_the_ocsp_responder_profile)", + in_service: ".github/workflows/ci.yml (Interroge le répondeur OCSP pour le certificat TSU)", target: "", }, Entry { @@ -931,6 +995,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "subjectKeyIdentifier (SHA-1 de la clé, méthode 1) et authorityKeyIdentifier (pointant vers le SKI de l'émetteur) posés sans condition à l'émission et dans la cérémonie : oe_ca_core::extensions, oe_ca_core::signing::subject_key_id.", test: "crates/oe-ca-core/tests/issuance.rs (issue_produces_a_certificate_signed_by_the_issuing_key, assert_ski_and_aki_present_and_linked)", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl)", target: "", }, Entry { @@ -938,6 +1003,7 @@ pub fn system_matrix() -> Matrix { status: Status::Covered, mechanism: "CRL régénérée avec cRLNumber, thisUpdate/nextUpdate et signature, republiée même vide : oe_ca_core::Issuer::publish_crl. La signature et le motif de révocation sont revérifiés par openssl.", test: "crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate, openssl_accepts_the_chain_and_honors_revocation)", + in_service: ".github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl), bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically)", target: "", }, Entry { @@ -945,6 +1011,7 @@ pub fn system_matrix() -> Matrix { status: Status::OutOfScope, mechanism: "Le dépôt est intégralement public ; cette matrice fournit le point d'entrée d'un audit, indépendamment du langage d'implémentation.", test: "", + in_service: "", target: "Audit par un organisme accrédité (LSTI, Apave), puis inscription à la liste de confiance nationale.", }, ]) @@ -1043,6 +1110,7 @@ mod tests { status: Status::Covered, mechanism: "", test: "", + in_service: "", target: "", }]); assert!(bad.validate().is_err()); @@ -1054,6 +1122,7 @@ mod tests { status: Status::Covered, mechanism: "m", test: "t", + in_service: "bin/x/tests/y.rs (z)", target: "", } } @@ -1099,6 +1168,15 @@ mod tests { .unwrap_err(); assert!(err.contains("deux versions"), "{err}"); + // Constat D-2 : couvert exige une preuve de mise en service par un + // binaire ; un test de bibliothèque ne suffit pas. + let library_only = Entry { + in_service: "crates/oe-audit/src/lib.rs (t)", + ..covered(OK) + }; + let err = Matrix(vec![library_only]).validate().unwrap_err(); + assert!(err.contains("sans preuve de mise en service"), "{err}"); + // Une RFC ne porte ni version ETSI ni identifiant. let rfc = Requirement { standard: "RFC 5280", diff --git a/crates/oe-conformance/tests/references.rs b/crates/oe-conformance/tests/references.rs new file mode 100644 index 0000000..6e24b99 --- /dev/null +++ b/crates/oe-conformance/tests/references.rs @@ -0,0 +1,185 @@ +//! Constat D-2 de l'audit du 2026-09-25 : la matrice ne doit citer que des +//! preuves qui existent et qui s'exécutent. Chaque référence des champs +//! `test` et `in_service` de `system_matrix()` est résolue dans le dépôt : +//! le fichier existe, chaque fonction citée y est définie et n'est pas +//! `#[ignore]`, chaque étape de job citée existe dans le workflow. Une +//! matrice qui citerait un test renommé, supprimé ou ignoré ne passe plus. + +use std::path::{Path, PathBuf}; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Une référence : un chemin, et ce qu'on y cite (fonctions ou étape). +#[derive(Debug, PartialEq)] +struct Reference { + path: String, + items: Vec, +} + +/// Découpe `a.rs (f, g), b.yml (Étape (détail))` en références, en tenant +/// compte des parenthèses imbriquées d'un nom d'étape. +fn parse(field: &str) -> Vec { + let mut refs = Vec::new(); + let mut depth = 0usize; + let mut path = String::new(); + let mut inner = String::new(); + let mut chars = field.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '(' => { + if depth > 0 { + inner.push(c); + } + depth += 1; + } + ')' => { + depth -= 1; + if depth > 0 { + inner.push(c); + } + } + ',' if depth == 0 && chars.peek() == Some(&' ') => { + chars.next(); + refs.push(finish(&path, &inner)); + path.clear(); + inner.clear(); + } + _ if depth > 0 => inner.push(c), + _ => path.push(c), + } + } + if !path.trim().is_empty() { + refs.push(finish(&path, &inner)); + } + refs +} + +fn finish(path: &str, inner: &str) -> Reference { + let path = path.trim().to_string(); + let items = if inner.is_empty() { + Vec::new() + } else if path.ends_with(".rs") { + inner.split(", ").map(|s| s.trim().to_string()).collect() + } else { + // Un nom d'étape de workflow peut contenir des virgules : un seul élément. + vec![inner.trim().to_string()] + }; + Reference { path, items } +} + +/// `Some(true)` si la fonction existe et n'est pas ignorée, `Some(false)` si +/// elle est ignorée, `None` si elle n'existe pas. +fn rust_fn_runs(source: &str, name: &str) -> Option { + let lines: Vec<&str> = source.lines().collect(); + let pos = lines.iter().position(|l| { + let l = l.trim_start(); + [format!("fn {name}("), format!("fn {name}<")] + .iter() + .any(|sig| { + l.starts_with(sig.as_str()) + || l.starts_with(&format!("async {sig}")) + || l.starts_with(&format!("pub {sig}")) + || l.starts_with(&format!("pub async {sig}")) + }) + })?; + let attributes = lines[..pos] + .iter() + .rev() + .take_while(|l| { + let t = l.trim_start(); + t.starts_with("#[") || t.starts_with("///") || t.starts_with("//") + }) + .any(|l| l.contains("#[ignore")); + Some(!attributes) +} + +fn check(field_name: &str, key: &str, field: &str, problems: &mut Vec) { + for r in parse(field) { + let file = root().join(&r.path); + let Ok(source) = std::fs::read_to_string(&file) else { + problems.push(format!( + "{key} ({field_name}) : fichier introuvable {}", + r.path + )); + continue; + }; + if r.path.ends_with(".rs") { + if r.items.is_empty() && source.contains("#[ignore") { + problems.push(format!( + "{key} ({field_name}) : {} contient des tests ignorés, citer ceux qui s'exécutent", + r.path + )); + } + for item in &r.items { + match rust_fn_runs(&source, item) { + Some(true) => {} + Some(false) => problems.push(format!( + "{key} ({field_name}) : {}::{item} est #[ignore]", + r.path + )), + None => problems.push(format!( + "{key} ({field_name}) : {}::{item} introuvable", + r.path + )), + } + } + } else { + for item in &r.items { + let step = format!("- name: {item}"); + if !source.lines().any(|l| l.trim() == step) { + problems.push(format!( + "{key} ({field_name}) : étape « {item} » introuvable dans {}", + r.path + )); + } + } + } + } +} + +#[test] +fn every_cited_test_and_in_service_proof_exists_and_runs() { + let mut problems = Vec::new(); + for e in &oe_conformance::system_matrix().0 { + let key = e.requirement.to_string(); + check("test", &key, e.test, &mut problems); + check("in_service", &key, e.in_service, &mut problems); + } + assert!( + problems.is_empty(), + "références de la matrice non résolues :\n{}", + problems.join("\n") + ); +} + +#[test] +fn references_are_parsed_with_nested_parentheses() { + assert_eq!( + parse("a/b.rs (f, g), .github/workflows/ci.yml (Horodate (openssl ts)), c.rs"), + vec![ + Reference { + path: "a/b.rs".into(), + items: vec!["f".into(), "g".into()] + }, + Reference { + path: ".github/workflows/ci.yml".into(), + items: vec!["Horodate (openssl ts)".into()] + }, + Reference { + path: "c.rs".into(), + items: vec![] + }, + ] + ); +} + +#[test] +fn an_ignored_or_missing_function_is_reported() { + let source = + "#[test]\n#[ignore = \"x\"]\nfn skipped() {}\n\n#[tokio::test]\nasync fn runs() {}\n"; + assert_eq!(rust_fn_runs(source, "skipped"), Some(false)); + assert_eq!(rust_fn_runs(source, "runs"), Some(true)); + assert_eq!(rust_fn_runs(source, "absent"), None); +} diff --git a/docs/CONFORMITE-ETSI.md b/docs/CONFORMITE-ETSI.md index 86382a1..8d1c4f2 100644 --- a/docs/CONFORMITE-ETSI.md +++ b/docs/CONFORMITE-ETSI.md @@ -5,11 +5,12 @@ correction se fait dans le code, pour que la matrice publiée reste celle que le système applique réellement. --> -**36 exigences** — 17 couvertes, 17 écarts documentés, 2 hors périmètre logiciel. +**36 exigences** — 17 couvertes, 1 implémentée(s) sans être en service, 16 écarts documentés, 2 hors périmètre logiciel. -Trois statuts seulement, pour qu'aucune zone grise ne puisse s'y loger : +Quatre statuts seulement, pour qu'aucune zone grise ne puisse s'y loger : -- **couvert** — l'exigence est appliquée par du code de ce dépôt et vérifiée par un test nommé ci-dessous ; +- **couvert** — l'exigence est appliquée par un binaire en service : le test nommé vérifie le mécanisme, la preuve de mise en service (test du binaire ou job de démonstration réelle) montre qu'il est appelé ; +- **implémenté, pas en service** — le mécanisme existe et il est testé, mais aucun binaire ne l'appelle : l'exigence n'est pas satisfaite ; - **écart documenté** — l'exigence n'est pas satisfaite en l'état ; la mesure compensatoire en place et la cible sont indiquées ; - **hors périmètre logiciel** — exigence organisationnelle, qu'aucun code ne peut établir seul. @@ -17,10 +18,10 @@ Trois statuts seulement, pour qu'aucune zone grise ne puisse s'y loger : | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §7.5 — REQ-7.5-01 | Gestion des clés du prestataire dans un module cryptographique | couvert | Toutes les clés vivent dans un token PKCS#11 et n'en sortent jamais : oe-hsm::Pkcs11Token, validé contre un vrai token SoftHSM2 (crates/oe-hsm/tests/pkcs11_integration.rs). | crates/oe-hsm/tests/pkcs11_integration.rs | +| §7.5 — REQ-7.5-01 | Gestion des clés du prestataire dans un module cryptographique | couvert | Toutes les clés vivent dans un token PKCS#11 et n'en sortent jamais : oe-hsm::Pkcs11Token, validé contre un vrai token SoftHSM2 (crates/oe-hsm/tests/pkcs11_integration.rs). | bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares) ; **en service :** bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares), .github/workflows/ci.yml (Horodate un fichier et vérifie le jeton (openssl ts)) | | §7.10 — REQ-7.10-01, REQ-7.10-07 | Journalisation des événements et durée de conservation | écart documenté | Journal JSON Lines chaîné par SHA-256 (oe-audit), écrit avant chaque émission dans les deux services ; durée de conservation contrôlée à la configuration par oe_conformance::check_audit_retention, mais côté CA seulement (bin/ca-server::Config::load). | **Cible :** Constat J-3 (PR #52) : consigner la série du jeton et l'empreinte soumise ; contrôler aussi la durée de conservation au démarrage de tsa-server (docs/CPS.md B.3). | | §7.10 — REQ-7.10-02, REQ-7.10-08 | Intégrité démontrable des enregistrements d'audit | écart documenté | Chaînage par hachage vérifié intégralement à l'ouverture ; verrou de fichier partagé entre plusieurs écrivains : oe-audit::Log. Le chaînage n'a pas de clé : une réécriture complète du fichier avec recalcul de la chaîne n'est pas détectée tant que la tête n'est pas ancrée hors du système. | **Cible :** Constat J-1 : sceller périodiquement la tête de chaîne (TSU et TSA tierce) et en déposer une copie hors site, depuis les binaires en service. | -| §7.11.1, §7.11.2 — REQ-7.11.1-01, REQ-7.11.2-01 | Copies de sauvegarde et plan de sauvegarde | écart documenté | Contreseing du journal par une TSA tierce (oe-crosstsa) et réplication WebDAV hors site (oe-replicate) écrits et testés contre un vrai serveur, mais appelés par aucun binaire : l'intégrité et la survie du journal reposent sur le contrôle d'accès et la sauvegarde de son volume. | **Cible :** Constat J-1 : câbler dans tsa-server serve puis ca-server serve le scellement périodique (log.sealed), le contreseing tiers (log.cross_sealed) et la copie hors site (PR #49, #50 pour le stockage S3), testés de bout en bout sur le binaire ; dégrader /healthz sur échec prolongé. | +| §7.11.1, §7.11.2 — REQ-7.11.1-01, REQ-7.11.2-01 | Copies de sauvegarde et plan de sauvegarde | implémenté, pas en service | Contreseing du journal par une TSA tierce (oe-crosstsa) et réplication WebDAV hors site (oe-replicate) écrits et testés contre un vrai serveur, mais appelés par aucun binaire : l'intégrité et la survie du journal reposent sur le contrôle d'accès et la sauvegarde de son volume. | **Cible :** Constat J-1 : câbler dans tsa-server serve puis ca-server serve le scellement périodique (log.sealed), le contreseing tiers (log.cross_sealed) et la copie hors site (PR #49, #50 pour le stockage S3), testés de bout en bout sur le binaire ; dégrader /healthz sur échec prolongé. | | §7.12 — REQ-7.12-02, REQ-7.12-10 | Plan de cessation d'activité | hors périmètre logiciel | Procédure organisationnelle décrite dans docs/CA.md, indépendante du langage d'implémentation. | **Cible :** Engagement juridique de l'association, dépôt auprès de l'organe de contrôle, séquestre des journaux. | | §6.1 — REQ-6.1-01, REQ-6.1-03, REQ-6.1-05 | Politique de service et déclaration des pratiques publiées | écart documenté | docs/CPS.md porte un brouillon structuré, déjà indépendant du langage d'implémentation du service. | **Cible :** Adoption formelle de docs/CPS.md par l'association (organisationnel, non affecté par le portage Rust). | @@ -34,13 +35,13 @@ Trois statuts seulement, pour qu'aucune zone grise ne puisse s'y loger : | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §6.6.1 — GEN-6.6.1-01 | Profil du certificat d'autorité de certification | couvert | Cérémonie produisant une racine et une CA émettrice au profil contrôlé (CA:TRUE critique, keyCertSign+cRLSign, SKI/AKI) : oe_ca_core::ceremony::run_ceremony, chaîne revérifiée par openssl. | crates/oe-ca-core/tests/issuance.rs (ceremony_is_idempotent, ceremony_rejects_mismatched_signer_on_replay, openssl_accepts_the_chain_and_honors_revocation) | -| §6.6.1 — GEN-6.6.1-02 | Certificats émis selon le profil applicable | couvert | Profils définis en structures Rust compilées, pas en configuration interprétée : oe_ca_core::profile. Contrôle de criticité (basicConstraints, keyUsage, EKU) posé à la main, vérifié par openssl. | crates/oe-ca-core/tests/issuance.rs (openssl_accepts_the_chain_and_honors_revocation) | +| §6.6.1 — GEN-6.6.1-01 | Profil du certificat d'autorité de certification | couvert | Cérémonie produisant une racine et une CA émettrice au profil contrôlé (CA:TRUE critique, keyCertSign+cRLSign, SKI/AKI) : oe_ca_core::ceremony::run_ceremony, chaîne revérifiée par openssl. | crates/oe-ca-core/tests/issuance.rs (ceremony_is_idempotent, ceremony_rejects_mismatched_signer_on_replay, openssl_accepts_the_chain_and_honors_revocation) ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | +| §6.6.1 — GEN-6.6.1-02 | Certificats émis selon le profil applicable | couvert | Profils définis en structures Rust compilées, pas en configuration interprétée : oe_ca_core::profile. Contrôle de criticité (basicConstraints, keyUsage, EKU) posé à la main, vérifié par openssl. | crates/oe-ca-core/tests/issuance.rs (openssl_accepts_the_chain_and_honors_revocation) ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | | §6.3.2, §6.4.5 — REG-6.3.2-00B, REG-6.4.5-03, REG-6.4.5-04 | Enregistrement et responsabilité de la décision d'émission | écart documenté | Aucune transition vers Approved n'existe sans identité d'opérateur : oe_raflow::Decider::approve/reject. L'identité est consignée en base et au journal d'audit. | **Cible :** Constat R-2 (PR #57) : approbation automatique désactivée par défaut dans le chart Helm, refusée avec production: true. | | §6.3.2 — REG-6.3.2-01 | Authentification de la demande de certificat | écart documenté | HMAC-SHA256 sur la CSR DER, vérifié en temps constant, et vérification de l'auto-signature de la CSR (preuve de possession) : oe_raflow::Flow::submit. | **Cible :** Constat R-3 : un secret, ou mieux une identité mTLS, par demandeur, liée aux profils qu'il peut demander ; ne révoquer au renouvellement que les certificats du même demandeur. | -| §6.3.3 — GEN-6.3.3-02A | Numéro de série aléatoire | couvert | Numéro de série de 128 bits tiré sur rand::thread_rng et réservé de façon atomique (contrainte d'unicité en base) : oe_ca_core::Issuer::reserve_serial, oe_castore::Store::reserve_serial. | crates/oe-castore/src/lib.rs (reserve_serial_twice_conflicts), crates/oe-castore/tests/postgres.rs (reserve_serial_twice_conflicts) | -| §6.4.5 — REV-6.4.5-09 | Demandes de révocation et motif consignés | couvert | Motif RFC 5280 obligatoire à la révocation (Issuer::revoke), persisté et repris dans chaque entrée de CRL avec son extension cRLReason. | crates/oe-ca-core/tests/issuance.rs (revoke_is_idempotent_and_keeps_first_reason, revoke_then_publish_crl_lists_the_certificate) | -| §6.3.9 — CSS-6.3.9-05, CSS-6.3.9-06 | Publication de la CRL au moins toutes les 24 heures, avec nextUpdate | couvert | oe_ca_core::Issuer::publish_crl produit une CRL signée, republiable même vide ; bin/ca-server::http::Server republie à intervalle régulier et dégrade /healthz (503) dès que la CRL servie est périmée, plutôt que de se déclarer sain sans pouvoir dire ce qui est révoqué. | crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate), bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically, healthz_degrades_when_the_published_crl_is_stale) | +| §6.3.3 — GEN-6.3.3-02A | Numéro de série aléatoire | couvert | Numéro de série de 128 bits tiré sur rand::thread_rng et réservé de façon atomique (contrainte d'unicité en base) : oe_ca_core::Issuer::reserve_serial, oe_castore::Store::reserve_serial. | crates/oe-castore/src/lib.rs (reserve_serial_twice_conflicts), crates/oe-castore/tests/postgres.rs (reserve_serial_twice_conflicts) ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | +| §6.4.5 — REV-6.4.5-09 | Demandes de révocation et motif consignés | couvert | Motif RFC 5280 obligatoire à la révocation (Issuer::revoke), persisté et repris dans chaque entrée de CRL avec son extension cRLReason. | crates/oe-ca-core/tests/issuance.rs (revoke_is_idempotent_and_keeps_first_reason, revoke_then_publish_crl_lists_the_certificate) ; **en service :** bin/ca-server/tests/revocation_action.rs (a_ca_operator_revokes_a_certificate_and_the_crl_carries_it) | +| §6.3.9 — CSS-6.3.9-05, CSS-6.3.9-06 | Publication de la CRL au moins toutes les 24 heures, avec nextUpdate | couvert | oe_ca_core::Issuer::publish_crl produit une CRL signée, republiable même vide ; bin/ca-server::http::Server republie à intervalle régulier et dégrade /healthz (503) dès que la CRL servie est périmée, plutôt que de se déclarer sain sans pouvoir dire ce qui est révoqué. | crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate), bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically, healthz_degrades_when_the_published_crl_is_stale) ; **en service :** bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically, healthz_degrades_when_the_published_crl_is_stale) | | §6.3.9, §6.3.10 — CSS-6.3.9-12, CSS-6.3.9-13, CSS-6.3.10-01 | Révocation d'une autorité et ARL | écart documenté | Aucune : la CA émettrice ne porte ni CDP ni AIA vers la racine, et la racine ne publie aucune ARL. | **Cible :** Constat C-1 (PR #56) : CDP/AIA vers la racine, `ca-server authority revoke\|publish-arl`, ARL servie, émission refusée par une émettrice révoquée. | | §6.6.3 — OVR-6.6.3-02 | Statut OCSP d'un certificat jamais émis | écart documenté | Le répondeur OCSP ne connaît que la CRL : un numéro de série absent de la CRL est déclaré good, y compris jamais émis. | **Cible :** Constat O-1 (PR #55) : publier les numéros émis avec la CRL et répondre unknown pour un numéro absent. | | §6.5.1 — GEN-6.5.1-04, GEN-6.5.1-11, GEN-6.5.1-13 | Cérémonie de génération des clés d'autorité | écart documenté | Cérémonie scriptée et idempotente (`ca-server ceremony`), produisant un procès-verbal consigné au journal d'audit (empreintes de clés, opérateur, date) : oe_ca_core::ceremony. | **Cible :** Cérémonie en double contrôle, sous témoin indépendant, sur HSM certifié, avec procès-verbal contresigné — écart organisationnel, pas seulement logiciel. | @@ -50,12 +51,12 @@ Trois statuts seulement, pour qu'aucune zone grise ne puisse s'y loger : | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §7.7.1, §7.7.2 — TIS-7.7.1-04, TIS-7.7.1-07, TIS-7.7.2-01, TIS-7.7.2-06 | Traçabilité de l'heure jusqu'à UTC et suspension en cas de dérive | couvert | Surveillance NTP multi-sources avec quorum, seuil de dérive (MaxOffset) et péremption (MaxAge) ; la politique enforce fait refuser chaque demande avec timeNotAvailable : oe_timesource::Monitor. | crates/oe-timesource/src/lib.rs (now_refuses_untraceable_time_in_enforce_mode, now_allows_untraceable_time_in_monitor_mode, new_rejects_quorum_larger_than_source_count), crates/oe-tsa-core/src/lib.rs (test_timestamp_refuses_when_time_is_not_traceable) | +| §7.7.1, §7.7.2 — TIS-7.7.1-04, TIS-7.7.1-07, TIS-7.7.2-01, TIS-7.7.2-06 | Traçabilité de l'heure jusqu'à UTC et suspension en cas de dérive | couvert | Surveillance NTP multi-sources avec quorum, seuil de dérive (MaxOffset) et péremption (MaxAge) ; la politique enforce fait refuser chaque demande avec timeNotAvailable : oe_timesource::Monitor. | crates/oe-timesource/src/lib.rs (now_refuses_untraceable_time_in_enforce_mode, now_allows_untraceable_time_in_monitor_mode, new_rejects_quorum_larger_than_source_count), crates/oe-tsa-core/src/lib.rs (test_timestamp_refuses_when_time_is_not_traceable) ; **en service :** bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares) | | §7.7.1, §7.7.2 — TIS-7.7.1-05, TIS-7.7.1-06, TIS-7.7.2-03 | Exactitude de l'heure du jeton (1 seconde ou mieux) | écart documenté | genTime est tronqué à la seconde alors que la dérive tolérée atteint OPENEIDAS_TIME_MAX_OFFSET : l'écart réel peut dépasser l'exactitude annoncée. | **Cible :** Constat T-1 (PR #53) : genTime à la milliseconde, et refus de démarrer si accuracy < max_offset + résolution. | | §7.7.2 — TIS-7.7.2-04, TIS-7.7.2-05 | Protection de l'horloge contre un changement non détecté | écart documenté | Plusieurs sources UTC(k) avec quorum et seuil de dérive (oe_timesource::Monitor), mais client NTP sans contrôle d'origine, de l'indicateur de seconde intercalaire ni NTS. | **Cible :** Constat T-2 : contrôler l'origine et le LI des réponses NTP, envisager NTS (RFC 8915). | | §7.7.2 — TIS-7.7.2-07, TIS-7.7.2-08, TIS-7.7.2-09 | Secondes intercalaires | écart documenté | Aucune : la seconde intercalaire n'est ni détectée ni consignée. | **Cible :** Constat T-4 : détecter l'annonce (LI), consigner l'instant du changement au journal. | | §7.6.2 — TIS-7.6.2-03 | Génération de la clé TSU dans le module cryptographique | écart documenté | La bi-clé est générée dans le token PKCS#11 (oe_hsm::Pkcs11Token::generate_rsa_key) et ne manipule qu'un SigningToken ; la clé privée n'est jamais extraite. | **Cible :** Constat H-1 : vérifier au chargement que la clé a été générée dans le module (CKA_LOCAL, CKA_NEVER_EXTRACTABLE). | -| §7.6.5 — TIS-7.6.5-01 | Durée de vie du certificat de l'unité d'horodatage plafonnée | couvert | oe_conformance::check_certificate_lifetime relit la validité du certificat réellement signé et la compare à un plafond indépendant du profil (MAX_END_ENTITY_LIFETIME/MAX_OCSP_LIFETIME) ; appelé via le champ Profile::check de oe_ca_core::Issuer::issue, comme profile.Check (Go). | crates/oe-conformance/src/lib.rs (check_certificate_lifetime_accepts_within_the_ceiling, check_certificate_lifetime_rejects_beyond_the_ceiling), crates/oe-conformance/tests/tsu_certificate.rs | +| §7.6.5 — TIS-7.6.5-01 | Durée de vie du certificat de l'unité d'horodatage plafonnée | couvert | oe_conformance::check_certificate_lifetime relit la validité du certificat réellement signé et la compare à un plafond indépendant du profil (MAX_END_ENTITY_LIFETIME/MAX_OCSP_LIFETIME) ; appelé via le champ Profile::check de oe_ca_core::Issuer::issue, comme profile.Check (Go). | crates/oe-conformance/src/lib.rs (check_certificate_lifetime_accepts_within_the_ceiling, check_certificate_lifetime_rejects_beyond_the_ceiling), crates/oe-conformance/tests/tsu_certificate.rs ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | | §7.6.7, §7.7.1 — TIS-7.6.7-01, TIS-7.6.7-02, TIS-7.6.7-04, TIS-7.6.7-06, TIS-7.6.7-07, TIS-7.6.7-09, TIS-7.7.1-09 | Date d'expiration de la clé TSU, refus d'émettre au-delà | écart documenté | Validité du certificat TSU contrôlée au démarrage seulement ; aucune date d'expiration de clé définie. | **Cible :** Constat T-3 (PR #54) : privateKeyUsagePeriod posé à l'émission, contrôlé à chaque signature ; nouvelle clé à chaque renouvellement. | | §7.13 — OVR-7.13-05 | Identification des jetons affectés par une compromission | écart documenté | Le journal consigne genTime et la série du certificat TSU, pas celle du jeton : les jetons émis ne sont pas identifiables un par un. | **Cible :** Constat J-3 (PR #52) : consigner la série du jeton, l'empreinte soumise et l'état de l'horloge. | @@ -63,29 +64,29 @@ Trois statuts seulement, pour qu'aucune zone grise ne puisse s'y loger : | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §6 | Profil du certificat de l'unité d'horodatage | couvert | oe_conformance::check_tsu_certificate (id-kp-timeStamping seul et critique, CA:FALSE, keyUsage restreint, durée de vie plafonnée) est appliquée à l'émission (Profile::check) ET re-contrôlée au démarrage de tsa-server (oe_tsa_core::Authority::new) — un certificat chargé depuis le disque peut venir d'ailleurs. | crates/oe-conformance/tests/tsu_certificate.rs (accepts_a_certificate_issued_with_the_tsa_signer_profile, rejects_a_certificate_issued_with_the_ocsp_responder_profile) | +| §6 | Profil du certificat de l'unité d'horodatage | couvert | oe_conformance::check_tsu_certificate (id-kp-timeStamping seul et critique, CA:FALSE, keyUsage restreint, durée de vie plafonnée) est appliquée à l'émission (Profile::check) ET re-contrôlée au démarrage de tsa-server (oe_tsa_core::Authority::new) — un certificat chargé depuis le disque peut venir d'ailleurs. | crates/oe-conformance/tests/tsu_certificate.rs (accepts_a_certificate_issued_with_the_tsa_signer_profile, rejects_a_certificate_issued_with_the_ocsp_responder_profile) ; **en service :** bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares), .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | | §5.2 | Profil du jeton d'horodatage | écart documenté | TSTInfo complet (politique, imprint, série, genTime UTC, précision), assemblé en CMS SignedData signé par le token : oe_rfc3161_asn1, oe_tsa_core::Authority::timestamp. | **Cible :** Constat T-1 (PR #53) : genTime avec fraction de seconde (§5.2.2), cohérent avec la précision annoncée. | -| §7 | Protocole d'horodatage RFC 3161 sur HTTP | couvert | Endpoint /tsa acceptant application/timestamp-query, refus protocolaires rendus en TimeStampResp valides : oe_httpapi, bin/tsa-server. | crates/oe-httpapi/tests/end_to_end.rs (serves_a_verifiable_token_over_http, vérification croisée openssl ts -verify) | +| §7 | Protocole d'horodatage RFC 3161 sur HTTP | couvert | Endpoint /tsa acceptant application/timestamp-query, refus protocolaires rendus en TimeStampResp valides : oe_httpapi, bin/tsa-server. | crates/oe-httpapi/tests/end_to_end.rs (serves_a_verifiable_token_over_http) ; **en service :** bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares), .github/workflows/ci.yml (Horodate un fichier et vérifie le jeton (openssl ts)) | ## ETSI TS 119 312 V2.1.1 (2026-06) | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §8.4 | Longueur de clé suffisante pour la durée de vie visée | couvert | oe-config et bin/ca-server/src/config.rs imposent OPENEIDAS_KEY_BITS >= 3072 à la configuration (clé des autorités elles-mêmes) ; oe_raflow::parse_and_verify_csr applique la même exigence à la clé publique portée par une CSR soumise à l'enrôlement. | crates/oe-config/src/lib.rs (load_fails_on_undersized_key_bits), crates/oe-raflow/tests/flow.rs (submit_rejects_a_csr_with_an_undersized_key) | -| §7.3 | Algorithme de signature et fonction de hachage admis | couvert | oe_conformance::check_signature_algorithm vérifie explicitement l'OID de signature d'un certificat contre la liste des suites admises (SHA-256/384/512 avec RSA), appelée via Profile::check à l'émission et à la re-vérification. | crates/oe-conformance/src/lib.rs (check_signature_algorithm_accepts_sha256_with_rsa, check_signature_algorithm_rejects_sha1) | -| §5.1 | Fonction de hachage admise pour l'empreinte soumise | couvert | oe_hsm::DigestAlg restreint la signature à SHA-256/384/512 ; une empreinte SHA-1 est refusée avec le failureInfo RFC 3161 badAlg : oe_tsa_core::Authority::timestamp. | crates/oe-tsa-core/src/lib.rs (test_timestamp_rejects_sha1) | +| §8.4 | Longueur de clé suffisante pour la durée de vie visée | couvert | oe-config et bin/ca-server/src/config.rs imposent OPENEIDAS_KEY_BITS >= 3072 à la configuration (clé des autorités elles-mêmes) ; oe_raflow::parse_and_verify_csr applique la même exigence à la clé publique portée par une CSR soumise à l'enrôlement. | crates/oe-config/src/lib.rs (load_fails_on_undersized_key_bits), crates/oe-raflow/tests/flow.rs (submit_rejects_a_csr_with_an_undersized_key) ; **en service :** bin/ca-server/tests/bootstrap_cli.rs (undersized_ca_keys_are_refused_by_the_binary) | +| §7.3 | Algorithme de signature et fonction de hachage admis | couvert | oe_conformance::check_signature_algorithm vérifie explicitement l'OID de signature d'un certificat contre la liste des suites admises (SHA-256/384/512 avec RSA), appelée via Profile::check à l'émission et à la re-vérification. | crates/oe-conformance/src/lib.rs (check_signature_algorithm_accepts_sha256_with_rsa, check_signature_algorithm_rejects_sha1) ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | +| §5.1 | Fonction de hachage admise pour l'empreinte soumise | couvert | oe_hsm::DigestAlg restreint la signature à SHA-256/384/512 ; une empreinte SHA-1 est refusée avec le failureInfo RFC 3161 badAlg : oe_tsa_core::Authority::timestamp. | crates/oe-tsa-core/src/lib.rs (test_timestamp_rejects_sha1) ; **en service :** bin/tsa-server/tests/serve.rs (the_binary_applies_what_the_matrix_declares) | ## RFC 5280 | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §4.2.1.1-4.2.1.2 | Identifiants de clé de sujet et d'autorité présents | couvert | subjectKeyIdentifier (SHA-1 de la clé, méthode 1) et authorityKeyIdentifier (pointant vers le SKI de l'émetteur) posés sans condition à l'émission et dans la cérémonie : oe_ca_core::extensions, oe_ca_core::signing::subject_key_id. | crates/oe-ca-core/tests/issuance.rs (issue_produces_a_certificate_signed_by_the_issuing_key, assert_ski_and_aki_present_and_linked) | -| §5.1 | Liste de révocation signée, numérotée et datée | couvert | CRL régénérée avec cRLNumber, thisUpdate/nextUpdate et signature, republiée même vide : oe_ca_core::Issuer::publish_crl. La signature et le motif de révocation sont revérifiés par openssl. | crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate, openssl_accepts_the_chain_and_honors_revocation) | +| §4.2.1.1-4.2.1.2 | Identifiants de clé de sujet et d'autorité présents | couvert | subjectKeyIdentifier (SHA-1 de la clé, méthode 1) et authorityKeyIdentifier (pointant vers le SKI de l'émetteur) posés sans condition à l'émission et dans la cérémonie : oe_ca_core::extensions, oe_ca_core::signing::subject_key_id. | crates/oe-ca-core/tests/issuance.rs (issue_produces_a_certificate_signed_by_the_issuing_key, assert_ski_and_aki_present_and_linked) ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl) | +| §5.1 | Liste de révocation signée, numérotée et datée | couvert | CRL régénérée avec cRLNumber, thisUpdate/nextUpdate et signature, republiée même vide : oe_ca_core::Issuer::publish_crl. La signature et le motif de révocation sont revérifiés par openssl. | crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate, openssl_accepts_the_chain_and_honors_revocation) ; **en service :** .github/workflows/ci.yml (Vérifie le certificat TSU et la CRL avec openssl), bin/ca-server/tests/crl_publication.rs (crl_is_republished_periodically) | ## RFC 6960 | Clause | Exigence | Statut | Mécanisme | Vérification / cible | |---|---|---|---|---| -| §2.1 | Service d'état de révocation interrogeable en ligne | couvert | Répondeur OCSP RFC 6960 s'appuyant sur la CRL publiée par la CA : oe_ocsp_core::Responder. | crates/oe-ocsp-core/tests/against_real_crl.rs (reports_good_status_for_a_non_revoked_certificate, reports_revoked_status_for_a_revoked_certificate) | -| §4.2.2.2 | Profil du certificat de signature du répondeur OCSP | couvert | Profil ocsp_responder (id-pkix-ocsp-nocheck, pas de CDP/AIA, durée de vie courte) appliqué à l'émission, re-contrôlé après signature par oe_conformance::check_ocsp_responder_certificate (Profile::check). | crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate, qui émet avec ce profil), crates/oe-conformance/tests/tsu_certificate.rs (rejects_a_certificate_issued_with_the_ocsp_responder_profile, qui prouve que check_ocsp_responder_certificate distingue bien ce profil de tsa_signer) | +| §2.1 | Service d'état de révocation interrogeable en ligne | couvert | Répondeur OCSP RFC 6960 s'appuyant sur la CRL publiée par la CA : oe_ocsp_core::Responder. | crates/oe-ocsp-core/tests/against_real_crl.rs (reports_good_status_for_a_non_revoked_certificate, reports_revoked_status_for_a_revoked_certificate) ; **en service :** .github/workflows/ci.yml (Interroge le répondeur OCSP pour le certificat TSU) | +| §4.2.2.2 | Profil du certificat de signature du répondeur OCSP | couvert | Profil ocsp_responder (id-pkix-ocsp-nocheck, pas de CDP/AIA, durée de vie courte) appliqué à l'émission, re-contrôlé après signature par oe_conformance::check_ocsp_responder_certificate (Profile::check). | crates/oe-ca-core/tests/issuance.rs (revoke_then_publish_crl_lists_the_certificate), crates/oe-conformance/tests/tsu_certificate.rs (rejects_a_certificate_issued_with_the_ocsp_responder_profile) ; **en service :** .github/workflows/ci.yml (Interroge le répondeur OCSP pour le certificat TSU) |