diff --git a/bin/ca-server/src/internal.rs b/bin/ca-server/src/internal.rs index a6a12ee..7cd1570 100644 --- a/bin/ca-server/src/internal.rs +++ b/bin/ca-server/src/internal.rs @@ -46,6 +46,11 @@ struct ExecuteRequest { /// Sortie brute de `navigator.credentials.get`. Aucun corps d'action : /// c'est celui figé à l'émission du challenge qui s'exécute. assertion: PublicKeyCredential, + /// Ce que l'appelant croit faire exécuter (type et cible), comparé au corps + /// figé avant toute vérification : ne peut que faire refuser, jamais changer + /// ce qui s'exécute. + #[serde(default)] + expect: Option, } #[derive(Deserialize)] @@ -101,6 +106,7 @@ fn failure(e: Error) -> Response { Error::Verification(_) => (StatusCode::UNAUTHORIZED, "signature_rejected"), Error::Journal(_) => (StatusCode::SERVICE_UNAVAILABLE, "journal_unavailable"), Error::Blocked(_) => (StatusCode::SERVICE_UNAVAILABLE, "registry_blocked"), + Error::Mismatch(_) => (StatusCode::CONFLICT, "action_mismatch"), Error::Db(_) | Error::Effect(_) => { tracing::error!(erreur = %e, "action interne en échec"); return error( @@ -160,7 +166,15 @@ async fn handle_actions(State(service): State>, body: Bytes) -> Res Ok(r) => r, Err(e) => return bad_json(e), }; - match service.execute(req.challenge_id, &req.assertion).await { + let done = match &req.expect { + Some(expect) => { + service + .execute_expecting(req.challenge_id, &req.assertion, expect) + .await + } + None => service.execute(req.challenge_id, &req.assertion).await, + }; + match done { // L'identité vient du registre de `ca-server`, jamais de l'appelant. Ok(done) => Json(serde_json::json!({ "action_id": done.action_id, diff --git a/bin/ra-console/src/audit.rs b/bin/ra-console/src/audit.rs index 703a4dc..bab082f 100644 --- a/bin/ra-console/src/audit.rs +++ b/bin/ra-console/src/audit.rs @@ -17,6 +17,15 @@ pub const EVENT_LOGIN_SUCCEEDED: &str = "ra.login_succeeded"; pub const EVENT_LOGIN_REFUSED: &str = "ra.login_refused"; pub const EVENT_SESSION_OPENED: &str = "ra.session_opened"; pub const EVENT_SESSION_CLOSED: &str = "ra.session_closed"; +/// Une action signée préparée par `ca-server` à la demande d'un opérateur +/// (docs/WEBUI.md §4, étapes 1 à 3) : l'identifiant de l'action et +/// l'empreinte du corps figé, pour rapprocher ce journal de celui de +/// `ca-server`, qui fait foi. +pub const EVENT_ACTION_CHALLENGE: &str = "ra.action_challenge"; +/// Une assertion d'opérateur relayée pour exécution (docs/WEBUI.md §4, étapes +/// 5 à 7), avec la réponse de `ca-server` : qui a signé selon son registre, +/// et le statut rendu. +pub const EVENT_ACTION_RELAYED: &str = "ra.action_relayed"; /// Même forme que `oe_ca_core::Recorder` / `oe_raflow::Recorder`, dupliquée /// plutôt que partagée (ce sont des traits d'un seul étage, la duplication diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 4d468a5..6c52296 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::{DefaultBodyLimit, Query, State}; +use axum::extract::{DefaultBodyLimit, Path, Query, State}; use axum::http::header::{COOKIE, SET_COOKIE}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; @@ -14,6 +14,7 @@ use axum::{Json, Router}; use serde::Deserialize; use sqlx::PgPool; +use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; use crate::requests; @@ -27,6 +28,7 @@ pub struct AppState { pub link: CaLink, pub login: LoginService, pub sessions: Sessions, + pub journal: Arc, } pub fn router(state: Arc) -> Router { @@ -45,6 +47,9 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/me", get(handle_me)) .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) + .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)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -438,6 +443,183 @@ 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. +fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { + matches!( + action, + oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. } + ) +} + +/// `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` +/// exécutera, à afficher tel quel. +/// +/// Ce que la console décide : que la session est valide, et **pour qui** le +/// challenge est émis — l'opérateur de la session, jamais une valeur du +/// navigateur. Ce qu'elle ne décide pas : le rôle suffisant, l'état de la +/// demande, le corps final. `ca-server` en juge. +async fn handle_action_challenge( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !is_json(&headers) { + return unsupported_media_type(); + } + let who = match authenticate(&state, &headers).await { + 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, + 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; + if let Ok(r) = &result { + state.journal.append( + audit::EVENT_ACTION_CHALLENGE, + serde_json::json!({ + "operator": who.operator, + "action": r.body.get("body").and_then(|b| b.get("action")), + "action_id": r.body.get("action_id"), + "body_hash": r.body.get("body_hash"), + "status": r.status, + "error": r.body.get("error"), + }), + ); + } + relayed(result) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Signed { + challenge_id: String, + /// La sortie brute de `navigator.credentials.get` : relayée telle quelle, + /// vérifiée par `ca-server` seul (§4, étape 6). + assertion: serde_json::Value, +} + +/// Un identifiant de transaction tel que `ca-server` les émet : borné, sans +/// caractère de contrôle. Il n'est qu'une attente : `ca-server` le compare au +/// corps qu'il a figé. +fn looks_like_a_transaction(s: &str) -> bool { + !s.is_empty() && s.len() <= 128 && s.chars().all(|c| c.is_ascii_graphic()) +} + +async fn handle_approve( + State(state): State>, + Path(id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay_decision(&state, "approve_request", &id, &headers, &body).await +} + +async fn handle_reject( + State(state): State>, + Path(id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + 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( + state: &AppState, + action: &str, + transaction_id: &str, + headers: &HeaderMap, + body: &[u8], +) -> Response { + if !is_json(headers) { + return 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 result = state + .link + .post( + "/internal/v1/actions", + &serde_json::json!({ + "challenge_id": req.challenge_id, + "assertion": req.assertion, + "expect": { "action": action, "transaction_id": transaction_id }, + }), + ) + .await; + if let Ok(r) = &result { + state.journal.append( + audit::EVENT_ACTION_RELAYED, + serde_json::json!({ + "session_operator": who.operator, + "action": action, + "transaction_id": transaction_id, + "action_id": r.body.get("action_id"), + "signed_by": r.body.get("operator"), + "status": r.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), + } +} + /// `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/main.rs b/bin/ra-console/src/main.rs index 3de65fb..cacf99f 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -93,7 +93,7 @@ async fn run_serve() { cfg.webauthn.login_decoy_secret.into_bytes(), journal.clone(), ); - let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal); + let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal.clone()); // Purge périodique des sessions et challenges expirés (§15 étape 1c-2b) : // aucune opération manuelle, arrêtée par le même signal que le serveur. @@ -104,6 +104,7 @@ async fn run_serve() { link, login, sessions, + journal, })); let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen)) .await diff --git a/bin/ra-console/src/session.rs b/bin/ra-console/src/session.rs index 87239d1..7d76ad6 100644 --- a/bin/ra-console/src/session.rs +++ b/bin/ra-console/src/session.rs @@ -38,6 +38,9 @@ pub enum SessionError { /// Une session authentifiée, relue en base à l'instant de l'appel. pub struct Authenticated { + /// Identifiant de l'opérateur dans le registre : ce qu'une route relaie à + /// `ca-server` (`operator_hint`), jamais une valeur venue du navigateur. + pub operator_id: Uuid, pub operator: String, pub role: Role, } @@ -118,6 +121,7 @@ impl Sessions { .execute(self.registry.pool()) .await; Ok(Authenticated { + operator_id: operator.id, operator: operator.name, role: operator.role, }) diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs new file mode 100644 index 0000000..852f99b --- /dev/null +++ b/bin/ra-console/tests/action_challenge.rs @@ -0,0 +1,557 @@ +//! Actions signées approve/reject (docs/WEBUI.md §4, §15 étape 3 : 3a +//! préparation, 3b exécution) de bout en bout : un navigateur factice connecté → `ra-console` → le +//! lien mTLS → le **vrai** service d'actions de `ca-server`, sur un vrai +//! PostgreSQL. Ce que le test prouve : le challenge est émis pour l'opérateur +//! de la session et pour personne d'autre, la console ne prépare que les +//! actions de l'étape 3, et c'est `ca-server` qui juge du rôle. +//! +//! DSN dans `OE_CASTORE_TEST_DSN` ; test ignoré si elle n'est pas définie. + +mod common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::{pki, tempdir::Dir, Pki}; +use http_body_util::BodyExt; +use oe_actions::{NewCredential, Registry, Role, Service}; +use oe_castore::{Postgres, RequestState, Store}; +use oe_raflow::{Decider, DeciderOptions, Recorder}; +use oe_webauthn::{trusted_models, TrustedModel, Url, Uuid, Verifier}; +use ra_console::ca_link::CaLink; +use ra_console::http::{router, AppState}; +use ra_console::login::LoginService; +use sqlx::postgres::PgPoolOptions; +use tower::ServiceExt; +use webauthn_authenticator_rs::softtoken::{SoftToken, AAGUID}; +use webauthn_authenticator_rs::WebauthnAuthenticator; + +const HOST: &str = "console.example.com"; +const LOGIN_BEGIN: &str = "/api/v1/webauthn/login/begin"; +const LOGIN_FINISH: &str = "/api/v1/webauthn/login/finish"; +const CHALLENGE: &str = "/api/v1/webauthn/challenge"; + +struct NullJournal; +#[async_trait::async_trait] +impl Recorder for NullJournal { + async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} + +fn origin() -> Url { + Url::parse(&format!("https://{HOST}")).unwrap() +} + +struct Env { + console: axum::Router, + registry: Registry, + store: Arc, + verifier: Verifier, + authn: WebauthnAuthenticator, + _dir: Dir, + _pki: Pki, +} + +impl Env { + async fn new() -> Option { + let base = std::env::var("OE_CASTORE_TEST_DSN").ok()?; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let name = format!("chal_{nanos}"); + let admin = PgPoolOptions::new().connect(&base).await.unwrap(); + sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin) + .await + .unwrap(); + let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0); + let store: Arc = Arc::new(Postgres::open(&dsn).await.unwrap()); + let registry = Registry::connect(&dsn).await.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(); + let root_pem = root.to_pem().unwrap(); + let verifier = || { + Verifier::new( + HOST, + &origin(), + "test", + trusted_models(&[TrustedModel { + root_pem: &root_pem, + aaguid: AAGUID, + description: "SoftToken (test)", + }]) + .unwrap(), + ) + .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 pki = pki().await; + let port = pki + .serve_router(ca_server::internal::router(service, 64 * 1024)) + .await; + let dir = Dir::new(); + let client = pki + .cert(&oe_ca_core::profile::internal_client(), "ra-console") + .await; + let link = CaLink::new(&pki.files(&dir, &client, port)).unwrap(); + + let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let console = router(Arc::new(AppState { + pool: pool.clone(), + link, + login: LoginService::new( + registry.clone(), + verifier(), + b"secret-de-test-au-moins-16-octets".to_vec(), + Arc::new(ra_console::audit::NullRecorder), + ), + sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), + })); + + Some(Env { + console, + registry, + store, + verifier: verifier(), + authn: WebauthnAuthenticator::new(token), + _dir: dir, + _pki: pki, + }) + } + + /// Un opérateur actif avec une clé enregistrée, posé directement dans le + /// registre : l'enrôlement a ses propres tests (register_relay.rs). + async fn operator_with_key(&mut self, name: &str, role: Role) -> Uuid { + let now = time::OffsetDateTime::now_utc(); + let id = self + .registry + .add_operator(name, role, "test", now) + .await + .unwrap(); + let (options, state) = self.verifier.start_registration(id, 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: id, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "test", + initiated_by: "test", + confirmed_by: Some("test"), + }, + now, + ) + .await + .unwrap(); + id + } + + async fn post( + &self, + path: &str, + body: serde_json::Value, + cookie: Option<&str>, + content_type: &str, + ) -> (StatusCode, axum::http::HeaderMap, serde_json::Value) { + let mut req = Request::post(path).header("content-type", content_type); + if let Some(c) = cookie { + req = req.header("cookie", c); + } + let res = self + .console + .clone() + .oneshot(req.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = res.status(); + let headers = res.headers().clone(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + ( + status, + headers, + serde_json::from_slice(&bytes).unwrap_or_default(), + ) + } + + async fn challenge( + &self, + cookie: Option<&str>, + body: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let (status, _, body) = self.post(CHALLENGE, body, cookie, "application/json").await; + (status, body) + } + + async fn log_in(&mut self, name: &str) -> String { + let (_, _, begun) = self + .post( + LOGIN_BEGIN, + serde_json::json!({ "name": name }), + None, + "application/json", + ) + .await; + let options: oe_webauthn::RequestChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": begun["webauthn"] })).unwrap(); + let assertion = self.authn.do_authentication(origin(), options).unwrap(); + let (status, headers, body) = self + .post( + LOGIN_FINISH, + serde_json::json!({ "challenge_id": begun["challenge_id"], "credential": assertion }), + None, + "application/json", + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let set_cookie = headers.get("set-cookie").unwrap().to_str().unwrap(); + set_cookie.split(';').next().unwrap().to_string() + } + + async fn pending_request(&self, transaction_id: &str) { + self.store + .create_request(oe_castore::Request { + transaction_id: transaction_id.to_string(), + csr_fingerprint: format!("empreinte-{transaction_id}"), + csr_der: vec![0x30, 0x00], + profile: "tsa_signer".to_string(), + subject_cn: "tsu.example.test".to_string(), + state: RequestState::Pending, + created_at: time::OffsetDateTime::now_utc(), + decided_at: None, + operator: String::new(), + comment: String::new(), + issued_at: None, + certificate_serial: None, + }) + .await + .unwrap(); + } + + async fn actions_frozen(&self) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM actions") + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + /// Identifiants (base64url) des clés d'un opérateur, tels que l'option + /// `allowCredentials` d'un challenge les désigne. + async fn credential_ids(&self, operator: Uuid) -> Vec { + sqlx::query_scalar("SELECT credential_id FROM webauthn_credentials WHERE operator_id = $1") + .bind(operator) + .fetch_all(self.registry.pool()) + .await + .unwrap() + } +} + +macro_rules! env { + () => { + match Env::new().await { + Some(e) => e, + None => { + eprintln!("OE_CASTORE_TEST_DSN non définie : test PostgreSQL ignoré"); + return; + } + } + }; +} + +fn approve(tx: &str) -> serde_json::Value { + serde_json::json!({ "action": "approve_request", "transaction_id": tx, "comment": "identité vérifiée" }) +} + +fn allowed(challenge: &serde_json::Value) -> Vec { + challenge["webauthn"]["allowCredentials"] + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap().to_string()) + .collect() +} + +#[tokio::test] +async fn a_logged_in_operator_gets_a_challenge_for_their_own_keys() { + let mut env = env!(); + let alice = env.operator_with_key("alice", Role::RaOperateur).await; + let bob = env.operator_with_key("bob", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + + // Le navigateur glisse l'identifiant de bob : la console ne le relaie pas, + // le challenge vise les clés de la session (alice), pas celles de bob. + let mut body = approve("tx-1"); + body["operator_hint"] = serde_json::json!(bob); + let (status, issued) = env.challenge(Some(&cookie), body).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + assert_eq!(issued["body"]["action"], "approve_request"); + assert_eq!(issued["body"]["transaction_id"], "tx-1"); + assert_eq!(issued["body_hash"].as_str().unwrap().len(), 64); + assert!(issued["challenge_id"].is_string() && issued["action_id"].is_string()); + let keys = allowed(&issued); + assert_eq!(keys, env.credential_ids(alice).await, "{issued}"); + assert!(env + .credential_ids(bob) + .await + .iter() + .all(|k| !keys.contains(k))); + assert_eq!(env.actions_frozen().await, 1); +} + +#[tokio::test] +async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + + // Sans session, ou avec une session inventée. + for c in [None, Some("session=n-importe-quoi")] { + let (status, err) = env.challenge(c, approve("tx-1")).await; + 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). + 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" }), + ] { + let (status, err) = env.challenge(Some(&cookie), action).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(err["error"], "action_not_available"); + } + + // Une action inconnue, ou un corps qui n'est pas une action. + for body in [ + serde_json::json!({ "action": "delete_everything" }), + serde_json::json!({ "transaction_id": "tx-1" }), + ] { + let (status, err) = env.challenge(Some(&cookie), body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{err}"); + } + + // Un corps qui ne se déclare pas JSON. + let (status, _, _) = env + .post(CHALLENGE, approve("tx-1"), Some(&cookie), "text/plain") + .await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + + assert_eq!( + env.actions_frozen().await, + 0, + "rien n'a été figé côté ca-server" + ); +} + +/// Le rôle affiché par la console n'est pas une barrière (§3) : c'est +/// `ca-server` qui refuse une approbation à un administrateur, et la console +/// relaie son refus. +#[tokio::test] +async fn ca_server_judges_the_role_not_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let cookie = env.log_in("root").await; + env.pending_request("tx-1").await; + + let (status, err) = env.challenge(Some(&cookie), approve("tx-1")).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(env.actions_frozen().await, 0); + + // Et une demande qui n'existe pas n'est pas figée non plus. + let mut env2 = env!(); + env2.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env2.log_in("alice").await; + let (status, err) = env2.challenge(Some(&cookie), approve("tx-inconnue")).await; + assert!(status.is_client_error(), "{status} {err}"); + assert_eq!(env2.actions_frozen().await, 0); +} + +impl Env { + /// L'opérateur touche sa clé : l'assertion du challenge rendu par la console. + fn sign(&mut self, issued: &serde_json::Value) -> serde_json::Value { + let options: oe_webauthn::RequestChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": issued["webauthn"] })).unwrap(); + serde_json::to_value(self.authn.do_authentication(origin(), options).unwrap()).unwrap() + } + + async fn decide( + &self, + cookie: Option<&str>, + path: &str, + issued: &serde_json::Value, + assertion: &serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let (status, _, body) = self + .post( + path, + serde_json::json!({ "challenge_id": issued["challenge_id"], "assertion": assertion }), + cookie, + "application/json", + ) + .await; + (status, body) + } + + async fn state_of(&self, tx: &str) -> (RequestState, String) { + let r = self.store.request_by_transaction_id(tx).await.unwrap(); + (r.state, r.operator) + } +} + +fn reject(tx: &str) -> serde_json::Value { + serde_json::json!({ "action": "reject_request", "transaction_id": tx, "comment": "sujet non reconnu" }) +} + +#[tokio::test] +async fn an_operator_approves_and_rejects_through_the_console() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + env.pending_request("tx-2").await; + + let (_, issued) = env.challenge(Some(&cookie), approve("tx-1")).await; + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-1/approve", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["transaction_id"], "tx-1"); + assert_eq!(done["state"], "APPROVED"); + // L'identité qui a décidé est celle du registre de ca-server. + assert_eq!(done["decided_by"], "alice"); + assert_eq!( + env.state_of("tx-1").await, + (RequestState::Approved, "alice".to_string()) + ); + + // Rejouer la même assertion ne décide rien de plus. + let (status, again) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-1/approve", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{again}"); + assert_eq!(again["error"], "already_used"); + + let (_, issued) = env.challenge(Some(&cookie), reject("tx-2")).await; + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-2/reject", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["state"], "REJECTED"); + assert_eq!(env.state_of("tx-2").await.0, RequestState::Rejected); +} + +/// Une signature obtenue pour une demande ne décide jamais d'une autre, ni +/// l'inverse de ce qui a été signé : `ca-server` compare la cible de la route +/// au corps figé **avant** de rien consommer, si bien que la même assertion +/// reste utilisable sur la bonne route. +#[tokio::test] +async fn a_signature_only_decides_what_was_signed() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-a").await; + env.pending_request("tx-b").await; + + let (_, issued) = env.challenge(Some(&cookie), approve("tx-a")).await; + let assertion = env.sign(&issued); + + for path in [ + "/api/v1/requests/tx-b/approve", + "/api/v1/requests/tx-a/reject", + ] { + let (status, err) = env.decide(Some(&cookie), path, &issued, &assertion).await; + assert_eq!(status, StatusCode::CONFLICT, "{path} {err}"); + assert_eq!(err["error"], "action_mismatch", "{path}"); + } + assert_eq!(env.state_of("tx-a").await.0, RequestState::Pending); + assert_eq!(env.state_of("tx-b").await.0, RequestState::Pending); + + let (status, done) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-a/approve", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(env.state_of("tx-a").await.0, RequestState::Approved); + assert_eq!(env.state_of("tx-b").await.0, RequestState::Pending); +} + +#[tokio::test] +async fn the_console_relays_no_decision_it_has_not_validated() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + let (_, issued) = env.challenge(Some(&cookie), approve("tx-1")).await; + let assertion = env.sign(&issued); + let path = "/api/v1/requests/tx-1/approve"; + + // Sans session. + let (status, _) = env.decide(None, path, &issued, &assertion).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // Des corps que la console ne relaie pas : identifiant de challenge qui + // n'est pas un UUID, assertion absente, et un corps d'action glissé en plus. + for body in [ + serde_json::json!({ "challenge_id": "pas-un-uuid", "assertion": assertion }), + serde_json::json!({ "challenge_id": issued["challenge_id"] }), + serde_json::json!({ "challenge_id": issued["challenge_id"], "assertion": assertion, "body": approve("tx-1") }), + ] { + let (status, _, err) = env + .post(path, body, Some(&cookie), "application/json") + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{err}"); + } + let (status, _, _) = env + .post(path, serde_json::json!({}), Some(&cookie), "text/plain") + .await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + + // Rien n'a été décidé, et l'assertion reste utilisable. + assert_eq!(env.state_of("tx-1").await.0, RequestState::Pending); + let (status, done) = env.decide(Some(&cookie), path, &issued, &assertion).await; + assert_eq!(status, StatusCode::OK, "{done}"); +} diff --git a/bin/ra-console/tests/healthz.rs b/bin/ra-console/tests/healthz.rs index dc12902..01442a3 100644 --- a/bin/ra-console/tests/healthz.rs +++ b/bin/ra-console/tests/healthz.rs @@ -49,6 +49,7 @@ async fn healthz_needs_both_the_database_and_the_link() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool.clone()), + journal: Arc::new(ra_console::audit::NullRecorder), })); let (status, body) = get(&app).await; assert_eq!(status, axum::http::StatusCode::OK, "{body}"); @@ -67,6 +68,7 @@ async fn healthz_needs_both_the_database_and_the_link() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), })); let (status, body) = get(&app).await; assert_eq!( diff --git a/bin/ra-console/tests/login.rs b/bin/ra-console/tests/login.rs index 1cc7beb..bcb037c 100644 --- a/bin/ra-console/tests/login.rs +++ b/bin/ra-console/tests/login.rs @@ -111,6 +111,7 @@ impl Env { pool, link, login, + journal: Arc::new(ra_console::audit::NullRecorder), })); Some(Env { console, diff --git a/bin/ra-console/tests/register_relay.rs b/bin/ra-console/tests/register_relay.rs index c988f19..4ca43a7 100644 --- a/bin/ra-console/tests/register_relay.rs +++ b/bin/ra-console/tests/register_relay.rs @@ -112,6 +112,7 @@ impl Env { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), })); Some(Env { @@ -353,6 +354,7 @@ async fn an_unreachable_ca_server_gives_a_generic_bad_gateway() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), })); let res = console diff --git a/bin/ra-console/tests/requests.rs b/bin/ra-console/tests/requests.rs index ccd1fbd..8a0b051 100644 --- a/bin/ra-console/tests/requests.rs +++ b/bin/ra-console/tests/requests.rs @@ -90,6 +90,7 @@ impl Env { pool: pool.clone(), link, login, + journal: Arc::new(ra_console::audit::NullRecorder), })); Some(Env { console, diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index 6006b91..23c20eb 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -185,6 +185,48 @@ pub enum Error { Effect(String), #[error("registre bloqué, aucune action n'est exécutée : {0}")] Blocked(String), + #[error("l'action figée n'est pas celle attendue : {0}")] + Mismatch(String), +} + +/// Ce que l'appelant croit faire exécuter (docs/WEBUI.md §5, +/// `/requests/{id}/approve`) : le type d'action et sa cible. Comparé au corps +/// figé **avant** toute vérification ou consommation, et refusé s'il diffère : +/// une assertion obtenue pour la demande A ne peut pas être présentée pour la +/// demande B. Ne peut que restreindre : ce qui s'exécute reste le corps figé. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Expect { + pub action: String, + #[serde(default)] + pub transaction_id: Option, +} + +impl Expect { + fn check(&self, action: &Action) -> Result<(), Error> { + if self.action != action.kind() { + return Err(Error::Mismatch(format!( + "attendu {}, figé {}", + self.action, + action.kind() + ))); + } + let target = match action { + Action::ApproveRequest { transaction_id, .. } + | Action::RejectRequest { transaction_id, .. } => Some(transaction_id), + _ => None, + }; + 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(), + )), + (Some(frozen), Some(expected)) => Err(Error::Mismatch(format!( + "demande attendue {expected}, figée {frozen}" + ))), + (None, _) => Ok(()), + } + } } /// Un challenge émis, à présenter à l'opérateur. @@ -618,6 +660,28 @@ impl Service { &self, challenge_id: Uuid, assertion: &PublicKeyCredential, + ) -> Result { + self.execute_inner(challenge_id, assertion, None).await + } + + /// Comme [`Service::execute`], mais refuse, avant de rien vérifier ni + /// consommer, si le corps figé n'est pas celui que l'appelant attend + /// (voir [`Expect`]). + pub async fn execute_expecting( + &self, + challenge_id: Uuid, + assertion: &PublicKeyCredential, + expect: &Expect, + ) -> Result { + self.execute_inner(challenge_id, assertion, Some(expect)) + .await + } + + async fn execute_inner( + &self, + challenge_id: Uuid, + assertion: &PublicKeyCredential, + expect: Option<&Expect>, ) -> Result { self.ensure_open()?; let now = self.now(); @@ -647,6 +711,13 @@ impl Service { if now > expires_at || now > action_expires_at { return Err(Error::Expired); } + let stored: Body = + serde_json::from_value(body).map_err(|e| Error::BadRequest(e.to_string()))?; + // 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)?; + } // Une seule tentative par cérémonie : l'état sort de la mémoire quoi // qu'il arrive ensuite. @@ -671,8 +742,6 @@ impl Service { .operator(key.operator_id) .await? .ok_or_else(|| Error::Denied("opérateur inconnu".to_string()))?; - let stored: Body = - serde_json::from_value(body).map_err(|e| Error::BadRequest(e.to_string()))?; if operator.disabled || !stored.action.allowed_roles().contains(&operator.role) { return Err(Error::Denied(format!( "le rôle {} ne peut pas signer {}", @@ -898,3 +967,49 @@ impl Service { }) } } + +#[cfg(test)] +mod expect_tests { + use super::*; + + fn approve(tx: &str) -> Action { + Action::ApproveRequest { + transaction_id: tx.to_string(), + csr_fingerprint: None, + comment: "ok".to_string(), + } + } + + fn expect(action: &str, tx: Option<&str>) -> Expect { + Expect { + action: action.to_string(), + transaction_id: tx.map(str::to_string), + } + } + + #[test] + fn only_the_frozen_action_and_target_pass() { + assert!(expect("approve_request", Some("tx-a")) + .check(&approve("tx-a")) + .is_ok()); + assert!(matches!( + expect("approve_request", Some("tx-b")).check(&approve("tx-a")), + Err(Error::Mismatch(_)) + )); + assert!(matches!( + expect("reject_request", Some("tx-a")).check(&approve("tx-a")), + 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")), + Err(Error::BadRequest(_)) + )); + // Une action sans demande visée : seul le type compte. + let role = Action::SetRole { + operator: "alice".to_string(), + role: Role::Auditeur, + }; + assert!(expect("set_role", None).check(&role).is_ok()); + } +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 4ca5ea1..14df368 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -53,6 +53,51 @@ gardé par la console. - Pas encore de limitation de débit (l'endpoint est anonyme ; le jeton fait 256 bits et vit 24 h au plus) : voir `TODO.md`. +## Préparation d'une action signée (relais du challenge) + +`POST /api/v1/webauthn/challenge`, avec une session ouverte : le corps est l'action +demandée, dans la forme d'`oe_actions` (`{"action": "approve_request", +"transaction_id": "…", "comment": "…"}`, ou `reject_request`). La console relaie à +`ca-server` (`/internal/v1/challenge`), qui **fige** l'action et rend le corps qu'il +exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la clé +(docs/WEBUI.md §4, étapes 1 à 3). + +- Le challenge est émis pour **l'opérateur de la session** : l'identifiant relayé + (`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`. +- 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` : + opérateur, action, `action_id`, `body_hash`, statut), rapprochable du journal de + `ca-server`, qui fait foi. + +## Exécution d'une décision signée (approuver, rejeter) + +`POST /api/v1/requests/{id}/approve` ou `/reject`, avec une session ouverte : +`{"challenge_id": "…", "assertion": {…}}`, l'assertion étant la sortie brute de +`navigator.credentials.get` sur les options du challenge. La console relaie à +`ca-server` (`/internal/v1/actions`) l'identifiant du challenge et l'assertion — +**jamais de corps** : `ca-server` exécute celui qu'il a figé (docs/WEBUI.md §4, +étapes 5 à 7). Réponse : `{"transaction_id", "state": "APPROVED" | "REJECTED", +"decided_by", "action_id"}`, où `decided_by` est l'opérateur **dont la clé a signé**, +lu dans le registre de `ca-server`, pas celui de la session. + +- La console joint ce que la route promet (`expect` : l'action et la demande du + chemin). `ca-server` le compare au corps figé **avant** toute vérification ou + consommation, et refuse (`409 action_mismatch`) s'il diffère : une signature obtenue + pour une demande ne décide jamais d'une autre, ni l'inverse de ce qui a été signé, + et l'assertion reste utilisable sur la bonne route. +- Une assertion déjà utilisée est refusée (`409 already_used`) : le rejeu est + impossible par construction. +- Chaque relais est inscrit au journal de la console (`ra.action_relayed` : opérateur + de la session, action, demande, `action_id`, signataire selon `ca-server`, statut). +- 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. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -87,8 +132,9 @@ gardé par la console. ## Ce qui n'existe pas encore -La connexion des opérateurs (login, sessions), la -lecture, les actions signées relayées, la révocation, le workflow d'incident et le -frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. L'image, le chart Helm et le +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 +connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas +encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se renouvelle à la main pour l'instant.