From 37958f4cbf732484a2295f97ea0e593af27e3e68 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 10:02:06 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(ra-console):=20pr=C3=A9paration=20des?= =?UTF-8?q?=20actions=20sign=C3=A9es=20approve/reject=20(=C3=A9tape=203a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §15 étape 3, schéma de signature §4 étapes 1 à 3 : `POST /api/v1/webauthn/challenge` relaie à ca-server (`/internal/v1/challenge`) l'action demandée par l'opérateur connecté ; ca-server la fige et rend le corps qu'il exécutera, son empreinte et les options WebAuthn. - Le challenge vise l'opérateur de la session : `operator_hint` vient de la session (Authenticated gagne `operator_id`), 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. - Seuls approve_request et reject_request sont préparés à ce stade (403 action_not_available sinon, sans solliciter ca-server). Le rôle et l'état de la demande restent jugés par ca-server. - Journal de la console : ra.action_challenge (AppState gagne `journal`). - action_challenge.rs, contre le vrai service d'actions de ca-server et PostgreSQL ; trois mutations tuées (indice venu du navigateur, filtre de l'étape, session non exigée). - docs/RA-CONSOLE.md mis à jour. Co-authored-by: Claude --- bin/ra-console/src/audit.rs | 5 + bin/ra-console/src/http.rs | 72 +++++ bin/ra-console/src/main.rs | 3 +- bin/ra-console/src/session.rs | 4 + bin/ra-console/tests/action_challenge.rs | 390 +++++++++++++++++++++++ bin/ra-console/tests/healthz.rs | 2 + bin/ra-console/tests/login.rs | 1 + bin/ra-console/tests/register_relay.rs | 2 + bin/ra-console/tests/requests.rs | 1 + docs/RA-CONSOLE.md | 30 +- 10 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 bin/ra-console/tests/action_challenge.rs diff --git a/bin/ra-console/src/audit.rs b/bin/ra-console/src/audit.rs index 703a4dc..5cf6bdc 100644 --- a/bin/ra-console/src/audit.rs +++ b/bin/ra-console/src/audit.rs @@ -17,6 +17,11 @@ 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"; /// 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..8e3a1ae 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -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,7 @@ 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)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -438,6 +441,75 @@ 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) +} + /// `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..433d3a9 --- /dev/null +++ b/bin/ra-console/tests/action_challenge.rs @@ -0,0 +1,390 @@ +//! Préparation d'une action signée (docs/WEBUI.md §4 étapes 1 à 3, §15 étape +//! 3a) 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); +} 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/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 4ca5ea1..b09f2dc 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -53,6 +53,29 @@ 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. +- L'exécution (relais de l'assertion signée) est l'étape 3b. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -87,8 +110,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 +L'exécution des actions signées (étape 3b : relais de l'assertion), la révocation, 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. From 457dc7efd0762a013d98fff0912e2220c79af56e Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 10:11:02 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(ra-console):=20ex=C3=A9cution=20des=20?= =?UTF-8?q?d=C3=A9cisions=20sign=C3=A9es=20approve/reject=20(=C3=A9tape=20?= =?UTF-8?q?3b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §15 étape 3, §4 étapes 5 à 7 : `POST /api/v1/requests/{id}/approve|reject` relaie à ca-server (`/internal/v1/actions`) l'identifiant du challenge et l'assertion brute, jamais de corps : ca-server exécute celui qu'il a figé. - Lien assertion ↔ demande contrôlé par ca-server (décision de l'utilisateur) : la console joint `expect` (action et demande du chemin) ; oe_actions::Service::execute_expecting le compare au corps figé avant de retirer l'état de la cérémonie, 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. `expect` ne peut que restreindre ; `execute` sans attente est inchangé. - Réponse de la forme du §5 : `decided_by` est l'opérateur dont la clé a signé, lu dans le registre de ca-server. - Journal de la console : ra.action_relayed. - Tests de bout en bout (approbation, rejet, rejeu, mauvaise cible, corps non relayés) et unitaire d'Expect ; trois mutations tuées (contrôle retiré, contrôle après consommation, expect non envoyé). Co-authored-by: Claude --- bin/ca-server/src/internal.rs | 16 ++- bin/ra-console/src/audit.rs | 4 + bin/ra-console/src/http.rs | 112 ++++++++++++++- bin/ra-console/tests/action_challenge.rs | 171 ++++++++++++++++++++++- crates/oe-actions/src/lib.rs | 119 +++++++++++++++- docs/RA-CONSOLE.md | 28 +++- 6 files changed, 441 insertions(+), 9 deletions(-) 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 5cf6bdc..bab082f 100644 --- a/bin/ra-console/src/audit.rs +++ b/bin/ra-console/src/audit.rs @@ -22,6 +22,10 @@ pub const EVENT_SESSION_CLOSED: &str = "ra.session_closed"; /// 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 8e3a1ae..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}; @@ -48,6 +48,8 @@ pub fn router(state: Arc) -> Router { .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) } @@ -510,6 +512,114 @@ async fn handle_action_challenge( 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/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 433d3a9..852f99b 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -1,5 +1,5 @@ -//! Préparation d'une action signée (docs/WEBUI.md §4 étapes 1 à 3, §15 étape -//! 3a) de bout en bout : un navigateur factice connecté → `ra-console` → le +//! 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 @@ -388,3 +388,170 @@ async fn ca_server_judges_the_role_not_the_console() { 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/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 b09f2dc..14df368 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -74,7 +74,29 @@ exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la - 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. -- L'exécution (relais de l'assertion signée) est l'étape 3b. + +## 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 @@ -110,8 +132,8 @@ exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la ## Ce qui n'existe pas encore -L'exécution des actions signées (étape 3b : relais de l'assertion), la révocation, le -workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +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 From aac4dbc298682d646b6bfa548fb68affd4ebd60a Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 11:42:15 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(ra-console):=20r=C3=A9vocation=20d'un?= =?UTF-8?q?=20certificat,=20premi=C3=A8re=20signature=20(=C3=A9tape=204a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §15 étape 4, §8 : la console prépare `revoke_certificate` et relaie la signature par `POST /api/v1/certificates/{serial}/revoke`. La politique de ca-server exige deux ca_operateur distincts : la première signature est enregistrée, rien n'est révoqué (AWAITING_QUORUM, 1/2). La co-signature est l'étape 4b. - oe_actions::Expect gagne `serial` : ca-server compare le certificat de la route au corps figé avant toute consommation, comme pour une décision ; une cible sans rapport avec le type d'action est refusée. - ra-console : relay_assertion factorise le relais d'une assertion (décisions et révocation) ; numéro de série exigé sous forme canonique (hexadécimal minuscule, 20 octets au plus) avant relais. - Tests : harnais doté d'une vraie CA sur PostgreSQL et du révocateur de ca-server ; première signature sans révocation, mauvaise cible, forme non canonique, refus pour un ra_operateur. Deux mutations tuées. Co-authored-by: Claude --- bin/ra-console/src/http.rs | 152 +++++++++++++------ bin/ra-console/tests/action_challenge.rs | 180 +++++++++++++++++++++-- crates/oe-actions/src/lib.rs | 52 ++++++- docs/RA-CONSOLE.md | 27 +++- 4 files changed, 339 insertions(+), 72 deletions(-) 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..ca78283 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,43 @@ 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(), + 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 +128,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 +171,7 @@ impl Env { console, registry, store, + issuer, verifier: verifier(), authn: WebauthnAuthenticator::new(token), _dir: dir, @@ -334,9 +376,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 +597,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 From 4525c07233c8005845fe709a6af4e25aa71cd626 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 11:52:11 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(ra-console):=20double=20contr=C3=B4le,?= =?UTF-8?q?=20co-signature=20et=20salle=20d'attente=20(=C3=A9tape=204b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §8, §15 étape 4 : deux ca_operateur distincts révoquent ensemble depuis la console. - Co-signature : `POST /api/v1/webauthn/challenge` accepte `{"action_id"}` (action existante, non exécutée, proposée à ce stade), puis `POST /api/v1/quorum/{action_id}/sign`. oe_actions::Expect gagne `action_id`, comparé avant toute consommation : une co-signature ne compte que pour l'action pour laquelle son challenge a été émis. - Salle d'attente : `GET /api/v1/quorum?state=PENDING` lit `actions` et `decision_evidence` de ca-server en lecture seule (décision de l'utilisateur) : corps figé, empreinte, signatures, signataires. Le rôle de la console gagne SELECT sur `actions` (aucun secret n'y figure) ; le test de schéma est mis à jour. - Écart assumé avec le §8, en plus sûr : pas de tables de collecte, la console ne conserve jamais d'assertion (ca-server enregistre chaque signature au fil de l'eau). WEBUI.md §8 dit ce qui est construit. - Tests : révocation à deux de bout en bout, seconde signature du même opérateur refusée, exécution unique, co-signature présentée pour une autre action sur le même certificat refusée. Deux mutations tuées. - Mise à jour d'un déploiement : rejouer ra_console_grants.sql. Co-authored-by: Claude --- bin/ra-console/src/http.rs | 181 +++++++++++++++++--- bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/quorum.rs | 93 ++++++++++ bin/ra-console/tests/action_challenge.rs | 175 +++++++++++++++++++ crates/oe-actions/src/lib.rs | 47 +++-- crates/oe-castore/sql/ra_console_grants.sql | 7 +- crates/oe-castore/tests/operators_schema.rs | 7 +- docs/RA-CONSOLE.md | 27 ++- docs/WEBUI.md | 13 +- 9 files changed, 513 insertions(+), 38 deletions(-) create mode 100644 bin/ra-console/src/quorum.rs diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index af2c920..6c12542 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -17,8 +17,8 @@ use sqlx::PgPool; use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; -use crate::requests; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; +use crate::{quorum, requests}; /// Assez pour un objet d'attestation, pas pour bourrer la mémoire. const MAX_BODY_BYTES: usize = 64 * 1024; @@ -51,6 +51,8 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/requests/{id}/approve", post(handle_approve)) .route("/api/v1/requests/{id}/reject", post(handle_reject)) .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) + .route("/api/v1/quorum", get(handle_quorum)) + .route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -457,6 +459,57 @@ fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { ) } +fn not_available() -> Response { + error( + StatusCode::FORBIDDEN, + "action_not_available", + "cette action n'est pas encore proposée par la console", + ) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CoSign { + action_id: String, +} + +/// Une action déjà figée par `ca-server`, que la console propose à ce stade et +/// qui attend encore des signatures. Lue dans la table `actions`, en lecture +/// seule : rien n'est décidé ici, `ca-server` recontrôle tout. +/// +/// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]). +#[allow(clippy::result_large_err)] +async fn frozen_at_this_stage( + state: &AppState, + action_id: &str, +) -> Result<(oe_webauthn::Uuid, oe_actions::Action), Response> { + let unknown = || error(StatusCode::NOT_FOUND, "unknown_action", "action inconnue"); + let id: oe_webauthn::Uuid = action_id.parse().map_err(|_| unknown())?; + let frozen = quorum::frozen(&state.pool, id) + .await + .map_err(|e| { + tracing::error!(erreur = %e, "quorum : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + })? + .ok_or_else(unknown)?; + if frozen.executed { + return Err(error( + StatusCode::CONFLICT, + "already_executed", + "action déjà exécutée", + )); + } + let action: oe_actions::Action = serde_json::from_value(frozen.body).map_err(|_| unknown())?; + if !relayed_at_this_stage(&action) { + return Err(not_available()); + } + Ok((id, action)) +} + /// `POST /api/v1/webauthn/challenge` (docs/WEBUI.md §4 étapes 1 à 3, §5) : /// l'opérateur connecté demande à `ca-server` de figer une action et d'émettre /// le challenge qu'il signera. Le corps rendu est celui que `ca-server` @@ -478,27 +531,35 @@ async fn handle_action_challenge( Ok(a) => a, Err(resp) => return resp, }; - // Relue dans l'énumération fermée d'`oe_actions`, puis resérialisée : un - // champ en trop (un `operator_hint` glissé par le navigateur, par exemple) - // ne franchit jamais la console. - let action: oe_actions::Action = match serde_json::from_slice(&body) { - Ok(a) => a, + let value: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"), }; - if !relayed_at_this_stage(&action) { - return error( - StatusCode::FORBIDDEN, - "action_not_available", - "cette action n'est pas encore proposée par la console", - ); - } - let result = state - .link - .post( - "/internal/v1/challenge", - &serde_json::json!({ "body": action, "operator_hint": who.operator_id }), - ) - .await; + // Deux formes (§8) : une action nouvelle, ou `{"action_id"}` pour signer + // une action déjà figée (double contrôle). Dans les deux cas, l'action est + // relue dans l'énumération fermée d'`oe_actions` : un champ en trop (un + // `operator_hint` glissé par le navigateur, par exemple) ne franchit + // jamais la console. + let relay = if value.get("action_id").is_some() { + let Ok(CoSign { action_id }) = serde_json::from_value::(value) else { + return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"); + }; + let (id, _) = match frozen_at_this_stage(&state, &action_id).await { + Ok(f) => f, + Err(resp) => return resp, + }; + serde_json::json!({ "action_id": id, "operator_hint": who.operator_id }) + } else { + let action: oe_actions::Action = match serde_json::from_value(value) { + Ok(a) => a, + Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"), + }; + if !relayed_at_this_stage(&action) { + return not_available(); + } + serde_json::json!({ "body": action, "operator_hint": who.operator_id }) + }; + let result = state.link.post("/internal/v1/challenge", &relay).await; if let Ok(r) = &result { state.journal.append( audit::EVENT_ACTION_CHALLENGE, @@ -665,6 +726,86 @@ async fn handle_revoke( } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct QuorumQuery { + state: Option, +} + +/// `GET /api/v1/quorum?state=PENDING` (docs/WEBUI.md §5, §8) : les actions à +/// plusieurs signatures ni exécutées ni expirées, avec qui a déjà signé. En +/// lecture seule sur l'état de `ca-server`, qui fait foi. +async fn handle_quorum( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + if q.state.as_deref().is_some_and(|s| s != "PENDING") { + return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide"); + } + match quorum::pending(&state.pool, time::OffsetDateTime::now_utc()).await { + Ok(list) => Json(list).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "quorum : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + +/// `POST /api/v1/quorum/{action_id}/sign` (docs/WEBUI.md §5, §8) : une +/// signature de plus sur une action figée, challenge obtenu par +/// `POST /api/v1/webauthn/challenge` avec `{"action_id"}`. `ca-server` +/// n'accepte qu'une signature par opérateur et exécute au seuil, une seule +/// fois ; la console lui dit ce qu'elle attend (l'action de la route et sa +/// cible), qu'il compare avant toute consommation. +async fn handle_quorum_sign( + State(state): State>, + Path(action_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !is_json(&headers) { + return unsupported_media_type(); + } + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + let (id, action) = match frozen_at_this_stage(&state, &action_id).await { + Ok(f) => f, + Err(resp) => return resp, + }; + let mut expect = serde_json::json!({ "action": action_kind(&action), "action_id": id }); + match &action { + oe_actions::Action::ApproveRequest { transaction_id, .. } + | oe_actions::Action::RejectRequest { transaction_id, .. } => { + expect["transaction_id"] = serde_json::json!(transaction_id); + } + oe_actions::Action::RevokeCertificate { serial, .. } => { + expect["serial"] = serde_json::json!(serial); + } + _ => {} + } + match relay_assertion(&state, &headers, &body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// Le nom sérialisé d'une action (`approve_request`…), celui du corps figé. +fn action_kind(action: &oe_actions::Action) -> String { + serde_json::to_value(action) + .ok() + .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string)) + .unwrap_or_default() +} + /// La forme du §5 pour une action à plusieurs signatures. fn quorum_status(body: &serde_json::Value) -> serde_json::Value { let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index ba4a1be..f855557 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -19,6 +19,7 @@ pub mod db_guard; pub mod http; pub mod login; pub mod purge; +pub mod quorum; pub mod requests; pub mod session; pub mod webauthn_models; diff --git a/bin/ra-console/src/quorum.rs b/bin/ra-console/src/quorum.rs new file mode 100644 index 0000000..b768d13 --- /dev/null +++ b/bin/ra-console/src/quorum.rs @@ -0,0 +1,93 @@ +//! Salle d'attente des actions à plusieurs signatures (docs/WEBUI.md §8, §15 +//! étape 4b), en lecture seule sur les tables de `ca-server`. +//! +//! Le §8 prévoyait des tables de collecte propres à la console, qui auraient +//! conservé les assertions jusqu'au seuil. Ce n'est pas ce qui est construit : +//! `ca-server` enregistre chaque signature au fil de l'eau (`decision_evidence`) +//! et n'exécute qu'au seuil. La console lit donc l'état qui fait foi, sans en +//! tenir de copie qui pourrait diverger, et ne garde jamais d'assertion. + +use oe_webauthn::Uuid; +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +/// Une action figée par `ca-server`, telle que la route de signature en a +/// besoin pour dire ce qu'elle attend (`expect`). +pub struct Frozen { + pub body: serde_json::Value, + pub executed: bool, +} + +/// Une action en attente de signatures, pour l'affichage (« 1 signature sur +/// 2 »). Le seuil qui fait foi reste celui de la politique de `ca-server`, +/// relu à l'exécution. +#[derive(Serialize)] +pub struct Pending { + pub action_id: Uuid, + pub action: String, + /// Le corps figé, à afficher tel quel à qui va co-signer (WYSIWYS). + pub body: serde_json::Value, + pub body_hash: String, + pub required: i32, + pub signatures: usize, + /// Qui a déjà signé, lu dans le registre de `ca-server`. + pub signed_by: Vec, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +pub async fn frozen(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query("SELECT body, executed_at FROM actions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| Frozen { + body: r.get("body"), + executed: r.get::, _>("executed_at").is_some(), + })) +} + +/// Les actions à plusieurs signatures ni exécutées ni expirées, des plus +/// anciennes aux plus récentes. +pub async fn pending(pool: &PgPool, now: OffsetDateTime) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT a.id, a.body, a.body_hash, a.required_signatures, a.created_at, a.expires_at, + COALESCE(array_agg(o.name ORDER BY e.verified_at) + FILTER (WHERE o.name IS NOT NULL), '{}') AS signed_by + FROM actions a + LEFT JOIN decision_evidence e ON e.action_id = a.id + LEFT JOIN operators o ON o.id = e.operator_id + WHERE a.executed_at IS NULL AND a.expires_at > $1 AND a.required_signatures > 1 + GROUP BY a.id + ORDER BY a.created_at", + ) + .bind(now) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| { + let body: serde_json::Value = r.get("body"); + let signed_by: Vec = r.get("signed_by"); + let hash: Vec = r.get("body_hash"); + Pending { + action_id: r.get("id"), + action: body + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_string(), + body, + body_hash: hash.iter().map(|b| format!("{b:02x}")).collect(), + required: r.get("required_signatures"), + signatures: signed_by.len(), + signed_by, + created_at: r.get("created_at"), + expires_at: r.get("expires_at"), + } + }) + .collect()) +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index ca78283..8b563ae 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -707,3 +707,178 @@ async fn an_ra_operator_cannot_prepare_a_revocation() { assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); assert_eq!(env.actions_frozen().await, 0); } + +impl Env { + async fn get(&self, path: &str, cookie: &str) -> (StatusCode, serde_json::Value) { + let res = self + .console + .clone() + .oneshot( + Request::get(path) + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or_default()) + } + + /// Première signature d'une révocation par l'opérateur de `cookie` : rend + /// l'identifiant de l'action figée. + async fn first_signature(&mut self, cookie: &str, serial: &str) -> String { + let (_, issued) = self.challenge(Some(cookie), revoke(serial)).await; + let assertion = self.sign(&issued); + let (status, done) = self + .decide( + Some(cookie), + &format!("/api/v1/certificates/{serial}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(done["status"], "AWAITING_QUORUM", "{status} {done}"); + done["action_id"].as_str().unwrap().to_string() + } +} + +/// Étape 4b : deux `ca_operateur` distincts révoquent ensemble. La salle +/// d'attente lit l'état de `ca-server` ; une seconde signature du même +/// opérateur ne compte pas ; la dernière signature exécute, une seule fois. +#[tokio::test] +async fn two_distinct_ca_operators_revoke_together() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let alice = env.log_in("alice").await; + let bob = env.log_in("bob").await; + let serial = env.certificate("tx-quorum").await; + let action_id = env.first_signature(&alice, &serial).await; + + let (status, waiting) = env.get("/api/v1/quorum?state=PENDING", &bob).await; + assert_eq!(status, StatusCode::OK, "{waiting}"); + let waiting = waiting.as_array().unwrap(); + assert_eq!(waiting.len(), 1); + assert_eq!(waiting[0]["action_id"], action_id.as_str()); + assert_eq!(waiting[0]["action"], "revoke_certificate"); + assert_eq!(waiting[0]["body"]["serial"], serial.as_str()); + assert_eq!(waiting[0]["signatures"], 1); + assert_eq!(waiting[0]["required"], 2); + assert_eq!(waiting[0]["signed_by"], serde_json::json!(["alice"])); + + // Alice ne peut pas signer une seconde fois sa propre action. + let (status, err) = env + .challenge(Some(&alice), serde_json::json!({ "action_id": action_id })) + .await; + if status == StatusCode::OK { + let assertion = env.sign(&err); + let (status, err) = env + .decide( + Some(&alice), + &format!("/api/v1/quorum/{action_id}/sign"), + &err, + &assertion, + ) + .await; + assert!(status.is_client_error(), "{status} {err}"); + } else { + assert!(status.is_client_error(), "{status} {err}"); + } + assert_eq!( + env.status_of(&serial).await, + oe_castore::CertificateStatus::Issued + ); + + // Bob co-signe : la révocation s'exécute. + let (status, issued) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&bob), + &format!("/api/v1/quorum/{action_id}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + assert_eq!(done["signatures"], 2); + assert_eq!(done["signed_by"], "bob"); + assert_eq!( + env.status_of(&serial).await, + oe_castore::CertificateStatus::Revoked + ); + + let (_, waiting) = env.get("/api/v1/quorum?state=PENDING", &bob).await; + assert_eq!(waiting, serde_json::json!([])); + // Une action exécutée ne se prépare plus. + let (status, err) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "already_executed"); +} + +/// Une co-signature ne compte que pour l'action pour laquelle son challenge a +/// été émis : présentée pour une autre, elle est refusée sans rien consommer. +/// Les deux actions visent le même certificat : seul leur identifiant les +/// distingue, c'est bien lui qui est contrôlé. +#[tokio::test] +async fn a_co_signature_only_counts_for_its_action() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let alice = env.log_in("alice").await; + let bob = env.log_in("bob").await; + let x = env.certificate("tx-x").await; + let action_x = env.first_signature(&alice, &x).await; + let action_y = env.first_signature(&alice, &x).await; + assert_ne!(action_x, action_y); + + let (_, issued) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action_x })) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&bob), + &format!("/api/v1/quorum/{action_y}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + assert_eq!( + env.status_of(&x).await, + oe_castore::CertificateStatus::Issued + ); + + let (status, done) = env + .decide( + Some(&bob), + &format!("/api/v1/quorum/{action_x}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["action_id"], action_x.as_str()); + assert_eq!( + env.status_of(&x).await, + oe_castore::CertificateStatus::Revoked + ); + + // Une action inconnue, ou un identifiant qui n'en est pas un. + for id in ["3f2b8c1e-9d4a-4e6b-8a7c-1234567890ab", "pas-un-uuid"] { + let (status, err) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": id })) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{id} {err}"); + } +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index df028d2..baf0ace 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -205,10 +205,20 @@ pub struct Expect { /// canonique du corps figé). #[serde(default)] pub serial: Option, + /// Action visée par une co-signature (double contrôle, §8) : le challenge + /// présenté doit avoir été émis pour elle. + #[serde(default)] + pub action_id: Option, } impl Expect { - fn check(&self, action: &Action) -> Result<(), Error> { + fn check(&self, action: &Action, action_id: Uuid) -> Result<(), Error> { + if self.action_id.is_some_and(|expected| expected != action_id) { + return Err(Error::Mismatch(format!( + "action attendue {}, challenge émis pour {action_id}", + self.action_id.unwrap_or_default() + ))); + } if self.action != action.kind() { return Err(Error::Mismatch(format!( "attendu {}, figé {}", @@ -733,7 +743,7 @@ impl Service { // Avant de retirer l'état de la cérémonie : une assertion présentée pour // une autre cible ne consomme rien, le bon appel reste possible. if let Some(expect) = expect { - expect.check(&stored.action)?; + expect.check(&stored.action, action_id)?; } // Une seule tentative par cérémonie : l'état sort de la mémoire quoi @@ -1002,25 +1012,26 @@ mod expect_tests { action: action.to_string(), transaction_id: tx.map(str::to_string), serial: None, + action_id: None, } } #[test] fn only_the_frozen_action_and_target_pass() { assert!(expect("approve_request", Some("tx-a")) - .check(&approve("tx-a")) + .check(&approve("tx-a"), Uuid::nil()) .is_ok()); assert!(matches!( - expect("approve_request", Some("tx-b")).check(&approve("tx-a")), + expect("approve_request", Some("tx-b")).check(&approve("tx-a"), Uuid::nil()), Err(Error::Mismatch(_)) )); assert!(matches!( - expect("reject_request", Some("tx-a")).check(&approve("tx-a")), + expect("reject_request", Some("tx-a")).check(&approve("tx-a"), Uuid::nil()), Err(Error::Mismatch(_)) )); // Une décision sans cible précisée n'est pas une attente : refusée. assert!(matches!( - expect("approve_request", None).check(&approve("tx-a")), + expect("approve_request", None).check(&approve("tx-a"), Uuid::nil()), Err(Error::BadRequest(_)) )); // Une action sans demande visée : seul le type compte. @@ -1028,7 +1039,7 @@ mod expect_tests { operator: "alice".to_string(), role: Role::Auditeur, }; - assert!(expect("set_role", None).check(&role).is_ok()); + assert!(expect("set_role", None).check(&role, Uuid::nil()).is_ok()); // Révocation : la cible est le numéro de série, jamais une demande. let revoke = Action::RevokeCertificate { @@ -1040,16 +1051,32 @@ mod expect_tests { action: "revoke_certificate".to_string(), transaction_id: None, serial: Some(s.to_string()), + action_id: None, }; - assert!(by_serial("0a1b").check(&revoke).is_ok()); + assert!(by_serial("0a1b").check(&revoke, Uuid::nil()).is_ok()); assert!(matches!( - by_serial("0a1c").check(&revoke), + by_serial("0a1c").check(&revoke, Uuid::nil()), Err(Error::Mismatch(_)) )); let mixed = Expect { transaction_id: Some("tx".to_string()), ..by_serial("0a1b") }; - assert!(matches!(mixed.check(&revoke), Err(Error::BadRequest(_)))); + assert!(matches!( + mixed.check(&revoke, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Co-signature : le challenge doit avoir été émis pour l'action visée. + let target = Uuid::from_u128(7); + let for_target = Expect { + action_id: Some(target), + ..by_serial("0a1b") + }; + assert!(for_target.check(&revoke, target).is_ok()); + assert!(matches!( + for_target.check(&revoke, Uuid::from_u128(8)), + Err(Error::Mismatch(_)) + )); } } diff --git a/crates/oe-castore/sql/ra_console_grants.sql b/crates/oe-castore/sql/ra_console_grants.sql index 07d10f9..db57cd0 100644 --- a/crates/oe-castore/sql/ra_console_grants.sql +++ b/crates/oe-castore/sql/ra_console_grants.sql @@ -23,7 +23,12 @@ GRANT SELECT ON operators, webauthn_credentials, pending_credentials, - decision_evidence + decision_evidence, + -- Les actions figées (corps, empreinte, seuil, échéance) : la salle + -- d'attente des actions à plusieurs signatures (docs/WEBUI.md §8) les lit + -- ici plutôt que d'en tenir une copie. Aucun secret n'y figure : le jeton + -- d'une invitation n'est rendu que dans le résultat de l'exécution. + actions TO openeidas_ra_console; -- Pour les clés étrangères des tables propres à ra-console. diff --git a/crates/oe-castore/tests/operators_schema.rs b/crates/oe-castore/tests/operators_schema.rs index 7c03d4d..de7ea59 100644 --- a/crates/oe-castore/tests/operators_schema.rs +++ b/crates/oe-castore/tests/operators_schema.rs @@ -318,11 +318,10 @@ async fn ra_console_role_cannot_write_ca_tables() { ); } - // Aucune lecture des tables sans droit : hachés de jetons, actions, - // challenges, autorités, CRL. + // Aucune lecture des tables sans droit : hachés de jetons, challenges, + // autorités, CRL. for table in [ "operator_invites", - "actions", "action_challenges", "authorities", "crls", @@ -336,6 +335,8 @@ async fn ra_console_role_cannot_write_ca_tables() { // La lecture, elle, fonctionne : c'est ce dont la console a besoin. for table in [ + // La salle d'attente des actions à plusieurs signatures (§8). + "actions", "enrollment_requests", "certificates", "operators", diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index ec77682..a66e9b7 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -110,11 +110,33 @@ forme canonique du corps figé) ; toute autre forme est refusée avant relais. distincts** (docs/WEBUI.md §8). La première signature est enregistrée par `ca-server` et **rien n'est révoqué** : réponse `{"status": "AWAITING_QUORUM", "signatures": 1, "required": 2, "action_id", "signed_by"}`. La signature suivante - (co-signature) arrive avec l'étape 4b. + (co-signature) passe par la salle d'attente, ci-dessous. - La cible est contrôlée par `ca-server` comme pour une décision (`expect` porte le numéro de série) : une signature ne révoque jamais un autre certificat. - Un `ra_operateur` ne peut pas préparer de révocation : `ca-server` refuse. +## Double contrôle : salle d'attente et co-signature + +- `GET /api/v1/quorum?state=PENDING` (session) : les actions à plusieurs signatures ni + exécutées ni expirées — identifiant, type, **corps figé** (à afficher tel quel à qui + va co-signer), empreinte, signatures recueillies et exigées, **qui a déjà signé**. + La console lit l'état qui fait foi, dans la table `actions` et `decision_evidence` + de `ca-server`, en lecture seule ; elle n'en tient aucune copie et ne conserve + jamais d'assertion. +- Co-signer : `POST /api/v1/webauthn/challenge` avec `{"action_id": "…"}` (la console + vérifie que l'action existe, n'est pas exécutée et relève des actions proposées), + puis `POST /api/v1/quorum/{action_id}/sign` avec `{"challenge_id", "assertion"}`. + La console joint à `expect` l'identifiant de l'action et sa cible : une + co-signature ne compte que pour l'action pour laquelle son challenge a été émis. +- `ca-server` n'accepte qu'une signature par opérateur, relit le rôle de chacun et + exécute **une seule fois**, au seuil fixé par sa politique : la dernière signature + rend `{"status": "EXECUTED", "signatures": 2, "required": 2, …}`. + +**Mise à jour d'un déploiement existant** : la salle d'attente exige le droit de +lecture sur `actions`, ajouté au script des droits. Rejouer +`psql -f crates/oe-castore/sql/ra_console_grants.sql` (idempotent) ; sans cela, +`GET /api/v1/quorum` et la co-signature répondent `503`. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -149,8 +171,7 @@ forme canonique du corps figé) ; toute autre forme est refusée avant relais. ## Ce qui n'existe pas encore -La co-signature et la liste des actions en attente (étape 4b), la gestion du registre -depuis la console, le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +La gestion du registre depuis la console (invitations, clés, rôles), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 8bf7fe6..e83da59 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 ( From 6e3e1c25183e4fe4c7ffa38c3357ca96ece591db Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 12:28:31 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(ra-console):=20frontend,=20socle=20(?= =?UTF-8?q?=C3=A9tape=206a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §15 étape 6, docs/UI-UX.md : la console sert son interface, en TypeScript compilé par esbuild (choix de l'utilisateur), sans framework ni dépendance d'exécution. - Assets embarqués dans le binaire (include_bytes!, src/web.rs) depuis web/dist, versionné : compiler ra-console n'exige pas Node. - En-têtes de sécurité sur toutes les réponses, API comprise (UI-UX §6.3) : CSP stricte sans unsafe-inline ni CDN, frame-ancestors 'none', X-Frame-Options DENY, nosniff, Referrer-Policy, Cache-Control no-store. `http::app` compose l'API, le frontend et ces en-têtes. - Bannière d'environnement (OPENEIDAS_RA_ENVIRONMENT, « non déclaré » à défaut), connexion par nom et clé FIDO2, poste de travail (identité et rôle relus sur le serveur, compteurs des files), déconnexion, verrouillage après 15 min d'inactivité (avertissement à 14). Aucun innerHTML : le contenu de l'API est inséré en texte. - Tests : tests/web.rs (en-têtes et assets, sans navigateur) ; Playwright contre une console réelle (examples/e2e_console.rs, PostgreSQL), clé du SoftToken confiée à l'authentificateur WebAuthn virtuel de Chromium. - CI : job frontend (typage strict, dist/ conforme aux sources, parcours de bout en bout). Dépendances npm de développement seulement (esbuild MIT, TypeScript et Playwright Apache-2.0), jamais dans l'image. Co-authored-by: Claude --- .github/workflows/ci.yml | 67 ++ Cargo.lock | 3 + bin/ra-console/Cargo.toml | 6 + bin/ra-console/examples/e2e_console.rs | 211 ++++++ bin/ra-console/src/config.rs | 5 + bin/ra-console/src/http.rs | 9 + bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/main.rs | 20 +- bin/ra-console/src/web.rs | 136 ++++ bin/ra-console/tests/web.rs | 141 ++++ bin/ra-console/web/.gitignore | 3 + bin/ra-console/web/README.md | 23 + bin/ra-console/web/dist/console.css | 212 ++++++ bin/ra-console/web/dist/console.js | 1 + bin/ra-console/web/dist/index.html | 16 + bin/ra-console/web/e2e/console.spec.ts | 102 +++ bin/ra-console/web/e2e/tsconfig.json | 16 + bin/ra-console/web/package-lock.json | 938 ++++++++++++++++++++++++ bin/ra-console/web/package.json | 18 + bin/ra-console/web/playwright.config.ts | 43 ++ bin/ra-console/web/src/api.ts | 46 ++ bin/ra-console/web/src/b64url.ts | 17 + bin/ra-console/web/src/banner.ts | 20 + bin/ra-console/web/src/dom.ts | 25 + bin/ra-console/web/src/idle.ts | 27 + bin/ra-console/web/src/login.ts | 69 ++ bin/ra-console/web/src/main.ts | 54 ++ bin/ra-console/web/src/shell.ts | 69 ++ bin/ra-console/web/src/webauthn.ts | 43 ++ bin/ra-console/web/static/console.css | 212 ++++++ bin/ra-console/web/static/index.html | 16 + bin/ra-console/web/tsconfig.json | 22 + docs/RA-CONSOLE.md | 21 + 33 files changed, 2605 insertions(+), 7 deletions(-) create mode 100644 bin/ra-console/examples/e2e_console.rs create mode 100644 bin/ra-console/src/web.rs create mode 100644 bin/ra-console/tests/web.rs create mode 100644 bin/ra-console/web/.gitignore create mode 100644 bin/ra-console/web/README.md create mode 100644 bin/ra-console/web/dist/console.css create mode 100644 bin/ra-console/web/dist/console.js create mode 100644 bin/ra-console/web/dist/index.html create mode 100644 bin/ra-console/web/e2e/console.spec.ts create mode 100644 bin/ra-console/web/e2e/tsconfig.json create mode 100644 bin/ra-console/web/package-lock.json create mode 100644 bin/ra-console/web/package.json create mode 100644 bin/ra-console/web/playwright.config.ts create mode 100644 bin/ra-console/web/src/api.ts create mode 100644 bin/ra-console/web/src/b64url.ts create mode 100644 bin/ra-console/web/src/banner.ts create mode 100644 bin/ra-console/web/src/dom.ts create mode 100644 bin/ra-console/web/src/idle.ts create mode 100644 bin/ra-console/web/src/login.ts create mode 100644 bin/ra-console/web/src/main.ts create mode 100644 bin/ra-console/web/src/shell.ts create mode 100644 bin/ra-console/web/src/webauthn.ts create mode 100644 bin/ra-console/web/static/console.css create mode 100644 bin/ra-console/web/static/index.html create mode 100644 bin/ra-console/web/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29bdc53..618902f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -358,6 +358,73 @@ jobs: git commit -m "Épingle le staging sur open-eidas/open-eidas@${GITHUB_SHA}" git push origin main + frontend: + name: Frontend de ra-console (typage, build, bout en bout) + runs-on: ubuntu-latest + # La console réelle tourne sur PostgreSQL (examples/e2e_console.rs), avec un + # opérateur dont la clé est confiée à l'authentificateur WebAuthn virtuel de + # Chromium : connexion, déconnexion et verrouillage sont prouvés dans un vrai + # navigateur, en-têtes de sécurité et CSP compris (docs/UI-UX.md §6.3). + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: bin/ra-console/web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: bin/ra-console/web/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Dépendances de développement (jamais dans l'image) + run: npm ci + + - name: Typage strict (navigateur et tests) + run: npm run typecheck + + - name: web/dist correspond aux sources + # Les assets sont versionnés pour que la compilation Rust n'exige pas + # Node ; un dist/ désynchronisé servirait autre chose que le code relu. + run: | + npm run build + git diff --exit-code -- dist || { + echo "::error::web/dist est désynchronisé : lancez 'npm run build' dans bin/ra-console/web" + exit 1 + } + + - name: Navigateur de test + run: npx playwright install --with-deps chromium + + - name: Parcours de bout en bout (Playwright) + env: + OE_CASTORE_TEST_DSN: postgres://postgres:test@localhost:5432/postgres + run: npx playwright test + + - name: Rapport Playwright (en cas d'échec) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: bin/ra-console/web/playwright-report + retention-days: 7 + helm-lint: name: Lint du chart Helm runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 486a1a1..adcc76e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2212,6 +2212,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "base64 0.22.1", "ca-server", "clap", "der 0.8.2", @@ -2227,11 +2228,13 @@ dependencies = [ "oe-hsm", "oe-raflow", "oe-webauthn", + "openssl", "rand 0.8.8", "reqwest", "rsa", "rustls", "serde", + "serde_cbor_2", "serde_json", "sha2 0.10.9", "sqlx", diff --git a/bin/ra-console/Cargo.toml b/bin/ra-console/Cargo.toml index 9532fcb..5a078a0 100644 --- a/bin/ra-console/Cargo.toml +++ b/bin/ra-console/Cargo.toml @@ -53,6 +53,12 @@ rand = "0.8" [dev-dependencies] async-trait = "0.1" +# Harnais de bout en bout du frontend (examples/e2e_console.rs) : export de la +# clé du SoftToken vers l'authentificateur virtuel du navigateur. Déjà dans +# l'arbre de dépendances (webauthn-authenticator-rs), aucune crate nouvelle. +serde_cbor_2 = "0.13" +openssl = "0.10" +base64 = "0.22" oe-raflow = { path = "../../crates/oe-raflow" } webauthn-authenticator-rs = { version = "0.5", features = ["softtoken"] } ca-server = { path = "../ca-server" } diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs new file mode 100644 index 0000000..8e080c9 --- /dev/null +++ b/bin/ra-console/examples/e2e_console.rs @@ -0,0 +1,211 @@ +//! Harnais des tests de bout en bout du frontend (bin/ra-console/web/e2e, +//! Playwright) : une console réelle (`ra_console::http::app`, assets embarqués +//! et en-têtes de sécurité compris) sur un vrai PostgreSQL, un opérateur +//! enregistré, et sa clé privée exportée pour l'authentificateur virtuel du +//! navigateur (CDP `WebAuthn.addCredential`). +//! +//! La liste blanche de modèles de clés refuserait l'attestation d'un +//! authentificateur virtuel : l'opérateur est donc enregistré avec le +//! `SoftToken` des tests Rust, dont la clé est ensuite confiée au navigateur. +//! +//! Variables : `OE_CASTORE_TEST_DSN` (obligatoire), `E2E_PORT` (défaut 8431), +//! `E2E_FIXTURE` (défaut `target/e2e-fixture.json`). +//! Ne sert qu'aux tests : jamais construit dans l'image. + +#[path = "../tests/common/mod.rs"] +mod common; + +use std::sync::Arc; + +use base64::Engine; +use oe_actions::{NewCredential, Registry, Role}; +use oe_webauthn::{trusted_models, TrustedModel, Url, Verifier}; +use ra_console::ca_link::CaLink; +use ra_console::http::{app, AppState}; +use ra_console::login::LoginService; +use sqlx::postgres::PgPoolOptions; +use webauthn_authenticator_rs::softtoken::{SoftToken, SoftTokenFile, AAGUID}; +use webauthn_authenticator_rs::WebauthnAuthenticator; + +#[tokio::main] +async fn main() { + let base = std::env::var("OE_CASTORE_TEST_DSN").expect("OE_CASTORE_TEST_DSN est obligatoire"); + let port: u16 = std::env::var("E2E_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8431); + let fixture = std::env::var("E2E_FIXTURE").unwrap_or_else(|_| "target/e2e-fixture.json".into()); + let origin = Url::parse(&format!("http://localhost:{port}")).unwrap(); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let name = format!("e2e_{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 _ = oe_castore::Postgres::open(&dsn).await.unwrap(); + let registry = Registry::connect(&dsn).await.unwrap(); + + let (token, root) = SoftToken::new(true).unwrap(); + let root_pem = root.to_pem().unwrap(); + let verifier = || { + Verifier::new( + "localhost", + &origin, + "Open eIDAS Console — e2e", + trusted_models(&[TrustedModel { + root_pem: &root_pem, + aaguid: AAGUID, + description: "SoftToken (e2e)", + }]) + .unwrap(), + ) + .unwrap() + }; + + // L'opérateur et sa clé, comme en production mais sans passer par + // l'enregistrement relayé (qui a ses propres tests). + let now = time::OffsetDateTime::now_utc(); + let operator = registry + .add_operator("alice", Role::RaOperateur, "e2e", now) + .await + .unwrap(); + let reg_verifier = verifier(); + // Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que + // la clé du credential créé ci-dessous se relit (aucun accesseur public). + let token_path = std::env::temp_dir().join(format!("{name}.softtoken")); + let token_file = std::fs::File::create(&token_path).unwrap(); + let mut authn = WebauthnAuthenticator::new(SoftTokenFile::new(token, token_file)); + let (options, state) = reg_verifier + .start_registration(operator, "alice", None) + .unwrap(); + let reg = authn.do_registration(origin.clone(), options).unwrap(); + drop(authn); + let key = reg_verifier.finish_registration(®, &state).unwrap(); + registry + .add_credential( + NewCredential { + operator_id: operator, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "e2e", + initiated_by: "e2e", + confirmed_by: Some("e2e"), + }, + now, + ) + .await + .unwrap(); + + // La clé privée du SoftToken (SEC1), convertie en PKCS#8 pour le navigateur. + let credential_id: Vec = reg.raw_id.as_ref().to_vec(); + let soft: serde_cbor_2::Value = + serde_cbor_2::from_slice(&std::fs::read(&token_path).unwrap()).unwrap(); + let _ = std::fs::remove_file(&token_path); + let (sec1, counter) = soft_key(&soft, &credential_id); + let ec = openssl::ec::EcKey::private_key_from_der(&sec1).unwrap(); + let pkcs8 = openssl::pkey::PKey::from_ec_key(ec) + .unwrap() + .private_key_to_pkcs8() + .unwrap(); + let b64 = base64::engine::general_purpose::STANDARD; + let out = serde_json::json!({ + "origin": origin.as_str().trim_end_matches('/'), + "operator": "alice", + "role": "ra_operateur", + "credential": { + "credentialId": b64.encode(&credential_id), + "isResidentCredential": false, + "rpId": "localhost", + "privateKey": b64.encode(pkcs8), + "userHandle": b64.encode(operator.as_bytes()), + "signCount": counter, + }, + }); + if let Some(parent) = std::path::Path::new(&fixture).parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&fixture, serde_json::to_vec_pretty(&out).unwrap()).unwrap(); + + // Un lien vers ca-server injoignable : la connexion et le poste n'en ont + // pas besoin (seul /healthz s'en soucie). + let pki = common::pki().await; + let dir = common::tempdir::Dir::new(); + let client = pki + .cert(&oe_ca_core::profile::internal_client(), "ra-console") + .await; + let link = CaLink::new(&pki.files(&dir, &client, 9)).unwrap(); + let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let console = app( + 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), + }), + ra_console::web::Console { + environment: ra_console::web::Environment::Staging, + }, + ); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .unwrap(); + eprintln!("e2e : console prête sur {origin}, fixture {fixture}"); + axum::serve(listener, console).await.unwrap(); +} + +/// La clé privée (DER SEC1) d'un credential, et le compteur de signatures. +fn soft_key(token: &serde_cbor_2::Value, credential_id: &[u8]) -> (Vec, u64) { + use serde_cbor_2::Value; + let Value::Map(fields) = token else { + panic!("SoftToken : format inattendu") + }; + let field = |name: &str| { + fields + .iter() + .find(|(k, _)| matches!(k, Value::Text(t) if t == name)) + .map(|(_, v)| v) + .unwrap_or_else(|| panic!("SoftToken : champ {name} absent")) + }; + let Value::Map(tokens) = field("tokens") else { + panic!("SoftToken : tokens inattendu") + }; + let key = tokens + .iter() + .find_map(|(k, v)| match (k, v) { + (Value::Bytes(id), Value::Bytes(der)) if id == credential_id => Some(der.clone()), + (Value::Array(id), Value::Array(der)) if bytes_of(id) == credential_id => { + Some(bytes_of(der)) + } + _ => None, + }) + .expect("SoftToken : clé du credential introuvable"); + let counter = match field("counter") { + Value::Integer(n) => *n as u64, + _ => 0, + }; + (key, counter) +} + +fn bytes_of(values: &[serde_cbor_2::Value]) -> Vec { + values + .iter() + .map(|v| match v { + serde_cbor_2::Value::Integer(n) => *n as u8, + _ => panic!("octet attendu"), + }) + .collect() +} diff --git a/bin/ra-console/src/config.rs b/bin/ra-console/src/config.rs index ef0f09f..b2e0de9 100644 --- a/bin/ra-console/src/config.rs +++ b/bin/ra-console/src/config.rs @@ -18,6 +18,8 @@ pub struct Config { /// Journal chaîné propre à `ra-console` (docs/WEBUI.md §7, §15 étape 2b-A) : /// jamais celui de `ca-server`, une chaîne distincte. pub audit_file: String, + /// Environnement annoncé par le frontend (docs/UI-UX.md §1, principe 4). + pub environment: crate::web::Environment, } /// Vérification des connexions (docs/WEBUI.md §15, étape 1c, §16) : `ra-console` @@ -117,6 +119,9 @@ impl Config { "OPENEIDAS_RA_AUDIT_FILE", "/var/lib/open-eidas/state/ra-console-audit.log", ), + environment: crate::web::Environment::parse( + &std::env::var("OPENEIDAS_RA_ENVIRONMENT").unwrap_or_default(), + )?, }) } diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c12542..9ec4649 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -31,6 +31,15 @@ pub struct AppState { pub journal: Arc, } +/// L'application complète servie par `ra-console` : l'API ([`router`]), le +/// frontend embarqué ([`crate::web`]) et les en-têtes de sécurité sur toutes +/// les réponses (docs/UI-UX.md §6.3). +pub fn app(state: Arc, console: crate::web::Console) -> Router { + router(state) + .merge(crate::web::router(console)) + .layer(axum::middleware::from_fn(crate::web::security_headers)) +} + pub fn router(state: Arc) -> Router { Router::new() .route("/healthz", get(handle_health)) diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index f855557..28838ae 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -22,4 +22,5 @@ pub mod purge; pub mod quorum; pub mod requests; pub mod session; +pub mod web; pub mod webauthn_models; diff --git a/bin/ra-console/src/main.rs b/bin/ra-console/src/main.rs index cacf99f..b600b85 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -99,13 +99,19 @@ async fn run_serve() { // aucune opération manuelle, arrêtée par le même signal que le serveur. purge::spawn_periodic(pool.clone(), cfg.purge_interval); - let app = http::router(Arc::new(http::AppState { - pool, - link, - login, - sessions, - journal, - })); + let console = ra_console::web::Console { + environment: cfg.environment, + }; + let app = http::app( + Arc::new(http::AppState { + pool, + link, + login, + sessions, + journal, + }), + console, + ); let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen)) .await .unwrap_or_else(|e| die(&format!("écoute sur {}", cfg.listen), e)); diff --git a/bin/ra-console/src/web.rs b/bin/ra-console/src/web.rs new file mode 100644 index 0000000..c2e70ca --- /dev/null +++ b/bin/ra-console/src/web.rs @@ -0,0 +1,136 @@ +//! Le frontend de `ra-console` (docs/WEBUI.md §15 étape 6, docs/UI-UX.md) : +//! des assets statiques **embarqués dans le binaire** (UI-UX §7 : un +//! déploiement est un binaire unique autonome, sans serveur web ni +//! répertoire d'assets à côté), et les en-têtes de sécurité posés sur +//! **toutes** les réponses, API comprise (UI-UX §6.3). +//! +//! Les assets sont construits depuis `bin/ra-console/web/` (TypeScript, +//! esbuild) et versionnés dans `web/dist/` : la compilation Rust n'exige pas +//! Node, et la CI vérifie que `dist/` correspond aux sources. + +use axum::extract::State; +use axum::http::{header, HeaderValue, Request}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; + +static INDEX_HTML: &[u8] = include_bytes!("../web/dist/index.html"); +static CONSOLE_JS: &[u8] = include_bytes!("../web/dist/console.js"); +static CONSOLE_CSS: &[u8] = include_bytes!("../web/dist/console.css"); + +/// Environnement annoncé en tête de chaque écran (UI-UX §1, principe 4) : +/// **PRODUCTION** en rouge, les autres en teinte discrète. Déclaré par le +/// déploiement (`OPENEIDAS_RA_ENVIRONMENT`) ; non déclaré, la console le dit +/// plutôt que de laisser croire à un environnement sans risque. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Environment { + Production, + Staging, + Demo, + Undeclared, +} + +impl Environment { + pub fn parse(value: &str) -> Result { + match value { + "" => Ok(Environment::Undeclared), + "production" => Ok(Environment::Production), + "staging" => Ok(Environment::Staging), + "demo" => Ok(Environment::Demo), + other => Err(format!( + "OPENEIDAS_RA_ENVIRONMENT={other:?} : production, staging ou demo" + )), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Environment::Production => "production", + Environment::Staging => "staging", + Environment::Demo => "demo", + Environment::Undeclared => "undeclared", + } + } +} + +/// Ce que le frontend doit savoir avant toute connexion. +#[derive(Debug, Clone)] +pub struct Console { + pub environment: Environment, +} + +/// Politique de contenu d'UI-UX §6.3, à l'identique : aucun script ni style +/// en ligne, rien hors de l'origine, pas d'intégration dans un cadre. +pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"; + +pub fn router(console: Console) -> Router { + Router::new() + .route("/", get(index)) + .route("/assets/console.js", get(script)) + .route("/assets/console.css", get(style)) + .route("/api/v1/console", get(describe)) + .with_state(console) +} + +/// Les en-têtes de sécurité, sur toutes les réponses (UI-UX §6.3). +pub async fn security_headers(req: Request, next: Next) -> Response { + let mut res = next.run(req).await; + let h = res.headers_mut(); + h.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CONTENT_SECURITY_POLICY), + ); + h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + h.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + h.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + h.insert( + "cross-origin-opener-policy", + HeaderValue::from_static("same-origin"), + ); + h.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"), + ); + // Rien de ce que sert la console ne doit rester dans un cache partagé + // (réponses d'API comprises : identité, files, corps à signer). + h.entry(header::CACHE_CONTROL) + .or_insert(HeaderValue::from_static("no-store")); + res +} + +async fn index() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + INDEX_HTML, + ) +} + +async fn script() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], + CONSOLE_JS, + ) +} + +async fn style() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/css; charset=utf-8")], + CONSOLE_CSS, + ) +} + +/// `GET /api/v1/console` : l'environnement et la version, sans session — la +/// bannière doit s'afficher dès l'écran de connexion. +async fn describe(State(console): State) -> impl IntoResponse { + Json(serde_json::json!({ + "environment": console.environment.as_str(), + "version": env!("CARGO_PKG_VERSION"), + })) +} diff --git a/bin/ra-console/tests/web.rs b/bin/ra-console/tests/web.rs new file mode 100644 index 0000000..06b3ecd --- /dev/null +++ b/bin/ra-console/tests/web.rs @@ -0,0 +1,141 @@ +//! Le frontend embarqué et les en-têtes de sécurité (docs/UI-UX.md §6.3, §7), +//! vérifiés sans navigateur : chaque réponse de l'application complète — API +//! comprise — porte la CSP stricte et les protections contre l'intégration en +//! cadre, et les assets servis sont bien ceux de `web/dist`. Les parcours dans +//! un vrai navigateur sont dans `web/e2e` (Playwright). +//! +//! DSN dans `OE_CASTORE_TEST_DSN` ; test ignoré si elle n'est pas définie +//! (l'`AppState` exige un pool PostgreSQL). + +mod common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::{pki, tempdir::Dir}; +use http_body_util::BodyExt; +use ra_console::ca_link::CaLink; +use ra_console::http::{app, AppState}; +use ra_console::web::{Console, Environment, CONTENT_SECURITY_POLICY}; +use sqlx::postgres::PgPoolOptions; +use tower::ServiceExt; + +async fn console(environment: Environment) -> Option { + let dsn = std::env::var("OE_CASTORE_TEST_DSN").ok()?; + let pool = PgPoolOptions::new().connect_lazy(&dsn).unwrap(); + let ca = pki().await; + let dir = Box::leak(Box::new(Dir::new())); + let client = ca + .cert(&oe_ca_core::profile::internal_client(), "ra-console") + .await; + let link = CaLink::new(&ca.files(dir, &client, 9)).unwrap(); + Some(app( + Arc::new(AppState { + pool: pool.clone(), + link, + login: common::login_service(pool.clone()), + sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), + }), + Console { environment }, + )) +} + +async fn get(app: &axum::Router, path: &str) -> (StatusCode, axum::http::HeaderMap, Vec) { + let res = app + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = res.status(); + let headers = res.headers().clone(); + let body = res.into_body().collect().await.unwrap().to_bytes().to_vec(); + (status, headers, body) +} + +#[tokio::test] +async fn every_response_carries_the_security_headers() { + let Some(app) = console(Environment::Production).await else { + eprintln!("OE_CASTORE_TEST_DSN non définie : test ignoré"); + return; + }; + // Des assets, une route anonyme, une route refusée sans session : toutes. + for path in [ + "/", + "/assets/console.js", + "/assets/console.css", + "/api/v1/console", + "/api/v1/me", + ] { + let (_, headers, _) = get(&app, path).await; + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + }; + assert_eq!( + header("content-security-policy"), + CONTENT_SECURITY_POLICY, + "{path}" + ); + assert_eq!(header("x-frame-options"), "DENY", "{path}"); + assert_eq!(header("x-content-type-options"), "nosniff", "{path}"); + assert_eq!(header("referrer-policy"), "no-referrer", "{path}"); + assert_eq!(header("cache-control"), "no-store", "{path}"); + } + // La politique elle-même : ni `unsafe-inline`, ni `unsafe-eval`, ni cadre. + assert!(!CONTENT_SECURITY_POLICY.contains("unsafe")); + assert!(CONTENT_SECURITY_POLICY.contains("frame-ancestors 'none'")); +} + +#[tokio::test] +async fn the_embedded_assets_are_served_with_their_types() { + let Some(app) = console(Environment::Production).await else { + eprintln!("OE_CASTORE_TEST_DSN non définie : test ignoré"); + return; + }; + let (status, headers, html) = get(&app, "/").await; + assert_eq!(status, StatusCode::OK); + assert!(headers["content-type"] + .to_str() + .unwrap() + .starts_with("text/html")); + let html = String::from_utf8(html).unwrap(); + assert!(html.contains(r#" + + + +
+ + + diff --git a/bin/ra-console/web/e2e/console.spec.ts b/bin/ra-console/web/e2e/console.spec.ts new file mode 100644 index 0000000..14261bf --- /dev/null +++ b/bin/ra-console/web/e2e/console.spec.ts @@ -0,0 +1,102 @@ +// Parcours de la console dans un vrai navigateur (docs/WEBUI.md §15 étape 6a, +// docs/UI-UX.md §6.3) : en-têtes de sécurité, connexion par clé FIDO2, +// déconnexion, verrouillage après inactivité. + +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fixturePath } from "../playwright.config"; + +interface Fixture { + operator: string; + credential: Record & { signCount: number }; +} + +const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +// Chaque test recrée un authentificateur : son compteur doit dépasser celui +// déjà vu par la console, sans quoi elle détecte (à juste titre) un clone. +let signCountBase = 1000; + +async function withOperatorKey(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + // La clé de test a été enregistrée par le SoftToken, qui s'annonce + // `internal` : le navigateur ne consulte que les authentificateurs de + // ce transport (les options relaient les transports enregistrés). + transport: "internal", + hasResidentKey: false, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + signCountBase += 1000; + await cdp.send("WebAuthn.addCredential", { + authenticatorId, + credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, + } as never); +} + +/// Exceptions de la page et violations de CSP : aucune n'est admise. Les +/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. +function collectErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (m) => { + if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); + }); + page.on("pageerror", (e) => errors.push(e.message)); + return errors; +} + +async function logIn(page: Page): Promise { + await page.getByTestId("login-name").fill(fixture().operator); + await page.getByTestId("login-submit").click(); + await expect(page.getByTestId("operator")).toHaveText(fixture().operator); +} + +test("chaque réponse porte les en-têtes de sécurité, sans script en ligne", async ({ request }) => { + for (const path of ["/", "/assets/console.js", "/api/v1/console", "/api/v1/me"]) { + const res = await request.get(path); + const headers = res.headers(); + expect(headers["content-security-policy"], path).toContain("script-src 'self'"); + expect(headers["content-security-policy"], path).toContain("frame-ancestors 'none'"); + expect(headers["x-frame-options"], path).toBe("DENY"); + expect(headers["x-content-type-options"], path).toBe("nosniff"); + expect(headers["cache-control"], path).toBe("no-store"); + } + const html = await (await request.get("/")).text(); + expect(html).not.toMatch(/]*\bsrc=)[^>]*>/); + expect(html).not.toMatch(/\sstyle=/); +}); + +test("connexion par clé FIDO2, puis déconnexion qui révoque la session", async ({ page }) => { + const errors = collectErrors(page); + await withOperatorKey(page); + await page.goto("/"); + await expect(page.getByTestId("env-banner")).toHaveText("STAGING"); + await logIn(page); + await expect(page.getByTestId("role")).toHaveText("opérateur RA"); + await expect(page.getByTestId("count-requests")).toHaveText("(0)"); + await expect(page.getByTestId("count-quorum")).toHaveText("(0)"); + + await page.getByTestId("logout").click(); + await expect(page.getByTestId("login-name")).toBeVisible(); + expect((await page.request.get("/api/v1/me")).status()).toBe(401); + expect(errors).toEqual([]); +}); + +test("verrouillage après 15 minutes d'inactivité", async ({ page }) => { + await page.clock.install(); + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + + await page.clock.fastForward("14:00"); + await expect(page.getByTestId("idle-warning")).toBeVisible(); + await page.clock.fastForward("01:00"); + await expect(page.getByTestId("login-status")).toContainText("verrouillée"); + expect((await page.request.get("/api/v1/me")).status()).toBe(401); +}); diff --git a/bin/ra-console/web/e2e/tsconfig.json b/bin/ra-console/web/e2e/tsconfig.json new file mode 100644 index 0000000..9100174 --- /dev/null +++ b/bin/ra-console/web/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": [ + "ES2023", + "DOM" + ], + "types": [ + "node" + ] + }, + "include": [ + "./**/*.ts", + "../playwright.config.ts" + ] +} diff --git a/bin/ra-console/web/package-lock.json b/bin/ra-console/web/package-lock.json new file mode 100644 index 0000000..14b9847 --- /dev/null +++ b/bin/ra-console/web/package-lock.json @@ -0,0 +1,938 @@ +{ + "name": "ra-console-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ra-console-web", + "license": "EUPL-1.2 OR AGPL-3.0-only", + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "24.19.0", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/bin/ra-console/web/package.json b/bin/ra-console/web/package.json new file mode 100644 index 0000000..b5a63e4 --- /dev/null +++ b/bin/ra-console/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "ra-console-web", + "private": true, + "description": "Frontend de ra-console (docs/UI-UX.md) : TypeScript compilé par esbuild, embarqué dans le binaire.", + "license": "EUPL-1.2 OR AGPL-3.0-only", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p e2e/tsconfig.json", + "build": "esbuild src/main.ts --bundle --format=esm --target=es2022 --minify --legal-comments=none --outfile=dist/console.js && cp static/index.html static/console.css dist/", + "e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "24.19.0", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } +} diff --git a/bin/ra-console/web/playwright.config.ts b/bin/ra-console/web/playwright.config.ts new file mode 100644 index 0000000..24db39e --- /dev/null +++ b/bin/ra-console/web/playwright.config.ts @@ -0,0 +1,43 @@ +// Tests de bout en bout du frontend (docs/UI-UX.md) contre une console réelle : +// `cargo run --example e2e_console` monte ra-console (assets embarqués, +// en-têtes de sécurité) sur PostgreSQL, avec un opérateur dont la clé est +// confiée à l'authentificateur WebAuthn virtuel du navigateur. +// +// Exige OE_CASTORE_TEST_DSN. En local, PW_CHANNEL=chrome utilise le Chrome +// installé plutôt qu'un navigateur téléchargé par Playwright. + +import { defineConfig, devices } from "@playwright/test"; +import { resolve } from "node:path"; + +const port = Number(process.env.E2E_PORT ?? "8431"); +const origin = `http://localhost:${port}`; +const repo = resolve(import.meta.dirname, "../../.."); +export const fixturePath = resolve(repo, "target/e2e-fixture.json"); + +export default defineConfig({ + testDir: "e2e", + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", + use: { + baseURL: origin, + trace: "retain-on-failure", + ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}), + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "cargo run -q -p ra-console --example e2e_console", + cwd: repo, + url: `${origin}/api/v1/console`, + timeout: 900_000, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", + env: { + OE_CASTORE_TEST_DSN: process.env.OE_CASTORE_TEST_DSN ?? "", + E2E_PORT: String(port), + E2E_FIXTURE: fixturePath, + }, + }, +}); diff --git a/bin/ra-console/web/src/api.ts b/bin/ra-console/web/src/api.ts new file mode 100644 index 0000000..5a2bff1 --- /dev/null +++ b/bin/ra-console/web/src/api.ts @@ -0,0 +1,46 @@ +// Appels à l'API de la console. Même origine, cookie de session posé par le +// serveur (`HttpOnly`, jamais lu ici). Toute erreur a la forme +// `{"error": "", "message": "..."}` (docs/WEBUI.md §5). + +export interface ApiError { + error: string; + message: string; +} + +export interface Reply { + status: number; + body: T | ApiError | null; +} + +export async function call(method: "GET" | "POST", path: string, body?: unknown): Promise> { + const init: RequestInit = { method, credentials: "same-origin", headers: {} }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + const res = await fetch(path, init); + const text = await res.text(); + let parsed: T | ApiError | null = null; + if (text !== "") { + try { + parsed = JSON.parse(text) as T | ApiError; + } catch { + parsed = null; + } + } + return { status: res.status, body: parsed }; +} + +export function isError(body: unknown): body is ApiError { + return typeof body === "object" && body !== null && "error" in body; +} + +export interface ConsoleInfo { + environment: "production" | "staging" | "demo" | "undeclared"; + version: string; +} + +export interface Me { + operator: string; + role: "auditeur" | "ra_operateur" | "ca_operateur" | "admin"; +} diff --git a/bin/ra-console/web/src/b64url.ts b/bin/ra-console/web/src/b64url.ts new file mode 100644 index 0000000..9dd9b98 --- /dev/null +++ b/bin/ra-console/web/src/b64url.ts @@ -0,0 +1,17 @@ +// base64url sans remplissage : la forme des champs WebAuthn en JSON (niveau 3). + +export function toBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function fromBase64Url(value: string): ArrayBuffer { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} diff --git a/bin/ra-console/web/src/banner.ts b/bin/ra-console/web/src/banner.ts new file mode 100644 index 0000000..c271e22 --- /dev/null +++ b/bin/ra-console/web/src/banner.ts @@ -0,0 +1,20 @@ +// Bannière d'environnement (docs/UI-UX.md §1 principe 4, §2.1) : PRODUCTION +// en rouge, toujours visible, sur tous les écrans y compris la connexion. + +import type { ConsoleInfo } from "./api"; +import { h } from "./dom"; + +const LABELS: Record = { + production: "PRODUCTION", + staging: "STAGING", + demo: "DÉMONSTRATION", + undeclared: "ENVIRONNEMENT NON DÉCLARÉ", +}; + +export function banner(info: ConsoleInfo): HTMLElement { + return h( + "div", + { class: `env-banner env-${info.environment}`, role: "status", "data-testid": "env-banner" }, + LABELS[info.environment], + ); +} diff --git a/bin/ra-console/web/src/dom.ts b/bin/ra-console/web/src/dom.ts new file mode 100644 index 0000000..b2788b9 --- /dev/null +++ b/bin/ra-console/web/src/dom.ts @@ -0,0 +1,25 @@ +// Construction du DOM sans `innerHTML` : tout contenu venu de l'API (noms, +// motifs, corps à signer) est inséré comme texte, jamais interprété. C'est ce +// qui ferme l'injection de HTML, en plus de la CSP (docs/UI-UX.md §6.3). + +type Child = Node | string | null | undefined | false; + +export function h( + tag: K, + attrs: Record = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + for (const [name, value] of Object.entries(attrs)) { + el.setAttribute(name, value); + } + for (const child of children) { + if (child === null || child === undefined || child === false) continue; + el.append(typeof child === "string" ? document.createTextNode(child) : child); + } + return el; +} + +export function replace(target: Element, ...children: Node[]): void { + target.replaceChildren(...children); +} diff --git a/bin/ra-console/web/src/idle.ts b/bin/ra-console/web/src/idle.ts new file mode 100644 index 0000000..bfb155c --- /dev/null +++ b/bin/ra-console/web/src/idle.ts @@ -0,0 +1,27 @@ +// Verrouillage de session inactive (docs/UI-UX.md §6.3) : avertissement à 14 +// minutes, verrouillage à 15 — la session est révoquée côté serveur, et une +// nouvelle authentification FIDO2 est exigée. Indépendant de la durée fixe de +// la session (8 h) : c'est l'inactivité du poste qui est bornée ici. + +export const WARN_AFTER_MS = 14 * 60 * 1000; +export const LOCK_AFTER_MS = 15 * 60 * 1000; + +const ACTIVITY = ["keydown", "pointerdown", "wheel", "touchstart"] as const; + +export function watchIdle(onWarn: () => void, onLock: () => void): () => void { + let warn = 0; + let lock = 0; + const arm = (): void => { + window.clearTimeout(warn); + window.clearTimeout(lock); + warn = window.setTimeout(onWarn, WARN_AFTER_MS); + lock = window.setTimeout(onLock, LOCK_AFTER_MS); + }; + for (const event of ACTIVITY) window.addEventListener(event, arm, { passive: true }); + arm(); + return () => { + window.clearTimeout(warn); + window.clearTimeout(lock); + for (const event of ACTIVITY) window.removeEventListener(event, arm); + }; +} diff --git a/bin/ra-console/web/src/login.ts b/bin/ra-console/web/src/login.ts new file mode 100644 index 0000000..744949e --- /dev/null +++ b/bin/ra-console/web/src/login.ts @@ -0,0 +1,69 @@ +// Écran de connexion (docs/WEBUI.md §15 étape 1c) : le nom de l'opérateur, +// puis sa clé FIDO2. Les refus ont tous la même forme (§16) : l'écran ne +// distingue jamais un nom inconnu d'une clé refusée. + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { banner } from "./banner"; +import { h, replace } from "./dom"; +import { assert } from "./webauthn"; + +interface Begun { + challenge_id: string; + webauthn: Parameters[0]; +} + +export function renderLogin(root: HTMLElement, info: ConsoleInfo, onLoggedIn: (me: Me) => void, notice?: string): void { + const status = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "login-status" }, notice ?? ""); + const name = h("input", { + id: "operator-name", + name: "operator", + autocomplete: "username webauthn", + required: "", + maxlength: "256", + "data-testid": "login-name", + }); + const submit = h("button", { type: "submit", class: "primary", "data-testid": "login-submit" }, "Se connecter avec ma clé FIDO2"); + const form = h( + "form", + { class: "login-form", "aria-labelledby": "login-title" }, + h("h1", { id: "login-title" }, "Console d'opération Open eIDAS"), + h("label", { for: "operator-name" }, "Nom d'opérateur"), + name, + submit, + status, + ); + form.addEventListener("submit", (event) => { + event.preventDefault(); + void login(name.value.trim(), submit, status, onLoggedIn); + }); + replace(root, banner(info), h("main", { class: "login" }, form)); + name.focus(); +} + +async function login(name: string, submit: HTMLButtonElement, status: HTMLElement, onLoggedIn: (me: Me) => void): Promise { + if (name === "") return; + submit.disabled = true; + status.textContent = "Touchez votre clé de sécurité matérielle…"; + try { + const begun = await call("POST", "/api/v1/webauthn/login/begin", { name }); + if (begun.status !== 200 || begun.body === null || isError(begun.body)) { + status.textContent = "Connexion impossible pour le moment."; + return; + } + const credential = await assert(begun.body.webauthn); + const done = await call("POST", "/api/v1/webauthn/login/finish", { + challenge_id: begun.body.challenge_id, + credential, + }); + if (done.status !== 200 || done.body === null || isError(done.body)) { + status.textContent = "Identifiants invalides."; + return; + } + onLoggedIn(done.body); + } catch { + // Annulation, délai dépassé, clé inconnue du navigateur : une seule forme. + status.textContent = "La clé n'a pas répondu. Réessayez."; + } finally { + submit.disabled = false; + } +} diff --git a/bin/ra-console/web/src/main.ts b/bin/ra-console/web/src/main.ts new file mode 100644 index 0000000..d8b517b --- /dev/null +++ b/bin/ra-console/web/src/main.ts @@ -0,0 +1,54 @@ +// Point d'entrée du frontend de ra-console (docs/WEBUI.md §15 étape 6a). + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { watchIdle } from "./idle"; +import { renderLogin } from "./login"; +import { idleWarning, renderShell } from "./shell"; + +async function boot(root: HTMLElement): Promise { + const described = await call("GET", "/api/v1/console"); + const info: ConsoleInfo = + described.body !== null && !isError(described.body) + ? described.body + : { environment: "undeclared", version: "?" }; + + let stopIdle: (() => void) | null = null; + + const showLogin = (notice?: string): void => { + stopIdle?.(); + stopIdle = null; + renderLogin(root, info, showShell, notice); + }; + + const logout = async (notice?: string): Promise => { + await call("POST", "/api/v1/logout"); + showLogin(notice); + }; + + const showShell = (me: Me): void => { + renderShell(root, info, me, () => void logout()); + let warning: HTMLElement | null = null; + stopIdle = watchIdle( + () => { + warning ??= idleWarning(root); + }, + () => void logout("Session verrouillée après 15 minutes d'inactivité : reconnectez-vous avec votre clé."), + ); + const clearWarning = (): void => { + warning?.remove(); + warning = null; + }; + window.addEventListener("keydown", clearWarning); + window.addEventListener("pointerdown", clearWarning); + }; + + const me = await call("GET", "/api/v1/me"); + if (me.status === 200 && me.body !== null && !isError(me.body)) { + showShell(me.body); + } else { + showLogin(); + } +} + +const root = document.getElementById("app"); +if (root !== null) void boot(root); diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts new file mode 100644 index 0000000..102ea22 --- /dev/null +++ b/bin/ra-console/web/src/shell.ts @@ -0,0 +1,69 @@ +// Le poste de travail une fois connecté (docs/UI-UX.md §2) : barre de +// sécurité (environnement, identité et rôle relus sur le serveur), navigation +// latérale avec les compteurs des files, zone de travail. Les écrans métier +// (demandes, révocation, quorum, audit) arrivent aux étapes 6b et suivantes. + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { banner } from "./banner"; +import { h, replace } from "./dom"; + +const ROLE_LABELS: Record = { + auditeur: "auditeur", + ra_operateur: "opérateur RA", + ca_operateur: "opérateur CA", + admin: "administrateur", +}; + +export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogout: () => void): void { + const logout = h("button", { type: "button", class: "quiet", "data-testid": "logout" }, "Se déconnecter"); + logout.addEventListener("click", onLogout); + const bar = h( + "header", + { class: "security-bar" }, + h("span", { class: "brand" }, "Open eIDAS"), + h( + "span", + { class: "identity" }, + h("span", { class: "operator", "data-testid": "operator" }, me.operator), + h("span", { class: `role role-${me.role}`, "data-testid": "role" }, ROLE_LABELS[me.role]), + ), + logout, + ); + const requests = h("span", { class: "count", "data-testid": "count-requests" }, "…"); + const quorum = h("span", { class: "count", "data-testid": "count-quorum" }, "…"); + const nav = h( + "nav", + { class: "sidebar", "aria-label": "Files de travail" }, + h("ul", {}, h("li", {}, "Demandes RA ", requests), h("li", {}, "Quorum ", quorum)), + ); + const work = h( + "main", + { class: "workspace", tabindex: "-1" }, + h("h1", {}, "Files de travail"), + h("p", { class: "muted" }, "Les écrans de décision arrivent avec les étapes suivantes."), + ); + replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); + void refreshCounts(requests, quorum); +} + +async function refreshCounts(requests: HTMLElement, quorum: HTMLElement): Promise { + const [pending, waiting] = await Promise.all([ + call("GET", "/api/v1/requests?state=PENDING"), + call("GET", "/api/v1/quorum?state=PENDING"), + ]); + requests.textContent = Array.isArray(pending.body) ? `(${pending.body.length})` : "(—)"; + quorum.textContent = Array.isArray(waiting.body) ? `(${waiting.body.length})` : "(—)"; + if (isError(pending.body) || isError(waiting.body)) { + requests.title = quorum.title = "compteur indisponible"; + } +} + +export function idleWarning(root: HTMLElement): HTMLElement { + const warning = h( + "div", + { class: "idle-warning", role: "alert", "data-testid": "idle-warning" }, + "Session inactive : verrouillage dans une minute. Une action au clavier ou à la souris la prolonge.", + ); + root.prepend(warning); + return warning; +} diff --git a/bin/ra-console/web/src/webauthn.ts b/bin/ra-console/web/src/webauthn.ts new file mode 100644 index 0000000..b323125 --- /dev/null +++ b/bin/ra-console/web/src/webauthn.ts @@ -0,0 +1,43 @@ +// Cérémonie d'authentification WebAuthn côté navigateur : seule l'API +// standard `navigator.credentials` est utilisée (docs/UI-UX.md §7). Les +// options viennent du serveur (webauthn-rs, JSON niveau 3) ; l'assertion est +// rendue dans la même forme, que le serveur vérifie seul. + +import { fromBase64Url, toBase64Url } from "./b64url"; + +interface RequestOptionsJson { + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials?: { type: "public-key"; id: string; transports?: AuthenticatorTransport[] }[]; + userVerification?: UserVerificationRequirement; +} + +export async function assert(options: RequestOptionsJson): Promise { + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: fromBase64Url(options.challenge), + allowCredentials: (options.allowCredentials ?? []).map((c) => ({ + type: c.type, + id: fromBase64Url(c.id), + ...(c.transports ? { transports: c.transports } : {}), + })), + ...(options.timeout !== undefined ? { timeout: options.timeout } : {}), + ...(options.rpId !== undefined ? { rpId: options.rpId } : {}), + ...(options.userVerification !== undefined ? { userVerification: options.userVerification } : {}), + }; + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (credential === null) throw new Error("aucune clé n'a répondu"); + const response = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: toBase64Url(response.clientDataJSON), + authenticatorData: toBase64Url(response.authenticatorData), + signature: toBase64Url(response.signature), + userHandle: response.userHandle ? toBase64Url(response.userHandle) : null, + }, + extensions: {}, + }; +} diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css new file mode 100644 index 0000000..15b2bc1 --- /dev/null +++ b/bin/ra-console/web/static/console.css @@ -0,0 +1,212 @@ +/* Console d'opération Open eIDAS — tokens de docs/UI-UX.md §4. + Aucun style en ligne ni police distante : la CSP l'interdit (§6.3). */ + +:root { + --bg-canvas: #090d16; + --bg-surface: #111827; + --bg-surface-elevated: #1f2937; + --border-subtle: #374151; + --border-focus: #38bdf8; + --text-main: #f3f4f6; + --text-muted: #9ca3af; + + --prod-bg: #881337; + --prod-text: #ffe4e6; + --staging-bg: #0c4a6e; + --staging-text: #e0f2fe; + --demo-bg: #1e293b; + --demo-text: #cbd5e1; + --undeclared-bg: #451a03; + --undeclared-text: #fbbf24; + + --primary-action: #059669; + --primary-action-hover: #10b981; + + --role-auditeur: #0284c7; + --role-ra_operateur: #059669; + --role-ca_operateur: #7c3aed; + --role-admin: #d97706; + + --font-ui: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + background: var(--bg-canvas); + color: var(--text-main); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.4; +} + +:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +.env-banner { + padding: 4px 16px; + font-weight: 700; + letter-spacing: 0.08em; + text-align: center; + font-size: 12px; +} +.env-production { + background: var(--prod-bg); + color: var(--prod-text); +} +.env-staging { + background: var(--staging-bg); + color: var(--staging-text); +} +.env-demo { + background: var(--demo-bg); + color: var(--demo-text); +} +.env-undeclared { + background: var(--undeclared-bg); + color: var(--undeclared-text); + border-bottom: 2px dashed var(--undeclared-text); +} + +.login { + display: grid; + place-items: center; + min-height: calc(100vh - 32px); +} +.login-form { + display: grid; + gap: 12px; + width: min(420px, 90vw); + padding: 32px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 8px; +} +.login-form h1 { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px; +} +input { + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px 10px; +} +button { + font: inherit; + cursor: pointer; + border-radius: 4px; + padding: 8px 14px; + border: 1px solid var(--border-subtle); + background: var(--bg-surface-elevated); + color: var(--text-main); +} +button.primary { + background: var(--primary-action); + border-color: var(--primary-action); + color: #ffffff; + font-weight: 600; +} +button.primary:hover { + background: var(--primary-action-hover); +} +button:disabled { + opacity: 0.6; + cursor: progress; +} +button.quiet { + background: transparent; +} +.status { + min-height: 1.4em; + color: var(--text-muted); + margin: 0; +} + +.security-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); +} +.security-bar .brand { + font-weight: 700; +} +.security-bar .identity { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} +.role { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid currentColor; +} +.role-auditeur { + color: var(--role-auditeur); +} +.role-ra_operateur { + color: var(--role-ra_operateur); +} +.role-ca_operateur { + color: var(--role-ca_operateur); +} +.role-admin { + color: var(--role-admin); +} + +.layout { + display: grid; + grid-template-columns: 220px 1fr; + min-height: calc(100vh - 80px); +} +.sidebar { + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + padding: 16px; +} +.sidebar ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} +.sidebar .count { + color: var(--text-muted); + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.workspace { + padding: 24px; +} +.workspace h1 { + font-size: 18px; + font-weight: 600; + margin-top: 0; +} +.muted { + color: var(--text-muted); +} + +.idle-warning { + padding: 8px 16px; + background: var(--undeclared-bg); + color: var(--undeclared-text); + border-bottom: 1px dashed var(--undeclared-text); +} diff --git a/bin/ra-console/web/static/index.html b/bin/ra-console/web/static/index.html new file mode 100644 index 0000000..f335804 --- /dev/null +++ b/bin/ra-console/web/static/index.html @@ -0,0 +1,16 @@ + + + + + + Open eIDAS — Console d'opération + + + + + + +
+ + + diff --git a/bin/ra-console/web/tsconfig.json b/bin/ra-console/web/tsconfig.json new file mode 100644 index 0000000..3428c20 --- /dev/null +++ b/bin/ra-console/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index a66e9b7..5c11e76 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -137,6 +137,26 @@ 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`. +## Frontend (étape 6a : socle) + +La console sert elle-même son interface (docs/UI-UX.md) : `/` et `/assets/*`, embarqués +dans le binaire (aucun serveur web ni répertoire d'assets à déployer). Sources et +construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). + +- **Toutes** les réponses, API comprise, portent la CSP stricte d'UI-UX §6.3 (aucun + script ni style en ligne, rien hors de l'origine, `frame-ancestors 'none'`), + `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: no-referrer` et + `Cache-Control: no-store`. +- Bannière d'environnement sur tous les écrans, connexion comprise : + `OPENEIDAS_RA_ENVIRONMENT` (`production`, `staging`, `demo`) ; non déclarée, la + console affiche « ENVIRONNEMENT NON DÉCLARÉ » plutôt qu'un environnement sans risque. + `GET /api/v1/console` (sans session) la rend au frontend. +- Connexion par nom et clé FIDO2, poste de travail (identité et rôle relus sur le + serveur, compteurs des files), déconnexion, **verrouillage après 15 minutes + d'inactivité** (avertissement à 14) : la session est révoquée côté serveur. +- Les écrans métier (décisions, révocation, quorum, audit) suivent (étapes 6b et + suivantes). + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -146,6 +166,7 @@ lecture sur `actions`, ajouté au script des droits. Rejouer | `OPENEIDAS_INTERNAL_TLS_CERT_FILE` / `_KEY_FILE` | — (obligatoires) | Certificat `internal_client` de la console et sa clé (PEM) | | `OPENEIDAS_CA_CERT_FILE` | — (obligatoire) | Certificat de la CA émettrice, seule racine de confiance du lien | | `OPENEIDAS_RA_LISTEN` | `:8330` | Adresse d'écoute | +| `OPENEIDAS_RA_ENVIRONMENT` | — (non déclaré) | `production`, `staging` ou `demo` : bannière du frontend | | `OPENEIDAS_ENROLL_URL` | — | (`internal-cert`) API d'enrôlement publique de la CA | | `OPENEIDAS_ENROLL_HMAC_KEY` | — | (`internal-cert`) secret partagé d'enrôlement | | `OPENEIDAS_ENROLL_TIMEOUT_SECONDS` | 600 | (`internal-cert`) attente de l'approbation | From d05e829a2e4b2c745d5b94249020327bc4d737e3 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 13:03:15 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat(ra-console):=20file=20des=20demandes?= =?UTF-8?q?=20et=20modale=20de=20signature=20(=C3=A9tape=206b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §15 étape 6, docs/UI-UX.md §3.1, §3.4, §5, §6.1 : les décisions RA se prennent dans le navigateur, avec la clé FIDO2. - File des demandes en attente : tableau dense, sélection au clavier (j/k, a approuver, r rejeter), inspecteur latéral. - Justification avant signature, obligatoire pour un rejet (garde d'interface : attribut required et contrôle du script). - Modale de signature ( natif) : corps figé par ca-server affiché tel quel avec son empreinte SHA-256, puis la clé ; erreur sans fermeture, challenge redemandé s'il est consommé ou expiré, Échap bloqué pendant la cérémonie matérielle. - Harnais e2e : le vrai service d'actions de ca-server derrière le lien mTLS, et huit demandes en attente. Parcours Playwright : approbation (corps et empreinte affichés, état APPROVED côté serveur), rejet au clavier avec motif obligatoire, renoncement sans effet. Co-authored-by: Claude --- bin/ra-console/examples/e2e_console.rs | 56 +++++- bin/ra-console/web/dist/console.css | 114 +++++++++++++ bin/ra-console/web/dist/console.js | 2 +- bin/ra-console/web/e2e/console.spec.ts | 58 +------ bin/ra-console/web/e2e/helpers.ts | 60 +++++++ bin/ra-console/web/e2e/requests.spec.ts | 85 ++++++++++ bin/ra-console/web/src/format.ts | 13 ++ bin/ra-console/web/src/main.ts | 5 +- bin/ra-console/web/src/requests.ts | 215 ++++++++++++++++++++++++ bin/ra-console/web/src/shell.ts | 14 +- bin/ra-console/web/src/sign.ts | 146 ++++++++++++++++ bin/ra-console/web/static/console.css | 114 +++++++++++++ docs/RA-CONSOLE.md | 11 +- 13 files changed, 822 insertions(+), 71 deletions(-) create mode 100644 bin/ra-console/web/e2e/helpers.ts create mode 100644 bin/ra-console/web/e2e/requests.spec.ts create mode 100644 bin/ra-console/web/src/format.ts create mode 100644 bin/ra-console/web/src/requests.ts create mode 100644 bin/ra-console/web/src/sign.ts diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs index 8e080c9..1dd0291 100644 --- a/bin/ra-console/examples/e2e_console.rs +++ b/bin/ra-console/examples/e2e_console.rs @@ -48,7 +48,8 @@ async fn main() { .await .unwrap(); let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0); - let _ = oe_castore::Postgres::open(&dsn).await.unwrap(); + let store: Arc = + Arc::new(oe_castore::Postgres::open(&dsn).await.unwrap()); let registry = Registry::connect(&dsn).await.unwrap(); let (token, root) = SoftToken::new(true).unwrap(); @@ -116,8 +117,10 @@ async fn main() { .private_key_to_pkcs8() .unwrap(); let b64 = base64::engine::general_purpose::STANDARD; + let pending: Vec = (1..=8).map(|i| format!("tx-e2e-{i}")).collect(); let out = serde_json::json!({ "origin": origin.as_str().trim_end_matches('/'), + "pending": pending, "operator": "alice", "role": "ra_operateur", "credential": { @@ -134,14 +137,50 @@ async fn main() { } std::fs::write(&fixture, serde_json::to_vec_pretty(&out).unwrap()).unwrap(); - // Un lien vers ca-server injoignable : la connexion et le poste n'en ont - // pas besoin (seul /healthz s'en soucie). + // Des demandes d'enrôlement en attente, pour les écrans de décision (6b). + for (i, tx) in pending.iter().enumerate() { + store + .create_request(oe_castore::Request { + transaction_id: tx.clone(), + csr_fingerprint: format!("empreinte-{tx}"), + csr_der: vec![0x30, 0x00], + profile: "tsa_signer".to_string(), + subject_cn: format!("tsu-{}.example.test", i + 1), + state: oe_castore::RequestState::Pending, + created_at: now, + decided_at: None, + operator: String::new(), + comment: String::new(), + issued_at: None, + certificate_serial: None, + }) + .await + .unwrap(); + } + + // Le vrai service d'actions de ca-server, derrière son routeur interne et le + // lien mTLS : les décisions signées dans le navigateur y sont vérifiées. + let service = Arc::new(oe_actions::Service::new( + registry.clone(), + verifier(), + store.clone(), + oe_raflow::Decider::new(oe_raflow::DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + )); let pki = common::pki().await; + let internal = pki + .serve_router(ca_server::internal::router(service, 64 * 1024)) + .await; let dir = common::tempdir::Dir::new(); let client = pki .cert(&oe_ca_core::profile::internal_client(), "ra-console") .await; - let link = CaLink::new(&pki.files(&dir, &client, 9)).unwrap(); + let link = CaLink::new(&pki.files(&dir, &client, internal)).unwrap(); let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); let console = app( Arc::new(AppState { @@ -209,3 +248,12 @@ fn bytes_of(values: &[serde_cbor_2::Value]) -> Vec { }) .collect() } + +struct NullJournal; + +#[async_trait::async_trait] +impl oe_raflow::Recorder for NullJournal { + async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} diff --git a/bin/ra-console/web/dist/console.css b/bin/ra-console/web/dist/console.css index 15b2bc1..e3c4755 100644 --- a/bin/ra-console/web/dist/console.css +++ b/bin/ra-console/web/dist/console.css @@ -210,3 +210,117 @@ button.quiet { color: var(--undeclared-text); border-bottom: 1px dashed var(--undeclared-text); } + +/* --- 6b : file des demandes, inspecteur, modale de signature (UI-UX §3.1, §5) --- */ + +.mono { + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.split { + display: grid; + grid-template-columns: 1fr minmax(420px, 34%); + gap: 16px; + align-items: start; +} +table.dense { + width: 100%; + border-collapse: collapse; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); +} +table.dense th { + position: sticky; + top: 0; + background: var(--bg-surface-elevated); + text-align: left; + font-size: 12px; + color: var(--text-muted); +} +table.dense th, +table.dense td { + padding: 8px 10px; + height: 38px; + border-bottom: 1px solid var(--border-subtle); +} +table.dense tr[aria-selected="true"] td { + background: var(--bg-surface-elevated); +} +table.dense tr[aria-selected="true"] td:first-child { + box-shadow: inset 3px 0 0 var(--border-focus); +} +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; +} +.badge.pending { + background: #451a03; + color: #fbbf24; + border: 1px dashed #d97706; +} +.inspector { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 16px; +} +.inspector h2, +dialog.signature h2 { + font-size: 16px; + margin: 0 0 12px; +} +dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0 0 16px; +} +dt { + color: var(--text-muted); +} +dd { + margin: 0; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +dialog.signature { + width: min(640px, 92vw); + background: var(--bg-surface-elevated); + color: var(--text-main); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 24px; +} +dialog.signature::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +dialog.signature textarea { + width: 100%; + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; +} +.frozen pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 240px; + font-size: 12px; +} +.hash { + font-size: 13px; + word-spacing: 0.4em; +} diff --git a/bin/ra-console/web/dist/console.js b/bin/ra-console/web/dist/console.js index be34a73..7cae5be 100644 --- a/bin/ra-console/web/dist/console.js +++ b/bin/ra-console/web/dist/console.js @@ -1 +1 @@ -async function d(e,r,n){let o={method:e,credentials:"same-origin",headers:{}};n!==void 0&&(o.headers={"Content-Type":"application/json"},o.body=JSON.stringify(n));let t=await fetch(r,o),a=await t.text(),s=null;if(a!=="")try{s=JSON.parse(a)}catch{s=null}return{status:t.status,body:s}}function u(e){return typeof e=="object"&&e!==null&&"error"in e}var b=["keydown","pointerdown","wheel","touchstart"];function w(e,r){let n=0,o=0,t=()=>{window.clearTimeout(n),window.clearTimeout(o),n=window.setTimeout(e,84e4),o=window.setTimeout(r,9e5)};for(let a of b)window.addEventListener(a,t,{passive:!0});return t(),()=>{window.clearTimeout(n),window.clearTimeout(o);for(let a of b)window.removeEventListener(a,t)}}function i(e,r={},...n){let o=document.createElement(e);for(let[t,a]of Object.entries(r))o.setAttribute(t,a);for(let t of n)t==null||t===!1||o.append(typeof t=="string"?document.createTextNode(t):t);return o}function f(e,...r){e.replaceChildren(...r)}var I={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function g(e){return i("div",{class:`env-banner env-${e.environment}`,role:"status","data-testid":"env-banner"},I[e.environment])}function p(e){let r=new Uint8Array(e),n="";for(let o of r)n+=String.fromCharCode(o);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function v(e){let r=e.replace(/-/g,"+").replace(/_/g,"/"),n=r+"=".repeat((4-r.length%4)%4),o=atob(n),t=new Uint8Array(o.length);for(let a=0;a({type:t.type,id:v(t.id),...t.transports?{transports:t.transports}:{}})),...e.timeout!==void 0?{timeout:e.timeout}:{},...e.rpId!==void 0?{rpId:e.rpId}:{},...e.userVerification!==void 0?{userVerification:e.userVerification}:{}},n=await navigator.credentials.get({publicKey:r});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let o=n.response;return{id:n.id,rawId:p(n.rawId),type:n.type,response:{clientDataJSON:p(o.clientDataJSON),authenticatorData:p(o.authenticatorData),signature:p(o.signature),userHandle:o.userHandle?p(o.userHandle):null},extensions:{}}}function T(e,r,n,o){let t=i("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},o??""),a=i("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),s=i("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),l=i("form",{class:"login-form","aria-labelledby":"login-title"},i("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),i("label",{for:"operator-name"},"Nom d'op\xE9rateur"),a,s,t);l.addEventListener("submit",c=>{c.preventDefault(),x(a.value.trim(),s,t,n)}),f(e,g(r),i("main",{class:"login"},l)),a.focus()}async function x(e,r,n,o){if(e!==""){r.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let t=await d("POST","/api/v1/webauthn/login/begin",{name:e});if(t.status!==200||t.body===null||u(t.body)){n.textContent="Connexion impossible pour le moment.";return}let a=await h(t.body.webauthn),s=await d("POST","/api/v1/webauthn/login/finish",{challenge_id:t.body.challenge_id,credential:a});if(s.status!==200||s.body===null||u(s.body)){n.textContent="Identifiants invalides.";return}o(s.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{r.disabled=!1}}}var L={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function E(e,r,n,o){let t=i("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");t.addEventListener("click",o);let a=i("header",{class:"security-bar"},i("span",{class:"brand"},"Open eIDAS"),i("span",{class:"identity"},i("span",{class:"operator","data-testid":"operator"},n.operator),i("span",{class:`role role-${n.role}`,"data-testid":"role"},L[n.role])),t),s=i("span",{class:"count","data-testid":"count-requests"},"\u2026"),l=i("span",{class:"count","data-testid":"count-quorum"},"\u2026"),c=i("nav",{class:"sidebar","aria-label":"Files de travail"},i("ul",{},i("li",{},"Demandes RA ",s),i("li",{},"Quorum ",l))),m=i("main",{class:"workspace",tabindex:"-1"},i("h1",{},"Files de travail"),i("p",{class:"muted"},"Les \xE9crans de d\xE9cision arrivent avec les \xE9tapes suivantes."));f(e,g(r),a,i("div",{class:"layout"},c,m)),M(s,l)}async function M(e,r){let[n,o]=await Promise.all([d("GET","/api/v1/requests?state=PENDING"),d("GET","/api/v1/quorum?state=PENDING")]);e.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",r.textContent=Array.isArray(o.body)?`(${o.body.length})`:"(\u2014)",(u(n.body)||u(o.body))&&(e.title=r.title="compteur indisponible")}function C(e){let r=i("div",{class:"idle-warning",role:"alert","data-testid":"idle-warning"},"Session inactive : verrouillage dans une minute. Une action au clavier ou \xE0 la souris la prolonge.");return e.prepend(r),r}async function N(e){let r=await d("GET","/api/v1/console"),n=r.body!==null&&!u(r.body)?r.body:{environment:"undeclared",version:"?"},o=null,t=c=>{o?.(),o=null,T(e,n,s,c)},a=async c=>{await d("POST","/api/v1/logout"),t(c)},s=c=>{E(e,n,c,()=>{a()});let m=null;o=w(()=>{m??=C(e)},()=>{a("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let y=()=>{m?.remove(),m=null};window.addEventListener("keydown",y),window.addEventListener("pointerdown",y)},l=await d("GET","/api/v1/me");l.status===200&&l.body!==null&&!u(l.body)?s(l.body):t()}var A=document.getElementById("app");A!==null&&N(A); +async function g(t,a,n){let o={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(o.headers={"Content-Type":"application/json"},o.body=JSON.stringify(n));let r=await fetch(a,o),i=await r.text(),l=null;if(i!=="")try{l=JSON.parse(i)}catch{l=null}return{status:r.status,body:l}}function y(t){return typeof t=="object"&&t!==null&&"error"in t}var L=["keydown","pointerdown","wheel","touchstart"];function S(t,a){let n=0,o=0,r=()=>{window.clearTimeout(n),window.clearTimeout(o),n=window.setTimeout(t,84e4),o=window.setTimeout(a,9e5)};for(let i of L)window.addEventListener(i,r,{passive:!0});return r(),()=>{window.clearTimeout(n),window.clearTimeout(o);for(let i of L)window.removeEventListener(i,r)}}function e(t,a={},...n){let o=document.createElement(t);for(let[r,i]of Object.entries(a))o.setAttribute(r,i);for(let r of n)r==null||r===!1||o.append(typeof r=="string"?document.createTextNode(r):r);return o}function v(t,...a){t.replaceChildren(...a)}var O={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function T(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},O[t.environment])}function h(t){let a=new Uint8Array(t),n="";for(let o of a)n+=String.fromCharCode(o);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function C(t){let a=t.replace(/-/g,"+").replace(/_/g,"/"),n=a+"=".repeat((4-a.length%4)%4),o=atob(n),r=new Uint8Array(o.length);for(let i=0;i({type:r.type,id:C(r.id),...r.transports?{transports:r.transports}:{}})),...t.timeout!==void 0?{timeout:t.timeout}:{},...t.rpId!==void 0?{rpId:t.rpId}:{},...t.userVerification!==void 0?{userVerification:t.userVerification}:{}},n=await navigator.credentials.get({publicKey:a});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let o=n.response;return{id:n.id,rawId:h(n.rawId),type:n.type,response:{clientDataJSON:h(o.clientDataJSON),authenticatorData:h(o.authenticatorData),signature:h(o.signature),userHandle:o.userHandle?h(o.userHandle):null},extensions:{}}}function _(t,a,n,o){let r=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},o??""),i=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),l=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),c=e("form",{class:"login-form","aria-labelledby":"login-title"},e("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),e("label",{for:"operator-name"},"Nom d'op\xE9rateur"),i,l,r);c.addEventListener("submit",u=>{u.preventDefault(),D(i.value.trim(),l,r,n)}),v(t,T(a),e("main",{class:"login"},c)),i.focus()}async function D(t,a,n,o){if(t!==""){a.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let r=await g("POST","/api/v1/webauthn/login/begin",{name:t});if(r.status!==200||r.body===null||y(r.body)){n.textContent="Connexion impossible pour le moment.";return}let i=await x(r.body.webauthn),l=await g("POST","/api/v1/webauthn/login/finish",{challenge_id:r.body.challenge_id,credential:i});if(l.status!==200||l.body===null||y(l.body)){n.textContent="Identifiants invalides.";return}o(l.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{a.disabled=!1}}}function A(t){let a=new Date(t);return Number.isNaN(a.getTime())?t:`${a.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var H={action_mismatch:"La signature ne correspond pas \xE0 cette action.",already_used:"Cette signature a d\xE9j\xE0 servi : relancez.",expired:"Le d\xE9lai de signature est d\xE9pass\xE9 : relancez.",denied:"Action refus\xE9e par l'autorit\xE9 (r\xF4le ou \xE9tat de la demande).",signature_rejected:"Signature refus\xE9e par l'autorit\xE9.",unauthenticated:"Session expir\xE9e : reconnectez-vous."};function M(t,a){return y(t)?H[t.error]??t.message:a}function R(t){return new Promise(a=>{let n=!1,o=!1,r=null,i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),l=e("div",{class:"frozen","data-testid":"frozen"}),c=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),u=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),f=e("dl",{class:"summary"},...t.summary.flatMap(([d,m])=>[e("dt",{},d),e("dd",{},m)])),p=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),f,l,i,e("div",{class:"actions"},u,c)),b=d=>{p.close(),p.remove(),a(d)},E=async()=>{c.disabled=!0,i.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let d=await g("POST","/api/v1/webauthn/challenge",t.action);return d.status!==200||d.body===null||y(d.body)?(i.textContent=M(d.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(r=d.body,v(l,e("p",{class:"muted"},"Corps exact qui sera ex\xE9cut\xE9 (fig\xE9 par l'autorit\xE9) :"),e("pre",{class:"mono","data-testid":"frozen-body"},JSON.stringify(r.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(r.body_hash))),i.textContent="",c.disabled=!1,!0)},s=async()=>{if(r===null&&!await E())return;let d=r;if(d!==null){n=!0,c.disabled=!0,u.disabled=!0,i.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let m=await x(d.webauthn),w=await g("POST",t.route,{challenge_id:d.challenge_id,assertion:m});if(w.status===200){o=!0,i.textContent="\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>b(!0),800);return}r=null,i.textContent=M(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{i.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,o||(c.disabled=!1,u.disabled=!1)}}};p.addEventListener("cancel",d=>{d.preventDefault(),!n&&!o&&b(!1)}),u.addEventListener("click",()=>{n||b(!1)}),c.addEventListener("click",()=>{s()}),document.body.append(p),p.showModal(),E().then(()=>c.focus())})}function N(t){let a=[],n=0,o=e("tbody",{}),r=e("table",{class:"dense","aria-label":"Demandes en attente","data-testid":"requests"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Transaction"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"D\xE9pos\xE9e le"))),o),i=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),l=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),c=e("section",{class:"requests"},e("h1",{},"Demandes d'enr\xF4lement en attente"),e("p",{class:"muted"},"j/k : se d\xE9placer \xB7 a : approuver \xB7 r : rejeter"),l,e("div",{class:"split"},r,i)),u=()=>{v(o,...a.map((s,d)=>{let m=e("tr",{"aria-selected":String(d===n),"data-testid":`row-${s.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},s.transaction_id),e("td",{},s.subject_cn),e("td",{},s.profile),e("td",{class:"mono"},A(s.created_at)));return m.addEventListener("click",()=>{n=d,u()}),m})),a.length===0&&v(o,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),f()},f=()=>{let s=a[n];if(s===void 0){v(i,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let d=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),m=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");d.addEventListener("click",()=>{p(s,"approve")}),m.addEventListener("click",()=>{p(s,"reject")}),v(i,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},s.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},s.subject_cn),e("dt",{},"Profil"),e("dd",{},s.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},A(s.created_at))),e("div",{class:"actions"},d,m))},p=async(s,d)=>{let m=await $(d);if(m===null)return;let w=d==="approve";await R({title:w?`Approbation de la demande ${s.transaction_id}`:`Rejet de la demande ${s.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",s.transaction_id],["Sujet (CN)",s.subject_cn],["Profil",s.profile],["Justification",m===""?"\u2014":m]],action:{action:w?"approve_request":"reject_request",transaction_id:s.transaction_id,comment:m},route:`/api/v1/requests/${encodeURIComponent(s.transaction_id)}/${d}`})&&(l.textContent=w?`Demande ${s.transaction_id} approuv\xE9e.`:`Demande ${s.transaction_id} rejet\xE9e.`,await b(),t())},b=async()=>{let s=await g("GET","/api/v1/requests?state=PENDING");s.status!==200||!Array.isArray(s.body)?(l.textContent=y(s.body)?s.body.message:"File indisponible.",a=[]):a=s.body,n=Math.min(n,Math.max(a.length-1,0)),u()},E=s=>{let d=s.target;if(document.querySelector("dialog[open]")!==null||d!==null&&["INPUT","TEXTAREA","SELECT"].includes(d.tagName))return;let m=a[n];if(s.key==="j"||s.key==="ArrowDown")n=Math.min(n+1,a.length-1),u();else if(s.key==="k"||s.key==="ArrowUp")n=Math.max(n-1,0),u();else if(s.key==="a"&&m!==void 0)p(m,"approve");else if(s.key==="r"&&m!==void 0)p(m,"reject");else return;s.preventDefault()};return window.addEventListener("keydown",E),b(),{element:c,dispose:()=>window.removeEventListener("keydown",E)}}function $(t){return new Promise(a=>{let n=t==="reject",o=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),r=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),i=e("button",{type:"button"},"Annuler"),l=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),o,e("div",{class:"actions"},i,r)),c=e("dialog",{class:"signature","data-testid":"comment-dialog"},l),u=f=>{c.close(),c.remove(),a(f)};l.addEventListener("submit",f=>{f.preventDefault();let p=o.value.trim();if(n&&p===""){o.focus();return}u(p)}),i.addEventListener("click",()=>u(null)),c.addEventListener("cancel",f=>{f.preventDefault(),u(null)}),document.body.append(c),c.showModal(),o.focus()})}var B={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function k(t,a,n,o){let r=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");r.addEventListener("click",o);let i=e("header",{class:"security-bar"},e("span",{class:"brand"},"Open eIDAS"),e("span",{class:"identity"},e("span",{class:"operator","data-testid":"operator"},n.operator),e("span",{class:`role role-${n.role}`,"data-testid":"role"},B[n.role])),r),l=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),c=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),u=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},e("li",{},"Demandes RA ",l),e("li",{},"Quorum ",c))),f=N(()=>{P(l,c)}),p=e("main",{class:"workspace",tabindex:"-1"},f.element);return v(t,T(a),i,e("div",{class:"layout"},u,p)),P(l,c),f.dispose}async function P(t,a){let[n,o]=await Promise.all([g("GET","/api/v1/requests?state=PENDING"),g("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",a.textContent=Array.isArray(o.body)?`(${o.body.length})`:"(\u2014)",(y(n.body)||y(o.body))&&(t.title=a.title="compteur indisponible")}function j(t){let a=e("div",{class:"idle-warning",role:"alert","data-testid":"idle-warning"},"Session inactive : verrouillage dans une minute. Une action au clavier ou \xE0 la souris la prolonge.");return t.prepend(a),a}async function G(t){let a=await g("GET","/api/v1/console"),n=a.body!==null&&!y(a.body)?a.body:{environment:"undeclared",version:"?"},o=null,r=null,i=f=>{o?.(),o=null,r?.(),r=null,_(t,n,c,f)},l=async f=>{await g("POST","/api/v1/logout"),i(f)},c=f=>{r=k(t,n,f,()=>{l()});let p=null;o=S(()=>{p??=j(t)},()=>{l("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let b=()=>{p?.remove(),p=null};window.addEventListener("keydown",b),window.addEventListener("pointerdown",b)},u=await g("GET","/api/v1/me");u.status===200&&u.body!==null&&!y(u.body)?c(u.body):i()}var q=document.getElementById("app");q!==null&&G(q); diff --git a/bin/ra-console/web/e2e/console.spec.ts b/bin/ra-console/web/e2e/console.spec.ts index 14261bf..a89cb83 100644 --- a/bin/ra-console/web/e2e/console.spec.ts +++ b/bin/ra-console/web/e2e/console.spec.ts @@ -2,60 +2,8 @@ // docs/UI-UX.md §6.3) : en-têtes de sécurité, connexion par clé FIDO2, // déconnexion, verrouillage après inactivité. -import { expect, test, type Page } from "@playwright/test"; -import { readFileSync } from "node:fs"; -import { fixturePath } from "../playwright.config"; - -interface Fixture { - operator: string; - credential: Record & { signCount: number }; -} - -const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; - -// Chaque test recrée un authentificateur : son compteur doit dépasser celui -// déjà vu par la console, sans quoi elle détecte (à juste titre) un clone. -let signCountBase = 1000; - -async function withOperatorKey(page: Page): Promise { - const cdp = await page.context().newCDPSession(page); - await cdp.send("WebAuthn.enable"); - const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { - options: { - protocol: "ctap2", - // La clé de test a été enregistrée par le SoftToken, qui s'annonce - // `internal` : le navigateur ne consulte que les authentificateurs de - // ce transport (les options relaient les transports enregistrés). - transport: "internal", - hasResidentKey: false, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); - signCountBase += 1000; - await cdp.send("WebAuthn.addCredential", { - authenticatorId, - credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, - } as never); -} - -/// Exceptions de la page et violations de CSP : aucune n'est admise. Les -/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. -function collectErrors(page: Page): string[] { - const errors: string[] = []; - page.on("console", (m) => { - if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); - }); - page.on("pageerror", (e) => errors.push(e.message)); - return errors; -} - -async function logIn(page: Page): Promise { - await page.getByTestId("login-name").fill(fixture().operator); - await page.getByTestId("login-submit").click(); - await expect(page.getByTestId("operator")).toHaveText(fixture().operator); -} +import { expect, test } from "@playwright/test"; +import { collectErrors, logIn, withOperatorKey } from "./helpers"; test("chaque réponse porte les en-têtes de sécurité, sans script en ligne", async ({ request }) => { for (const path of ["/", "/assets/console.js", "/api/v1/console", "/api/v1/me"]) { @@ -79,7 +27,7 @@ test("connexion par clé FIDO2, puis déconnexion qui révoque la session", asyn await expect(page.getByTestId("env-banner")).toHaveText("STAGING"); await logIn(page); await expect(page.getByTestId("role")).toHaveText("opérateur RA"); - await expect(page.getByTestId("count-requests")).toHaveText("(0)"); + await expect(page.getByTestId("count-requests")).toHaveText(/^\(\d+\)$/); await expect(page.getByTestId("count-quorum")).toHaveText("(0)"); await page.getByTestId("logout").click(); diff --git a/bin/ra-console/web/e2e/helpers.ts b/bin/ra-console/web/e2e/helpers.ts new file mode 100644 index 0000000..4cf2557 --- /dev/null +++ b/bin/ra-console/web/e2e/helpers.ts @@ -0,0 +1,60 @@ +// Aides communes aux parcours Playwright : la fixture écrite par +// examples/e2e_console.rs, la clé de l'opérateur confiée à l'authentificateur +// WebAuthn virtuel, la connexion. + +import { expect, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fixturePath } from "../playwright.config"; + +export interface Fixture { + operator: string; + pending: string[]; + credential: Record & { signCount: number }; +} + +export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +// Chaque test recrée un authentificateur : son compteur doit dépasser ceux +// déjà vus par la console et par ca-server, sans quoi ils détectent (à juste +// titre) un clone. Module partagé : le compteur croît d'un fichier à l'autre. +let signCountBase = 1000; + +export async function withOperatorKey(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + // La clé de test a été enregistrée par le SoftToken, qui s'annonce + // `internal` : le navigateur ne consulte que les authentificateurs de + // ce transport (les options relaient les transports enregistrés). + transport: "internal", + hasResidentKey: false, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + signCountBase += 1000; + await cdp.send("WebAuthn.addCredential", { + authenticatorId, + credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, + } as never); +} + +/// Exceptions de la page et violations de CSP : aucune n'est admise. Les +/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. +export function collectErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (m) => { + if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); + }); + page.on("pageerror", (e) => errors.push(e.message)); + return errors; +} + +export async function logIn(page: Page): Promise { + await page.getByTestId("login-name").fill(fixture().operator); + await page.getByTestId("login-submit").click(); + await expect(page.getByTestId("operator")).toHaveText(fixture().operator); +} diff --git a/bin/ra-console/web/e2e/requests.spec.ts b/bin/ra-console/web/e2e/requests.spec.ts new file mode 100644 index 0000000..a5672b4 --- /dev/null +++ b/bin/ra-console/web/e2e/requests.spec.ts @@ -0,0 +1,85 @@ +// Décisions RA signées dans le navigateur (docs/WEBUI.md §15 étape 6b, +// docs/UI-UX.md §3.1, §3.4) : la modale affiche le corps figé par ca-server et +// son empreinte, la clé signe, ca-server exécute ; au clavier comme à la souris. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, fixture, logIn, withOperatorKey } from "./helpers"; + +async function stateOf(page: Page, tx: string): Promise<{ state: string; operator: string | null } | undefined> { + for (const state of ["PENDING", "APPROVED", "REJECTED"]) { + const list = (await (await page.request.get(`/api/v1/requests?state=${state}`)).json()) as { + transaction_id: string; + state: string; + operator: string | null; + }[]; + const found = list.find((r) => r.transaction_id === tx); + if (found) return found; + } + return undefined; +} + +test("approuver une demande : corps figé affiché, signé, exécuté par ca-server", async ({ page }) => { + const errors = collectErrors(page); + const tx = fixture().pending[0]!; + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + + await page.getByTestId(`row-${tx}`).click(); + await page.getByTestId("approve").click(); + await page.getByTestId("comment").fill("identité vérifiée au guichet"); + await page.getByTestId("comment-next").click(); + + // WYSIWYS : ce qui sera exécuté, tel que ca-server l'a figé, et son empreinte. + const body = page.getByTestId("frozen-body"); + await expect(body).toContainText(`"transaction_id": "${tx}"`); + await expect(body).toContainText(`"action": "approve_request"`); + await expect(body).toContainText("identité vérifiée au guichet"); + await expect(page.getByTestId("body-hash")).toHaveText(/^([0-9a-f]{8} ){7}[0-9a-f]{8}$/); + + await page.getByTestId("sign").click(); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + await expect(page.getByTestId("requests-status")).toContainText(`${tx} approuvée`); + await expect(page.getByTestId(`row-${tx}`)).toHaveCount(0); + expect(await stateOf(page, tx)).toMatchObject({ state: "APPROVED", operator: fixture().operator }); + expect(errors).toEqual([]); +}); + +test("rejeter au clavier : motif obligatoire, puis signature", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await expect(page.locator("tr[aria-selected='true']")).toHaveCount(1); + + // j déplace la sélection ; r ouvre le rejet de la demande sélectionnée. + const first = await page.locator("tr[aria-selected='true']").getAttribute("data-testid"); + await page.keyboard.press("j"); + const second = await page.locator("tr[aria-selected='true']").getAttribute("data-testid"); + expect(second).not.toBe(first); + const tx = second!.replace("row-", ""); + await page.keyboard.press("r"); + + // Sans motif, rien ne part. + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("comment-dialog")).toBeVisible(); + await page.getByTestId("comment").fill("sujet non reconnu"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "reject_request"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + expect(await stateOf(page, tx)).toMatchObject({ state: "REJECTED" }); +}); + +test("renoncer avant de signer ne décide rien", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + const tx = fixture().pending[5]!; + await page.getByTestId(`row-${tx}`).click(); + await page.getByTestId("approve").click(); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(tx); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + expect(await stateOf(page, tx)).toMatchObject({ state: "PENDING" }); +}); diff --git a/bin/ra-console/web/src/format.ts b/bin/ra-console/web/src/format.ts new file mode 100644 index 0000000..f3cf765 --- /dev/null +++ b/bin/ra-console/web/src/format.ts @@ -0,0 +1,13 @@ +// Formats d'affichage de docs/UI-UX.md §4.2 : dates ISO 8601 en UTC, +// empreintes groupées, identifiants en monospace (le style s'en charge). + +export function utc(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${d.toISOString().slice(0, 19).replace("T", " ")} UTC`; +} + +/// Empreinte hexadécimale groupée par 8 caractères, lisible à voix haute. +export function groupedHash(hex: string): string { + return (hex.match(/.{1,8}/g) ?? []).join(" "); +} diff --git a/bin/ra-console/web/src/main.ts b/bin/ra-console/web/src/main.ts index d8b517b..a516e6e 100644 --- a/bin/ra-console/web/src/main.ts +++ b/bin/ra-console/web/src/main.ts @@ -13,10 +13,13 @@ async function boot(root: HTMLElement): Promise { : { environment: "undeclared", version: "?" }; let stopIdle: (() => void) | null = null; + let disposeView: (() => void) | null = null; const showLogin = (notice?: string): void => { stopIdle?.(); stopIdle = null; + disposeView?.(); + disposeView = null; renderLogin(root, info, showShell, notice); }; @@ -26,7 +29,7 @@ async function boot(root: HTMLElement): Promise { }; const showShell = (me: Me): void => { - renderShell(root, info, me, () => void logout()); + disposeView = renderShell(root, info, me, () => void logout()); let warning: HTMLElement | null = null; stopIdle = watchIdle( () => { diff --git a/bin/ra-console/web/src/requests.ts b/bin/ra-console/web/src/requests.ts new file mode 100644 index 0000000..096f0e5 --- /dev/null +++ b/bin/ra-console/web/src/requests.ts @@ -0,0 +1,215 @@ +// File des demandes d'enrôlement (docs/UI-UX.md §3.4, §5.1, §6.1) : tableau +// dense, sélection au clavier (j/k), inspecteur latéral, décisions signées. + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; + +interface EnrollmentRequest { + transaction_id: string; + profile: string; + subject_cn: string; + state: string; + operator: string | null; + created_at: string; +} + +export interface RequestsView { + element: HTMLElement; + /// Retire les raccourcis clavier quand la vue est quittée. + dispose: () => void; +} + +export function requestsView(onDecided: () => void): RequestsView { + let rows: EnrollmentRequest[] = []; + let selected = 0; + + const body = h("tbody", {}); + const table = h( + "table", + { class: "dense", "aria-label": "Demandes en attente", "data-testid": "requests" }, + h( + "thead", + {}, + h("tr", {}, h("th", {}, "Statut"), h("th", {}, "Transaction"), h("th", {}, "Sujet"), h("th", {}, "Profil"), h("th", {}, "Déposée le")), + ), + body, + ); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur", "data-testid": "inspector" }); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "requests-status" }); + const element = h( + "section", + { class: "requests" }, + h("h1", {}, "Demandes d'enrôlement en attente"), + h("p", { class: "muted" }, "j/k : se déplacer · a : approuver · r : rejeter"), + notice, + h("div", { class: "split" }, table, inspector), + ); + + const render = (): void => { + replace( + body, + ...rows.map((r, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `row-${r.transaction_id}` }, + h("td", {}, h("span", { class: "badge pending" }, "⏳ en attente")), + h("td", { class: "mono" }, r.transaction_id), + h("td", {}, r.subject_cn), + h("td", {}, r.profile), + h("td", { class: "mono" }, utc(r.created_at)), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + if (rows.length === 0) { + replace(body, h("tr", {}, h("td", { colspan: "5", class: "muted" }, "Aucune demande en attente."))); + } + renderInspector(); + }; + + const renderInspector = (): void => { + const r = rows[selected]; + if (r === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucune demande sélectionnée.")); + return; + } + const approve = h("button", { type: "button", class: "primary", "data-testid": "approve" }, "Approuver la demande…"); + const reject = h("button", { type: "button", "data-testid": "reject" }, "Rejeter la demande…"); + approve.addEventListener("click", () => void decide(r, "approve")); + reject.addEventListener("click", () => void decide(r, "reject")); + replace( + inspector, + h("h2", {}, "Général"), + h( + "dl", + {}, + h("dt", {}, "Transaction"), + h("dd", { class: "mono" }, r.transaction_id), + h("dt", {}, "Sujet (CN)"), + h("dd", {}, r.subject_cn), + h("dt", {}, "Profil"), + h("dd", {}, r.profile), + h("dt", {}, "Déposée le"), + h("dd", { class: "mono" }, utc(r.created_at)), + ), + h("div", { class: "actions" }, approve, reject), + ); + }; + + const decide = async (r: EnrollmentRequest, kind: "approve" | "reject"): Promise => { + const comment = await askComment(kind); + if (comment === null) return; + const approving = kind === "approve"; + const ok = await sign({ + title: approving ? `Approbation de la demande ${r.transaction_id}` : `Rejet de la demande ${r.transaction_id}`, + summary: [ + ["Action", approving ? "approuver l'émission du certificat" : "rejeter définitivement la demande"], + ["Transaction", r.transaction_id], + ["Sujet (CN)", r.subject_cn], + ["Profil", r.profile], + ["Justification", comment === "" ? "—" : comment], + ], + action: { + action: approving ? "approve_request" : "reject_request", + transaction_id: r.transaction_id, + comment, + }, + route: `/api/v1/requests/${encodeURIComponent(r.transaction_id)}/${kind}`, + }); + if (ok) { + notice.textContent = approving ? `Demande ${r.transaction_id} approuvée.` : `Demande ${r.transaction_id} rejetée.`; + await load(); + onDecided(); + } + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/requests?state=PENDING"); + if (reply.status !== 200 || !Array.isArray(reply.body)) { + notice.textContent = isError(reply.body) ? reply.body.message : "File indisponible."; + rows = []; + } else { + rows = reply.body; + } + selected = Math.min(selected, Math.max(rows.length - 1, 0)); + render(); + }; + + const onKey = (event: KeyboardEvent): void => { + const target = event.target as HTMLElement | null; + if (document.querySelector("dialog[open]") !== null) return; + if (target !== null && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + const r = rows[selected]; + if (event.key === "j" || event.key === "ArrowDown") { + selected = Math.min(selected + 1, rows.length - 1); + render(); + } else if (event.key === "k" || event.key === "ArrowUp") { + selected = Math.max(selected - 1, 0); + render(); + } else if (event.key === "a" && r !== undefined) { + void decide(r, "approve"); + } else if (event.key === "r" && r !== undefined) { + void decide(r, "reject"); + } else { + return; + } + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + void load(); + return { element, dispose: () => window.removeEventListener("keydown", onKey) }; +} + +/// Justification de la décision : obligatoire pour un rejet (UI-UX §3.4), +/// facultative pour une approbation. `null` si l'opérateur renonce. +function askComment(kind: "approve" | "reject"): Promise { + return new Promise((resolve) => { + const required = kind === "reject"; + const input = h("textarea", { + id: "decision-comment", + rows: "3", + maxlength: "1000", + "data-testid": "comment", + ...(required ? { required: "" } : {}), + }); + const next = h("button", { type: "submit", class: "primary", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h( + "form", + { method: "dialog" }, + h("h2", {}, kind === "reject" ? "Motif du rejet (obligatoire)" : "Commentaire (facultatif)"), + h("label", { for: "decision-comment" }, "Justification consignée au journal"), + input, + h("div", { class: "actions" }, cancel, next), + ); + const dialog = h("dialog", { class: "signature", "data-testid": "comment-dialog" }, form); + const finish = (value: string | null): void => { + dialog.close(); + dialog.remove(); + resolve(value); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const value = input.value.trim(); + if (required && value === "") { + input.focus(); + return; + } + finish(value); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + document.body.append(dialog); + dialog.showModal(); + input.focus(); + }); +} diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts index 102ea22..2a088b7 100644 --- a/bin/ra-console/web/src/shell.ts +++ b/bin/ra-console/web/src/shell.ts @@ -1,11 +1,12 @@ // Le poste de travail une fois connecté (docs/UI-UX.md §2) : barre de // sécurité (environnement, identité et rôle relus sur le serveur), navigation // latérale avec les compteurs des files, zone de travail. Les écrans métier -// (demandes, révocation, quorum, audit) arrivent aux étapes 6b et suivantes. +// commencent par la file des demandes (6b) ; révocation, quorum et audit suivent. import { call, isError, type ConsoleInfo, type Me } from "./api"; import { banner } from "./banner"; import { h, replace } from "./dom"; +import { requestsView } from "./requests"; const ROLE_LABELS: Record = { auditeur: "auditeur", @@ -14,7 +15,7 @@ const ROLE_LABELS: Record = { admin: "administrateur", }; -export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogout: () => void): void { +export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogout: () => void): () => void { const logout = h("button", { type: "button", class: "quiet", "data-testid": "logout" }, "Se déconnecter"); logout.addEventListener("click", onLogout); const bar = h( @@ -36,14 +37,11 @@ export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogo { class: "sidebar", "aria-label": "Files de travail" }, h("ul", {}, h("li", {}, "Demandes RA ", requests), h("li", {}, "Quorum ", quorum)), ); - const work = h( - "main", - { class: "workspace", tabindex: "-1" }, - h("h1", {}, "Files de travail"), - h("p", { class: "muted" }, "Les écrans de décision arrivent avec les étapes suivantes."), - ); + const view = requestsView(() => void refreshCounts(requests, quorum)); + const work = h("main", { class: "workspace", tabindex: "-1" }, view.element); replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); void refreshCounts(requests, quorum); + return view.dispose; } async function refreshCounts(requests: HTMLElement, quorum: HTMLElement): Promise { diff --git a/bin/ra-console/web/src/sign.ts b/bin/ra-console/web/src/sign.ts new file mode 100644 index 0000000..79e0b56 --- /dev/null +++ b/bin/ra-console/web/src/sign.ts @@ -0,0 +1,146 @@ +// Cérémonie de signature d'une action (docs/UI-UX.md §3.1, docs/WEBUI.md §4). +// +// 1. La console demande à ca-server de figer l'action : le corps rendu est +// celui qui sera exécuté, affiché tel quel, avec son empreinte SHA-256. +// 2. L'opérateur signe avec sa clé FIDO2. +// 3. L'assertion est relayée ; en cas d'erreur, la modale reste ouverte et +// l'opérateur peut relancer sans ressaisir. +// +// La modale est un natif : focus piégé, Échap ferme — sauf pendant la +// cérémonie matérielle — et un clic extérieur ne la ferme jamais (§5.2). + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { groupedHash } from "./format"; +import { assert } from "./webauthn"; + +interface Issued { + challenge_id: string; + body: Record; + body_hash: string; + webauthn: Parameters[0]; +} + +export interface Signing { + /// Titre de la modale (« Approbation de la demande tx-… »). + title: string; + /// Résumé en clair, ligne par ligne, de ce qui va être signé. + summary: [string, string][]; + /// L'action à figer (forme d'oe_actions). + action: Record; + /// La route qui relaie l'assertion (ex. /api/v1/requests/{id}/approve). + route: string; +} + +const MESSAGES: Record = { + action_mismatch: "La signature ne correspond pas à cette action.", + already_used: "Cette signature a déjà servi : relancez.", + expired: "Le délai de signature est dépassé : relancez.", + denied: "Action refusée par l'autorité (rôle ou état de la demande).", + signature_rejected: "Signature refusée par l'autorité.", + unauthenticated: "Session expirée : reconnectez-vous.", +}; + +function explain(body: unknown, fallback: string): string { + if (isError(body)) return MESSAGES[body.error] ?? body.message; + return fallback; +} + +/// Ouvre la modale ; rend `true` si l'action a été signée et relayée avec succès. +export function sign(signing: Signing): Promise { + return new Promise((resolve) => { + let busy = false; + let done = false; + let issued: Issued | null = null; + + const status = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "sign-status" }); + const frozen = h("div", { class: "frozen", "data-testid": "frozen" }); + const signButton = h("button", { type: "button", class: "primary", "data-testid": "sign" }, "Signer avec ma clé FIDO2"); + const cancel = h("button", { type: "button", "data-testid": "sign-cancel" }, "Annuler"); + const summary = h( + "dl", + { class: "summary" }, + ...signing.summary.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)]), + ); + const dialog = h( + "dialog", + { class: "signature", "aria-labelledby": "sign-title", "data-testid": "sign-dialog" }, + h("h2", { id: "sign-title" }, `🔑 ${signing.title}`), + summary, + frozen, + status, + h("div", { class: "actions" }, cancel, signButton), + ); + + const close = (result: boolean): void => { + dialog.close(); + dialog.remove(); + resolve(result); + }; + + const prepare = async (): Promise => { + signButton.disabled = true; + status.textContent = "Préparation de l'action par l'autorité…"; + const reply = await call("POST", "/api/v1/webauthn/challenge", signing.action); + if (reply.status !== 200 || reply.body === null || isError(reply.body)) { + status.textContent = explain(reply.body, "L'autorité n'a pas pu préparer l'action."); + return false; + } + issued = reply.body; + replace( + frozen, + h("p", { class: "muted" }, "Corps exact qui sera exécuté (figé par l'autorité) :"), + h("pre", { class: "mono", "data-testid": "frozen-body" }, JSON.stringify(issued.body, null, 2)), + h("p", { class: "muted" }, "Empreinte de la requête (SHA-256) :"), + h("p", { class: "mono hash", "data-testid": "body-hash" }, groupedHash(issued.body_hash)), + ); + status.textContent = ""; + signButton.disabled = false; + return true; + }; + + const ceremony = async (): Promise => { + if (issued === null && !(await prepare())) return; + const current = issued; + if (current === null) return; + busy = true; + signButton.disabled = true; + cancel.disabled = true; + status.textContent = "Touchez votre clé de sécurité matérielle…"; + try { + const assertion = await assert(current.webauthn); + const reply = await call("POST", signing.route, { challenge_id: current.challenge_id, assertion }); + if (reply.status === 200) { + done = true; + status.textContent = "✓ Action signée et exécutée."; + window.setTimeout(() => close(true), 800); + return; + } + // Un challenge consommé ou expiré ne resservira pas : on en redemande un. + issued = null; + status.textContent = explain(reply.body, "L'autorité a refusé l'action."); + } catch { + status.textContent = "La clé n'a pas répondu (annulation ou délai). Réessayez."; + } finally { + busy = false; + if (!done) { + signButton.disabled = false; + cancel.disabled = false; + } + } + }; + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + if (!busy && !done) close(false); + }); + cancel.addEventListener("click", () => { + if (!busy) close(false); + }); + signButton.addEventListener("click", () => void ceremony()); + + document.body.append(dialog); + dialog.showModal(); + void prepare().then(() => signButton.focus()); + }); +} diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css index 15b2bc1..e3c4755 100644 --- a/bin/ra-console/web/static/console.css +++ b/bin/ra-console/web/static/console.css @@ -210,3 +210,117 @@ button.quiet { color: var(--undeclared-text); border-bottom: 1px dashed var(--undeclared-text); } + +/* --- 6b : file des demandes, inspecteur, modale de signature (UI-UX §3.1, §5) --- */ + +.mono { + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.split { + display: grid; + grid-template-columns: 1fr minmax(420px, 34%); + gap: 16px; + align-items: start; +} +table.dense { + width: 100%; + border-collapse: collapse; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); +} +table.dense th { + position: sticky; + top: 0; + background: var(--bg-surface-elevated); + text-align: left; + font-size: 12px; + color: var(--text-muted); +} +table.dense th, +table.dense td { + padding: 8px 10px; + height: 38px; + border-bottom: 1px solid var(--border-subtle); +} +table.dense tr[aria-selected="true"] td { + background: var(--bg-surface-elevated); +} +table.dense tr[aria-selected="true"] td:first-child { + box-shadow: inset 3px 0 0 var(--border-focus); +} +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; +} +.badge.pending { + background: #451a03; + color: #fbbf24; + border: 1px dashed #d97706; +} +.inspector { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 16px; +} +.inspector h2, +dialog.signature h2 { + font-size: 16px; + margin: 0 0 12px; +} +dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0 0 16px; +} +dt { + color: var(--text-muted); +} +dd { + margin: 0; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +dialog.signature { + width: min(640px, 92vw); + background: var(--bg-surface-elevated); + color: var(--text-main); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 24px; +} +dialog.signature::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +dialog.signature textarea { + width: 100%; + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; +} +.frozen pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 240px; + font-size: 12px; +} +.hash { + font-size: 13px; + word-spacing: 0.4em; +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 5c11e76..fc47f4a 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -154,8 +154,15 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). - Connexion par nom et clé FIDO2, poste de travail (identité et rôle relus sur le serveur, compteurs des files), déconnexion, **verrouillage après 15 minutes d'inactivité** (avertissement à 14) : la session est révoquée côté serveur. -- Les écrans métier (décisions, révocation, quorum, audit) suivent (étapes 6b et - suivantes). +- **File des demandes (6b)** : tableau dense des demandes en attente, sélection au + clavier (`j`/`k`, `a` approuver, `r` rejeter), inspecteur latéral. Une décision passe + par une justification (**obligatoire pour un rejet**, garde d'interface seulement : + `ca-server` ne l'exige pas pour la voie signée), puis par la **modale de signature** + (`` natif) : elle affiche le corps que `ca-server` a figé, tel quel, et son + empreinte SHA-256, avant tout geste sur la clé. Une erreur laisse la modale ouverte ; + un challenge consommé ou expiré est redemandé au besoin. Échap annule, sauf pendant + la cérémonie matérielle. +- Révocation, salle de quorum et audit suivent (étapes 6c et suivantes). ## Variables d'environnement