diff --git a/bin/ca-server/src/main.rs b/bin/ca-server/src/main.rs index ad9eafa..794cb51 100644 --- a/bin/ca-server/src/main.rs +++ b/bin/ca-server/src/main.rs @@ -40,14 +40,17 @@ enum Command { #[command(subcommand)] action: RaAction, }, - /// Révoque un certificat émis et republie la CRL. + /// Révoque un certificat émis et republie la CRL. Voie de secours : la + /// voie primaire est l'action signée (`revoke_certificate`, docs/WEBUI.md §20). Revoke { /// Numéro de série en hexadécimal (voir `ra list`, le journal d'audit). serial_hex: String, /// Code motif RFC 5280 §5.3.1 (1=keyCompromise, 4=superseded, 5=cessationOfOperation, ...). reason: i32, operator: String, - #[arg(trailing_var_arg = true)] + /// Motif de la décision, obligatoire : voie de secours, l'opérateur + /// n'est que déclaré (docs/WEBUI.md §20). + #[arg(trailing_var_arg = true, required = true)] comment: Vec, }, /// Actes de la racine hors ligne (constat C-1) : révocation d'une autorité @@ -94,7 +97,9 @@ enum AuthorityAction { /// 4=superseded, 5=cessationOfOperation, ...). reason: i32, operator: String, - #[arg(trailing_var_arg = true)] + /// Motif de la décision, obligatoire : voie de secours, l'opérateur + /// n'est que déclaré (docs/WEBUI.md §20). + #[arg(trailing_var_arg = true, required = true)] comment: Vec, }, /// Publie une nouvelle ARL, même vide : à relancer avant l'échéance de la @@ -190,18 +195,24 @@ enum OperatorsAction { enum RaAction { /// Liste les demandes d'enrôlement. List { state: Option }, - /// Approuve une demande, sous l'identité d'un opérateur. + /// Approuve une demande, sous l'identité déclarée d'un opérateur (voie de + /// secours, consignée `authenticated_via: cli` avec l'identité système). Approve { transaction_id: String, operator: String, - #[arg(trailing_var_arg = true)] + /// Motif de la décision, obligatoire : voie de secours, l'opérateur + /// n'est que déclaré (docs/WEBUI.md §20). + #[arg(trailing_var_arg = true, required = true)] comment: Vec, }, - /// Rejette une demande, sous l'identité d'un opérateur. + /// Rejette une demande, sous l'identité déclarée d'un opérateur (voie de + /// secours, consignée `authenticated_via: cli` avec l'identité système). Reject { transaction_id: String, operator: String, - #[arg(trailing_var_arg = true)] + /// Motif de la décision, obligatoire : voie de secours, l'opérateur + /// n'est que déclaré (docs/WEBUI.md §20). + #[arg(trailing_var_arg = true, required = true)] comment: Vec, }, } @@ -481,8 +492,10 @@ async fn run_authority(action: AuthorityAction) { comment, } => { let comment = join_comment(&comment); + // Voie de secours (docs/WEBUI.md §20, constat R-1), comme `revoke`. + let via = oe_ca_core::Via::Cli(oe_ca_core::SystemIdentity::current()); let arl = root - .revoke_authority(&name, reason, &operator, &comment) + .revoke_authority(&name, reason, &operator, &comment, &via) .await .unwrap_or_else(|e| die("révocation de l'autorité", e)); tracing::info!(autorite = %name, motif = reason, operateur = %operator, arl = arl.number, "autorité révoquée et ARL republiée"); @@ -839,10 +852,17 @@ async fn run_decide( }); let comment = join_comment(&comment); + // Voie de secours (docs/WEBUI.md §20, constat R-1) : l'opérateur n'est que + // déclaré ; le journal le marque comme tel, avec l'identité système réelle. + let via = oe_raflow::Via::Cli(oe_raflow::SystemIdentity::current()); let r = if action_name == "approve" { - decider.approve(&transaction_id, &operator, &comment).await + decider + .approve(&transaction_id, &operator, &comment, &via) + .await } else { - decider.reject(&transaction_id, &operator, &comment).await + decider + .reject(&transaction_id, &operator, &comment, &via) + .await }; let r = r.unwrap_or_else(|e| die("décision RA", e)); tracing::info!(action = action_name, transaction = %r.transaction_id, profil = %r.profile, sujet_cn = %r.subject_cn, operateur = %operator, "décision enregistrée"); @@ -859,8 +879,10 @@ async fn run_revoke(serial_hex: String, reason: i32, operator: String, comment: let issuer = build_issuer(&cfg, store, recorder).await; let comment = join_comment(&comment); + // Voie de secours (docs/WEBUI.md §20, constat R-1), comme `ra approve`. + let via = oe_ca_core::Via::Cli(oe_ca_core::SystemIdentity::current()); issuer - .revoke(&serial, reason, &operator, &comment) + .revoke(&serial, reason, &operator, &comment, &via) .await .unwrap_or_else(|e| die("révocation", e)); // La CRL est republiée immédiatement : une révocation qui n'est pas diff --git a/bin/ca-server/src/revoker.rs b/bin/ca-server/src/revoker.rs index 96af83b..a3461f9 100644 --- a/bin/ca-server/src/revoker.rs +++ b/bin/ca-server/src/revoker.rs @@ -1,5 +1,7 @@ //! Branche `oe_ca_core::Issuer` sur l'action signée de révocation de -//! certificat (`oe_actions::Revoker`). +//! certificat (`oe_actions::Revoker`). Ce branchement ne sert qu'à elle : +//! toute révocation qui passe ici a été signée par WebAuthn, d'où +//! `Via::WebAuthn` (constat R-1). use std::sync::Arc; @@ -17,7 +19,13 @@ impl oe_actions::Revoker for IssuerRevoker { comment: &str, ) -> Result<(), String> { self.0 - .revoke(serial, reason, operator, comment) + .revoke( + serial, + reason, + operator, + comment, + &oe_ca_core::Via::WebAuthn, + ) .await .map_err(|e| e.to_string()) } diff --git a/bin/ca-server/tests/arl_publication.rs b/bin/ca-server/tests/arl_publication.rs index 075a07c..dbdcbbb 100644 --- a/bin/ca-server/tests/arl_publication.rs +++ b/bin/ca-server/tests/arl_publication.rs @@ -177,7 +177,13 @@ async fn the_arl_is_served_where_the_issuing_cdp_points_and_reflects_revocation( // relit en base, sans redémarrage. let revoked = f .root_authority - .revoke_authority(AUTHORITY_ISSUING, 2, "test-operator", "compromission") + .revoke_authority( + AUTHORITY_ISSUING, + 2, + "test-operator", + "compromission", + &oe_ca_core::Via::WebAuthn, + ) .await .unwrap(); let (status, body) = get(&f.server, &path).await; diff --git a/bin/ca-server/tests/internal_tls.rs b/bin/ca-server/tests/internal_tls.rs index 48ff58f..029507d 100644 --- a/bin/ca-server/tests/internal_tls.rs +++ b/bin/ca-server/tests/internal_tls.rs @@ -238,7 +238,13 @@ async fn revoking_the_client_certificate_cuts_access_at_the_next_connection() { let serial = oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()); pki.issuer - .revoke(&serial, 1, "operateur-test", "clé compromise") + .revoke( + &serial, + 1, + "operateur-test", + "clé compromise", + &oe_ca_core::Via::WebAuthn, + ) .await .unwrap(); assert!(!ok(&get_ping(&pki, port, Some((&cert, &key))).await)); diff --git a/bin/ra-console/tests/ca_link.rs b/bin/ra-console/tests/ca_link.rs index 502e6a8..acd27a6 100644 --- a/bin/ra-console/tests/ca_link.rs +++ b/bin/ra-console/tests/ca_link.rs @@ -66,7 +66,13 @@ async fn revoking_the_client_certificate_cuts_the_link() { let serial = oe_ca_core::canonical_serial(client.0.tbs_certificate().serial_number()); pki.issuer - .revoke(&serial, 1, "operateur-test", "clé compromise") + .revoke( + &serial, + 1, + "operateur-test", + "clé compromise", + &oe_ca_core::Via::WebAuthn, + ) .await .unwrap(); let err = link.ping().await.expect_err("certificat révoqué"); diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index 6006b91..5cacba8 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -858,7 +858,12 @@ impl Service { .. } => self .decider - .approve(transaction_id, &operator.name, comment) + .approve( + transaction_id, + &operator.name, + comment, + &oe_raflow::Via::WebAuthn, + ) .await .map(|_| None) .map_err(|e| Error::Effect(e.to_string()))?, @@ -867,7 +872,12 @@ impl Service { comment, } => self .decider - .reject(transaction_id, &operator.name, comment) + .reject( + transaction_id, + &operator.name, + comment, + &oe_raflow::Via::WebAuthn, + ) .await .map(|_| None) .map_err(|e| Error::Effect(e.to_string()))?, diff --git a/crates/oe-actions/tests/actions.rs b/crates/oe-actions/tests/actions.rs index e88ccbd..69e26fe 100644 --- a/crates/oe-actions/tests/actions.rs +++ b/crates/oe-actions/tests/actions.rs @@ -531,7 +531,12 @@ async fn a_request_decided_meanwhile_is_not_overwritten() { // Un autre chemin (le CLI de secours) rejette la demande entre-temps. env.decider - .reject(&r.transaction_id, "cli", "refusée par le secours") + .reject( + &r.transaction_id, + "cli", + "refusée par le secours", + &oe_raflow::Via::Cli(oe_raflow::SystemIdentity::current()), + ) .await .unwrap(); diff --git a/crates/oe-ca-core/src/lib.rs b/crates/oe-ca-core/src/lib.rs index a24ec33..8a4cb23 100644 --- a/crates/oe-ca-core/src/lib.rs +++ b/crates/oe-ca-core/src/lib.rs @@ -22,6 +22,7 @@ mod extensions; pub mod profile; +pub mod provenance; pub mod root; mod signing; @@ -41,6 +42,7 @@ use oe_castore::{Store, StoreError}; use oe_hsm::SigningToken; pub use profile::{profile_by_name, Profile}; +pub use provenance::{SystemIdentity, Via}; /// Consigne les décisions de l'autorité au journal d'audit — reproduit /// `ca.Recorder` (Go) ; comme `oe_tsa_core::Recorder`, découplé de @@ -411,37 +413,38 @@ impl Issuer { } /// Révoque un certificat émis par cette autorité et consigne la - /// décision. La CRL n'est pas republiée ici : `publish_crl` la reprend. + /// décision, avec la voie par laquelle son opérateur a été identifié + /// (constat R-1). La CRL n'est pas republiée ici : `publish_crl` la reprend. pub async fn revoke( &self, serial: &[u8], reason: i32, operator: &str, comment: &str, + via: &Via, ) -> Result<(), CaError> { if operator.is_empty() { return Err(CaError::Other( "la révocation exige l'identité de l'opérateur qui la décide".to_string(), )); } + via.check_comment(comment).map_err(CaError::Other)?; let cert = self.opts.store.certificate(&serial.to_vec()).await?; let at = time::OffsetDateTime::now_utc(); // Le journal *avant* la révocation en base (§15 étape 2b) : si // l'écriture échoue, le certificat reste actif — pas de révocation // à moitié consignée. - self.record( - "ca.certificate_revoked", - serde_json::json!({ - "serie": hex::encode(serial), - "sujet": cert.subject_dn, - "motif": reason, - "operateur": operator, - "commentaire": comment, - "date": at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), - }), - ) - .await?; + let mut data = serde_json::json!({ + "serie": hex::encode(serial), + "sujet": cert.subject_dn, + "motif": reason, + "operateur": operator, + "commentaire": comment, + "date": at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), + }); + via.annotate(&mut data); + self.record("ca.certificate_revoked", data).await?; self.opts.store.revoke(&serial.to_vec(), at, reason).await?; Ok(()) diff --git a/crates/oe-ca-core/src/provenance.rs b/crates/oe-ca-core/src/provenance.rs new file mode 100644 index 0000000..8de0fff --- /dev/null +++ b/crates/oe-ca-core/src/provenance.rs @@ -0,0 +1,176 @@ +//! Constat R-1 de l'audit du 2026-09-25 (EN 319 411-1 `GEN-6.5.5-04`, +//! `CSS-6.5.5-06` ; EN 319 401 `REQ-7.4.1-11`, `-12`) et docs/WEBUI.md §20 : +//! une décision d'approbation, de rejet ou de révocation dit **par quelle +//! voie** son opérateur a été identifié. La voie est fixée par l'appelant au +//! moment de l'appel, jamais reconstruite après coup à partir du format du +//! champ `operateur`. +//! +//! La voie de secours (CLI sur l'hôte de `ca-server`) reste ouverte, par +//! décision (§20 : une panne de `ra-console` ne doit jamais rendre une +//! révocation d'urgence impossible), mais son identité n'est que déclarée : +//! elle est consignée avec l'identité système réelle du processus et marquée +//! non authentifiée, et elle exige un commentaire. + +/// Par où l'identité de l'opérateur d'une décision a été établie. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Via { + /// Action signée par une clé WebAuthn enregistrée, rôle lu dans le + /// registre (`oe_actions`) : la voie primaire. + WebAuthn, + /// Voie de secours : commande `ca-server` lancée sur son hôte. L'opérateur + /// est déclaré, pas authentifié ; le contrôle réel est l'accès à l'hôte + /// (RBAC `kubectl exec`). + Cli(SystemIdentity), + /// Décision prise par le système lui-même, sans opérateur humain + /// (révocation `superseded` au renouvellement). + Automatic, +} + +/// Identité du processus qui exécute une commande de secours, relevée par le +/// système et non déclarée par l'opérateur. Derrière un `kubectl exec`, elle +/// désigne le conteneur, pas la personne : celle-ci n'apparaît que dans le +/// journal d'audit de l'API Kubernetes, à recouper avec l'hôte consigné ici. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SystemIdentity { + pub uid: Option, + pub user: String, + pub host: String, +} + +impl SystemIdentity { + /// Relève l'identité du processus courant (Linux : `/proc`, `/etc/passwd`). + /// Ce qui ne peut être lu reste vide plutôt que d'être inventé. + pub fn current() -> SystemIdentity { + let uid = std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| parse_real_uid(&s)); + let user = uid + .and_then(|uid| { + std::fs::read_to_string("/etc/passwd") + .ok() + .and_then(|p| user_name(&p, uid)) + }) + .or_else(|| std::env::var("USER").ok().filter(|u| !u.is_empty())) + .unwrap_or_default(); + let host = std::fs::read_to_string("/proc/sys/kernel/hostname") + .ok() + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .or_else(|| std::env::var("HOSTNAME").ok()) + .unwrap_or_default(); + SystemIdentity { uid, user, host } + } +} + +/// UID réel (premier champ de la ligne `Uid:` de `/proc//status`). +fn parse_real_uid(status: &str) -> Option { + status + .lines() + .find_map(|l| l.strip_prefix("Uid:")) + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|uid| uid.parse().ok()) +} + +fn user_name(passwd: &str, uid: u32) -> Option { + passwd.lines().find_map(|line| { + let mut fields = line.split(':'); + let name = fields.next()?; + let _password = fields.next()?; + let line_uid: u32 = fields.next()?.parse().ok()?; + (line_uid == uid).then(|| name.to_string()) + }) +} + +impl Via { + /// Valeur du champ `authenticated_via` du journal (docs/WEBUI.md §20). + pub fn label(&self) -> &'static str { + match self { + Via::WebAuthn => "webauthn", + Via::Cli(_) => "cli", + Via::Automatic => "automatique", + } + } + + /// Une décision prise par la voie de secours doit se justifier : sans + /// signature, le commentaire est la seule trace de son motif. + pub fn check_comment(&self, comment: &str) -> Result<(), String> { + if matches!(self, Via::Cli(_)) && comment.trim().is_empty() { + return Err( + "une décision prise par le CLI (voie de secours) exige un commentaire qui la motive" + .to_string(), + ); + } + Ok(()) + } + + /// Ajoute `authenticated_via` (et, pour le CLI, `identite_systeme`) aux + /// données d'un événement du journal. + pub fn annotate(&self, data: &mut serde_json::Value) { + let Some(map) = data.as_object_mut() else { + return; + }; + map.insert( + "authenticated_via".to_string(), + serde_json::Value::from(self.label()), + ); + if let Via::Cli(identity) = self { + map.insert( + "identite_systeme".to_string(), + serde_json::json!({ + "authentifiee": false, + "uid": identity.uid, + "utilisateur": identity.user, + "hote": identity.host, + }), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_real_uid_is_read_from_proc_status() { + let status = "Name:\tca-server\nUid:\t1000\t0\t0\t0\nGid:\t1000\t1000\t1000\t1000\n"; + assert_eq!(parse_real_uid(status), Some(1000)); + assert_eq!(parse_real_uid("Name:\tx\n"), None); + } + + #[test] + fn the_user_name_is_looked_up_by_uid() { + let passwd = + "root:x:0:0:root:/root:/bin/sh\nopeneidas:x:10001:10001::/home:/sbin/nologin\n"; + assert_eq!(user_name(passwd, 10001).as_deref(), Some("openeidas")); + assert_eq!(user_name(passwd, 42), None); + } + + #[test] + fn a_cli_decision_requires_a_comment() { + let cli = Via::Cli(SystemIdentity::current()); + assert!(cli.check_comment(" ").is_err()); + assert!(cli.check_comment("identité vérifiée").is_ok()); + assert!(Via::WebAuthn.check_comment("").is_ok()); + assert!(Via::Automatic.check_comment("").is_ok()); + } + + #[test] + fn only_the_cli_carries_the_system_identity() { + let mut data = serde_json::json!({"operateur": "prenom.nom"}); + Via::Cli(SystemIdentity { + uid: Some(0), + user: "root".to_string(), + host: "ca-0".to_string(), + }) + .annotate(&mut data); + assert_eq!(data["authenticated_via"], "cli"); + assert_eq!(data["identite_systeme"]["authentifiee"], false); + assert_eq!(data["identite_systeme"]["hote"], "ca-0"); + + let mut data = serde_json::json!({}); + Via::WebAuthn.annotate(&mut data); + assert_eq!(data["authenticated_via"], "webauthn"); + assert!(data.get("identite_systeme").is_none()); + } +} diff --git a/crates/oe-ca-core/src/root.rs b/crates/oe-ca-core/src/root.rs index 2c74375..aaeeffe 100644 --- a/crates/oe-ca-core/src/root.rs +++ b/crates/oe-ca-core/src/root.rs @@ -14,7 +14,7 @@ use x509_cert::Certificate; use oe_castore::{Arl, Store}; use oe_hsm::SigningToken; -use crate::{extensions, extensions_crl_reason, signing, to_x509_time, CaError, Recorder}; +use crate::{extensions, extensions_crl_reason, signing, to_x509_time, CaError, Recorder, Via}; pub struct RootAuthorityOptions { pub signer: Arc, @@ -48,13 +48,16 @@ impl RootAuthority { /// Révoque une autorité subordonnée (jamais la racine elle-même, qui /// n'a pas d'émetteur à qui le signaler) et republie immédiatement /// l'ARL — même raisonnement que `Issuer::revoke`/`publish_crl` : une - /// révocation qui n'est pas publiée ne protège personne. + /// révocation qui n'est pas publiée ne protège personne. `via` dit par + /// quelle voie l'opérateur a été identifié (constat R-1) : aujourd'hui + /// toujours le CLI (`ca-server authority revoke`), consigné comme tel. pub async fn revoke_authority( &self, name: &str, reason: i32, operator: &str, comment: &str, + via: &Via, ) -> Result { if operator.is_empty() { return Err(CaError::Other( @@ -62,6 +65,7 @@ impl RootAuthority { .to_string(), )); } + via.check_comment(comment).map_err(CaError::Other)?; if name == crate::ceremony::AUTHORITY_ROOT { return Err(CaError::Other( "la racine ne peut pas se révoquer elle-même".to_string(), @@ -73,18 +77,16 @@ impl RootAuthority { // Le journal *avant* la révocation en base (§15 étape 2b) : si // l'écriture échoue, l'autorité reste active — pas de révocation à // moitié consignée. - self.record( - "ca.authority_revoked", - serde_json::json!({ - "autorite": name, - "sujet": authority.subject_dn, - "motif": reason, - "operateur": operator, - "commentaire": comment, - "date": at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), - }), - ) - .await?; + let mut data = serde_json::json!({ + "autorite": name, + "sujet": authority.subject_dn, + "motif": reason, + "operateur": operator, + "commentaire": comment, + "date": at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), + }); + via.annotate(&mut data); + self.record("ca.authority_revoked", data).await?; self.opts.store.revoke_authority(name, at, reason).await?; self.publish_arl().await diff --git a/crates/oe-ca-core/tests/issuance.rs b/crates/oe-ca-core/tests/issuance.rs index f828966..777fe6a 100644 --- a/crates/oe-ca-core/tests/issuance.rs +++ b/crates/oe-ca-core/tests/issuance.rs @@ -12,7 +12,7 @@ use der::{Decode, Encode}; use x509_cert::Certificate; use oe_ca_core::ceremony::{run_ceremony, CeremonyOptions, AUTHORITY_ISSUING, AUTHORITY_ROOT}; -use oe_ca_core::{profile, Issuer, Options}; +use oe_ca_core::{profile, Issuer, Options, Via}; use oe_castore::{Memory, Store}; use oe_hsm::testing::SoftwareToken; use oe_hsm::SigningToken; @@ -119,6 +119,7 @@ async fn root_revokes_the_issuing_authority_and_publishes_the_arl() { 1, "test-operator", "clé émettrice compromise", + &Via::WebAuthn, ) .await .expect("la révocation de l'émettrice doit réussir"); @@ -156,7 +157,7 @@ async fn root_cannot_revoke_itself() { recorder: None, }); let err = root_authority - .revoke_authority(AUTHORITY_ROOT, 1, "test-operator", "") + .revoke_authority(AUTHORITY_ROOT, 1, "test-operator", "", &Via::WebAuthn) .await .unwrap_err(); assert!(err.to_string().contains("racine"), "{err}"); @@ -176,7 +177,7 @@ async fn a_journal_failure_blocks_authority_revocation_before_the_store_is_touch }); let err = root_authority - .revoke_authority(AUTHORITY_ISSUING, 1, "test-operator", "") + .revoke_authority(AUTHORITY_ISSUING, 1, "test-operator", "", &Via::WebAuthn) .await .unwrap_err(); assert!(err.to_string().contains("journal"), "{err}"); @@ -345,7 +346,7 @@ async fn revoke_then_publish_crl_lists_the_certificate() { assert!(parsed_empty.tbs_cert_list.revoked_certificates.is_none()); issuer - .revoke(&serial, 1, "test-operator", "") + .revoke(&serial, 1, "test-operator", "", &Via::WebAuthn) .await .expect("la révocation doit réussir"); @@ -433,11 +434,11 @@ async fn revoke_is_idempotent_and_keeps_first_reason() { let serial = oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()); issuer - .revoke(&serial, 1, "test-operator", "") + .revoke(&serial, 1, "test-operator", "", &Via::WebAuthn) .await .unwrap(); issuer - .revoke(&serial, 5, "test-operator", "") + .revoke(&serial, 5, "test-operator", "", &Via::WebAuthn) .await .unwrap(); @@ -520,7 +521,7 @@ async fn openssl_accepts_the_chain_and_honors_revocation() { ); issuer - .revoke(&serial, 1, "test-operator", "") + .revoke(&serial, 1, "test-operator", "", &Via::WebAuthn) .await .unwrap(); let crl = issuer.publish_crl().await.unwrap(); @@ -694,7 +695,7 @@ async fn every_authority_decision_is_recorded() { .unwrap(); let serial = oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()); issuer - .revoke(&serial, 1, "test-operator", "test") + .revoke(&serial, 1, "test-operator", "test", &Via::WebAuthn) .await .unwrap(); issuer.publish_crl().await.unwrap(); @@ -725,7 +726,7 @@ async fn revoke_rejects_empty_operator() { .unwrap(); let serial = oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()); - let err = issuer.revoke(&serial, 1, "", "").await; + let err = issuer.revoke(&serial, 1, "", "", &Via::WebAuthn).await; assert!( err.is_err(), "révoquer sans identité d'opérateur doit être refusé — traçabilité de la décision" @@ -970,7 +971,7 @@ async fn a_journal_failure_blocks_revocation_before_the_store_is_touched() { .unwrap(); let err = failing_issuer - .revoke(&serial, 1, "test-operator", "test") + .revoke(&serial, 1, "test-operator", "test", &Via::WebAuthn) .await .unwrap_err(); assert!(err.to_string().contains("journal"), "{err}"); @@ -1064,7 +1065,13 @@ async fn a_revoked_issuing_authority_refuses_to_issue() { .expect("avant révocation, l'émission doit réussir"); root_authority - .revoke_authority(AUTHORITY_ISSUING, 1, "test-operator", "compromission") + .revoke_authority( + AUTHORITY_ISSUING, + 1, + "test-operator", + "compromission", + &Via::WebAuthn, + ) .await .unwrap(); @@ -1147,7 +1154,13 @@ async fn openssl_rejects_a_leaf_under_a_revoked_issuing_authority() { ); let arl = root_authority - .revoke_authority(AUTHORITY_ISSUING, 1, "test-operator", "compromission") + .revoke_authority( + AUTHORITY_ISSUING, + 1, + "test-operator", + "compromission", + &Via::WebAuthn, + ) .await .unwrap(); let after = verify(&arl.der); @@ -1167,3 +1180,42 @@ async fn openssl_rejects_a_leaf_under_a_revoked_issuing_authority() { let _ = std::fs::remove_dir_all(&dir); } + +/// Constat R-1 : une révocation prise par le CLI (voie de secours) sans +/// commentaire est refusée avant toute écriture, et le certificat reste actif. +#[tokio::test] +async fn a_cli_revocation_without_a_comment_is_refused_before_the_store_is_touched() { + let store = store(); + let (issuer, _issuing_signer) = issuer_from_ceremony(store.clone()).await; + let public_key_der = SoftwareToken::generate(2048).public_key_der().unwrap(); + let cert = issuer + .issue( + &public_key_der, + "tsu.example.test", + &profile::tsa_signer(), + "txn-cli", + ) + .await + .unwrap(); + let serial = oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()); + + let cli = Via::Cli(oe_ca_core::SystemIdentity::current()); + let err = issuer + .revoke(&serial, 1, "prenom.nom", "", &cli) + .await + .expect_err("une révocation CLI sans commentaire doit être refusée"); + assert!(err.to_string().contains("commentaire"), "{err}"); + assert_eq!( + store.certificate(&serial).await.unwrap().status, + oe_castore::CertificateStatus::Issued + ); + + issuer + .revoke(&serial, 1, "prenom.nom", "clé exposée", &cli) + .await + .expect("avec un commentaire, la voie de secours reste ouverte"); + assert_eq!( + store.certificate(&serial).await.unwrap().status, + oe_castore::CertificateStatus::Revoked + ); +} diff --git a/crates/oe-raflow/src/lib.rs b/crates/oe-raflow/src/lib.rs index becf45f..8d29284 100644 --- a/crates/oe-raflow/src/lib.rs +++ b/crates/oe-raflow/src/lib.rs @@ -28,6 +28,8 @@ use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; use x509_cert::request::CertReq; +/// Voie par laquelle l'opérateur d'une décision a été identifié (constat R-1). +pub use oe_ca_core::{SystemIdentity, Via}; use oe_castore::{Request, RequestState, Store, StoreError}; #[derive(Debug, thiserror::Error)] @@ -219,9 +221,16 @@ impl Decider { transaction_id: &str, operator: &str, comment: &str, + via: &Via, ) -> Result { - self.decide(transaction_id, operator, comment, RequestState::Approved) - .await + self.decide( + transaction_id, + operator, + comment, + via, + RequestState::Approved, + ) + .await } /// Refuse définitivement une demande. Le demandeur en est informé lors @@ -231,9 +240,16 @@ impl Decider { transaction_id: &str, operator: &str, comment: &str, + via: &Via, ) -> Result { - self.decide(transaction_id, operator, comment, RequestState::Rejected) - .await + self.decide( + transaction_id, + operator, + comment, + via, + RequestState::Rejected, + ) + .await } async fn decide( @@ -241,6 +257,7 @@ impl Decider { transaction_id: &str, operator: &str, comment: &str, + via: &Via, target: RequestState, ) -> Result { if operator.is_empty() { @@ -248,6 +265,7 @@ impl Decider { "la décision exige l'identité de l'opérateur qui la prend".to_string(), )); } + via.check_comment(comment).map_err(RaflowError::Other)?; let r = match self .opts .store @@ -288,17 +306,15 @@ impl Decider { // Exception documentée (voir `Recorder`) : l'écriture optimiste // ci-dessus, qui départage deux décisions concurrentes, reste avant // le journal — pas après, comme partout ailleurs. - self.record( - event, - serde_json::json!({ - "transaction": updated.transaction_id, - "profil": updated.profile, - "sujet_cn": updated.subject_cn, - "operateur": operator, - "commentaire": comment, - }), - ) - .await?; + let mut data = serde_json::json!({ + "transaction": updated.transaction_id, + "profil": updated.profile, + "sujet_cn": updated.subject_cn, + "operateur": operator, + "commentaire": comment, + }); + via.annotate(&mut data); + self.record(event, data).await?; Ok(updated) } @@ -572,6 +588,7 @@ impl Flow { SUPERSEDED, "raflow:renouvellement", &format!("remplacé par {}", hex::encode(&serial)), + &Via::Automatic, ) .await?; } diff --git a/crates/oe-raflow/tests/flow.rs b/crates/oe-raflow/tests/flow.rs index 8dcdcff..6aec0e5 100644 --- a/crates/oe-raflow/tests/flow.rs +++ b/crates/oe-raflow/tests/flow.rs @@ -22,7 +22,7 @@ use oe_ca_core::ceremony::{run_ceremony, CeremonyOptions}; use oe_ca_core::{profile, Issuer, Options as CaOptions}; use oe_castore::{Memory, RequestState, Store}; use oe_hsm::testing::SoftwareToken; -use oe_raflow::{Decider, DeciderOptions, Flow, Options, RaflowError}; +use oe_raflow::{Decider, DeciderOptions, Flow, Options, RaflowError, Via}; const HMAC_SECRET: &str = "secret-de-test"; @@ -65,10 +65,13 @@ fn build_csr_with_bits(cn: &str, bits: usize) -> (Vec, RsaPrivateKey) { } async fn test_flow() -> (Flow, Arc) { - test_flow_with(None).await + test_flow_with(None, None).await } -async fn test_flow_with(recorder: Option>) -> (Flow, Arc) { +async fn test_flow_with( + recorder: Option>, + ca_recorder: Option>, +) -> (Flow, Arc) { let store = Arc::new(Memory::new()); let root_signer = Arc::new(SoftwareToken::generate(2048)); let issuing_signer = Arc::new(SoftwareToken::generate(2048)); @@ -102,7 +105,7 @@ async fn test_flow_with(recorder: Option>) -> (Flow ocsp_url: None, crl_validity: time::Duration::hours(24), crl_grace: time::Duration::hours(1), - recorder: None, + recorder: ca_recorder, }) .unwrap(); @@ -188,7 +191,12 @@ async fn approve_then_resubmit_issues_a_certificate_signed_by_the_issuing_key() .await .unwrap(); flow.decider() - .approve(&opened.transaction_id, "operateur-ra", "conforme") + .approve( + &opened.transaction_id, + "operateur-ra", + "conforme", + &Via::WebAuthn, + ) .await .unwrap(); @@ -236,7 +244,12 @@ async fn issuance_immediately_republishes_the_crl_with_the_new_serial() { .await .unwrap(); flow.decider() - .approve(&opened.transaction_id, "operateur-ra", "conforme") + .approve( + &opened.transaction_id, + "operateur-ra", + "conforme", + &Via::WebAuthn, + ) .await .unwrap(); let issued = flow @@ -279,7 +292,12 @@ async fn reject_then_resubmit_reports_the_operator_and_comment() { .await .unwrap(); flow.decider() - .reject(&opened.transaction_id, "operateur-ra", "sujet non autorisé") + .reject( + &opened.transaction_id, + "operateur-ra", + "sujet non autorisé", + &Via::WebAuthn, + ) .await .unwrap(); @@ -308,7 +326,7 @@ async fn issuing_a_renewal_revokes_the_previous_certificate_for_the_same_subject .await .unwrap(); flow.decider() - .approve(&opened1.transaction_id, "operateur-ra", "") + .approve(&opened1.transaction_id, "operateur-ra", "", &Via::WebAuthn) .await .unwrap(); let issued1 = flow @@ -330,7 +348,7 @@ async fn issuing_a_renewal_revokes_the_previous_certificate_for_the_same_subject .await .unwrap(); flow.decider() - .approve(&opened2.transaction_id, "operateur-ra", "") + .approve(&opened2.transaction_id, "operateur-ra", "", &Via::WebAuthn) .await .unwrap(); let issued2 = flow @@ -367,7 +385,10 @@ async fn decide_without_operator_identity_is_refused() { .await .unwrap(); - let err = flow.decider().approve(&opened.transaction_id, "", "").await; + let err = flow + .decider() + .approve(&opened.transaction_id, "", "", &Via::WebAuthn) + .await; assert!( err.is_err(), "approuver sans identité d'opérateur doit être refusé — traçabilité de la décision" @@ -383,7 +404,7 @@ async fn approve_unknown_transaction_is_not_found() { clock: None, }); let err = decider - .approve("transaction-inconnue", "operateur-ra", "") + .approve("transaction-inconnue", "operateur-ra", "", &Via::WebAuthn) .await; assert!(matches!(err, Err(RaflowError::NotFound))); } @@ -404,7 +425,7 @@ impl oe_raflow::Recorder for FailingRecorder { #[tokio::test] async fn a_journal_failure_blocks_submission_before_any_request_is_created() { - let (flow, store) = test_flow_with(Some(Arc::new(FailingRecorder))).await; + let (flow, store) = test_flow_with(Some(Arc::new(FailingRecorder)), None).await; let (csr_der, _key) = build_csr("audit.example.test"); let signature = oe_raflow::signature(&csr_der, HMAC_SECRET); @@ -442,7 +463,7 @@ async fn a_journal_failure_on_decide_still_leaves_the_decision_applied() { clock: None, }); let err = failing_decider - .approve(&request.transaction_id, "operateur-ra", "") + .approve(&request.transaction_id, "operateur-ra", "", &Via::WebAuthn) .await .unwrap_err(); assert!(err.to_string().contains("journal"), "{err}"); @@ -457,3 +478,112 @@ async fn a_journal_failure_on_decide_still_leaves_the_decision_applied() { "la décision reste appliquée malgré l'échec du journal (exception documentée)" ); } + +/// Journal en mémoire qui garde les données de chaque événement, des deux +/// sources (`oe_raflow` et `oe_ca_core`). +#[derive(Default, Clone)] +struct DataLog(Arc>>); + +impl DataLog { + fn last(&self, event: &str) -> serde_json::Value { + self.0 + .lock() + .unwrap() + .iter() + .rev() + .find(|(e, _)| e == event) + .map(|(_, d)| d.clone()) + .unwrap_or_else(|| panic!("événement {event} absent du journal")) + } +} + +#[async_trait::async_trait] +impl oe_raflow::Recorder for DataLog { + async fn append(&self, event: &str, data: serde_json::Value) -> Result<(), String> { + self.0.lock().unwrap().push((event.to_string(), data)); + Ok(()) + } +} + +#[async_trait::async_trait] +impl oe_ca_core::Recorder for DataLog { + async fn append(&self, event: &str, data: serde_json::Value) -> Result<(), String> { + self.0.lock().unwrap().push((event.to_string(), data)); + Ok(()) + } +} + +async fn open_request(flow: &Flow, cn: &str) -> (String, Vec, String) { + let (csr_der, _key) = build_csr(cn); + let sig = oe_raflow::signature(&csr_der, HMAC_SECRET); + let opened = flow + .submit(&csr_der, profile::PROFILE_TSA_SIGNER, &sig) + .await + .unwrap(); + (opened.transaction_id, csr_der, sig) +} + +/// Constat R-1 : chaque décision dit par quelle voie son opérateur a été +/// identifié (docs/WEBUI.md §20) — la voie signée, la voie de secours (avec +/// l'identité système réelle, marquée non authentifiée) et le remplacement +/// automatique au renouvellement. +#[tokio::test] +async fn each_decision_records_how_its_operator_was_identified() { + let log = DataLog::default(); + let (flow, _store) = + test_flow_with(Some(Arc::new(log.clone())), Some(Arc::new(log.clone()))).await; + + let (tx1, csr1, sig1) = open_request(&flow, "tsu.example.test").await; + let cli = Via::Cli(oe_raflow::SystemIdentity { + uid: Some(0), + user: "root".to_string(), + host: "open-eidas-ca-0".to_string(), + }); + flow.decider() + .approve(&tx1, "prenom.nom", "identité vérifiée au guichet", &cli) + .await + .unwrap(); + let approved = log.last("ca.request_approved"); + assert_eq!(approved["authenticated_via"], "cli"); + assert_eq!(approved["identite_systeme"]["authentifiee"], false); + assert_eq!(approved["identite_systeme"]["uid"], 0); + assert_eq!(approved["identite_systeme"]["hote"], "open-eidas-ca-0"); + flow.submit(&csr1, profile::PROFILE_TSA_SIGNER, &sig1) + .await + .unwrap(); + + // Renouvellement : la décision signée, puis la révocation automatique de + // l'ancien certificat du même sujet. + let (tx2, csr2, sig2) = open_request(&flow, "tsu.example.test").await; + flow.decider() + .approve(&tx2, "operateur-ra", "", &Via::WebAuthn) + .await + .unwrap(); + let approved = log.last("ca.request_approved"); + assert_eq!(approved["authenticated_via"], "webauthn"); + assert!(approved.get("identite_systeme").is_none()); + flow.submit(&csr2, profile::PROFILE_TSA_SIGNER, &sig2) + .await + .unwrap(); + let superseded = log.last("ca.certificate_revoked"); + assert_eq!(superseded["authenticated_via"], "automatique"); + assert_eq!(superseded["motif"], 4); +} + +/// Constat R-1 : une décision prise par le CLI (voie de secours) sans +/// commentaire est refusée avant que la demande ne change d'état. +#[tokio::test] +async fn a_cli_decision_without_a_comment_is_refused_before_the_request_changes() { + let (flow, store) = test_flow().await; + let (tx, _csr, _sig) = open_request(&flow, "tsu.example.test").await; + let cli = Via::Cli(oe_raflow::SystemIdentity::current()); + + let err = flow + .decider() + .approve(&tx, "prenom.nom", " ", &cli) + .await + .expect_err("une décision CLI sans commentaire doit être refusée"); + assert!(err.to_string().contains("commentaire"), "{err}"); + let request = store.request_by_transaction_id(&tx).await.unwrap(); + assert_eq!(request.state, RequestState::Pending); +} diff --git a/docs/CA.md b/docs/CA.md index 635c9f2..4dfe1c0 100644 --- a/docs/CA.md +++ b/docs/CA.md @@ -311,6 +311,12 @@ ca-server ra reject "prenom.nom" "sujet non reconnu" ca-server ra list ``` +Le commentaire est obligatoire, et chaque décision prise ainsi est consignée +comme venant de la **voie de secours** (`"authenticated_via": "cli"`, identité +système réelle, non authentifiée) : voir §5. Les décisions du conteneur +`ra-autoapprove` passent par cette même commande et sont consignées de même, +avec le nom de son pod. + ### Écart assumé En démonstration et en CI, l'approbation est automatisée sous l'identité @@ -342,6 +348,17 @@ Un motif est **obligatoire** : `unspecified` (0) est accepté par RFC 5280 mais signalé comme insuffisant par les règles de conformité — il ne justifie rien devant un auditeur. +Le commentaire est **obligatoire** : `revoke`, comme `ra approve|reject`, est +la **voie de secours** (docs/WEBUI.md §20), la voie primaire étant l'action +signée par WebAuthn. L'opérateur n'y est que déclaré : l'événement du journal +porte `"authenticated_via": "cli"` et l'identité système réelle du processus +(`identite_systeme` : UID, utilisateur, hôte — le nom du pod sous Kubernetes), +marquée `"authentifiee": false`. Derrière un `kubectl exec`, la personne +n'apparaît que dans le journal d'audit de l'API Kubernetes : le contrôle réel de +cette voie est le RBAC qui autorise `exec` sur le pod de la CA. Les décisions +signées portent `"authenticated_via": "webauthn"`, la révocation `superseded` +d'un renouvellement `"authenticated_via": "automatique"`. + La révocation est idempotente et la **première date fait foi** : la réappliquer ne repousse pas l'instant à partir duquel le certificat cesse d'être fiable. `revoke` republie la CRL immédiatement — une révocation non @@ -362,7 +379,9 @@ ca-server authority revoke issuing "prenom.nom" "commentaire" Le code usuel est `2` (`cACompromise`) pour une clé d'autorité exposée, `4` ou `5` pour un remplacement ou un arrêt. La racine ne peut pas se révoquer -elle-même. L'événement `ca.authority_revoked` est écrit au journal **avant** la +elle-même. Le commentaire est obligatoire (voie de secours, `"authenticated_via": +"cli"` et identité système au journal, comme `revoke`). L'événement +`ca.authority_revoked` est écrit au journal **avant** la mise à jour du registre, puis l'ARL est republiée immédiatement (`ca.arl_published`). L'émettrice révoquée refuse aussitôt toute émission, y compris dans un `serve` déjà lancé : la révocation est relue à chaque