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/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c52296..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; @@ -50,6 +50,9 @@ 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)) + .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) } @@ -443,17 +446,70 @@ 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 { .. } ) } +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` @@ -475,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, @@ -546,35 +610,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 +645,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 +654,171 @@ 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() + 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, + } +} + +#[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); } - other => relayed(other), + _ => {} + } + 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"); + 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/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 852f99b..9ad8469 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,288 @@ 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); +} + +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 23c20eb..baf0ace 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -198,12 +198,27 @@ 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, + /// 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é {}", @@ -211,18 +226,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(()), } @@ -716,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 @@ -984,25 +1011,27 @@ mod expect_tests { Expect { 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. @@ -1010,6 +1039,44 @@ 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 { + 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()), + action_id: None, + }; + assert!(by_serial("0a1b").check(&revoke, Uuid::nil()).is_ok()); + assert!(matches!( + 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, 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/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..22cb44a --- /dev/null +++ b/docker-compose.console.yml @@ -0,0 +1,96 @@ +# 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 +# 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" +# +# 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:?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: + - /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:?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 + 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:?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 + depends_on: + ra-console-grants: + condition: service_completed_successfully + +volumes: + rastate: diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 14df368..82e6df2 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,45 @@ 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) 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 | @@ -110,11 +149,35 @@ lu dans le registre de `ca-server`, pas celui de la session. | `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 : @@ -132,9 +195,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 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. 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 (