From 9e06cda1b83703d018124cc2758503e27216d403 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 11:52:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(ra-console):=20double=20contr=C3=B4le,=20c?= =?UTF-8?q?o-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 (