diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c52296..abb4010 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,10 @@ 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)) + .merge(crate::registry_routes::routes()) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -89,7 +93,7 @@ async fn handle_health(State(state): State>) -> Response { } /// `{"error": "", "message": "..."}` (docs/WEBUI.md §5), sans trace interne. -fn error(status: StatusCode, code: &str, message: &str) -> Response { +pub(crate) fn error(status: StatusCode, code: &str, message: &str) -> Response { ( status, Json(serde_json::json!({ "error": code, "message": message })), @@ -247,7 +251,7 @@ struct LoginFinish { /// Un nom d'opérateur : ce qu'un humain saisit, pas un identifiant technique. /// Une longueur bornée suffit à écarter un corps abusif avant toute requête ; /// le reste (existe ou non) ne se voit jamais dans la réponse (§16). -fn looks_like_a_name(s: &str) -> bool { +pub(crate) fn looks_like_a_name(s: &str) -> bool { !s.is_empty() && s.chars().count() <= 256 } @@ -443,17 +447,76 @@ 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 (docs/WEBUI.md §5, §15 étapes 3 et 4) : +/// décider d'une demande d'enrôlement, révoquer un certificat, et gérer le +/// registre des opérateurs (inviter, confirmer ou révoquer une clé, changer un +/// rôle). L'énumération d'`oe_actions` est fermée : il n'en existe pas d'autre +/// aujourd'hui ; une action ajoutée plus tard n'est pas relayée tant qu'elle +/// n'est pas nommée ici. 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 { .. } + | oe_actions::Action::InviteOperator { .. } + | oe_actions::Action::ConfirmKey { .. } + | oe_actions::Action::RevokeKey { .. } + | oe_actions::Action::SetRole { .. } ) } +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 +538,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 +617,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)] +pub(crate) 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 +652,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 +661,178 @@ 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", + ) } - other => relayed(other), } } +/// `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); + } + oe_actions::Action::ConfirmKey { credential_id, .. } + | oe_actions::Action::RevokeKey { credential_id, .. } => { + expect["credential_id"] = serde_json::json!(credential_id); + } + oe_actions::Action::SetRole { operator, .. } => { + expect["operator"] = serde_json::json!(operator); + } + oe_actions::Action::InviteOperator { .. } => {} + } + 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. +pub(crate) 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..1e82d38 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -19,6 +19,8 @@ pub mod db_guard; pub mod http; pub mod login; pub mod purge; +pub mod quorum; +pub mod registry_routes; 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/src/registry_routes.rs b/bin/ra-console/src/registry_routes.rs new file mode 100644 index 0000000..51dcaab --- /dev/null +++ b/bin/ra-console/src/registry_routes.rs @@ -0,0 +1,138 @@ +//! Gestion du registre des opérateurs depuis la console (docs/WEBUI.md §5, +//! §10) : inviter un opérateur, confirmer ou révoquer une clé, changer un rôle. +//! Même schéma que les décisions et la révocation (§4) : le challenge est +//! préparé par `POST /api/v1/webauthn/challenge` avec l'action voulue, puis +//! l'assertion est relayée ici, **sans corps** ; `ca-server` exécute celui +//! qu'il a figé, après avoir comparé la cible de la route (`expect`) au corps +//! figé. Seul un `admin` signe ces actions, et deux pour créer un +//! administrateur ou changer le rôle de l'un d'eux : c'est la politique de +//! `ca-server`, jamais une décision de la console. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; + +use crate::http::{error, looks_like_a_name, quorum_status, relay_assertion, AppState}; + +pub(crate) fn routes() -> Router> { + Router::new() + .route("/api/v1/operators", post(handle_invite)) + .route( + "/api/v1/credentials/{credential_id}/confirm", + post(handle_confirm_key), + ) + .route( + "/api/v1/credentials/{credential_id}/revoke", + post(handle_revoke_key), + ) + .route("/api/v1/operators/{name}/role", post(handle_set_role)) +} + +/// Un identifiant de clé WebAuthn tel que le registre le range : base64url, +/// borné. Il n'est qu'une attente : `ca-server` le compare au corps figé. +fn looks_like_a_credential_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 1024 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +async fn relay( + state: &AppState, + headers: &HeaderMap, + body: &[u8], + expect: serde_json::Value, +) -> Response { + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// `POST /api/v1/operators` : exécute l'invitation figée +/// (`{"action": "invite_operator", "name", "role"}`). Le jeton d'invitation +/// n'existe que dans `result.invite_token` de la réponse d'exécution : ni +/// `ca-server` ni la console ne le journalisent ni ne le conservent. Une +/// invitation n'a pas de cible dans la route : seul le type est contrôlé. +async fn handle_invite( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "invite_operator" }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/confirm` : active une clé en +/// attente. L'empreinte, transmise hors bande par l'invité (§10), fait partie +/// du corps signé ; `ca-server` la recompare à la clé en attente. +async fn handle_confirm_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "confirm_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/revoke` (perte de clé, départ, +/// §14). `ca-server` refuse de révoquer la dernière clé d'administrateur active. +async fn handle_revoke_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "revoke_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/operators/{name}/role` : l'opérateur est désigné par son +/// **nom**, comme dans le corps signé (`set_role`), pas par un identifiant +/// technique. Élever un opérateur au rôle `admin`, ou changer celui d'un +/// administrateur, exige deux administrateurs : la première signature rend +/// `AWAITING_QUORUM`, la seconde passe par `/api/v1/quorum/{id}/sign`. +async fn handle_set_role( + State(state): State>, + Path(name): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_name(&name) { + return error(StatusCode::BAD_REQUEST, "bad_request", "opérateur invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "set_role", "operator": name }), + ) + .await +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 852f99b..32eb816 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -44,10 +44,23 @@ fn origin() -> Url { Url::parse(&format!("https://{HOST}")).unwrap() } +/// Le journal de la console, relu par les tests : un jeton d'invitation ne +/// doit jamais y figurer. +#[derive(Default)] +struct ConsoleJournal(std::sync::Mutex>); + +impl ra_console::audit::Recorder for ConsoleJournal { + fn append(&self, event: &str, data: serde_json::Value) { + self.0.lock().unwrap().push(format!("{event} {data}")); + } +} + struct Env { console: axum::Router, + journal: Arc, registry: Registry, store: Arc, + issuer: Arc, verifier: Verifier, authn: WebauthnAuthenticator, _dir: Dir, @@ -71,6 +84,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 +141,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)) @@ -113,6 +167,7 @@ impl Env { let link = CaLink::new(&pki.files(&dir, &client, port)).unwrap(); let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let journal = Arc::new(ConsoleJournal::default()); let console = router(Arc::new(AppState { pool: pool.clone(), link, @@ -123,13 +178,15 @@ impl Env { Arc::new(ra_console::audit::NullRecorder), ), sessions: common::sessions(pool), - journal: Arc::new(ra_console::audit::NullRecorder), + journal: journal.clone(), })); Some(Env { console, + journal, registry, store, + issuer, verifier: verifier(), authn: WebauthnAuthenticator::new(token), _dir: dir, @@ -333,15 +390,15 @@ async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { assert_eq!(status, StatusCode::UNAUTHORIZED, "{err}"); } - // Une action que ca-server saurait exécuter, mais que la console ne propose - // pas encore (révocation : étape 4 ; registre : plus tard). + // La gestion du registre est relayée, mais réservée aux administrateurs : + // c'est ca-server qui refuse à un ca_operateur, sans rien figer. for action in [ - serde_json::json!({ "action": "revoke_certificate", "serial": "0a", "reason": 1, "comment": "x" }), - serde_json::json!({ "action": "set_role", "operator": "alice", "role": "admin" }), + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "auditeur" }), + serde_json::json!({ "action": "set_role", "operator": "alice", "role": "auditeur" }), ] { let (status, err) = env.challenge(Some(&cookie), action).await; assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); - assert_eq!(err["error"], "action_not_available"); + assert_eq!(err["error"], "denied"); } // Une action inconnue, ou un corps qui n'est pas une action. @@ -555,3 +612,555 @@ 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}"); + } +} + +// --- Gestion du registre depuis la console (invitation, clés, rôles) --- + +impl Env { + /// Prépare `action`, la signe, et la relaie sur `path` : rend la réponse. + async fn sign_and_send( + &mut self, + cookie: &str, + action: serde_json::Value, + path: &str, + ) -> (StatusCode, serde_json::Value) { + let (status, issued) = self.challenge(Some(cookie), action).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = self.sign(&issued); + self.decide(Some(cookie), path, &issued, &assertion).await + } + + async fn role_of(&self, name: &str) -> String { + sqlx::query_scalar("SELECT role FROM operators WHERE name = $1") + .bind(name) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + async fn key_revoked(&self, credential_id: &str) -> bool { + sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM webauthn_credentials WHERE credential_id = $1", + ) + .bind(credential_id) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + /// Une seconde clé pour un opérateur existant, posée dans le registre. + async fn second_key(&mut self, operator: Uuid, name: &str) -> String { + let before = self.credential_ids(operator).await; + let now = time::OffsetDateTime::now_utc(); + let (options, state) = self + .verifier + .start_registration(operator, name, None) + .unwrap(); + let reg = self.authn.do_registration(origin(), options).unwrap(); + let key = self.verifier.finish_registration(®, &state).unwrap(); + self.registry + .add_credential( + NewCredential { + operator_id: operator, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "secours", + initiated_by: "test", + confirmed_by: Some("test"), + }, + now, + ) + .await + .unwrap(); + self.credential_ids(operator) + .await + .into_iter() + .find(|k| !before.contains(k)) + .unwrap() + } +} + +/// Un administrateur invite un opérateur par la console : le jeton n'est rendu +/// qu'une fois, dans la réponse, et ne figure dans aucun journal de la console. +/// L'invité enregistre sa clé par le relais existant ; l'administrateur la +/// confirme en signant son empreinte, transmise hors bande (§10). +#[tokio::test] +async fn an_admin_invites_and_confirms_an_operator_through_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let admin = env.log_in("root").await; + + let invite = + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "ra_operateur" }); + let (status, done) = env.sign_and_send(&admin, invite, "/api/v1/operators").await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + let token = done["result"]["invite_token"].as_str().unwrap().to_string(); + assert!(token.len() > 30); + assert_eq!(env.role_of("eve").await, "ra_operateur"); + let journal = env.journal.0.lock().unwrap().join("\n"); + assert!(journal.contains("ra.action_relayed"), "{journal}"); + assert!( + !journal.contains(&token), + "le jeton est journalisé : {journal}" + ); + + // L'invitée enregistre sa clé : elle attend la confirmation d'un tiers. + let (_, _, begun) = env + .post( + "/api/v1/webauthn/register/begin", + serde_json::json!({ "token": token }), + None, + "application/json", + ) + .await; + let options: oe_webauthn::CreationChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": begun["webauthn"] })).unwrap(); + let credential = env.authn.do_registration(origin(), options).unwrap(); + let (status, _, pending) = env + .post( + "/api/v1/webauthn/register/finish", + serde_json::json!({ "ceremony_id": begun["ceremony_id"], "credential": credential }), + None, + "application/json", + ) + .await; + assert_eq!(status, StatusCode::OK, "{pending}"); + assert_eq!(pending["status"], "pending_confirmation", "{pending}"); + let credential_id = pending["credential_id"].as_str().unwrap().to_string(); + + let confirm = serde_json::json!({ + "action": "confirm_key", + "credential_id": credential_id, + "key_fingerprint": pending["key_fingerprint"], + }); + let (status, done) = env + .sign_and_send( + &admin, + confirm, + &format!("/api/v1/credentials/{credential_id}/confirm"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + // La clé est active : l'invitée peut se connecter. + let eve = env.log_in("eve").await; + assert!(eve.starts_with("session=")); +} + +/// Révocation d'une clé : la cible de la route est contrôlée par ca-server. +/// Les deux clés appartiennent au même opérateur et l'action est la même : +/// seul l'identifiant de la clé distingue, c'est bien lui qui est comparé. +#[tokio::test] +async fn a_key_revocation_only_revokes_the_signed_key() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let carol = env.operator_with_key("carol", Role::RaOperateur).await; + let k1 = env.credential_ids(carol).await.remove(0); + let k2 = env.second_key(carol, "carol").await; + assert_ne!(k1, k2); + let admin = env.log_in("root").await; + + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "revoke_key", "credential_id": k1, "reason": "perdue" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k2}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + assert!(!env.key_revoked(&k1).await && !env.key_revoked(&k2).await); + + let (status, done) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k1}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED"); + assert!(env.key_revoked(&k1).await); + assert!(!env.key_revoked(&k2).await); + + // Un identifiant de clé hors de la forme base64url n'est pas relayé. + let (status, _) = env + .decide( + Some(&admin), + "/api/v1/credentials/cl%C3%A9%20invalide/revoke", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +/// Changement de rôle : la cible (l'opérateur, par son nom) est contrôlée ; +/// élever au rôle admin exige deux administrateurs (co-signature par la salle +/// d'attente). +#[tokio::test] +async fn role_changes_target_the_signed_operator_and_admin_needs_two() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + env.operator_with_key("root2", Role::Admin).await; + env.operator_with_key("carol", Role::RaOperateur).await; + env.operator_with_key("dave", Role::RaOperateur).await; + let admin = env.log_in("root").await; + + // Même action, même rôle : seul l'opérateur visé distingue. + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "set_role", "operator": "carol", "role": "auditeur" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + "/api/v1/operators/dave/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + let (status, done) = env + .decide( + Some(&admin), + "/api/v1/operators/carol/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(env.role_of("carol").await, "auditeur"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + + // Élever dave au rôle admin : une signature ne suffit pas. + let (status, done) = env + .sign_and_send( + &admin, + serde_json::json!({ "action": "set_role", "operator": "dave", "role": "admin" }), + "/api/v1/operators/dave/role", + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "AWAITING_QUORUM", "{done}"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + let action_id = done["action_id"].as_str().unwrap().to_string(); + + let second = env.log_in("root2").await; + let (status, issued) = env + .challenge(Some(&second), 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(&second), + &format!("/api/v1/quorum/{action_id}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + assert_eq!(env.role_of("dave").await, "admin"); +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index 23c20eb..d09ab59 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -198,12 +198,34 @@ 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, + /// Clé visée par une confirmation ou une révocation de clé. + #[serde(default)] + pub credential_id: Option, + /// Opérateur visé par un changement de rôle (par son nom, celui du corps + /// figé). + #[serde(default)] + pub operator: 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,20 +233,50 @@ impl Expect { action.kind() ))); } - let target = match action { + // La cible que porte le corps figé, par le nom du champ d'attente qui + // la désigne. Une invitation n'a pas de cible (l'opérateur n'existe pas + // encore) : seul le type compte. + let frozen: Option<(&str, &String)> = match action { Action::ApproveRequest { transaction_id, .. } - | Action::RejectRequest { transaction_id, .. } => Some(transaction_id), - _ => None, + | Action::RejectRequest { transaction_id, .. } => { + Some(("transaction_id", transaction_id)) + } + Action::RevokeCertificate { serial, .. } => Some(("serial", serial)), + Action::ConfirmKey { credential_id, .. } | Action::RevokeKey { credential_id, .. } => { + Some(("credential_id", credential_id)) + } + Action::SetRole { operator, .. } => Some(("operator", operator)), + Action::InviteOperator { .. } => None, + }; + let targets = [ + ("transaction_id", &self.transaction_id), + ("serial", &self.serial), + ("credential_id", &self.credential_id), + ("operator", &self.operator), + ]; + // Tout autre champ d'attente doit rester vide. + for (name, value) in targets { + if value.is_some() && frozen.map(|(f, _)| f) != Some(name) { + return Err(Error::BadRequest( + "cible sans rapport avec ce type d'action".to_string(), + )); + } + } + let Some((field, frozen)) = frozen else { + return Ok(()); }; - match (target, &self.transaction_id) { - (Some(frozen), Some(expected)) if frozen == expected => Ok(()), - (Some(_), None) => Err(Error::BadRequest( - "la demande visée doit être précisée".to_string(), + let expected = targets + .iter() + .find(|(name, _)| *name == field) + .and_then(|(_, v)| v.as_ref()); + match expected { + Some(expected) if expected == frozen => Ok(()), + None => Err(Error::BadRequest( + "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}" + Some(expected) => Err(Error::Mismatch(format!( + "cible attendue {expected}, figée {frozen}" ))), - (None, _) => Ok(()), } } } @@ -716,7 +768,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,32 +1036,131 @@ mod expect_tests { Expect { action: action.to_string(), transaction_id: tx.map(str::to_string), + serial: None, + action_id: None, + credential_id: None, + operator: 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. + // Une invitation n'a pas de cible : seul le type compte. + let invite = Action::InviteOperator { + name: "eve".to_string(), + role: Role::Auditeur, + ttl_minutes: 60, + }; + assert!(expect("invite_operator", None) + .check(&invite, Uuid::nil()) + .is_ok()); + + // Changement de rôle : la cible est l'opérateur, par son nom. let role = Action::SetRole { operator: "alice".to_string(), role: Role::Auditeur, }; - assert!(expect("set_role", None).check(&role).is_ok()); + let for_operator = |o: &str| Expect { + operator: Some(o.to_string()), + ..expect("set_role", None) + }; + assert!(for_operator("alice").check(&role, Uuid::nil()).is_ok()); + assert!(matches!( + for_operator("bob").check(&role, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + assert!(matches!( + expect("set_role", None).check(&role, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Clés : la cible est l'identifiant de la clé. + let revoke_key = Action::RevokeKey { + credential_id: "k1".to_string(), + reason: "perdue".to_string(), + }; + let for_key = |k: &str, action: &str| Expect { + credential_id: Some(k.to_string()), + ..expect(action, None) + }; + assert!(for_key("k1", "revoke_key") + .check(&revoke_key, Uuid::nil()) + .is_ok()); + assert!(matches!( + for_key("k2", "revoke_key").check(&revoke_key, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + let confirm = Action::ConfirmKey { + credential_id: "k1".to_string(), + key_fingerprint: "AA".to_string(), + }; + assert!(for_key("k1", "confirm_key") + .check(&confirm, Uuid::nil()) + .is_ok()); + // Une cible d'un autre type (opérateur sur une clé) est refusée. + let mixed = Expect { + operator: Some("alice".to_string()), + ..for_key("k1", "revoke_key") + }; + assert!(matches!( + mixed.check(&revoke_key, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // 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, + credential_id: None, + operator: 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/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 14df368..9dd991c 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -66,9 +66,11 @@ 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ées toutes les actions d'`oe_actions` : l'approbation et le rejet d'une + demande (§15, étape 3), la révocation d'un certificat (`revoke_certificate`, étape + 4), et la gestion du registre (`invite_operator`, `confirm_key`, `revoke_key`, + `set_role`, voir plus bas). Une action ajoutée plus tard à l'énumération ne sera pas + préparée tant que la console ne la nomme pas (`403 action_not_available`). - 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 +100,75 @@ 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`. + +## Gestion du registre des opérateurs + +Même schéma : le challenge est préparé avec l'action voulue, puis l'assertion est +relayée à la route correspondante (`{"challenge_id", "assertion"}`), qui rend la forme +d'une action à plusieurs signatures (`status`, `signatures`, `required`, `signed_by`, +`result`). Seul un `admin` signe ces actions ; `ca-server` en décide. + +| Route | Action préparée | Cible contrôlée par `ca-server` | +|---|---|---| +| `POST /api/v1/operators` | `{"action": "invite_operator", "name", "role"}` | le type seulement (l'opérateur n'existe pas encore) | +| `POST /api/v1/credentials/{credential_id}/confirm` | `{"action": "confirm_key", "credential_id", "key_fingerprint"}` | l'identifiant de la clé | +| `POST /api/v1/credentials/{credential_id}/revoke` | `{"action": "revoke_key", "credential_id", "reason"}` | l'identifiant de la clé | +| `POST /api/v1/operators/{name}/role` | `{"action": "set_role", "operator", "role"}` | l'opérateur, par son nom | + +- **Invitation** : le jeton n'existe que dans `result.invite_token` de la réponse + d'exécution, rendu **une seule fois** ; ni `ca-server` ni la console ne le + journalisent ni ne le conservent. L'invité enregistre ensuite sa clé par le relais + d'enregistrement (plus haut) : elle reste en attente, avec une empreinte que l'invité + transmet hors bande. +- **Confirmation** : l'administrateur signe l'empreinte ; `ca-server` la recompare à la + clé en attente et refuse qu'un opérateur confirme sa propre clé. +- **Révocation de clé** : motif obligatoire ; `ca-server` refuse de révoquer la dernière + clé d'administrateur active (la voie de secours est `recover-admin`). +- **Rôle `admin`** : créer un administrateur, élever un opérateur au rôle `admin` ou + changer le rôle d'un administrateur exige **deux administrateurs** — la première + signature rend `AWAITING_QUORUM`, la seconde passe par la salle d'attente + (`/api/v1/quorum/{action_id}/sign`). Un opérateur ne change pas son propre rôle. +- Identifiant de clé : base64url, 1 024 caractères au plus ; nom d'opérateur : 1 à 256 + caractères. Toute autre forme est refusée avant relais. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -132,8 +203,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 liste des clés en attente de confirmation, le libre-service (ajout et retrait de ses +propres clés, §10), 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 (