diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c52296..af2c920 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -50,6 +50,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) .route("/api/v1/requests/{id}/approve", post(handle_approve)) .route("/api/v1/requests/{id}/reject", post(handle_reject)) + .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -443,14 +444,16 @@ async fn handle_requests( } } -/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étape 3) : -/// décider d'une demande d'enrôlement. La révocation (étape 4) et la gestion -/// du registre suivront ; d'ici là, la console refuse de les préparer, même si -/// `ca-server` saurait les exécuter. +/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étapes 3 +/// et 4) : décider d'une demande d'enrôlement, révoquer un certificat. La +/// gestion du registre suivra ; d'ici là, la console refuse de la préparer, +/// même si `ca-server` saurait l'exécuter. fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { matches!( action, - oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. } + oe_actions::Action::ApproveRequest { .. } + | oe_actions::Action::RejectRequest { .. } + | oe_actions::Action::RevokeCertificate { .. } ) } @@ -546,35 +549,33 @@ async fn handle_reject( relay_decision(&state, "reject_request", &id, &headers, &body).await } -/// `POST /api/v1/requests/{id}/approve|reject` (docs/WEBUI.md §4 étapes 5 à 7, -/// §5) : la console relaie l'identifiant du challenge et l'assertion brute — -/// **jamais de corps** : `ca-server` exécute celui qu'il a figé. Elle y joint -/// ce que la route promet (`expect` : l'action et la demande du chemin), que -/// `ca-server` compare au corps figé avant toute vérification : une signature -/// obtenue pour une demande ne décide jamais d'une autre. -async fn relay_decision( +/// Relaie l'identifiant du challenge et l'assertion brute d'un opérateur +/// connecté à `ca-server` (docs/WEBUI.md §4 étapes 5 à 7) — **jamais de +/// corps** : `ca-server` exécute celui qu'il a figé. `expect` dit ce que la +/// route promet (action et cible) ; `ca-server` le compare au corps figé avant +/// toute vérification, si bien qu'une signature ne décide jamais d'autre chose +/// que ce qui a été signé. Chaque relais est inscrit au journal de la console. +/// +/// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]). +#[allow(clippy::result_large_err)] +async fn relay_assertion( state: &AppState, - action: &str, - transaction_id: &str, headers: &HeaderMap, body: &[u8], -) -> Response { + expect: serde_json::Value, +) -> Result { if !is_json(headers) { - return unsupported_media_type(); + return Err(unsupported_media_type()); } - let who = match authenticate(state, headers).await { - Ok(a) => a, - Err(resp) => return resp, - }; - let req: Signed = match serde_json::from_slice(body) { - Ok(r) => r, - Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"), - }; - if !looks_like_uuid(&req.challenge_id) - || !req.assertion.is_object() - || !looks_like_a_transaction(transaction_id) - { - return error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"); + let who = authenticate(state, headers).await?; + let req: Signed = serde_json::from_slice(body) + .map_err(|_| error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"))?; + if !looks_like_uuid(&req.challenge_id) || !req.assertion.is_object() { + return Err(error( + StatusCode::BAD_REQUEST, + "bad_request", + "corps invalide", + )); } let result = state .link @@ -583,7 +584,7 @@ async fn relay_decision( &serde_json::json!({ "challenge_id": req.challenge_id, "assertion": req.assertion, - "expect": { "action": action, "transaction_id": transaction_id }, + "expect": expect, }), ) .await; @@ -592,34 +593,91 @@ async fn relay_decision( audit::EVENT_ACTION_RELAYED, serde_json::json!({ "session_operator": who.operator, - "action": action, - "transaction_id": transaction_id, + "expect": expect, "action_id": r.body.get("action_id"), "signed_by": r.body.get("operator"), "status": r.status, + "outcome": r.body.get("status"), "error": r.body.get("error"), }), ); } match result { - // La forme du §5 : l'identité qui a décidé est celle que `ca-server` a - // lue dans son registre, pas celle de la session. - Ok(r) - if r.status == 200 - && r.body.get("status").and_then(|s| s.as_str()) == Some("executed") => - { - Json(serde_json::json!({ - "transaction_id": transaction_id, - "state": if action == "approve_request" { "APPROVED" } else { "REJECTED" }, - "decided_by": r.body.get("operator"), - "action_id": r.body.get("action_id"), - })) - .into_response() - } - other => relayed(other), + Ok(r) if r.status == 200 => Ok(r), + other => Err(relayed(other)), } } +/// `POST /api/v1/requests/{id}/approve|reject` (docs/WEBUI.md §5) : la décision +/// signée sur une demande d'enrôlement. `decided_by` est l'opérateur dont la clé +/// a signé, lu dans le registre de `ca-server`, pas celui de la session. +async fn relay_decision( + state: &AppState, + action: &str, + transaction_id: &str, + headers: &HeaderMap, + body: &[u8], +) -> Response { + if !looks_like_a_transaction(transaction_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "demande invalide"); + } + let expect = serde_json::json!({ "action": action, "transaction_id": transaction_id }); + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(serde_json::json!({ + "transaction_id": transaction_id, + "state": if action == "approve_request" { "APPROVED" } else { "REJECTED" }, + "decided_by": r.body.get("operator"), + "action_id": r.body.get("action_id"), + })) + .into_response(), + Err(resp) => resp, + } +} + +/// Un numéro de série dans la forme canonique du corps figé : hexadécimal +/// minuscule, sans préfixe, 20 octets au plus (RFC 5280 §4.1.2.2). +fn looks_like_a_serial(s: &str) -> bool { + !s.is_empty() && s.len() <= 40 && s.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) +} + +/// `POST /api/v1/certificates/{serial}/revoke` (docs/WEBUI.md §5, §8, §15 étape +/// 4) : une signature de plus sur la révocation figée. `ca-server` exige, par +/// sa propre politique, deux `ca_operateur` distincts : tant que le seuil n'est +/// pas atteint, la signature est enregistrée et rien n'est révoqué +/// (`AWAITING_QUORUM`) ; la dernière signature exécute (`EXECUTED`). +async fn handle_revoke( + State(state): State>, + Path(serial): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_serial(&serial) { + return error( + StatusCode::BAD_REQUEST, + "bad_request", + "numéro de série invalide", + ); + } + let expect = serde_json::json!({ "action": "revoke_certificate", "serial": serial }); + match relay_assertion(&state, &headers, &body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// La forme du §5 pour une action à plusieurs signatures. +fn quorum_status(body: &serde_json::Value) -> serde_json::Value { + let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); + serde_json::json!({ + "action_id": body.get("action_id"), + "status": if executed { "EXECUTED" } else { "AWAITING_QUORUM" }, + "signatures": body.get("signatures"), + "required": body.get("required"), + "signed_by": body.get("operator"), + "result": body.get("result"), + }) +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 852f99b..293a73a 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -48,6 +48,7 @@ struct Env { console: axum::Router, registry: Registry, store: Arc, + issuer: Arc, verifier: Verifier, authn: WebauthnAuthenticator, _dir: Dir, @@ -71,6 +72,44 @@ impl Env { let store: Arc = Arc::new(Postgres::open(&dsn).await.unwrap()); let registry = Registry::connect(&dsn).await.unwrap(); + // Une vraie CA sur la même base, pour que la révocation porte sur un + // certificat réellement émis (étape 4). + let issuing = Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)); + let h = oe_ca_core::ceremony::run_ceremony(oe_ca_core::ceremony::CeremonyOptions { + root_signer: Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)), + issuing_signer: issuing.clone(), + root_cn: "Test Root CA".into(), + issuing_cn: "Test Issuing CA".into(), + organization: "Open eIDAS Test".into(), + country: "FR".into(), + root_validity: time::Duration::days(3650), + issuing_validity: time::Duration::days(3650), + root_token_label: "r".into(), + root_key_label: "r".into(), + issuing_token_label: "i".into(), + issuing_key_label: "i".into(), + store: store.clone(), + operator: "test".into(), + public_url: "https://ca.example.test".into(), + recorder: None, + }) + .await + .unwrap(); + let issuer = Arc::new( + oe_ca_core::Issuer::new(oe_ca_core::Options { + signer: issuing, + certificate: h.issuing, + chain: vec![], + store: store.clone(), + public_url: "https://ca.example.test".into(), + ocsp_url: None, + crl_validity: time::Duration::hours(24), + crl_grace: time::Duration::hours(1), + recorder: None, + }) + .unwrap(), + ); + // Un seul modèle de clé de confiance, le même pour ca-server (qui // vérifiera les assertions d'action) et pour la console (connexion). let (token, root) = SoftToken::new(true).unwrap(); @@ -90,18 +129,21 @@ impl Env { .unwrap() }; - let service = Arc::new(Service::new( - registry.clone(), - verifier(), - store.clone(), - Decider::new(DeciderOptions { - store: store.clone(), - recorder: None, - clock: None, - }), - Arc::new(NullJournal), - Arc::new(time::OffsetDateTime::now_utc), - )); + let service = Arc::new( + Service::new( + registry.clone(), + verifier(), + store.clone(), + Decider::new(DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + ) + .with_revoker(Arc::new(ca_server::revoker::IssuerRevoker(issuer.clone()))), + ); let pki = pki().await; let port = pki .serve_router(ca_server::internal::router(service, 64 * 1024)) @@ -130,6 +172,7 @@ impl Env { console, registry, store, + issuer, verifier: verifier(), authn: WebauthnAuthenticator::new(token), _dir: dir, @@ -334,9 +377,9 @@ async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { } // Une action que ca-server saurait exécuter, mais que la console ne propose - // pas encore (révocation : étape 4 ; registre : plus tard). + // pas encore (gestion du registre : après l'étape 4). for action in [ - serde_json::json!({ "action": "revoke_certificate", "serial": "0a", "reason": 1, "comment": "x" }), + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "admin" }), serde_json::json!({ "action": "set_role", "operator": "alice", "role": "admin" }), ] { let (status, err) = env.challenge(Some(&cookie), action).await; @@ -555,3 +598,113 @@ async fn the_console_relays_no_decision_it_has_not_validated() { let (status, done) = env.decide(Some(&cookie), path, &issued, &assertion).await; assert_eq!(status, StatusCode::OK, "{done}"); } + +impl Env { + /// Un certificat de TSU émis par la CA de test, et son numéro de série + /// dans la forme canonique des corps signés (hexadécimal minuscule). + async fn certificate(&self, tx: &str) -> String { + let key = oe_hsm::testing::SoftwareToken::generate(2048); + let cert = self + .issuer + .issue( + &oe_hsm::SigningToken::public_key_der(&key).unwrap(), + "tsu.example.test", + &oe_ca_core::profile::tsa_signer(), + tx, + ) + .await + .unwrap(); + oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()) + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + async fn status_of(&self, serial: &str) -> oe_castore::CertificateStatus { + let bytes: Vec = (0..serial.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&serial[i..i + 2], 16).unwrap()) + .collect(); + self.store.certificate(&bytes).await.unwrap().status + } +} + +fn revoke(serial: &str) -> serde_json::Value { + serde_json::json!({ "action": "revoke_certificate", "serial": serial, "reason": 1, "comment": "clé exposée" }) +} + +/// Étape 4a : la première signature d'une révocation est enregistrée par +/// `ca-server`, mais rien n'est révoqué avant le second `ca_operateur` (§8) ; +/// la cible de la route est contrôlée comme pour les décisions. +#[tokio::test] +async fn one_ca_operator_alone_does_not_revoke() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let cookie = env.log_in("alice").await; + let serial = env.certificate("tx-rev-1").await; + let other = env.certificate("tx-rev-2").await; + + let (status, issued) = env.challenge(Some(&cookie), revoke(&serial)).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + assert_eq!(issued["required_signatures"], 2, "{issued}"); + let assertion = env.sign(&issued); + + // Présentée pour un autre certificat : refusée, rien de consommé. + let (status, err) = env + .decide( + Some(&cookie), + &format!("/api/v1/certificates/{other}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + + let path = format!("/api/v1/certificates/{serial}/revoke"); + let (status, done) = env.decide(Some(&cookie), &path, &issued, &assertion).await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "AWAITING_QUORUM", "{done}"); + assert_eq!(done["signatures"], 1); + assert_eq!(done["required"], 2); + assert_eq!(done["signed_by"], "alice"); + assert_eq!( + env.status_of(&serial).await, + oe_castore::CertificateStatus::Issued + ); + assert_eq!( + env.status_of(&other).await, + oe_castore::CertificateStatus::Issued + ); + + // Un numéro de série hors de la forme canonique n'est pas relayé. + for bad in [ + serial.to_uppercase(), + format!("0x{serial}"), + "zz".to_string(), + ] { + let (status, _) = env + .decide( + Some(&cookie), + &format!("/api/v1/certificates/{bad}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{bad}"); + } +} + +/// La révocation est réservée aux `ca_operateur` : `ca-server` refuse d'en +/// préparer une pour un `ra_operateur`, la console relaie le refus. +#[tokio::test] +async fn an_ra_operator_cannot_prepare_a_revocation() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + let serial = env.certificate("tx-rev").await; + let (status, err) = env.challenge(Some(&cookie), revoke(&serial)).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(env.actions_frozen().await, 0); +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index 23c20eb..df028d2 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -198,8 +198,13 @@ pub enum Error { #[serde(deny_unknown_fields)] pub struct Expect { pub action: String, + /// Demande visée par une décision d'enrôlement. #[serde(default)] pub transaction_id: Option, + /// Certificat visé par une révocation (hexadécimal minuscule, forme + /// canonique du corps figé). + #[serde(default)] + pub serial: Option, } impl Expect { @@ -211,18 +216,30 @@ impl Expect { action.kind() ))); } - let target = match action { + // La cible que porte le corps figé, et celle que l'appelant attend pour + // ce type d'action ; l'autre champ d'attente doit rester vide. + let (frozen, expected, other) = match action { Action::ApproveRequest { transaction_id, .. } - | Action::RejectRequest { transaction_id, .. } => Some(transaction_id), - _ => None, + | Action::RejectRequest { transaction_id, .. } => { + (Some(transaction_id), &self.transaction_id, &self.serial) + } + Action::RevokeCertificate { serial, .. } => { + (Some(serial), &self.serial, &self.transaction_id) + } + _ => (None, &None, &None), }; - match (target, &self.transaction_id) { + if other.is_some() { + return Err(Error::BadRequest( + "cible sans rapport avec ce type d'action".to_string(), + )); + } + match (frozen, expected) { (Some(frozen), Some(expected)) if frozen == expected => Ok(()), (Some(_), None) => Err(Error::BadRequest( - "la demande visée doit être précisée".to_string(), + "la cible visée doit être précisée".to_string(), )), (Some(frozen), Some(expected)) => Err(Error::Mismatch(format!( - "demande attendue {expected}, figée {frozen}" + "cible attendue {expected}, figée {frozen}" ))), (None, _) => Ok(()), } @@ -984,6 +1001,7 @@ mod expect_tests { Expect { action: action.to_string(), transaction_id: tx.map(str::to_string), + serial: None, } } @@ -1011,5 +1029,27 @@ mod expect_tests { role: Role::Auditeur, }; assert!(expect("set_role", None).check(&role).is_ok()); + + // Révocation : la cible est le numéro de série, jamais une demande. + let revoke = Action::RevokeCertificate { + serial: "0a1b".to_string(), + reason: 1, + comment: "x".to_string(), + }; + let by_serial = |s: &str| Expect { + action: "revoke_certificate".to_string(), + transaction_id: None, + serial: Some(s.to_string()), + }; + assert!(by_serial("0a1b").check(&revoke).is_ok()); + assert!(matches!( + by_serial("0a1c").check(&revoke), + Err(Error::Mismatch(_)) + )); + let mixed = Expect { + transaction_id: Some("tx".to_string()), + ..by_serial("0a1b") + }; + assert!(matches!(mixed.check(&revoke), Err(Error::BadRequest(_)))); } } diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 14df368..ec77682 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -66,9 +66,9 @@ exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la (`operator_hint`) vient de la session, jamais du navigateur. L'action est relue dans l'énumération fermée d'`oe_actions` puis resérialisée : un champ en trop ne franchit pas la console. -- Seules l'approbation et le rejet d'une demande sont préparés à ce stade (§15, étape - 3) ; toute autre action est refusée (`403 action_not_available`) sans solliciter - `ca-server`. +- Sont préparés à ce stade l'approbation et le rejet d'une demande (§15, étape 3) et + la révocation d'un certificat (`revoke_certificate`, étape 4) ; toute autre action + est refusée (`403 action_not_available`) sans solliciter `ca-server`. - Le rôle et l'état de la demande sont jugés par `ca-server` (un administrateur ne peut pas approuver) ; la console relaie son refus. - Chaque préparation est inscrite au journal de la console (`ra.action_challenge` : @@ -98,6 +98,23 @@ lu dans le registre de `ca-server`, pas celui de la session. - Le certificat n'est pas émis à ce moment : comme avec `ca-server ra approve`, il l'est au prochain appel du demandeur à l'enrôlement. +## Révocation d'un certificat (première signature) + +`POST /api/v1/certificates/{serial}/revoke`, avec une session ouverte et la même forme +de corps qu'une décision (`{"challenge_id", "assertion"}`), le challenge ayant été +préparé pour `{"action": "revoke_certificate", "serial": "…", "reason": …, +"comment": "…"}`. Le numéro de série est en hexadécimal minuscule, sans préfixe (la +forme canonique du corps figé) ; toute autre forme est refusée avant relais. + +- La révocation exige, par la politique de `ca-server`, **deux `ca_operateur` + distincts** (docs/WEBUI.md §8). La première signature est enregistrée par + `ca-server` et **rien n'est révoqué** : réponse `{"status": "AWAITING_QUORUM", + "signatures": 1, "required": 2, "action_id", "signed_by"}`. La signature suivante + (co-signature) arrive avec l'étape 4b. +- La cible est contrôlée par `ca-server` comme pour une décision (`expect` porte le + numéro de série) : une signature ne révoque jamais un autre certificat. +- Un `ra_operateur` ne peut pas préparer de révocation : `ca-server` refuse. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -132,8 +149,8 @@ lu dans le registre de `ca-server`, pas celui de la session. ## Ce qui n'existe pas encore -La révocation et le double contrôle (étape 4), la gestion du registre depuis la -console, le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +La co-signature et la liste des actions en attente (étape 4b), la gestion du registre +depuis la console, le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se