diff --git a/bin/ca-server/Cargo.toml b/bin/ca-server/Cargo.toml index f1ee7b6..4b39d56 100644 --- a/bin/ca-server/Cargo.toml +++ b/bin/ca-server/Cargo.toml @@ -35,7 +35,7 @@ tracing = "0.1" tracing-subscriber = "0.3" der = { version = "0.8", features = ["pem", "std"] } x509-cert = "0.3" -time = { version = "0.3", features = ["std", "formatting"] } +time = { version = "0.3", features = ["std", "formatting", "parsing"] } serde = { version = "1", features = ["derive"] } serde_json = "1" hex = "0.4" diff --git a/bin/ca-server/src/cli_review.rs b/bin/ca-server/src/cli_review.rs new file mode 100644 index 0000000..96b9957 --- /dev/null +++ b/bin/ca-server/src/cli_review.rs @@ -0,0 +1,126 @@ +//! Revue a posteriori des décisions prises par la voie de secours (constat +//! R-1 de l'audit du 2026-09-25, docs/WEBUI.md §20, décision O4) : le CLI +//! `ra approve|reject` et `revoke` reste ouvert, mais chacune de ses décisions +//! (`authenticated_via: "cli"`) doit être relue par une personne, et cette +//! relecture doit elle-même laisser une trace. +//! +//! Tout part du journal chaîné, qui fait foi : la revue lit ce qu'il atteste, +//! jamais la base. Un acquittement (`ra.cli_decisions_reviewed`) couvre les +//! enregistrements jusqu'à un numéro donné ; la revue suivante repart de là. + +use oe_audit::Record; + +/// Événement d'acquittement d'une revue. +pub const REVIEW_EVENT: &str = "ra.cli_decisions_reviewed"; + +/// Une décision prise par la voie de secours, telle que le journal l'atteste. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct CliDecision { + pub seq: u64, + pub time: String, + pub event: String, + pub operator: String, + pub comment: String, + /// Transaction (décision RA) ou numéro de série (révocation). + pub subject: String, + pub host: String, + pub uid: Option, +} + +/// Ce qu'une revue a devant elle. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct Review { + /// Décisions CLI non encore couvertes par un acquittement (ou postérieures + /// à `--since`). + pub decisions: Vec, + /// Numéro du dernier enregistrement couvert par le dernier acquittement + /// (0 : aucun). + pub acknowledged_up_to: u64, + /// Numéro du dernier enregistrement lu : ce qu'un acquittement couvrira. + pub head: u64, +} + +fn str_field(data: &oe_audit::Data, key: &str) -> String { + data.get(key) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() +} + +/// Relève les décisions CLI à revoir. Sans `since`, la fenêtre part du dernier +/// acquittement ; avec `since` (date de l'enregistrement, RFC 3339), elle part +/// de cette date, acquittements ou non — pour relire une période passée. +pub fn review(records: &[Record], since: Option) -> Result { + let acknowledged_up_to = records + .iter() + .rev() + .find(|r| r.event == REVIEW_EVENT) + .and_then(|r| r.data.as_ref()?.get("jusqu_a")?.as_u64()) + .unwrap_or(0); + let head = records.last().map(|r| r.seq).unwrap_or(0); + + let mut decisions = Vec::new(); + for r in records { + // L'acquittement passe lui aussi par le CLI, mais ce n'est pas une + // décision : le relister rendrait toute revue interminable. + if r.event == REVIEW_EVENT { + continue; + } + let Some(data) = &r.data else { continue }; + if data.get("authenticated_via").and_then(|v| v.as_str()) != Some("cli") { + continue; + } + let in_window = match since { + Some(since) => { + let at = time::OffsetDateTime::parse( + &r.time, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|e| format!("enregistrement n° {} : date illisible : {e}", r.seq))?; + at >= since + } + None => r.seq > acknowledged_up_to, + }; + if !in_window { + continue; + } + let identity = data.get("identite_systeme"); + let subject = [str_field(data, "transaction"), str_field(data, "serie")] + .into_iter() + .find(|s| !s.is_empty()) + .unwrap_or_default(); + decisions.push(CliDecision { + seq: r.seq, + time: r.time.clone(), + event: r.event.clone(), + operator: str_field(data, "operateur"), + comment: str_field(data, "commentaire"), + subject, + host: identity + .and_then(|i| i.get("hote")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + uid: identity.and_then(|i| i.get("uid")).and_then(|v| v.as_u64()), + }); + } + Ok(Review { + decisions, + acknowledged_up_to, + head, + }) +} + +/// Données de l'événement d'acquittement : qui a relu, jusqu'où, combien de +/// décisions, et pourquoi (le relecteur n'est que déclaré, comme toute +/// commande de secours : l'appelant y ajoute `authenticated_via`). +pub fn acknowledgement(review: &Review, reviewer: &str, comment: &str) -> serde_json::Value { + serde_json::json!({ + "relecteur": reviewer, + "commentaire": comment, + "jusqu_a": review.head, + "depuis": review.acknowledged_up_to, + "decisions": review.decisions.len(), + "sequences": review.decisions.iter().map(|d| d.seq).collect::>(), + }) +} diff --git a/bin/ca-server/src/lib.rs b/bin/ca-server/src/lib.rs index b1c980c..2bdade2 100644 --- a/bin/ca-server/src/lib.rs +++ b/bin/ca-server/src/lib.rs @@ -2,6 +2,7 @@ //! que ses tests d'intégration (`tests/`) puissent construire un `Server` //! réel — la CLI (`main.rs`) reste le seul point d'entrée exécutable. +pub mod cli_review; pub mod config; pub mod http; pub mod internal; diff --git a/bin/ca-server/src/main.rs b/bin/ca-server/src/main.rs index 794cb51..d42da55 100644 --- a/bin/ca-server/src/main.rs +++ b/bin/ca-server/src/main.rs @@ -9,11 +9,13 @@ //! Sous-commandes : `ceremony`, `serve`, `ra list|approve|reject`, `revoke`, //! `authority revoke|publish-arl`, //! `operators bootstrap-admin|recover-admin|audit|reconcile`, `internal-cert server`, `conformance`, -//! `healthcheck`, `verify-audit`. +//! `healthcheck`, `verify-audit`, `audit cli-decisions`. use std::sync::Arc; -use ca_server::{config, http, internal, internal_tls, registry_check, revoker, webauthn_models}; +use ca_server::{ + cli_review, config, http, internal, internal_tls, registry_check, revoker, webauthn_models, +}; use clap::{Parser, Subcommand}; use config::Config; use oe_hsm::SigningToken; @@ -79,6 +81,11 @@ enum Command { Healthcheck, /// Vérifie l'intégrité de la chaîne du journal d'audit. VerifyAudit { path: Option }, + /// Revues fondées sur le journal chaîné. + Audit { + #[command(subcommand)] + action: AuditAction, + }, /// Affiche la version (identique à `--version`, sous forme de /// sous-commande — reproduit `cmd/ca-server` (Go), qui n'a que celle-ci). Version, @@ -107,6 +114,36 @@ enum AuthorityAction { PublishArl, } +#[derive(Subcommand)] +enum AuditAction { + /// Revue a posteriori des décisions prises par la voie de secours + /// (`authenticated_via: cli`, docs/WEBUI.md §20) : liste celles qui n'ont + /// pas encore été revues. Code de sortie 0 : rien à revoir ; 1 : des + /// décisions à revoir ; 2 : journal illisible ou rompu. + CliDecisions { + /// Relit à partir de cette date (RFC 3339), revues antérieures ou non. + #[arg(long)] + since: Option, + /// Journal à lire (défaut : `OPENEIDAS_AUDIT_FILE`), par exemple la + /// copie répliquée hors de l'hôte. Incompatible avec `--acknowledge`. + #[arg(long)] + journal: Option, + /// Sortie JSON sur la sortie standard. + #[arg(long)] + json: bool, + /// Consigne la revue au journal (`ra.cli_decisions_reviewed`) : les + /// décisions listées sont couvertes, la revue suivante repart d'ici. + #[arg(long, requires = "reviewer")] + acknowledge: bool, + /// Identité de la personne qui a relu (avec `--acknowledge`). + #[arg(long, requires = "acknowledge")] + reviewer: Option, + /// Commentaire de la revue (obligatoire avec `--acknowledge`). + #[arg(trailing_var_arg = true)] + comment: Vec, + }, +} + #[derive(Subcommand)] enum InternalCertAction { /// Demande (ou récupère) le certificat `internal_server` de ce service. @@ -1238,6 +1275,105 @@ async fn run_healthcheck() { println!("{}", body.trim()); } +fn audit_file_path() -> String { + std::env::var("OPENEIDAS_AUDIT_FILE") + .ok() + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "/var/lib/open-eidas/state/ca-audit.log".to_string()) +} + +/// Voir `AuditAction::CliDecisions`. N'ouvre ni la base ni un token : le +/// journal chaîné fait foi, et une copie répliquée suffit pour la lecture. +fn run_audit_cli_decisions( + since: Option, + journal: Option, + json: bool, + acknowledge: bool, + reviewer: Option, + comment: Vec, +) { + if acknowledge && journal.is_some() { + die( + "revue des décisions CLI", + "--acknowledge écrit dans le journal en service : incompatible avec --journal", + ); + } + let comment = join_comment(&comment); + if acknowledge && comment.trim().is_empty() { + die( + "revue des décisions CLI", + "--acknowledge exige un commentaire (ce qui a été vérifié)", + ); + } + let since = since.map(|s| { + time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339) + .unwrap_or_else(|e| die("--since (RFC 3339 attendu)", e)) + }); + let path = journal.unwrap_or_else(audit_file_path); + let records = match oe_audit::read(&path) { + Ok(r) => r, + Err(e) => { + eprintln!("journal illisible ou rompu ({path}) : {e}"); + eprintln!("Aucune revue sur un journal dont l'intégrité n'est pas garantie."); + std::process::exit(2); + } + }; + let review = cli_review::review(&records, since).unwrap_or_else(|e| { + eprintln!("journal inexploitable ({path}) : {e}"); + std::process::exit(2); + }); + + if json { + println!( + "{}", + serde_json::to_string_pretty(&review).unwrap_or_default() + ); + } else { + eprintln!( + "journal : {path} (enregistrements 1 à {}, dernière revue jusqu'au n° {})", + review.head, review.acknowledged_up_to + ); + eprintln!( + "{} décision(s) prise(s) par la voie de secours à revoir", + review.decisions.len() + ); + for d in &review.decisions { + let uid = d + .uid + .map(|u| u.to_string()) + .unwrap_or_else(|| "?".to_string()); + println!( + "n° {}\t{}\t{}\t{}\topérateur déclaré : {}\thôte : {} (uid {})\t{}", + d.seq, d.time, d.event, d.subject, d.operator, d.host, uid, d.comment + ); + } + } + + if acknowledge { + let reviewer = reviewer.unwrap_or_default(); + if reviewer.trim().is_empty() { + die( + "revue des décisions CLI", + "--reviewer ne peut pas être vide", + ); + } + let mut data = cli_review::acknowledgement(&review, &reviewer, &comment); + oe_ca_core::Via::Cli(oe_ca_core::SystemIdentity::current()).annotate(&mut data); + let log = oe_audit::Log::open(&path).unwrap_or_else(|e| die("journal d'audit", e)); + log.append(cli_review::REVIEW_EVENT, json_to_audit_data(data)) + .unwrap_or_else(|e| die("consignation de la revue", e)); + eprintln!( + "revue consignée par {reviewer} jusqu'au n° {} ({} décision(s))", + review.head, + review.decisions.len() + ); + return; + } + if !review.decisions.is_empty() { + std::process::exit(1); + } +} + async fn run_verify_audit(path: Option) { let path = path .or_else(|| std::env::var("OPENEIDAS_AUDIT_FILE").ok()) @@ -1280,6 +1416,16 @@ async fn main() { comment, } => run_revoke(serial_hex, reason, operator, comment).await, Command::Authority { action } => run_authority(action).await, + Command::Audit { action } => match action { + AuditAction::CliDecisions { + since, + journal, + json, + acknowledge, + reviewer, + comment, + } => run_audit_cli_decisions(since, journal, json, acknowledge, reviewer, comment), + }, Command::Operators { action } => match action { OperatorsAction::BootstrapAdmin { name, ttl_minutes } => { run_operators_bootstrap_admin(name, ttl_minutes).await diff --git a/bin/ca-server/tests/cli_review.rs b/bin/ca-server/tests/cli_review.rs new file mode 100644 index 0000000..e9cfae6 --- /dev/null +++ b/bin/ca-server/tests/cli_review.rs @@ -0,0 +1,193 @@ +//! Revue a posteriori des décisions de la voie de secours (constat R-1, +//! docs/WEBUI.md §20, décision O4) : `ca-server audit cli-decisions`, contre un +//! vrai journal chaîné `oe-audit`, sans base de données. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use ca_server::cli_review; + +fn journal_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("oe-cli-review-{name}-{nanos}.log")) +} + +fn append(path: &Path, event: &str, data: serde_json::Value) { + let log = oe_audit::Log::open(path).unwrap(); + let data = match data { + serde_json::Value::Object(map) => Some(map.into_iter().collect()), + _ => None, + }; + log.append(event, data).unwrap(); +} + +fn cli_decision(path: &Path, transaction: &str) { + append( + path, + "ca.request_approved", + serde_json::json!({ + "transaction": transaction, + "operateur": "prenom.nom", + "commentaire": "identité vérifiée", + "authenticated_via": "cli", + "identite_systeme": {"authentifiee": false, "uid": 0, "utilisateur": "root", "hote": "open-eidas-ca-0"}, + }), + ); +} + +/// Un journal typique : une décision signée, une révocation automatique, et +/// deux décisions de la voie de secours (une approbation, une révocation). +fn seeded_journal(name: &str) -> PathBuf { + let path = journal_path(name); + append( + &path, + "ca.request_approved", + serde_json::json!({"transaction": "tx-signee", "operateur": "alice", "authenticated_via": "webauthn"}), + ); + cli_decision(&path, "tx-secours"); + append( + &path, + "ca.certificate_revoked", + serde_json::json!({"serie": "0a", "operateur": "raflow:renouvellement", "authenticated_via": "automatique"}), + ); + append( + &path, + "ca.certificate_revoked", + serde_json::json!({ + "serie": "0b", "operateur": "prenom.nom", "commentaire": "clé exposée", + "authenticated_via": "cli", + "identite_systeme": {"authentifiee": false, "uid": 1000, "utilisateur": "ops", "hote": "open-eidas-ca-0"}, + }), + ); + path +} + +#[test] +fn only_decisions_of_the_emergency_path_are_listed_until_acknowledged() { + let path = seeded_journal("logic"); + let records = oe_audit::read(&path).unwrap(); + let review = cli_review::review(&records, None).unwrap(); + assert_eq!(review.acknowledged_up_to, 0); + let subjects: Vec<_> = review + .decisions + .iter() + .map(|d| d.subject.as_str()) + .collect(); + assert_eq!(subjects, ["tx-secours", "0b"]); + assert_eq!(review.decisions[1].uid, Some(1000)); + assert_eq!(review.decisions[1].host, "open-eidas-ca-0"); + + // Acquittement jusqu'à la tête lue : plus rien à revoir… + append( + &path, + cli_review::REVIEW_EVENT, + cli_review::acknowledgement(&review, "relecteur", "rien d'anormal"), + ); + let records = oe_audit::read(&path).unwrap(); + let after = cli_review::review(&records, None).unwrap(); + assert_eq!(after.acknowledged_up_to, review.head); + assert!(after.decisions.is_empty()); + + // …jusqu'à la décision de secours suivante. + cli_decision(&path, "tx-suivante"); + let records = oe_audit::read(&path).unwrap(); + let next = cli_review::review(&records, None).unwrap(); + assert_eq!(next.decisions.len(), 1); + assert_eq!(next.decisions[0].subject, "tx-suivante"); + + // `since` relit une période passée, acquittements ou non. + let since = time::OffsetDateTime::UNIX_EPOCH; + let all = cli_review::review(&records, Some(since)).unwrap(); + assert_eq!(all.decisions.len(), 3); + + let _ = std::fs::remove_file(&path); +} + +fn run(path: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ca-server")) + .args(["audit", "cli-decisions"]) + .args(args) + .env("OPENEIDAS_AUDIT_FILE", path) + .env_remove("OPENEIDAS_DB_DSN") + .output() + .expect("lancement de ca-server") +} + +#[test] +fn the_command_exits_1_until_the_review_is_acknowledged() { + let path = seeded_journal("cli"); + + let out = run(&path, &[]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("tx-secours") && stdout.contains("0b"), + "{stdout}" + ); + assert!(!stdout.contains("tx-signee"), "{stdout}"); + + // Un acquittement sans commentaire, ou sur une copie, est refusé. + let before = oe_audit::read(&path).unwrap().len(); + let out = run(&path, &["--acknowledge", "--reviewer", "relecteur"]); + assert!(!out.status.success(), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("exige un commentaire"), + "{out:?}" + ); + let copy = path.to_string_lossy().to_string(); + let out = run( + &path, + &[ + "--acknowledge", + "--reviewer", + "relecteur", + "--journal", + ©, + "vu", + ], + ); + assert!(!out.status.success(), "{out:?}"); + assert_eq!( + oe_audit::read(&path).unwrap().len(), + before, + "un acquittement refusé n'écrit rien" + ); + + let out = run( + &path, + &[ + "--acknowledge", + "--reviewer", + "relecteur", + "décisions justifiées", + ], + ); + assert_eq!(out.status.code(), Some(0), "{out:?}"); + let last = oe_audit::read(&path).unwrap().pop().unwrap(); + assert_eq!(last.event, cli_review::REVIEW_EVENT); + let data = last.data.unwrap(); + assert_eq!(data["relecteur"], "relecteur"); + assert_eq!(data["decisions"], 2); + assert_eq!(data["authenticated_via"], "cli"); + + let out = run(&path, &[]); + assert_eq!(out.status.code(), Some(0), "{out:?}"); + + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_tampered_journal_is_not_reviewed() { + let path = seeded_journal("tampered"); + let content = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, content.replacen("tx-secours", "tx-falsifie", 1)).unwrap(); + + let out = run(&path, &[]); + assert_eq!(out.status.code(), Some(2), "{out:?}"); + assert!(out.stdout.is_empty(), "rien n'est listé d'un journal rompu"); + + let _ = std::fs::remove_file(&path); +} diff --git a/docs/CA.md b/docs/CA.md index 4dfe1c0..edebbf2 100644 --- a/docs/CA.md +++ b/docs/CA.md @@ -359,6 +359,26 @@ 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"`. +**Revue a posteriori** (décision O4 : sa fréquence est fixée par +l'association). Le journal chaîné fait foi ; la commande n'ouvre ni la base ni +un token, et lit aussi bien une copie répliquée : + +```bash +# Décisions de secours pas encore revues (code 0 : aucune ; 1 : à revoir ; +# 2 : journal illisible ou rompu, rien n'est jugé) +ca-server audit cli-decisions +ca-server audit cli-decisions --since 2026-09-01T00:00:00Z --journal /copie/ca-audit.log --json + +# Consigner la revue (événement ra.cli_decisions_reviewed) : la suivante repart d'ici +ca-server audit cli-decisions --acknowledge --reviewer "prenom.nom" "décisions justifiées par le ticket …" +``` + +L'acquittement couvre les enregistrements lus (jusqu'à la tête du journal au +moment de la lecture), refuse de s'écrire ailleurs que dans le journal en +service, et exige un commentaire. Lui aussi passe par le CLI : il porte donc +`"authenticated_via": "cli"` et l'identité système, mais n'est pas relisté +comme une décision. + 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