From c40a402375d896a049219c6f4ed1c033642f8f71 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 11:42:15 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(ra-console):=20r=C3=A9vocation=20d'un?= =?UTF-8?q?=20certificat,=20premi=C3=A8re=20signature=20(=C3=A9tape=204a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §15 étape 4, §8 : la console prépare `revoke_certificate` et relaie la signature par `POST /api/v1/certificates/{serial}/revoke`. La politique de ca-server exige deux ca_operateur distincts : la première signature est enregistrée, rien n'est révoqué (AWAITING_QUORUM, 1/2). La co-signature est l'étape 4b. - oe_actions::Expect gagne `serial` : ca-server compare le certificat de la route au corps figé avant toute consommation, comme pour une décision ; une cible sans rapport avec le type d'action est refusée. - ra-console : relay_assertion factorise le relais d'une assertion (décisions et révocation) ; numéro de série exigé sous forme canonique (hexadécimal minuscule, 20 octets au plus) avant relais. - Tests : harnais doté d'une vraie CA sur PostgreSQL et du révocateur de ca-server ; première signature sans révocation, mauvaise cible, forme non canonique, refus pour un ra_operateur. Deux mutations tuées. Co-authored-by: Claude --- bin/ra-console/src/http.rs | 152 +++++++++++++------ bin/ra-console/tests/action_challenge.rs | 181 +++++++++++++++++++++-- crates/oe-actions/src/lib.rs | 52 ++++++- docs/RA-CONSOLE.md | 27 +++- 4 files changed, 340 insertions(+), 72 deletions(-) diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c52296..af2c920 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -50,6 +50,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) .route("/api/v1/requests/{id}/approve", post(handle_approve)) .route("/api/v1/requests/{id}/reject", post(handle_reject)) + .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -443,14 +444,16 @@ async fn handle_requests( } } -/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étape 3) : -/// décider d'une demande d'enrôlement. La révocation (étape 4) et la gestion -/// du registre suivront ; d'ici là, la console refuse de les préparer, même si -/// `ca-server` saurait les exécuter. +/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étapes 3 +/// et 4) : décider d'une demande d'enrôlement, révoquer un certificat. La +/// gestion du registre suivra ; d'ici là, la console refuse de la préparer, +/// même si `ca-server` saurait l'exécuter. fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { matches!( action, - oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. } + oe_actions::Action::ApproveRequest { .. } + | oe_actions::Action::RejectRequest { .. } + | oe_actions::Action::RevokeCertificate { .. } ) } @@ -546,35 +549,33 @@ async fn handle_reject( relay_decision(&state, "reject_request", &id, &headers, &body).await } -/// `POST /api/v1/requests/{id}/approve|reject` (docs/WEBUI.md §4 étapes 5 à 7, -/// §5) : la console relaie l'identifiant du challenge et l'assertion brute — -/// **jamais de corps** : `ca-server` exécute celui qu'il a figé. Elle y joint -/// ce que la route promet (`expect` : l'action et la demande du chemin), que -/// `ca-server` compare au corps figé avant toute vérification : une signature -/// obtenue pour une demande ne décide jamais d'une autre. -async fn relay_decision( +/// Relaie l'identifiant du challenge et l'assertion brute d'un opérateur +/// connecté à `ca-server` (docs/WEBUI.md §4 étapes 5 à 7) — **jamais de +/// corps** : `ca-server` exécute celui qu'il a figé. `expect` dit ce que la +/// route promet (action et cible) ; `ca-server` le compare au corps figé avant +/// toute vérification, si bien qu'une signature ne décide jamais d'autre chose +/// que ce qui a été signé. Chaque relais est inscrit au journal de la console. +/// +/// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]). +#[allow(clippy::result_large_err)] +async fn relay_assertion( state: &AppState, - action: &str, - transaction_id: &str, headers: &HeaderMap, body: &[u8], -) -> Response { + expect: serde_json::Value, +) -> Result { if !is_json(headers) { - return unsupported_media_type(); + return Err(unsupported_media_type()); } - let who = match authenticate(state, headers).await { - Ok(a) => a, - Err(resp) => return resp, - }; - let req: Signed = match serde_json::from_slice(body) { - Ok(r) => r, - Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"), - }; - if !looks_like_uuid(&req.challenge_id) - || !req.assertion.is_object() - || !looks_like_a_transaction(transaction_id) - { - return error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"); + let who = authenticate(state, headers).await?; + let req: Signed = serde_json::from_slice(body) + .map_err(|_| error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"))?; + if !looks_like_uuid(&req.challenge_id) || !req.assertion.is_object() { + return Err(error( + StatusCode::BAD_REQUEST, + "bad_request", + "corps invalide", + )); } let result = state .link @@ -583,7 +584,7 @@ async fn relay_decision( &serde_json::json!({ "challenge_id": req.challenge_id, "assertion": req.assertion, - "expect": { "action": action, "transaction_id": transaction_id }, + "expect": expect, }), ) .await; @@ -592,34 +593,91 @@ async fn relay_decision( audit::EVENT_ACTION_RELAYED, serde_json::json!({ "session_operator": who.operator, - "action": action, - "transaction_id": transaction_id, + "expect": expect, "action_id": r.body.get("action_id"), "signed_by": r.body.get("operator"), "status": r.status, + "outcome": r.body.get("status"), "error": r.body.get("error"), }), ); } match result { - // La forme du §5 : l'identité qui a décidé est celle que `ca-server` a - // lue dans son registre, pas celle de la session. - Ok(r) - if r.status == 200 - && r.body.get("status").and_then(|s| s.as_str()) == Some("executed") => - { - Json(serde_json::json!({ - "transaction_id": transaction_id, - "state": if action == "approve_request" { "APPROVED" } else { "REJECTED" }, - "decided_by": r.body.get("operator"), - "action_id": r.body.get("action_id"), - })) - .into_response() - } - other => relayed(other), + Ok(r) if r.status == 200 => Ok(r), + other => Err(relayed(other)), } } +/// `POST /api/v1/requests/{id}/approve|reject` (docs/WEBUI.md §5) : la décision +/// signée sur une demande d'enrôlement. `decided_by` est l'opérateur dont la clé +/// a signé, lu dans le registre de `ca-server`, pas celui de la session. +async fn relay_decision( + state: &AppState, + action: &str, + transaction_id: &str, + headers: &HeaderMap, + body: &[u8], +) -> Response { + if !looks_like_a_transaction(transaction_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "demande invalide"); + } + let expect = serde_json::json!({ "action": action, "transaction_id": transaction_id }); + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(serde_json::json!({ + "transaction_id": transaction_id, + "state": if action == "approve_request" { "APPROVED" } else { "REJECTED" }, + "decided_by": r.body.get("operator"), + "action_id": r.body.get("action_id"), + })) + .into_response(), + Err(resp) => resp, + } +} + +/// Un numéro de série dans la forme canonique du corps figé : hexadécimal +/// minuscule, sans préfixe, 20 octets au plus (RFC 5280 §4.1.2.2). +fn looks_like_a_serial(s: &str) -> bool { + !s.is_empty() && s.len() <= 40 && s.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) +} + +/// `POST /api/v1/certificates/{serial}/revoke` (docs/WEBUI.md §5, §8, §15 étape +/// 4) : une signature de plus sur la révocation figée. `ca-server` exige, par +/// sa propre politique, deux `ca_operateur` distincts : tant que le seuil n'est +/// pas atteint, la signature est enregistrée et rien n'est révoqué +/// (`AWAITING_QUORUM`) ; la dernière signature exécute (`EXECUTED`). +async fn handle_revoke( + State(state): State>, + Path(serial): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_serial(&serial) { + return error( + StatusCode::BAD_REQUEST, + "bad_request", + "numéro de série invalide", + ); + } + let expect = serde_json::json!({ "action": "revoke_certificate", "serial": serial }); + match relay_assertion(&state, &headers, &body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// La forme du §5 pour une action à plusieurs signatures. +fn quorum_status(body: &serde_json::Value) -> serde_json::Value { + let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); + serde_json::json!({ + "action_id": body.get("action_id"), + "status": if executed { "EXECUTED" } else { "AWAITING_QUORUM" }, + "signatures": body.get("signatures"), + "required": body.get("required"), + "signed_by": body.get("operator"), + "result": body.get("result"), + }) +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 852f99b..fdf3295 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -48,6 +48,7 @@ struct Env { console: axum::Router, registry: Registry, store: Arc, + issuer: Arc, verifier: Verifier, authn: WebauthnAuthenticator, _dir: Dir, @@ -71,6 +72,44 @@ impl Env { let store: Arc = Arc::new(Postgres::open(&dsn).await.unwrap()); let registry = Registry::connect(&dsn).await.unwrap(); + // Une vraie CA sur la même base, pour que la révocation porte sur un + // certificat réellement émis (étape 4). + let issuing = Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)); + let h = oe_ca_core::ceremony::run_ceremony(oe_ca_core::ceremony::CeremonyOptions { + root_signer: Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)), + issuing_signer: issuing.clone(), + root_cn: "Test Root CA".into(), + issuing_cn: "Test Issuing CA".into(), + organization: "Open eIDAS Test".into(), + country: "FR".into(), + root_validity: time::Duration::days(3650), + issuing_validity: time::Duration::days(3650), + root_token_label: "r".into(), + root_key_label: "r".into(), + issuing_token_label: "i".into(), + issuing_key_label: "i".into(), + store: store.clone(), + operator: "test".into(), + public_url: "https://ca.example.test".into(), + recorder: None, + }) + .await + .unwrap(); + let issuer = Arc::new( + oe_ca_core::Issuer::new(oe_ca_core::Options { + signer: issuing, + certificate: h.issuing, + chain: vec![], + store: store.clone(), + public_url: "https://ca.example.test".into(), + ocsp_url: None, + crl_validity: time::Duration::hours(24), + crl_grace: time::Duration::hours(1), + recorder: None, + }) + .unwrap(), + ); + // Un seul modèle de clé de confiance, le même pour ca-server (qui // vérifiera les assertions d'action) et pour la console (connexion). let (token, root) = SoftToken::new(true).unwrap(); @@ -90,18 +129,21 @@ impl Env { .unwrap() }; - let service = Arc::new(Service::new( - registry.clone(), - verifier(), - store.clone(), - Decider::new(DeciderOptions { - store: store.clone(), - recorder: None, - clock: None, - }), - Arc::new(NullJournal), - Arc::new(time::OffsetDateTime::now_utc), - )); + let service = Arc::new( + Service::new( + registry.clone(), + verifier(), + store.clone(), + Decider::new(DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + ) + .with_revoker(Arc::new(ca_server::revoker::IssuerRevoker(issuer.clone()))), + ); let pki = pki().await; let port = pki .serve_router(ca_server::internal::router(service, 64 * 1024)) @@ -130,6 +172,7 @@ impl Env { console, registry, store, + issuer, verifier: verifier(), authn: WebauthnAuthenticator::new(token), _dir: dir, @@ -334,9 +377,9 @@ async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { } // Une action que ca-server saurait exécuter, mais que la console ne propose - // pas encore (révocation : étape 4 ; registre : plus tard). + // pas encore (gestion du registre : après l'étape 4). for action in [ - serde_json::json!({ "action": "revoke_certificate", "serial": "0a", "reason": 1, "comment": "x" }), + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "admin" }), serde_json::json!({ "action": "set_role", "operator": "alice", "role": "admin" }), ] { let (status, err) = env.challenge(Some(&cookie), action).await; @@ -555,3 +598,113 @@ async fn the_console_relays_no_decision_it_has_not_validated() { let (status, done) = env.decide(Some(&cookie), path, &issued, &assertion).await; assert_eq!(status, StatusCode::OK, "{done}"); } + +impl Env { + /// Un certificat de TSU émis par la CA de test, et son numéro de série + /// dans la forme canonique des corps signés (hexadécimal minuscule). + async fn certificate(&self, tx: &str) -> String { + let key = oe_hsm::testing::SoftwareToken::generate(2048); + let cert = self + .issuer + .issue( + &oe_hsm::SigningToken::public_key_der(&key).unwrap(), + "tsu.example.test", + &oe_ca_core::profile::tsa_signer(), + tx, + ) + .await + .unwrap(); + oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()) + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + async fn status_of(&self, serial: &str) -> oe_castore::CertificateStatus { + let bytes: Vec = (0..serial.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&serial[i..i + 2], 16).unwrap()) + .collect(); + self.store.certificate(&bytes).await.unwrap().status + } +} + +fn revoke(serial: &str) -> serde_json::Value { + serde_json::json!({ "action": "revoke_certificate", "serial": serial, "reason": 1, "comment": "clé exposée" }) +} + +/// Étape 4a : la première signature d'une révocation est enregistrée par +/// `ca-server`, mais rien n'est révoqué avant le second `ca_operateur` (§8) ; +/// la cible de la route est contrôlée comme pour les décisions. +#[tokio::test] +async fn one_ca_operator_alone_does_not_revoke() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let cookie = env.log_in("alice").await; + let serial = env.certificate("tx-rev-1").await; + let other = env.certificate("tx-rev-2").await; + + let (status, issued) = env.challenge(Some(&cookie), revoke(&serial)).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + assert_eq!(issued["required_signatures"], 2, "{issued}"); + let assertion = env.sign(&issued); + + // Présentée pour un autre certificat : refusée, rien de consommé. + let (status, err) = env + .decide( + Some(&cookie), + &format!("/api/v1/certificates/{other}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + + let path = format!("/api/v1/certificates/{serial}/revoke"); + let (status, done) = env.decide(Some(&cookie), &path, &issued, &assertion).await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "AWAITING_QUORUM", "{done}"); + assert_eq!(done["signatures"], 1); + assert_eq!(done["required"], 2); + assert_eq!(done["signed_by"], "alice"); + assert_eq!( + env.status_of(&serial).await, + oe_castore::CertificateStatus::Issued + ); + assert_eq!( + env.status_of(&other).await, + oe_castore::CertificateStatus::Issued + ); + + // Un numéro de série hors de la forme canonique n'est pas relayé. + for (label, bad) in [ + ("majuscules", serial.to_uppercase()), + ("préfixe 0x", format!("0x{serial}")), + ("non hexadécimal", "zz".to_string()), + ] { + let (status, _) = env + .decide( + Some(&cookie), + &format!("/api/v1/certificates/{bad}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{label}"); + } +} + +/// La révocation est réservée aux `ca_operateur` : `ca-server` refuse d'en +/// préparer une pour un `ra_operateur`, la console relaie le refus. +#[tokio::test] +async fn an_ra_operator_cannot_prepare_a_revocation() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + let serial = env.certificate("tx-rev").await; + let (status, err) = env.challenge(Some(&cookie), revoke(&serial)).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(env.actions_frozen().await, 0); +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index 23c20eb..df028d2 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -198,8 +198,13 @@ pub enum Error { #[serde(deny_unknown_fields)] pub struct Expect { pub action: String, + /// Demande visée par une décision d'enrôlement. #[serde(default)] pub transaction_id: Option, + /// Certificat visé par une révocation (hexadécimal minuscule, forme + /// canonique du corps figé). + #[serde(default)] + pub serial: Option, } impl Expect { @@ -211,18 +216,30 @@ impl Expect { action.kind() ))); } - let target = match action { + // La cible que porte le corps figé, et celle que l'appelant attend pour + // ce type d'action ; l'autre champ d'attente doit rester vide. + let (frozen, expected, other) = match action { Action::ApproveRequest { transaction_id, .. } - | Action::RejectRequest { transaction_id, .. } => Some(transaction_id), - _ => None, + | Action::RejectRequest { transaction_id, .. } => { + (Some(transaction_id), &self.transaction_id, &self.serial) + } + Action::RevokeCertificate { serial, .. } => { + (Some(serial), &self.serial, &self.transaction_id) + } + _ => (None, &None, &None), }; - match (target, &self.transaction_id) { + if other.is_some() { + return Err(Error::BadRequest( + "cible sans rapport avec ce type d'action".to_string(), + )); + } + match (frozen, expected) { (Some(frozen), Some(expected)) if frozen == expected => Ok(()), (Some(_), None) => Err(Error::BadRequest( - "la demande visée doit être précisée".to_string(), + "la cible visée doit être précisée".to_string(), )), (Some(frozen), Some(expected)) => Err(Error::Mismatch(format!( - "demande attendue {expected}, figée {frozen}" + "cible attendue {expected}, figée {frozen}" ))), (None, _) => Ok(()), } @@ -984,6 +1001,7 @@ mod expect_tests { Expect { action: action.to_string(), transaction_id: tx.map(str::to_string), + serial: None, } } @@ -1011,5 +1029,27 @@ mod expect_tests { role: Role::Auditeur, }; assert!(expect("set_role", None).check(&role).is_ok()); + + // Révocation : la cible est le numéro de série, jamais une demande. + let revoke = Action::RevokeCertificate { + serial: "0a1b".to_string(), + reason: 1, + comment: "x".to_string(), + }; + let by_serial = |s: &str| Expect { + action: "revoke_certificate".to_string(), + transaction_id: None, + serial: Some(s.to_string()), + }; + assert!(by_serial("0a1b").check(&revoke).is_ok()); + assert!(matches!( + by_serial("0a1c").check(&revoke), + Err(Error::Mismatch(_)) + )); + let mixed = Expect { + transaction_id: Some("tx".to_string()), + ..by_serial("0a1b") + }; + assert!(matches!(mixed.check(&revoke), Err(Error::BadRequest(_)))); } } diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 14df368..ec77682 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -66,9 +66,9 @@ exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la (`operator_hint`) vient de la session, jamais du navigateur. L'action est relue dans l'énumération fermée d'`oe_actions` puis resérialisée : un champ en trop ne franchit pas la console. -- Seules l'approbation et le rejet d'une demande sont préparés à ce stade (§15, étape - 3) ; toute autre action est refusée (`403 action_not_available`) sans solliciter - `ca-server`. +- Sont préparés à ce stade l'approbation et le rejet d'une demande (§15, étape 3) et + la révocation d'un certificat (`revoke_certificate`, étape 4) ; toute autre action + est refusée (`403 action_not_available`) sans solliciter `ca-server`. - Le rôle et l'état de la demande sont jugés par `ca-server` (un administrateur ne peut pas approuver) ; la console relaie son refus. - Chaque préparation est inscrite au journal de la console (`ra.action_challenge` : @@ -98,6 +98,23 @@ lu dans le registre de `ca-server`, pas celui de la session. - Le certificat n'est pas émis à ce moment : comme avec `ca-server ra approve`, il l'est au prochain appel du demandeur à l'enrôlement. +## Révocation d'un certificat (première signature) + +`POST /api/v1/certificates/{serial}/revoke`, avec une session ouverte et la même forme +de corps qu'une décision (`{"challenge_id", "assertion"}`), le challenge ayant été +préparé pour `{"action": "revoke_certificate", "serial": "…", "reason": …, +"comment": "…"}`. Le numéro de série est en hexadécimal minuscule, sans préfixe (la +forme canonique du corps figé) ; toute autre forme est refusée avant relais. + +- La révocation exige, par la politique de `ca-server`, **deux `ca_operateur` + distincts** (docs/WEBUI.md §8). La première signature est enregistrée par + `ca-server` et **rien n'est révoqué** : réponse `{"status": "AWAITING_QUORUM", + "signatures": 1, "required": 2, "action_id", "signed_by"}`. La signature suivante + (co-signature) arrive avec l'étape 4b. +- La cible est contrôlée par `ca-server` comme pour une décision (`expect` porte le + numéro de série) : une signature ne révoque jamais un autre certificat. +- Un `ra_operateur` ne peut pas préparer de révocation : `ca-server` refuse. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -132,8 +149,8 @@ lu dans le registre de `ca-server`, pas celui de la session. ## Ce qui n'existe pas encore -La révocation et le double contrôle (étape 4), la gestion du registre depuis la -console, le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +La co-signature et la liste des actions en attente (étape 4b), la gestion du registre +depuis la console, le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se From 9e06cda1b83703d018124cc2758503e27216d403 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 11:52:11 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(ra-console):=20double=20contr=C3=B4le,?= =?UTF-8?q?=20co-signature=20et=20salle=20d'attente=20(=C3=A9tape=204b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §8, §15 étape 4 : deux ca_operateur distincts révoquent ensemble depuis la console. - Co-signature : `POST /api/v1/webauthn/challenge` accepte `{"action_id"}` (action existante, non exécutée, proposée à ce stade), puis `POST /api/v1/quorum/{action_id}/sign`. oe_actions::Expect gagne `action_id`, comparé avant toute consommation : une co-signature ne compte que pour l'action pour laquelle son challenge a été émis. - Salle d'attente : `GET /api/v1/quorum?state=PENDING` lit `actions` et `decision_evidence` de ca-server en lecture seule (décision de l'utilisateur) : corps figé, empreinte, signatures, signataires. Le rôle de la console gagne SELECT sur `actions` (aucun secret n'y figure) ; le test de schéma est mis à jour. - Écart assumé avec le §8, en plus sûr : pas de tables de collecte, la console ne conserve jamais d'assertion (ca-server enregistre chaque signature au fil de l'eau). WEBUI.md §8 dit ce qui est construit. - Tests : révocation à deux de bout en bout, seconde signature du même opérateur refusée, exécution unique, co-signature présentée pour une autre action sur le même certificat refusée. Deux mutations tuées. - Mise à jour d'un déploiement : rejouer ra_console_grants.sql. Co-authored-by: Claude --- bin/ra-console/src/http.rs | 181 +++++++++++++++++--- bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/quorum.rs | 93 ++++++++++ bin/ra-console/tests/action_challenge.rs | 175 +++++++++++++++++++ crates/oe-actions/src/lib.rs | 47 +++-- crates/oe-castore/sql/ra_console_grants.sql | 7 +- crates/oe-castore/tests/operators_schema.rs | 7 +- docs/RA-CONSOLE.md | 27 ++- docs/WEBUI.md | 13 +- 9 files changed, 513 insertions(+), 38 deletions(-) create mode 100644 bin/ra-console/src/quorum.rs diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index af2c920..6c12542 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -17,8 +17,8 @@ use sqlx::PgPool; use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; -use crate::requests; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; +use crate::{quorum, requests}; /// Assez pour un objet d'attestation, pas pour bourrer la mémoire. const MAX_BODY_BYTES: usize = 64 * 1024; @@ -51,6 +51,8 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/requests/{id}/approve", post(handle_approve)) .route("/api/v1/requests/{id}/reject", post(handle_reject)) .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) + .route("/api/v1/quorum", get(handle_quorum)) + .route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -457,6 +459,57 @@ fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { ) } +fn not_available() -> Response { + error( + StatusCode::FORBIDDEN, + "action_not_available", + "cette action n'est pas encore proposée par la console", + ) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CoSign { + action_id: String, +} + +/// Une action déjà figée par `ca-server`, que la console propose à ce stade et +/// qui attend encore des signatures. Lue dans la table `actions`, en lecture +/// seule : rien n'est décidé ici, `ca-server` recontrôle tout. +/// +/// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]). +#[allow(clippy::result_large_err)] +async fn frozen_at_this_stage( + state: &AppState, + action_id: &str, +) -> Result<(oe_webauthn::Uuid, oe_actions::Action), Response> { + let unknown = || error(StatusCode::NOT_FOUND, "unknown_action", "action inconnue"); + let id: oe_webauthn::Uuid = action_id.parse().map_err(|_| unknown())?; + let frozen = quorum::frozen(&state.pool, id) + .await + .map_err(|e| { + tracing::error!(erreur = %e, "quorum : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + })? + .ok_or_else(unknown)?; + if frozen.executed { + return Err(error( + StatusCode::CONFLICT, + "already_executed", + "action déjà exécutée", + )); + } + let action: oe_actions::Action = serde_json::from_value(frozen.body).map_err(|_| unknown())?; + if !relayed_at_this_stage(&action) { + return Err(not_available()); + } + Ok((id, action)) +} + /// `POST /api/v1/webauthn/challenge` (docs/WEBUI.md §4 étapes 1 à 3, §5) : /// l'opérateur connecté demande à `ca-server` de figer une action et d'émettre /// le challenge qu'il signera. Le corps rendu est celui que `ca-server` @@ -478,27 +531,35 @@ async fn handle_action_challenge( Ok(a) => a, Err(resp) => return resp, }; - // Relue dans l'énumération fermée d'`oe_actions`, puis resérialisée : un - // champ en trop (un `operator_hint` glissé par le navigateur, par exemple) - // ne franchit jamais la console. - let action: oe_actions::Action = match serde_json::from_slice(&body) { - Ok(a) => a, + let value: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"), }; - if !relayed_at_this_stage(&action) { - return error( - StatusCode::FORBIDDEN, - "action_not_available", - "cette action n'est pas encore proposée par la console", - ); - } - let result = state - .link - .post( - "/internal/v1/challenge", - &serde_json::json!({ "body": action, "operator_hint": who.operator_id }), - ) - .await; + // Deux formes (§8) : une action nouvelle, ou `{"action_id"}` pour signer + // une action déjà figée (double contrôle). Dans les deux cas, l'action est + // relue dans l'énumération fermée d'`oe_actions` : un champ en trop (un + // `operator_hint` glissé par le navigateur, par exemple) ne franchit + // jamais la console. + let relay = if value.get("action_id").is_some() { + let Ok(CoSign { action_id }) = serde_json::from_value::(value) else { + return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"); + }; + let (id, _) = match frozen_at_this_stage(&state, &action_id).await { + Ok(f) => f, + Err(resp) => return resp, + }; + serde_json::json!({ "action_id": id, "operator_hint": who.operator_id }) + } else { + let action: oe_actions::Action = match serde_json::from_value(value) { + Ok(a) => a, + Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"), + }; + if !relayed_at_this_stage(&action) { + return not_available(); + } + serde_json::json!({ "body": action, "operator_hint": who.operator_id }) + }; + let result = state.link.post("/internal/v1/challenge", &relay).await; if let Ok(r) = &result { state.journal.append( audit::EVENT_ACTION_CHALLENGE, @@ -665,6 +726,86 @@ async fn handle_revoke( } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct QuorumQuery { + state: Option, +} + +/// `GET /api/v1/quorum?state=PENDING` (docs/WEBUI.md §5, §8) : les actions à +/// plusieurs signatures ni exécutées ni expirées, avec qui a déjà signé. En +/// lecture seule sur l'état de `ca-server`, qui fait foi. +async fn handle_quorum( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + if q.state.as_deref().is_some_and(|s| s != "PENDING") { + return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide"); + } + match quorum::pending(&state.pool, time::OffsetDateTime::now_utc()).await { + Ok(list) => Json(list).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "quorum : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + +/// `POST /api/v1/quorum/{action_id}/sign` (docs/WEBUI.md §5, §8) : une +/// signature de plus sur une action figée, challenge obtenu par +/// `POST /api/v1/webauthn/challenge` avec `{"action_id"}`. `ca-server` +/// n'accepte qu'une signature par opérateur et exécute au seuil, une seule +/// fois ; la console lui dit ce qu'elle attend (l'action de la route et sa +/// cible), qu'il compare avant toute consommation. +async fn handle_quorum_sign( + State(state): State>, + Path(action_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !is_json(&headers) { + return unsupported_media_type(); + } + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + let (id, action) = match frozen_at_this_stage(&state, &action_id).await { + Ok(f) => f, + Err(resp) => return resp, + }; + let mut expect = serde_json::json!({ "action": action_kind(&action), "action_id": id }); + match &action { + oe_actions::Action::ApproveRequest { transaction_id, .. } + | oe_actions::Action::RejectRequest { transaction_id, .. } => { + expect["transaction_id"] = serde_json::json!(transaction_id); + } + oe_actions::Action::RevokeCertificate { serial, .. } => { + expect["serial"] = serde_json::json!(serial); + } + _ => {} + } + match relay_assertion(&state, &headers, &body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// Le nom sérialisé d'une action (`approve_request`…), celui du corps figé. +fn action_kind(action: &oe_actions::Action) -> String { + serde_json::to_value(action) + .ok() + .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string)) + .unwrap_or_default() +} + /// La forme du §5 pour une action à plusieurs signatures. fn quorum_status(body: &serde_json::Value) -> serde_json::Value { let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index ba4a1be..f855557 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -19,6 +19,7 @@ pub mod db_guard; pub mod http; pub mod login; pub mod purge; +pub mod quorum; pub mod requests; pub mod session; pub mod webauthn_models; diff --git a/bin/ra-console/src/quorum.rs b/bin/ra-console/src/quorum.rs new file mode 100644 index 0000000..b768d13 --- /dev/null +++ b/bin/ra-console/src/quorum.rs @@ -0,0 +1,93 @@ +//! Salle d'attente des actions à plusieurs signatures (docs/WEBUI.md §8, §15 +//! étape 4b), en lecture seule sur les tables de `ca-server`. +//! +//! Le §8 prévoyait des tables de collecte propres à la console, qui auraient +//! conservé les assertions jusqu'au seuil. Ce n'est pas ce qui est construit : +//! `ca-server` enregistre chaque signature au fil de l'eau (`decision_evidence`) +//! et n'exécute qu'au seuil. La console lit donc l'état qui fait foi, sans en +//! tenir de copie qui pourrait diverger, et ne garde jamais d'assertion. + +use oe_webauthn::Uuid; +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +/// Une action figée par `ca-server`, telle que la route de signature en a +/// besoin pour dire ce qu'elle attend (`expect`). +pub struct Frozen { + pub body: serde_json::Value, + pub executed: bool, +} + +/// Une action en attente de signatures, pour l'affichage (« 1 signature sur +/// 2 »). Le seuil qui fait foi reste celui de la politique de `ca-server`, +/// relu à l'exécution. +#[derive(Serialize)] +pub struct Pending { + pub action_id: Uuid, + pub action: String, + /// Le corps figé, à afficher tel quel à qui va co-signer (WYSIWYS). + pub body: serde_json::Value, + pub body_hash: String, + pub required: i32, + pub signatures: usize, + /// Qui a déjà signé, lu dans le registre de `ca-server`. + pub signed_by: Vec, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +pub async fn frozen(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query("SELECT body, executed_at FROM actions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| Frozen { + body: r.get("body"), + executed: r.get::, _>("executed_at").is_some(), + })) +} + +/// Les actions à plusieurs signatures ni exécutées ni expirées, des plus +/// anciennes aux plus récentes. +pub async fn pending(pool: &PgPool, now: OffsetDateTime) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT a.id, a.body, a.body_hash, a.required_signatures, a.created_at, a.expires_at, + COALESCE(array_agg(o.name ORDER BY e.verified_at) + FILTER (WHERE o.name IS NOT NULL), '{}') AS signed_by + FROM actions a + LEFT JOIN decision_evidence e ON e.action_id = a.id + LEFT JOIN operators o ON o.id = e.operator_id + WHERE a.executed_at IS NULL AND a.expires_at > $1 AND a.required_signatures > 1 + GROUP BY a.id + ORDER BY a.created_at", + ) + .bind(now) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| { + let body: serde_json::Value = r.get("body"); + let signed_by: Vec = r.get("signed_by"); + let hash: Vec = r.get("body_hash"); + Pending { + action_id: r.get("id"), + action: body + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_string(), + body, + body_hash: hash.iter().map(|b| format!("{b:02x}")).collect(), + required: r.get("required_signatures"), + signatures: signed_by.len(), + signed_by, + created_at: r.get("created_at"), + expires_at: r.get("expires_at"), + } + }) + .collect()) +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index fdf3295..9ad8469 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -708,3 +708,178 @@ async fn an_ra_operator_cannot_prepare_a_revocation() { assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); assert_eq!(env.actions_frozen().await, 0); } + +impl Env { + async fn get(&self, path: &str, cookie: &str) -> (StatusCode, serde_json::Value) { + let res = self + .console + .clone() + .oneshot( + Request::get(path) + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or_default()) + } + + /// Première signature d'une révocation par l'opérateur de `cookie` : rend + /// l'identifiant de l'action figée. + async fn first_signature(&mut self, cookie: &str, serial: &str) -> String { + let (_, issued) = self.challenge(Some(cookie), revoke(serial)).await; + let assertion = self.sign(&issued); + let (status, done) = self + .decide( + Some(cookie), + &format!("/api/v1/certificates/{serial}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(done["status"], "AWAITING_QUORUM", "{status} {done}"); + done["action_id"].as_str().unwrap().to_string() + } +} + +/// Étape 4b : deux `ca_operateur` distincts révoquent ensemble. La salle +/// d'attente lit l'état de `ca-server` ; une seconde signature du même +/// opérateur ne compte pas ; la dernière signature exécute, une seule fois. +#[tokio::test] +async fn two_distinct_ca_operators_revoke_together() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let alice = env.log_in("alice").await; + let bob = env.log_in("bob").await; + let serial = env.certificate("tx-quorum").await; + let action_id = env.first_signature(&alice, &serial).await; + + let (status, waiting) = env.get("/api/v1/quorum?state=PENDING", &bob).await; + assert_eq!(status, StatusCode::OK, "{waiting}"); + let waiting = waiting.as_array().unwrap(); + assert_eq!(waiting.len(), 1); + assert_eq!(waiting[0]["action_id"], action_id.as_str()); + assert_eq!(waiting[0]["action"], "revoke_certificate"); + assert_eq!(waiting[0]["body"]["serial"], serial.as_str()); + assert_eq!(waiting[0]["signatures"], 1); + assert_eq!(waiting[0]["required"], 2); + assert_eq!(waiting[0]["signed_by"], serde_json::json!(["alice"])); + + // Alice ne peut pas signer une seconde fois sa propre action. + let (status, err) = env + .challenge(Some(&alice), serde_json::json!({ "action_id": action_id })) + .await; + if status == StatusCode::OK { + let assertion = env.sign(&err); + let (status, err) = env + .decide( + Some(&alice), + &format!("/api/v1/quorum/{action_id}/sign"), + &err, + &assertion, + ) + .await; + assert!(status.is_client_error(), "{status} {err}"); + } else { + assert!(status.is_client_error(), "{status} {err}"); + } + assert_eq!( + env.status_of(&serial).await, + oe_castore::CertificateStatus::Issued + ); + + // Bob co-signe : la révocation s'exécute. + let (status, issued) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&bob), + &format!("/api/v1/quorum/{action_id}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + assert_eq!(done["signatures"], 2); + assert_eq!(done["signed_by"], "bob"); + assert_eq!( + env.status_of(&serial).await, + oe_castore::CertificateStatus::Revoked + ); + + let (_, waiting) = env.get("/api/v1/quorum?state=PENDING", &bob).await; + assert_eq!(waiting, serde_json::json!([])); + // Une action exécutée ne se prépare plus. + let (status, err) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "already_executed"); +} + +/// Une co-signature ne compte que pour l'action pour laquelle son challenge a +/// été émis : présentée pour une autre, elle est refusée sans rien consommer. +/// Les deux actions visent le même certificat : seul leur identifiant les +/// distingue, c'est bien lui qui est contrôlé. +#[tokio::test] +async fn a_co_signature_only_counts_for_its_action() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let alice = env.log_in("alice").await; + let bob = env.log_in("bob").await; + let x = env.certificate("tx-x").await; + let action_x = env.first_signature(&alice, &x).await; + let action_y = env.first_signature(&alice, &x).await; + assert_ne!(action_x, action_y); + + let (_, issued) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action_x })) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&bob), + &format!("/api/v1/quorum/{action_y}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + assert_eq!( + env.status_of(&x).await, + oe_castore::CertificateStatus::Issued + ); + + let (status, done) = env + .decide( + Some(&bob), + &format!("/api/v1/quorum/{action_x}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["action_id"], action_x.as_str()); + assert_eq!( + env.status_of(&x).await, + oe_castore::CertificateStatus::Revoked + ); + + // Une action inconnue, ou un identifiant qui n'en est pas un. + for id in ["3f2b8c1e-9d4a-4e6b-8a7c-1234567890ab", "pas-un-uuid"] { + let (status, err) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": id })) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{id} {err}"); + } +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index df028d2..baf0ace 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -205,10 +205,20 @@ pub struct Expect { /// canonique du corps figé). #[serde(default)] pub serial: Option, + /// Action visée par une co-signature (double contrôle, §8) : le challenge + /// présenté doit avoir été émis pour elle. + #[serde(default)] + pub action_id: Option, } impl Expect { - fn check(&self, action: &Action) -> Result<(), Error> { + fn check(&self, action: &Action, action_id: Uuid) -> Result<(), Error> { + if self.action_id.is_some_and(|expected| expected != action_id) { + return Err(Error::Mismatch(format!( + "action attendue {}, challenge émis pour {action_id}", + self.action_id.unwrap_or_default() + ))); + } if self.action != action.kind() { return Err(Error::Mismatch(format!( "attendu {}, figé {}", @@ -733,7 +743,7 @@ impl Service { // Avant de retirer l'état de la cérémonie : une assertion présentée pour // une autre cible ne consomme rien, le bon appel reste possible. if let Some(expect) = expect { - expect.check(&stored.action)?; + expect.check(&stored.action, action_id)?; } // Une seule tentative par cérémonie : l'état sort de la mémoire quoi @@ -1002,25 +1012,26 @@ mod expect_tests { action: action.to_string(), transaction_id: tx.map(str::to_string), serial: None, + action_id: None, } } #[test] fn only_the_frozen_action_and_target_pass() { assert!(expect("approve_request", Some("tx-a")) - .check(&approve("tx-a")) + .check(&approve("tx-a"), Uuid::nil()) .is_ok()); assert!(matches!( - expect("approve_request", Some("tx-b")).check(&approve("tx-a")), + expect("approve_request", Some("tx-b")).check(&approve("tx-a"), Uuid::nil()), Err(Error::Mismatch(_)) )); assert!(matches!( - expect("reject_request", Some("tx-a")).check(&approve("tx-a")), + expect("reject_request", Some("tx-a")).check(&approve("tx-a"), Uuid::nil()), Err(Error::Mismatch(_)) )); // Une décision sans cible précisée n'est pas une attente : refusée. assert!(matches!( - expect("approve_request", None).check(&approve("tx-a")), + expect("approve_request", None).check(&approve("tx-a"), Uuid::nil()), Err(Error::BadRequest(_)) )); // Une action sans demande visée : seul le type compte. @@ -1028,7 +1039,7 @@ mod expect_tests { operator: "alice".to_string(), role: Role::Auditeur, }; - assert!(expect("set_role", None).check(&role).is_ok()); + assert!(expect("set_role", None).check(&role, Uuid::nil()).is_ok()); // Révocation : la cible est le numéro de série, jamais une demande. let revoke = Action::RevokeCertificate { @@ -1040,16 +1051,32 @@ mod expect_tests { action: "revoke_certificate".to_string(), transaction_id: None, serial: Some(s.to_string()), + action_id: None, }; - assert!(by_serial("0a1b").check(&revoke).is_ok()); + assert!(by_serial("0a1b").check(&revoke, Uuid::nil()).is_ok()); assert!(matches!( - by_serial("0a1c").check(&revoke), + by_serial("0a1c").check(&revoke, Uuid::nil()), Err(Error::Mismatch(_)) )); let mixed = Expect { transaction_id: Some("tx".to_string()), ..by_serial("0a1b") }; - assert!(matches!(mixed.check(&revoke), Err(Error::BadRequest(_)))); + assert!(matches!( + mixed.check(&revoke, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Co-signature : le challenge doit avoir été émis pour l'action visée. + let target = Uuid::from_u128(7); + let for_target = Expect { + action_id: Some(target), + ..by_serial("0a1b") + }; + assert!(for_target.check(&revoke, target).is_ok()); + assert!(matches!( + for_target.check(&revoke, Uuid::from_u128(8)), + Err(Error::Mismatch(_)) + )); } } diff --git a/crates/oe-castore/sql/ra_console_grants.sql b/crates/oe-castore/sql/ra_console_grants.sql index 07d10f9..db57cd0 100644 --- a/crates/oe-castore/sql/ra_console_grants.sql +++ b/crates/oe-castore/sql/ra_console_grants.sql @@ -23,7 +23,12 @@ GRANT SELECT ON operators, webauthn_credentials, pending_credentials, - decision_evidence + decision_evidence, + -- Les actions figées (corps, empreinte, seuil, échéance) : la salle + -- d'attente des actions à plusieurs signatures (docs/WEBUI.md §8) les lit + -- ici plutôt que d'en tenir une copie. Aucun secret n'y figure : le jeton + -- d'une invitation n'est rendu que dans le résultat de l'exécution. + actions TO openeidas_ra_console; -- Pour les clés étrangères des tables propres à ra-console. diff --git a/crates/oe-castore/tests/operators_schema.rs b/crates/oe-castore/tests/operators_schema.rs index 7c03d4d..de7ea59 100644 --- a/crates/oe-castore/tests/operators_schema.rs +++ b/crates/oe-castore/tests/operators_schema.rs @@ -318,11 +318,10 @@ async fn ra_console_role_cannot_write_ca_tables() { ); } - // Aucune lecture des tables sans droit : hachés de jetons, actions, - // challenges, autorités, CRL. + // Aucune lecture des tables sans droit : hachés de jetons, challenges, + // autorités, CRL. for table in [ "operator_invites", - "actions", "action_challenges", "authorities", "crls", @@ -336,6 +335,8 @@ async fn ra_console_role_cannot_write_ca_tables() { // La lecture, elle, fonctionne : c'est ce dont la console a besoin. for table in [ + // La salle d'attente des actions à plusieurs signatures (§8). + "actions", "enrollment_requests", "certificates", "operators", diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index ec77682..a66e9b7 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -110,11 +110,33 @@ forme canonique du corps figé) ; toute autre forme est refusée avant relais. distincts** (docs/WEBUI.md §8). La première signature est enregistrée par `ca-server` et **rien n'est révoqué** : réponse `{"status": "AWAITING_QUORUM", "signatures": 1, "required": 2, "action_id", "signed_by"}`. La signature suivante - (co-signature) arrive avec l'étape 4b. + (co-signature) passe par la salle d'attente, ci-dessous. - La cible est contrôlée par `ca-server` comme pour une décision (`expect` porte le numéro de série) : une signature ne révoque jamais un autre certificat. - Un `ra_operateur` ne peut pas préparer de révocation : `ca-server` refuse. +## Double contrôle : salle d'attente et co-signature + +- `GET /api/v1/quorum?state=PENDING` (session) : les actions à plusieurs signatures ni + exécutées ni expirées — identifiant, type, **corps figé** (à afficher tel quel à qui + va co-signer), empreinte, signatures recueillies et exigées, **qui a déjà signé**. + La console lit l'état qui fait foi, dans la table `actions` et `decision_evidence` + de `ca-server`, en lecture seule ; elle n'en tient aucune copie et ne conserve + jamais d'assertion. +- Co-signer : `POST /api/v1/webauthn/challenge` avec `{"action_id": "…"}` (la console + vérifie que l'action existe, n'est pas exécutée et relève des actions proposées), + puis `POST /api/v1/quorum/{action_id}/sign` avec `{"challenge_id", "assertion"}`. + La console joint à `expect` l'identifiant de l'action et sa cible : une + co-signature ne compte que pour l'action pour laquelle son challenge a été émis. +- `ca-server` n'accepte qu'une signature par opérateur, relit le rôle de chacun et + exécute **une seule fois**, au seuil fixé par sa politique : la dernière signature + rend `{"status": "EXECUTED", "signatures": 2, "required": 2, …}`. + +**Mise à jour d'un déploiement existant** : la salle d'attente exige le droit de +lecture sur `actions`, ajouté au script des droits. Rejouer +`psql -f crates/oe-castore/sql/ra_console_grants.sql` (idempotent) ; sans cela, +`GET /api/v1/quorum` et la co-signature répondent `503`. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -149,8 +171,7 @@ forme canonique du corps figé) ; toute autre forme est refusée avant relais. ## Ce qui n'existe pas encore -La co-signature et la liste des actions en attente (étape 4b), la gestion du registre -depuis la console, le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +La gestion du registre depuis la console (invitations, clés, rôles), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 12e5cab..6726b20 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -911,7 +911,18 @@ que vise la cible actuelle du CPS ; il en est le pendant numérique pour les actions qui, elles, doivent rester exécutables à distance (ex. révocation d'urgence d'une CA hors heures ouvrées). -Tables de collecte, côté `ra-console` (propriété et droits : §2, §16) : +**Ce qui est construit (étape 4, 2026-09-27) diffère du schéma ci-dessous, en +plus sûr.** `ca-server` enregistre chaque signature au fil de l'eau +(`decision_evidence`, une ligne par opérateur, `UNIQUE(action_id, operator_id)`) +et n'exécute qu'au seuil : chaque signataire obtient son propre challenge sur +l'action figée (`issue_challenge_for`), et son assertion est vérifiée et +consommée aussitôt. `ra-console` n'a donc aucune table de collecte et ne +conserve jamais d'assertion : sa salle d'attente lit `actions` et +`decision_evidence` en lecture seule (voir [RA-CONSOLE.md](RA-CONSOLE.md)). +Les tables qui suivent sont conservées pour mémoire de la conception initiale. + +Tables de collecte, côté `ra-console` (propriété et droits : §2, §16) — **non +construites** : ```sql CREATE TABLE quorum_requests ( From 4934b5771897fb3ce4cf310102f33e30c902a971 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 12:20:40 +0200 Subject: [PATCH 3/4] feat(deploy): image, chart Helm et surcouche compose de ra-console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO §1, docs/WEBUI.md §16-17 : ra-console devient déployable, sans changement de son code. - Image deploy/ra-console/Dockerfile, construite avec `-p ra-console` seul (cryptoki non lié, garde no_pkcs11.rs), sans SoftHSM ; ajoutée aux matrices de construction/scan et de publication de la CI. La vérification de démarrage accepte `--version` (la console n'a ni PIN ni sous-commande `version`). - Entrypoint : au premier démarrage, certificat de la CA émettrice (/api/v1/ca.pem, conservé) puis certificat client internal_client (`ra-console internal-cert`, attend l'approbation). - Helm, désactivé par défaut (raConsole.enabled) et refusé au rendu sans ca.internal.enabled : Deployment (volume d'état, sondes), Service, HTTPRoute optionnelle, NetworkPolicy (entrée : namespace de la Gateway seul ; sortie : DNS, PostgreSQL, CA), Job hook post-install/post-upgrade qui applique ra_console_grants.sql (copie dans files/, identité vérifiée par la CI) : les identifiants d'administration de la base n'entrent jamais dans le pod de la console. Réutilise la Relying Party WebAuthn et la liste blanche de la CA. Deux clés au Secret généré. - docker-compose.console.yml : surcouche opt-in (liste blanche de modèles requise), jamais chargée par `make up` ni la CI. - CI et `make helm-lint` : rendu avec la console, refus sans lien interne (vérifié par mutation), identité du script des droits. - Docs : README du chart, docs/RA-CONSOLE.md. Co-authored-by: Claude --- .github/workflows/ci.yml | 34 +++- Makefile | 3 + deploy/helm/open-eidas/.helmignore | 2 + deploy/helm/open-eidas/README.md | 58 ++++++- .../helm/open-eidas/ci/ra-console-values.yaml | 20 +++ .../open-eidas/files/ra_console_grants.sql | 45 ++++++ deploy/helm/open-eidas/templates/_helpers.tpl | 19 +++ .../templates/ra-console/deployment.yaml | 148 ++++++++++++++++++ .../templates/ra-console/grants.yaml | 87 ++++++++++ .../templates/ra-console/httproute.yaml | 25 +++ .../templates/ra-console/networkpolicy.yaml | 57 +++++++ .../templates/ra-console/pvc-state.yaml | 16 ++ .../templates/ra-console/service.yaml | 15 ++ .../templates/secrets/generated.yaml | 14 ++ deploy/helm/open-eidas/values.yaml | 50 +++++- deploy/ra-console/Dockerfile | 49 ++++++ deploy/ra-console/entrypoint.sh | 54 +++++++ docker-compose.console.yml | 89 +++++++++++ docs/RA-CONSOLE.md | 32 +++- 19 files changed, 810 insertions(+), 7 deletions(-) create mode 100644 deploy/helm/open-eidas/.helmignore create mode 100644 deploy/helm/open-eidas/ci/ra-console-values.yaml create mode 100644 deploy/helm/open-eidas/files/ra_console_grants.sql create mode 100644 deploy/helm/open-eidas/templates/ra-console/deployment.yaml create mode 100644 deploy/helm/open-eidas/templates/ra-console/grants.yaml create mode 100644 deploy/helm/open-eidas/templates/ra-console/httproute.yaml create mode 100644 deploy/helm/open-eidas/templates/ra-console/networkpolicy.yaml create mode 100644 deploy/helm/open-eidas/templates/ra-console/pvc-state.yaml create mode 100644 deploy/helm/open-eidas/templates/ra-console/service.yaml create mode 100644 deploy/ra-console/Dockerfile create mode 100755 deploy/ra-console/entrypoint.sh create mode 100644 docker-compose.console.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9591347..e7cae47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,12 @@ jobs: dockerfile: deploy/ocsp-responder/Dockerfile tag: openeidas-ocsp-responder:ci pin_env: OPENEIDAS_PIN + # Console d'exploitation : aucun token, donc aucun PIN, et pas de + # sous-commande `version` (option `--version`, clap). + - image: console RA + dockerfile: deploy/ra-console/Dockerfile + tag: openeidas-ra-console:ci + version_arg: --version steps: - uses: actions/checkout@v4 @@ -200,7 +206,7 @@ jobs: CACHEBUST=${{ steps.cachebust.outputs.value }} - name: Vérifie que le binaire démarre - run: docker run --rm -e ${{ matrix.pin_env }}=1234 ${{ matrix.tag }} version + run: docker run --rm ${{ matrix.pin_env && format('-e {0}=1234', matrix.pin_env) || '' }} ${{ matrix.tag }} ${{ matrix.version_arg || 'version' }} - name: Scan de vulnérabilités (Trivy) uses: aquasecurity/trivy-action@v0.36.0 @@ -236,6 +242,10 @@ jobs: dockerfile: deploy/tsa/Dockerfile - image: open-eidas-ocsp-responder dockerfile: deploy/ocsp-responder/Dockerfile + # Image par défaut de raConsole.image dans le chart. Pas épinglée sur + # le staging (job suivant) : la console n'y est pas déployée. + - image: open-eidas-ra-console + dockerfile: deploy/ra-console/Dockerfile steps: - uses: actions/checkout@v4 @@ -376,6 +386,28 @@ jobs: exit 1 fi helm template open-eidas deploy/helm/open-eidas --set production=true > /dev/null + - name: helm lint et rendu avec ra-console + run: | + helm lint deploy/helm/open-eidas -f deploy/helm/open-eidas/ci/ra-console-values.yaml + helm template open-eidas deploy/helm/open-eidas \ + -f deploy/helm/open-eidas/ci/ra-console-values.yaml > /dev/null + - name: helm template refuse ra-console sans le lien interne de la CA + # La console n'agit que par le lien interne : le rendu doit échouer, + # pas déployer une console inutilisable. Valeurs complètes par + # ailleurs : seule la garde peut faire échouer ce rendu. + run: | + if helm template open-eidas deploy/helm/open-eidas \ + -f deploy/helm/open-eidas/ci/ra-console-values.yaml \ + --set ca.internal.enabled=false > /dev/null 2>&1; then + echo "le chart accepte raConsole.enabled sans ca.internal.enabled" >&2 + exit 1 + fi + - name: Le script des droits du chart est celui du dépôt + # Helm ne lit pas de fichier hors du chart : files/ en porte une copie, + # qui ne doit jamais diverger de l'original. + run: | + diff -u crates/oe-castore/sql/ra_console_grants.sql \ + deploy/helm/open-eidas/files/ra_console_grants.sql helm-kind-smoke-test: name: Amorçage complet + démonstration (Helm sur kind) diff --git a/Makefile b/Makefile index f6e86af..56c489e 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,9 @@ helm-lint: ## Vérifie le chart Helm (lint + rendu complet) helm lint deploy/helm/open-eidas helm template open-eidas deploy/helm/open-eidas > /dev/null ! helm template open-eidas deploy/helm/open-eidas --set production=true --set ca.autoApprove.enabled=true > /dev/null 2>&1 + helm template open-eidas deploy/helm/open-eidas -f deploy/helm/open-eidas/ci/ra-console-values.yaml > /dev/null + ! helm template open-eidas deploy/helm/open-eidas -f deploy/helm/open-eidas/ci/ra-console-values.yaml --set ca.internal.enabled=false > /dev/null 2>&1 + diff -u crates/oe-castore/sql/ra_console_grants.sql deploy/helm/open-eidas/files/ra_console_grants.sql logs: ## Suit les journaux de la TSA docker compose logs -f tsa diff --git a/deploy/helm/open-eidas/.helmignore b/deploy/helm/open-eidas/.helmignore new file mode 100644 index 0000000..d31cbaf --- /dev/null +++ b/deploy/helm/open-eidas/.helmignore @@ -0,0 +1,2 @@ +# Valeurs de vérification du rendu (CI), pas une partie du chart publié. +ci/ diff --git a/deploy/helm/open-eidas/README.md b/deploy/helm/open-eidas/README.md index 62cf530..d180692 100644 --- a/deploy/helm/open-eidas/README.md +++ b/deploy/helm/open-eidas/README.md @@ -140,7 +140,7 @@ kubectl -n open-eidas exec deploy/open-eidas-ca -c ca -- \ `ca-server` peut exposer, sur un **second port** (`ca.service.internalPort`, 8321), les routes `/internal/v1/*` par lesquelles la console d'exploitation -(`ra-console`, pas encore déployable) fait exécuter des actions signées par des +(`ra-console`, section suivante) fait exécuter des actions signées par des opérateurs ([docs/WEBUI.md](../../../docs/WEBUI.md) §16-17). Désactivé par défaut : un port qui accepte des actions privilégiées ne s'ouvre que sur décision explicite. @@ -185,6 +185,61 @@ et n'est lu qu'au démarrage : le renouveler demande de supprimer `server.pem` puis de redémarrer le pod. Le certificat **client** de `ra-console` se demande côté `ra-console`, jamais approuvé par elle-même. +## Console d'exploitation `ra-console` (optionnelle) + +La console RA/CA ([docs/RA-CONSOLE.md](../../../docs/RA-CONSOLE.md)) : connexion +des opérateurs par clé FIDO2, décisions d'enrôlement et révocations signées, +relayées à `ca-server` par le lien interne. **Désactivée par défaut** ; elle exige +le lien interne ci-dessus, dont elle **réutilise** la configuration WebAuthn et la +liste blanche de modèles (une seule Relying Party). Le rendu **échoue** si +`raConsole.enabled` est posé sans `ca.internal.enabled`. + +```yaml +ca: + internal: + enabled: true + webauthn: { rpId: console.open-eidas.example, origin: https://console.open-eidas.example } + models: [...] +raConsole: + enabled: true + gateway: # Gateway INTERNE : jamais une Gateway publique + enabled: true + name: internal-gateway + namespace: ingress + host: console.open-eidas.example # = l'hôte de ca.internal.webauthn.origin +``` + +Ce que ça ajoute : + +- un `Deployment` (une instance, volume d'état `ReadWriteOnce` : clé et certificat + client, certificat de la CA, journal d'audit de la console), son `Service` et, si + `raConsole.gateway.enabled`, une `HTTPRoute` ; +- une `NetworkPolicy` : en entrée, le seul namespace de la Gateway ; en sortie, le + DNS, PostgreSQL et la CA (API publique et port interne). Sans Gateway, rien + n'entre par le réseau du cluster (`kubectl port-forward` reste possible) ; +- un **Job** (hook `post-install`/`post-upgrade`) qui applique les droits du rôle + PostgreSQL en lecture seule `openeidas_ra_console` + (`files/ra_console_grants.sql`, copie vérifiée par la CI de + `crates/oe-castore/sql/ra_console_grants.sql`) et lui donne son mot de passe. Un + Job plutôt qu'un conteneur d'initialisation de la console : **les identifiants + d'administration de la base n'entrent jamais dans le pod de la console**, le + composant le plus exposé. Rejoué à chaque mise à jour, il fait arriver les + nouveaux droits sans geste manuel. Il attend que `ca-server` ait appliqué ses + migrations. Le compte `postgres.user` doit pouvoir créer un rôle (c'est le cas du + StatefulSet intégré ; pour une base externe, à vérifier) ; +- deux clés au Secret généré : `ra-console-db-password` et + `ra-console-decoy-secret` (réponses de connexion uniformes). Avec + `secrets.existingSecret`, le Secret fourni doit les contenir. + +**Premier démarrage.** L'entrypoint (`deploy/ra-console/entrypoint.sh`) récupère +le certificat de la CA émettrice sur l'API interne de la CA (`/api/v1/ca.pem`, le +premier certificat fait foi), puis demande le certificat client `internal_client` +(`ra-console internal-cert`) et **attend son approbation** : le sidecar +d'approbation la traite en démonstration ; en production, un opérateur nommé +l'approuve sur la CA (`ca-server ra approve`), comme pour `internal_server`. La +console n'approuve jamais son propre certificat. Un conteneur d'initialisation +attend, avec les identifiants **de la console**, que le Job ait créé son rôle. + ## Écarts notables avec le docker-compose Aucun, désormais, sur le plan des permissions : le remplacement d'OpenXPKI par @@ -216,6 +271,7 @@ Voir `values.yaml` pour la liste complète. Les plus utiles : | `ca.crl.validity` / `ca.crl.refresh` | Fenêtre de validité des CRL et fréquence de republication | | `ca.audit.retention` | Durée de conservation du journal (ETSI EN 319 401 §7.10) ; le service refuse de démarrer en deçà d'un an | | `ocsp.publicURL` / `ocsp.gateway.enabled` / `ocsp.gateway.host` | Adresse publique gravée dans l'extension AIA du certificat TSU, et son exposition HTTP(S) | +| `raConsole.enabled`, `raConsole.gateway.*`, `raConsole.networkPolicy.enabled` | Console d'exploitation (voir ci-dessus), désactivée par défaut | | `secrets.existingSecret` | Secret existant (ex. scellé via kubeseal) à utiliser à la place de celui généré par le motif `lookup` | | `postgres.external.enabled` / `postgres.external.host` / `postgres.external.port` | PostgreSQL externe (ex. CloudNativePG) à la place du StatefulSet intégré | | `postgres.persistence.size`, `tsa.persistence.*.size`, `ocsp.persistence.*.size`, `auditReplica.persistence.size` | Tailles des volumes persistants | diff --git a/deploy/helm/open-eidas/ci/ra-console-values.yaml b/deploy/helm/open-eidas/ci/ra-console-values.yaml new file mode 100644 index 0000000..52aea4e --- /dev/null +++ b/deploy/helm/open-eidas/ci/ra-console-values.yaml @@ -0,0 +1,20 @@ +# Valeurs de vérification du rendu de ra-console (job CI helm-lint) : le lien +# interne de la CA, sa Relying Party WebAuthn et un modèle de clé factice. Pas +# une configuration de déploiement. +ca: + internal: + enabled: true + webauthn: + rpId: console.example.test + origin: https://console.example.test + models: + - description: "Modèle factice (vérification du rendu)" + aaguid: "00000000-0000-0000-0000-000000000000" + rootPem: | + -----BEGIN CERTIFICATE----- + MIIB + -----END CERTIFICATE----- +raConsole: + enabled: true + gateway: + enabled: true diff --git a/deploy/helm/open-eidas/files/ra_console_grants.sql b/deploy/helm/open-eidas/files/ra_console_grants.sql new file mode 100644 index 0000000..db57cd0 --- /dev/null +++ b/deploy/helm/open-eidas/files/ra_console_grants.sql @@ -0,0 +1,45 @@ +-- Droits du rôle applicatif de ra-console sur les tables de ca-server +-- (docs/WEBUI.md §16). Règle unique : lecture seule sur tout ce qui +-- appartient à ca-server. Aucun INSERT, UPDATE ni DELETE : c'est ce qui +-- ferme la faille où une console compromise écrivait une approbation en +-- base et obtenait l'émission d'un certificat. +-- +-- Le rôle est créé sans droit de connexion : le déploiement lui donne son +-- mot de passe (Helm). Idempotent, rejouable à chaque déploiement. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'openeidas_ra_console') THEN + CREATE ROLE openeidas_ra_console NOLOGIN; + END IF; +END +$$; + +REVOKE ALL ON ALL TABLES IN SCHEMA public FROM openeidas_ra_console; + +GRANT SELECT ON + enrollment_requests, + certificates, + operators, + webauthn_credentials, + pending_credentials, + decision_evidence, + -- Les actions figées (corps, empreinte, seuil, échéance) : la salle + -- d'attente des actions à plusieurs signatures (docs/WEBUI.md §8) les lit + -- ici plutôt que d'en tenir une copie. Aucun secret n'y figure : le jeton + -- d'une invitation n'est rendu que dans le résultat de l'exécution. + actions +TO openeidas_ra_console; + +-- Pour les clés étrangères des tables propres à ra-console. +GRANT REFERENCES ON operators, webauthn_credentials TO openeidas_ra_console; + +-- Ses propres tables (migration 0006) : ce qui ne donne aucun pouvoir sur la PKI. +GRANT SELECT, INSERT, UPDATE, DELETE ON + webauthn_challenges, + sessions, + login_counters +TO openeidas_ra_console; + +-- Aucun droit, même en lecture : operator_invites (hachés de jetons), +-- actions, action_challenges, authorities, crls. diff --git a/deploy/helm/open-eidas/templates/_helpers.tpl b/deploy/helm/open-eidas/templates/_helpers.tpl index 2f8b00c..8e03a76 100644 --- a/deploy/helm/open-eidas/templates/_helpers.tpl +++ b/deploy/helm/open-eidas/templates/_helpers.tpl @@ -212,3 +212,22 @@ rendu : un lien interne à moitié configuré n'est pas déployé. - name: OPENEIDAS_WEBAUTHN_MODELS_FILE value: /etc/open-eidas/webauthn/models.json {{- end -}} + +{{/* +Hôte et port de PostgreSQL : le StatefulSet intégré, ou la base externe. +*/}} +{{- define "open-eidas.postgresHost" -}} +{{- if .Values.postgres.external.enabled -}} +{{- .Values.postgres.external.host -}} +{{- else -}} +{{- printf "%s-postgres" (include "open-eidas.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "open-eidas.postgresPort" -}} +{{- if .Values.postgres.external.enabled -}} +{{- .Values.postgres.external.port | int -}} +{{- else -}} +5432 +{{- end -}} +{{- end -}} diff --git a/deploy/helm/open-eidas/templates/ra-console/deployment.yaml b/deploy/helm/open-eidas/templates/ra-console/deployment.yaml new file mode 100644 index 0000000..8a90a34 --- /dev/null +++ b/deploy/helm/open-eidas/templates/ra-console/deployment.yaml @@ -0,0 +1,148 @@ +{{- if .Values.raConsole.enabled -}} +{{- /* La console n'agit que par le lien interne de la CA : sans lui, elle ne + démarrerait pas (le rendu échoue plutôt que de déployer une console + inutilisable, ou d'ouvrir le lien à moitié configuré). */ -}} +{{- if not .Values.ca.internal.enabled -}} +{{- fail "raConsole.enabled exige ca.internal.enabled : la console n'agit que par le lien interne de la CA (docs/WEBUI.md §16)" -}} +{{- end -}} +{{- $fullname := include "open-eidas.fullname" . -}} +{{- $name := printf "%s-ra-console" $fullname -}} +{{- $secretName := include "open-eidas.secretName" . -}} +{{- $pgHost := include "open-eidas.postgresHost" . -}} +{{- $pgPort := include "open-eidas.postgresPort" . -}} +{{- $w := .Values.ca.internal.webauthn -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} +spec: + # Une instance : la clé du certificat client et le journal d'audit chaîné de + # la console vivent sur un volume ReadWriteOnce. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "ra-console") | nindent 6 }} + template: + metadata: + labels: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "ra-console") | nindent 8 }} + spec: + securityContext: + fsGroup: 10004 + # Attend que le Job des droits ait créé le rôle en lecture seule, avec les + # identifiants de la console elle-même (jamais ceux d'administration). + initContainers: + - name: wait-db-role + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + imagePullPolicy: {{ .Values.imagePullPolicy }} + command: + - /bin/sh + - -c + - | + until psql "$OPENEIDAS_DATABASE_URL" -tAc 'SELECT 1 FROM sessions LIMIT 0' >/dev/null 2>&1; do + echo "rôle openeidas_ra_console pas encore prêt, nouvel essai dans 5 s" + sleep 5 + done + env: + - name: RA_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: ra-console-db-password + - name: OPENEIDAS_DATABASE_URL + value: {{ printf "postgres://openeidas_ra_console:$(RA_DB_PASSWORD)@%s:%s/%s?sslmode=disable" $pgHost $pgPort .Values.postgres.database | quote }} + containers: + - name: ra-console + image: "{{ .Values.raConsole.image.repository }}:{{ .Values.raConsole.image.tag }}" + imagePullPolicy: {{ .Values.imagePullPolicy }} + ports: + - name: http + containerPort: {{ .Values.raConsole.service.port }} + env: + - name: OPENEIDAS_RA_LISTEN + value: {{ printf ":%d" (.Values.raConsole.service.port | int) | quote }} + - name: RA_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: ra-console-db-password + # Le rôle en lecture seule, jamais le DSN de ca-server : la console + # refuse d'ailleurs de démarrer si son rôle peut écrire (db_guard). + - name: OPENEIDAS_DATABASE_URL + value: {{ printf "postgres://openeidas_ra_console:$(RA_DB_PASSWORD)@%s:%s/%s?sslmode=disable" $pgHost $pgPort .Values.postgres.database | quote }} + - name: OPENEIDAS_CA_INTERNAL_URL + value: {{ printf "https://%s:%d" (include "open-eidas.caInternalDNS" .) (.Values.ca.service.internalPort | int) | quote }} + # Premier démarrage (deploy/ra-console/entrypoint.sh) : certificat de + # la CA émettrice, puis certificat client `internal_client`. + - name: OPENEIDAS_CA_CHAIN_URL + value: {{ printf "%s/api/v1/ca.pem" (include "open-eidas.caInternalURL" .) | quote }} + - name: OPENEIDAS_ENROLL_URL + value: {{ printf "%s/api/v1/enroll" (include "open-eidas.caInternalURL" .) | quote }} + - name: OPENEIDAS_ENROLL_HMAC_KEY + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: enroll-hmac-key + - name: OPENEIDAS_ENROLL_TIMEOUT_SECONDS + value: {{ .Values.raConsole.enrollTimeoutSeconds | quote }} + # Même Relying Party que ca-server : une seule configuration. + - name: OPENEIDAS_WEBAUTHN_RP_ID + value: {{ required "ca.internal.webauthn.rpId est obligatoire quand raConsole.enabled" $w.rpId | quote }} + - name: OPENEIDAS_WEBAUTHN_ORIGIN + value: {{ required "ca.internal.webauthn.origin est obligatoire quand raConsole.enabled" $w.origin | quote }} + - name: OPENEIDAS_WEBAUTHN_RP_NAME + value: {{ $w.rpName | quote }} + - name: OPENEIDAS_WEBAUTHN_MODELS_FILE + value: /etc/open-eidas/webauthn/models.json + - name: OPENEIDAS_LOGIN_DECOY_SECRET + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: ra-console-decoy-secret + - name: OPENEIDAS_PURGE_INTERVAL_SECONDS + value: {{ .Values.raConsole.purgeIntervalSeconds | quote }} + volumeMounts: + - name: state + mountPath: /var/lib/open-eidas/state + - name: webauthn-models + mountPath: /etc/open-eidas/webauthn + readOnly: true + # Le premier démarrage attend l'approbation du certificat client : + # la sonde de démarrage laisse ce temps. + startupProbe: + tcpSocket: + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 90 + # Prête seulement si la base ET le lien vers ca-server répondent + # (/healthz de la console). + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # Vivante tant qu'elle écoute : une CA indisponible rend la console non + # prête, pas à redémarrer. + livenessProbe: + tcpSocket: + port: http + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.raConsole.resources | nindent 12 }} + volumes: + - name: state + persistentVolumeClaim: + claimName: {{ $name }}-state + - name: webauthn-models + configMap: + name: {{ $fullname }}-ca-webauthn-models +{{- end }} diff --git a/deploy/helm/open-eidas/templates/ra-console/grants.yaml b/deploy/helm/open-eidas/templates/ra-console/grants.yaml new file mode 100644 index 0000000..21c74f5 --- /dev/null +++ b/deploy/helm/open-eidas/templates/ra-console/grants.yaml @@ -0,0 +1,87 @@ +{{- if .Values.raConsole.enabled -}} +{{- $fullname := include "open-eidas.fullname" . -}} +{{- $name := printf "%s-ra-console-grants" $fullname -}} +{{- $secretName := include "open-eidas.secretName" . -}} +# Droits du rôle PostgreSQL en lecture seule de ra-console. Le script est une +# copie de crates/oe-castore/sql/ra_console_grants.sql (la CI vérifie que les +# deux sont identiques) : Helm ne lit pas de fichier hors du chart. +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $name }} + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} +data: + ra_console_grants.sql: | + {{- .Files.Get "files/ra_console_grants.sql" | nindent 4 }} +--- +# Appliqué par un Job à chaque installation ET mise à jour (idempotent) : un +# nouveau droit (ex. SELECT sur `actions`) arrive ainsi sans geste manuel. Un +# Job plutôt qu'un initContainer de la console : les identifiants +# d'administration de la base n'entrent jamais dans le pod le plus exposé +# (docs/WEBUI.md §16). Il attend que ca-server ait appliqué ses migrations +# (les tables à autoriser doivent exister). +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $name }} + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + activeDeadlineSeconds: {{ .Values.raConsole.grantsJob.timeoutSeconds }} + template: + metadata: + labels: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "ra-console-grants") | nindent 8 }} + spec: + restartPolicy: OnFailure + containers: + - name: grants + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + imagePullPolicy: {{ .Values.imagePullPolicy }} + command: + - /bin/sh + - -c + - | + set -eu + until psql -tAc "SELECT to_regclass('public.sessions') IS NOT NULL AND to_regclass('public.actions') IS NOT NULL" 2>/dev/null | grep -q t; do + echo "migrations de ca-server pas encore appliquées, nouvel essai dans 5 s" + sleep 5 + done + psql -v ON_ERROR_STOP=1 -f /sql/ra_console_grants.sql + # Mot de passe par variable psql, jamais dans la ligne de commande. + echo "ALTER ROLE openeidas_ra_console LOGIN PASSWORD :'pw';" \ + | psql -v ON_ERROR_STOP=1 -v pw="$RA_DB_PASSWORD" + echo "droits de openeidas_ra_console appliqués" + env: + - name: PGHOST + value: {{ include "open-eidas.postgresHost" . | quote }} + - name: PGPORT + value: {{ include "open-eidas.postgresPort" . | quote }} + - name: PGDATABASE + value: {{ .Values.postgres.database | quote }} + - name: PGUSER + value: {{ .Values.postgres.user | quote }} + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: postgres-password + - name: RA_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: ra-console-db-password + volumeMounts: + - name: sql + mountPath: /sql + readOnly: true + volumes: + - name: sql + configMap: + name: {{ $name }} +{{- end }} diff --git a/deploy/helm/open-eidas/templates/ra-console/httproute.yaml b/deploy/helm/open-eidas/templates/ra-console/httproute.yaml new file mode 100644 index 0000000..840f1b3 --- /dev/null +++ b/deploy/helm/open-eidas/templates/ra-console/httproute.yaml @@ -0,0 +1,25 @@ +{{- if and .Values.raConsole.enabled .Values.raConsole.gateway.enabled }} +# Exposition aux navigateurs des opérateurs, par une Gateway INTERNE. L'hôte doit +# être celui de ca.internal.webauthn.origin : WebAuthn lie chaque clé à cette +# origine exacte. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "open-eidas.fullname" . }}-ra-console + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} +spec: + parentRefs: + - name: {{ .Values.raConsole.gateway.name }} + namespace: {{ .Values.raConsole.gateway.namespace }} + hostnames: + - {{ .Values.raConsole.gateway.host | quote }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ include "open-eidas.fullname" . }}-ra-console + port: {{ .Values.raConsole.service.port }} +{{- end }} diff --git a/deploy/helm/open-eidas/templates/ra-console/networkpolicy.yaml b/deploy/helm/open-eidas/templates/ra-console/networkpolicy.yaml new file mode 100644 index 0000000..7056c15 --- /dev/null +++ b/deploy/helm/open-eidas/templates/ra-console/networkpolicy.yaml @@ -0,0 +1,57 @@ +{{- if and .Values.raConsole.enabled .Values.raConsole.networkPolicy.enabled -}} +# La console est le seul composant exposé aux navigateurs, donc le plus +# probablement compromis un jour (docs/WEBUI.md §16-17) : ce qu'elle peut +# joindre est borné au strict nécessaire. +# +# Entrée : le seul namespace de la Gateway interne (raConsole.gateway). Sans +# Gateway, personne n'atteint la console par le réseau du cluster ; +# `kubectl port-forward` reste possible pour l'exploitation. +# Sortie : DNS, PostgreSQL (rôle en lecture seule), et la CA — son API +# publique (certificat de la CA, enrôlement du certificat client) et son port +# interne (actions signées, mTLS). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "open-eidas.fullname" . }}-ra-console + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "ra-console") | nindent 6 }} + policyTypes: + - Ingress + - Egress + {{- if .Values.raConsole.gateway.enabled }} + ingress: + - ports: + - port: {{ .Values.raConsole.service.port }} + from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.raConsole.gateway.namespace }} + {{- else }} + ingress: [] + {{- end }} + egress: + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - ports: + - port: {{ include "open-eidas.postgresPort" . }} + {{- if not .Values.postgres.external.enabled }} + to: + - podSelector: + matchLabels: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "postgres") | nindent 14 }} + {{- end }} + - ports: + - port: {{ .Values.ca.service.port }} + - port: {{ .Values.ca.service.internalPort }} + to: + - podSelector: + matchLabels: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "ca") | nindent 14 }} +{{- end }} diff --git a/deploy/helm/open-eidas/templates/ra-console/pvc-state.yaml b/deploy/helm/open-eidas/templates/ra-console/pvc-state.yaml new file mode 100644 index 0000000..ccac110 --- /dev/null +++ b/deploy/helm/open-eidas/templates/ra-console/pvc-state.yaml @@ -0,0 +1,16 @@ +{{- if .Values.raConsole.enabled -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "open-eidas.fullname" . }}-ra-console-state + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} +spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.raConsole.persistence.state.storageClassName }} + storageClassName: {{ .Values.raConsole.persistence.state.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.raConsole.persistence.state.size }} +{{- end }} diff --git a/deploy/helm/open-eidas/templates/ra-console/service.yaml b/deploy/helm/open-eidas/templates/ra-console/service.yaml new file mode 100644 index 0000000..448e4ff --- /dev/null +++ b/deploy/helm/open-eidas/templates/ra-console/service.yaml @@ -0,0 +1,15 @@ +{{- if .Values.raConsole.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "open-eidas.fullname" . }}-ra-console + labels: + {{- include "open-eidas.componentLabels" (dict "context" . "component" "ra-console") | nindent 4 }} +spec: + selector: + {{- include "open-eidas.componentSelectorLabels" (dict "context" . "component" "ra-console") | nindent 4 }} + ports: + - name: http + port: {{ .Values.raConsole.service.port }} + targetPort: http +{{- end }} diff --git a/deploy/helm/open-eidas/templates/secrets/generated.yaml b/deploy/helm/open-eidas/templates/secrets/generated.yaml index e01855b..f3cbc26 100644 --- a/deploy/helm/open-eidas/templates/secrets/generated.yaml +++ b/deploy/helm/open-eidas/templates/secrets/generated.yaml @@ -25,6 +25,8 @@ open-eidas.secretName dans _helpers.tpl. {{- $caIssuingPin := randNumeric 8 | b64enc -}} {{- $webdavPassword := randAlphaNum 24 | b64enc -}} {{- $enrollHMACKey := randAlphaNum 48 | b64enc -}} +{{- $raConsoleDbPassword := randAlphaNum 32 | b64enc -}} +{{- $raConsoleDecoySecret := randAlphaNum 48 | b64enc -}} {{- if $existing }} {{- $postgresPassword = index $existing.data "postgres-password" }} @@ -34,6 +36,14 @@ open-eidas.secretName dans _helpers.tpl. {{- $caIssuingPin = index $existing.data "ca-issuing-pin" }} {{- $webdavPassword = index $existing.data "webdav-password" }} {{- $enrollHMACKey = index $existing.data "enroll-hmac-key" }} +{{- /* Clés ajoutées avec ra-console : un Secret créé avant elles ne les porte + pas encore, elles sont alors générées une fois puis conservées. */ -}} +{{- if index $existing.data "ra-console-db-password" }} +{{- $raConsoleDbPassword = index $existing.data "ra-console-db-password" }} +{{- end }} +{{- if index $existing.data "ra-console-decoy-secret" }} +{{- $raConsoleDecoySecret = index $existing.data "ra-console-decoy-secret" }} +{{- end }} {{- end }} {{- if .Values.tsa.pin }} @@ -63,4 +73,8 @@ data: ca-issuing-pin: {{ $caIssuingPin }} webdav-password: {{ $webdavPassword }} enroll-hmac-key: {{ $enrollHMACKey }} + # Rôle PostgreSQL en lecture seule de ra-console, et secret de ses réponses + # de connexion uniformes (clé factice d'un nom inconnu, docs/WEBUI.md §16). + ra-console-db-password: {{ $raConsoleDbPassword }} + ra-console-decoy-secret: {{ $raConsoleDecoySecret }} {{- end }} diff --git a/deploy/helm/open-eidas/values.yaml b/deploy/helm/open-eidas/values.yaml index 56a9bdf..bb0c343 100644 --- a/deploy/helm/open-eidas/values.yaml +++ b/deploy/helm/open-eidas/values.yaml @@ -224,6 +224,53 @@ ca: namespace: ingress host: pki.open-eidas.example +# Console d'exploitation RA/CA (bin/ra-console, docs/WEBUI.md §16-17, +# docs/RA-CONSOLE.md). DÉSACTIVÉE par défaut : elle exige le lien interne de la +# CA (ca.internal.enabled, refusé au rendu sinon), dont elle réutilise la +# configuration WebAuthn et la liste blanche de modèles de clés — une seule +# Relying Party, pas deux réglages qui pourraient diverger. +raConsole: + enabled: false + image: + repository: ghcr.io/open-eidas/open-eidas-ra-console + tag: "latest" + service: + port: 8330 + # Purge des sessions et challenges expirés (secondes). + purgeIntervalSeconds: 60 + # Délai d'attente de l'approbation du certificat client `internal_client` au + # premier démarrage, par tentative (secondes). + enrollTimeoutSeconds: 600 + # Exposition au navigateur des opérateurs, par une Gateway INTERNE (jamais + # une Gateway publique : la console est le composant le plus exposé, §16). + # L'hôte doit correspondre à ca.internal.webauthn.origin. + gateway: + enabled: false + name: internal-gateway + namespace: ingress + host: console.open-eidas.example + # Seul le namespace de la Gateway ci-dessus atteint la console ; en sortie, + # seuls le DNS, PostgreSQL et la CA (API publique et port interne). + networkPolicy: + enabled: true + # Application des droits du rôle PostgreSQL en lecture seule + # (crates/oe-castore/sql/ra_console_grants.sql, copié dans files/ du chart) + # par un Job, hook post-install/post-upgrade : les identifiants + # d'administration de la base n'entrent jamais dans le pod de la console. + # Le compte postgres.user doit pouvoir créer un rôle (CREATEROLE) : c'est le + # cas du StatefulSet intégré ; pour une base externe, à vérifier. + grantsJob: + timeoutSeconds: 900 + resources: + requests: { cpu: 20m, memory: 32Mi } + limits: { cpu: 500m, memory: 128Mi } + persistence: + # Clé et certificat client du lien interne, certificat de la CA, journal + # d'audit chaîné de la console. + state: + size: 256Mi + storageClassName: "" + postgres: image: repository: postgres @@ -251,7 +298,8 @@ postgres: # Nom d'un Secret existant à utiliser à la place de celui généré par # templates/secrets/generated.yaml (motif `lookup`, voir ce fichier). Doit # contenir les mêmes clés (postgres-password, tsa-pin, ocsp-pin, -# ca-root-pin, ca-issuing-pin, webdav-password, enroll-hmac-key) : c'est le +# ca-root-pin, ca-issuing-pin, webdav-password, enroll-hmac-key, et, avec +# raConsole.enabled, ra-console-db-password et ra-console-decoy-secret) : c'est le # mécanisme prévu pour committer ces secrets scellés (kubeseal) dans un dépôt # de déploiement plutôt que de les laisser générer sur le cluster. secrets: diff --git a/deploy/ra-console/Dockerfile b/deploy/ra-console/Dockerfile new file mode 100644 index 0000000..a1b0871 --- /dev/null +++ b/deploy/ra-console/Dockerfile @@ -0,0 +1,49 @@ +FROM rust:1-bookworm AS build + +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY bin ./bin + +ARG VERSION=dev +# `-p ra-console` seul, jamais `--workspace` : la console n'ouvre aucune session +# PKCS#11 et ne doit pas lier `cryptoki` (docs/WEBUI.md §16). Construite avec +# le reste du workspace, l'unification des fonctionnalités de Cargo réactiverait +# la feature `pkcs11` d'`oe-hsm` ; `bin/ra-console/tests/no_pkcs11.rs` en est la +# garde. `oe-webauthn` lie OpenSSL dynamiquement (`libssl3` à l'exécution). +RUN cargo build --release -p ra-console --bin ra-console \ + && cp target/release/ra-console /out-ra-console \ + && rm -rf target + + +FROM debian:bookworm-slim + +# Même invalidation quotidienne du cache que les autres images (CACHEBUST, voir +# .github/workflows/ci.yml) : sans elle, `apt-get upgrade` resterait gelé. +# Ni SoftHSM ni opensc : la console n'a aucun token. `curl` sert à l'entrypoint +# pour récupérer le certificat de la CA émettrice au premier démarrage. +ARG CACHEBUST=unset +RUN echo "cachebust=${CACHEBUST}" \ + && apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends libssl3 ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10004 --create-home --home-dir /var/lib/open-eidas ra \ + && mkdir -p /var/lib/open-eidas/state \ + && chown -R ra:ra /var/lib/open-eidas + +COPY --from=build /out-ra-console /usr/local/bin/ra-console +COPY deploy/ra-console/entrypoint.sh /usr/local/bin/entrypoint.sh + +ENV OPENEIDAS_RA_LISTEN=:8330 \ + OPENEIDAS_INTERNAL_TLS_CERT_FILE=/var/lib/open-eidas/state/internal-tls/client.pem \ + OPENEIDAS_INTERNAL_TLS_KEY_FILE=/var/lib/open-eidas/state/internal-tls/client.key \ + OPENEIDAS_CA_CERT_FILE=/var/lib/open-eidas/state/internal-tls/ca.pem \ + OPENEIDAS_RA_AUDIT_FILE=/var/lib/open-eidas/state/ra-console-audit.log + +USER ra +WORKDIR /var/lib/open-eidas +EXPOSE 8330 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["serve"] diff --git a/deploy/ra-console/entrypoint.sh b/deploy/ra-console/entrypoint.sh new file mode 100755 index 0000000..47b6c33 --- /dev/null +++ b/deploy/ra-console/entrypoint.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# Premier démarrage de ra-console (docs/WEBUI.md §14, §16, Jour 0) : obtient le +# certificat de la CA émettrice et le certificat client `internal_client` du +# lien interne, puis démarre la console. +# +# Le certificat de la CA est la seule racine de confiance du lien mTLS. Il est +# récupéré une fois, sur l'API de la CA interne au cluster (même canal que +# l'enrôlement), puis conservé : un redémarrage ne le remplace pas. Le premier +# certificat du fichier fait foi, et `/api/v1/ca.pem` commence par la CA +# émettrice. +# +# `ra-console internal-cert` dépose la demande et attend qu'un opérateur nommé +# l'approuve sur la CA (`ca-server ra approve`) ; en démonstration, le sidecar +# d'approbation automatique la traite. La clé survit à un redémarrage. +set -eu + +: "${OPENEIDAS_CA_CERT_FILE:?OPENEIDAS_CA_CERT_FILE est obligatoire}" +: "${OPENEIDAS_INTERNAL_TLS_CERT_FILE:?OPENEIDAS_INTERNAL_TLS_CERT_FILE est obligatoire}" +: "${OPENEIDAS_INTERNAL_TLS_KEY_FILE:?OPENEIDAS_INTERNAL_TLS_KEY_FILE est obligatoire}" + +if [ "${1:-serve}" = "serve" ]; then + # La clé privée n'est lisible que par ce service, dès la création du dossier. + (umask 077 && mkdir -p "$(dirname "${OPENEIDAS_INTERNAL_TLS_KEY_FILE}")") + + if [ ! -s "${OPENEIDAS_CA_CERT_FILE}" ]; then + : "${OPENEIDAS_CA_CHAIN_URL:?OPENEIDAS_CA_CHAIN_URL est obligatoire au premier démarrage}" + attempt=1 + until curl -fsS "${OPENEIDAS_CA_CHAIN_URL}" -o "${OPENEIDAS_CA_CERT_FILE}.tmp"; do + if [ "${attempt}" -ge "${OPENEIDAS_CA_ATTEMPTS:-60}" ]; then + echo "abandon : certificat de la CA injoignable (${OPENEIDAS_CA_CHAIN_URL})" >&2 + exit 1 + fi + echo "CA injoignable (tentative ${attempt}), nouvel essai dans 5 s" + attempt=$((attempt + 1)) + sleep 5 + done + mv "${OPENEIDAS_CA_CERT_FILE}.tmp" "${OPENEIDAS_CA_CERT_FILE}" + fi + + if [ ! -s "${OPENEIDAS_INTERNAL_TLS_CERT_FILE}" ]; then + attempt=1 + until ra-console internal-cert; do + if [ "${attempt}" -ge "${OPENEIDAS_INTERNAL_CERT_ATTEMPTS:-12}" ]; then + echo "abandon : certificat client du lien interne toujours non obtenu après ${attempt} tentatives" >&2 + exit 1 + fi + echo "certificat client non obtenu (tentative ${attempt}), nouvel essai dans 10 s" + attempt=$((attempt + 1)) + sleep 10 + done + fi +fi + +exec ra-console "$@" diff --git a/docker-compose.console.yml b/docker-compose.console.yml new file mode 100644 index 0000000..354cb16 --- /dev/null +++ b/docker-compose.console.yml @@ -0,0 +1,89 @@ +# Surcouche de docker-compose.yml : la console d'exploitation RA/CA +# (bin/ra-console, docs/RA-CONSOLE.md, docs/WEBUI.md §17). Opt-in, jamais +# chargée par `make up` ni par la CI : elle exige une liste blanche de modèles +# de clés FIDO2 (décision O6), qu'aucune valeur par défaut ne peut fournir. +# +# cp mes-modeles.json deploy/ra-console/models.json # liste blanche, voir docs/CA.md +# docker compose -f docker-compose.yml -f docker-compose.console.yml up -d --build +# docker compose exec ca ca-server ra list PENDING # certificats internal_server/_client +# docker compose exec ca ca-server ra approve "prenom.nom" "lien interne" +# +# Elle ouvre le lien interne de la CA (port 8321, mTLS, jamais publié sur +# l'hôte), applique les droits du rôle PostgreSQL en lecture seule de la +# console (service one-shot, identifiants d'administration hors du conteneur +# de la console), puis sert la console sur http://localhost:8330. + +services: + ca: + environment: + OPENEIDAS_INTERNAL_LISTEN: ":8321" + OPENEIDAS_INTERNAL_DNS_NAME: ca + OPENEIDAS_INTERNAL_TLS_CERT_FILE: /var/lib/open-eidas/state/internal-tls/server.pem + OPENEIDAS_INTERNAL_TLS_KEY_FILE: /var/lib/open-eidas/state/internal-tls/server.key + OPENEIDAS_WEBAUTHN_RP_ID: ${OPENEIDAS_WEBAUTHN_RP_ID:-localhost} + OPENEIDAS_WEBAUTHN_ORIGIN: ${OPENEIDAS_WEBAUTHN_ORIGIN:-http://localhost:8330} + OPENEIDAS_WEBAUTHN_RP_NAME: Open eIDAS Console + OPENEIDAS_WEBAUTHN_MODELS_FILE: /etc/open-eidas/webauthn/models.json + volumes: + - ./deploy/ra-console/models.json:/etc/open-eidas/webauthn/models.json:ro + + # Droits du rôle openeidas_ra_console (crates/oe-castore/sql/ra_console_grants.sql), + # une fois les migrations de ca-server appliquées. Idempotent. + ra-console-grants: + image: postgres:17-alpine + restart: "no" + environment: + PGHOST: db + PGDATABASE: openeidas + PGUSER: openeidas + PGPASSWORD: ${OPENEIDAS_DB_PASSWORD:-openeidas} + RA_DB_PASSWORD: ${OPENEIDAS_RA_DB_PASSWORD:-ra-console-demo} + volumes: + - ./crates/oe-castore/sql/ra_console_grants.sql:/sql/ra_console_grants.sql:ro + command: + - /bin/sh + - -c + - | + set -eu + until psql -tAc "SELECT to_regclass('public.sessions') IS NOT NULL AND to_regclass('public.actions') IS NOT NULL" | grep -q t; do + echo "migrations de ca-server pas encore appliquées, nouvel essai dans 5 s" + sleep 5 + done + psql -v ON_ERROR_STOP=1 -f /sql/ra_console_grants.sql + echo "ALTER ROLE openeidas_ra_console LOGIN PASSWORD :'pw';" | psql -v ON_ERROR_STOP=1 -v pw="$$RA_DB_PASSWORD" + depends_on: + ca: + condition: service_healthy + + ra-console: + container_name: openeidas_ra_console + mem_reservation: 32m + mem_limit: 128m + cpus: 0.25 + build: + context: . + dockerfile: deploy/ra-console/Dockerfile + args: + VERSION: ${OPENEIDAS_VERSION:-dev} + ports: + - "8330:8330" + environment: + OPENEIDAS_DATABASE_URL: postgres://openeidas_ra_console:${OPENEIDAS_RA_DB_PASSWORD:-ra-console-demo}@db:5432/openeidas?sslmode=disable + OPENEIDAS_CA_INTERNAL_URL: https://ca:8321 + OPENEIDAS_CA_CHAIN_URL: http://ca:8320/api/v1/ca.pem + OPENEIDAS_ENROLL_URL: http://ca:8320/api/v1/enroll + OPENEIDAS_ENROLL_HMAC_KEY: ${OPENEIDAS_ENROLL_HMAC_KEY:-} + OPENEIDAS_WEBAUTHN_RP_ID: ${OPENEIDAS_WEBAUTHN_RP_ID:-localhost} + OPENEIDAS_WEBAUTHN_ORIGIN: ${OPENEIDAS_WEBAUTHN_ORIGIN:-http://localhost:8330} + OPENEIDAS_WEBAUTHN_RP_NAME: Open eIDAS Console + OPENEIDAS_WEBAUTHN_MODELS_FILE: /etc/open-eidas/webauthn/models.json + OPENEIDAS_LOGIN_DECOY_SECRET: ${OPENEIDAS_LOGIN_DECOY_SECRET:-secret-de-demonstration-a-changer} + volumes: + - rastate:/var/lib/open-eidas/state + - ./deploy/ra-console/models.json:/etc/open-eidas/webauthn/models.json:ro + depends_on: + ra-console-grants: + condition: service_completed_successfully + +volumes: + rastate: diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index a66e9b7..82e6df2 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -149,11 +149,35 @@ lecture sur `actions`, ajouté au script des droits. Rejouer | `OPENEIDAS_ENROLL_URL` | — | (`internal-cert`) API d'enrôlement publique de la CA | | `OPENEIDAS_ENROLL_HMAC_KEY` | — | (`internal-cert`) secret partagé d'enrôlement | | `OPENEIDAS_ENROLL_TIMEOUT_SECONDS` | 600 | (`internal-cert`) attente de l'approbation | +| `OPENEIDAS_WEBAUTHN_RP_ID` / `_ORIGIN` / `_RP_NAME` | — (obligatoires, sauf le nom) | Relying Party WebAuthn, **la même** que celle de `ca-server` | +| `OPENEIDAS_WEBAUTHN_MODELS_FILE` | — (obligatoire) | Liste blanche de modèles de clés, **la même** que celle de `ca-server` | +| `OPENEIDAS_LOGIN_DECOY_SECRET` | — (obligatoire, 16 octets au moins) | Secret des réponses de connexion uniformes (clé factice d'un nom inconnu) | +| `OPENEIDAS_PURGE_INTERVAL_SECONDS` | 60 | Purge des sessions et challenges expirés | +| `OPENEIDAS_RA_AUDIT_FILE` | `/var/lib/open-eidas/state/ra-console-audit.log` | Journal d'audit chaîné de la console | +| `OPENEIDAS_CA_CHAIN_URL` | — | (image, premier démarrage) d'où l'entrypoint récupère le certificat de la CA | + +## Déploiement + +- **Image** : `deploy/ra-console/Dockerfile`, construite avec `-p ra-console` seul, pour + que `cryptoki` ne soit pas lié (garde : `bin/ra-console/tests/no_pkcs11.rs`). Ni + SoftHSM ni opensc dans l'image. Construite et scannée par la CI comme les trois + autres, publiée sur GHCR (`open-eidas-ra-console`) à chaque push sur `dev`. +- **Entrypoint** (`deploy/ra-console/entrypoint.sh`) : au premier démarrage, il + récupère le certificat de la CA émettrice (`OPENEIDAS_CA_CHAIN_URL`, conservé + ensuite), puis demande le certificat client et attend son approbation (étapes 3 et + 4 du Jour 0 ci-dessous, automatisées). +- **Helm** : `raConsole.enabled`, désactivé par défaut, exige `ca.internal.enabled`. + Détail (NetworkPolicy, Job des droits, secrets) dans + [le README du chart](../deploy/helm/open-eidas/README.md). +- **docker-compose** : surcouche opt-in `docker-compose.console.yml`, jamais chargée + par `make up` ni par la CI (elle exige une liste blanche de modèles de clés, + `deploy/ra-console/models.json`, qu'aucune valeur par défaut ne peut fournir). ## Jour 0 1. Créer le rôle de la console et lui donner ses droits : - `psql -f crates/oe-castore/sql/ra_console_grants.sql`, puis un mot de passe. + `psql -f crates/oe-castore/sql/ra_console_grants.sql`, puis un mot de passe. Le + chart Helm et la surcouche compose le font eux-mêmes. 2. Sur `ca-server` : le lien interne activé et son certificat `internal_server` ([CA.md](CA.md)). 3. Le certificat client de la console : @@ -173,6 +197,6 @@ lecture sur `actions`, ajouté au script des droits. Rejouer La gestion du registre depuis la console (invitations, clés, rôles), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas -encore décrites ici. L'image, le chart Helm et le -`docker-compose.yml` de la console non plus. Le certificat client (3 mois) se -renouvelle à la main pour l'instant. +encore décrites ici. Le certificat client (3 mois) se renouvelle à la main pour +l'instant. Aucun job de +démonstration réelle (kind, compose) ne déploie encore la console. From 01bf0133fb62dd1648cf00fadda5659bbbcee2d5 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 12:30:21 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(deploy):=20aucun=20secret=20par=20d?= =?UTF-8?q?=C3=A9faut=20dans=20la=20surcouche=20compose=20de=20ra-console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitGuardian signalait les valeurs de repli des mots de passe de la surcouche docker-compose.console.yml. Pour une surcouche opt-in, mieux vaut exiger des secrets explicites qu'en fournir de démonstration : OPENEIDAS_DB_PASSWORD, OPENEIDAS_RA_DB_PASSWORD et OPENEIDAS_LOGIN_DECOY_SECRET sont désormais obligatoires (`:?`), avec un exemple de génération en tête du fichier. docker-compose.yml seul n'est pas concerné. Co-authored-by: Claude --- docker-compose.console.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docker-compose.console.yml b/docker-compose.console.yml index 354cb16..22cb44a 100644 --- a/docker-compose.console.yml +++ b/docker-compose.console.yml @@ -4,7 +4,14 @@ # de clés FIDO2 (décision O6), qu'aucune valeur par défaut ne peut fournir. # # cp mes-modeles.json deploy/ra-console/models.json # liste blanche, voir docs/CA.md +# export OPENEIDAS_DB_PASSWORD=openeidas # celui de la base déjà amorcée +# export OPENEIDAS_RA_DB_PASSWORD="$(openssl rand -hex 24)" +# export OPENEIDAS_LOGIN_DECOY_SECRET="$(openssl rand -hex 32)" # docker compose -f docker-compose.yml -f docker-compose.console.yml up -d --build +# +# Aucun secret n'a de valeur par défaut dans cette surcouche : la console tient +# un rôle PostgreSQL et un secret de service qui ne doivent jamais valoir la +# même chose d'une installation à l'autre. # docker compose exec ca ca-server ra list PENDING # certificats internal_server/_client # docker compose exec ca ca-server ra approve "prenom.nom" "lien interne" # @@ -36,8 +43,8 @@ services: PGHOST: db PGDATABASE: openeidas PGUSER: openeidas - PGPASSWORD: ${OPENEIDAS_DB_PASSWORD:-openeidas} - RA_DB_PASSWORD: ${OPENEIDAS_RA_DB_PASSWORD:-ra-console-demo} + PGPASSWORD: ${OPENEIDAS_DB_PASSWORD:?définir OPENEIDAS_DB_PASSWORD (mot de passe de la base, le même que pour docker-compose.yml)} + RA_DB_PASSWORD: ${OPENEIDAS_RA_DB_PASSWORD:?définir OPENEIDAS_RA_DB_PASSWORD (rôle en lecture seule de la console)} volumes: - ./crates/oe-castore/sql/ra_console_grants.sql:/sql/ra_console_grants.sql:ro command: @@ -68,7 +75,7 @@ services: ports: - "8330:8330" environment: - OPENEIDAS_DATABASE_URL: postgres://openeidas_ra_console:${OPENEIDAS_RA_DB_PASSWORD:-ra-console-demo}@db:5432/openeidas?sslmode=disable + OPENEIDAS_DATABASE_URL: postgres://openeidas_ra_console:${OPENEIDAS_RA_DB_PASSWORD:?définir OPENEIDAS_RA_DB_PASSWORD}@db:5432/openeidas?sslmode=disable OPENEIDAS_CA_INTERNAL_URL: https://ca:8321 OPENEIDAS_CA_CHAIN_URL: http://ca:8320/api/v1/ca.pem OPENEIDAS_ENROLL_URL: http://ca:8320/api/v1/enroll @@ -77,7 +84,7 @@ services: OPENEIDAS_WEBAUTHN_ORIGIN: ${OPENEIDAS_WEBAUTHN_ORIGIN:-http://localhost:8330} OPENEIDAS_WEBAUTHN_RP_NAME: Open eIDAS Console OPENEIDAS_WEBAUTHN_MODELS_FILE: /etc/open-eidas/webauthn/models.json - OPENEIDAS_LOGIN_DECOY_SECRET: ${OPENEIDAS_LOGIN_DECOY_SECRET:-secret-de-demonstration-a-changer} + OPENEIDAS_LOGIN_DECOY_SECRET: ${OPENEIDAS_LOGIN_DECOY_SECRET:?définir OPENEIDAS_LOGIN_DECOY_SECRET (au moins 16 octets aléatoires)} volumes: - rastate:/var/lib/open-eidas/state - ./deploy/ra-console/models.json:/etc/open-eidas/webauthn/models.json:ro