From 37958f4cbf732484a2295f97ea0e593af27e3e68 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 10:02:06 +0200 Subject: [PATCH 1/9] =?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/9] =?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/9] =?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/9] =?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 bea11becc9424137ceb22394c89310b7c0f83207 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 12:23:39 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(ra-console):=20gestion=20du=20registre?= =?UTF-8?q?=20des=20op=C3=A9rateurs=20depuis=20la=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §5, §10 : ra-console relaie, sur le schéma des étapes 3 et 4 (challenge puis exécution, cible contrôlée par ca-server), les actions de registre qu'oe_actions exécute déjà : invite_operator, confirm_key, revoke_key, set_role. - Routes (module registry_routes) : POST /api/v1/operators, POST /api/v1/credentials/{credential_id}/confirm et /revoke, POST /api/v1/operators/{name}/role. Le jeton d'invitation n'existe que dans result.invite_token de la réponse, jamais journalisé. - oe_actions::Expect gagne credential_id et operator ; le contrôle de cible devient générique (une cible d'un autre type est refusée), avant toute consommation de la cérémonie. Une invitation n'a pas de cible. - Élever au rôle admin ou changer celui d'un administrateur : deux administrateurs, co-signature par /api/v1/quorum/{id}/sign (la route de signature joint désormais la cible des actions de registre). - Tests de bout en bout : invitation puis enregistrement puis confirmation, jeton absent du journal de la console ; révocation de clé et changement de rôle où seule la cible distingue ; élévation admin à deux. Cinq mutations tuées. Co-authored-by: Claude --- bin/ra-console/src/http.rs | 32 ++- bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/registry_routes.rs | 138 +++++++++++ bin/ra-console/tests/action_challenge.rs | 293 ++++++++++++++++++++++- crates/oe-actions/src/lib.rs | 120 ++++++++-- docs/RA-CONSOLE.md | 41 +++- 6 files changed, 588 insertions(+), 37 deletions(-) create mode 100644 bin/ra-console/src/registry_routes.rs diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c12542..abb4010 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -53,6 +53,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) .route("/api/v1/quorum", get(handle_quorum)) .route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign)) + .merge(crate::registry_routes::routes()) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -92,7 +93,7 @@ async fn handle_health(State(state): State>) -> Response { } /// `{"error": "", "message": "..."}` (docs/WEBUI.md §5), sans trace interne. -fn error(status: StatusCode, code: &str, message: &str) -> Response { +pub(crate) fn error(status: StatusCode, code: &str, message: &str) -> Response { ( status, Json(serde_json::json!({ "error": code, "message": message })), @@ -250,7 +251,7 @@ struct LoginFinish { /// Un nom d'opérateur : ce qu'un humain saisit, pas un identifiant technique. /// Une longueur bornée suffit à écarter un corps abusif avant toute requête ; /// le reste (existe ou non) ne se voit jamais dans la réponse (§16). -fn looks_like_a_name(s: &str) -> bool { +pub(crate) fn looks_like_a_name(s: &str) -> bool { !s.is_empty() && s.chars().count() <= 256 } @@ -446,16 +447,22 @@ async fn handle_requests( } } -/// 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. +/// Les actions que la console relaie (docs/WEBUI.md §5, §15 étapes 3 et 4) : +/// décider d'une demande d'enrôlement, révoquer un certificat, et gérer le +/// registre des opérateurs (inviter, confirmer ou révoquer une clé, changer un +/// rôle). L'énumération d'`oe_actions` est fermée : il n'en existe pas d'autre +/// aujourd'hui ; une action ajoutée plus tard n'est pas relayée tant qu'elle +/// n'est pas nommée ici. fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { matches!( action, oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. } | oe_actions::Action::RevokeCertificate { .. } + | oe_actions::Action::InviteOperator { .. } + | oe_actions::Action::ConfirmKey { .. } + | oe_actions::Action::RevokeKey { .. } + | oe_actions::Action::SetRole { .. } ) } @@ -619,7 +626,7 @@ async fn handle_reject( /// /// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]). #[allow(clippy::result_large_err)] -async fn relay_assertion( +pub(crate) async fn relay_assertion( state: &AppState, headers: &HeaderMap, body: &[u8], @@ -790,7 +797,14 @@ async fn handle_quorum_sign( oe_actions::Action::RevokeCertificate { serial, .. } => { expect["serial"] = serde_json::json!(serial); } - _ => {} + oe_actions::Action::ConfirmKey { credential_id, .. } + | oe_actions::Action::RevokeKey { credential_id, .. } => { + expect["credential_id"] = serde_json::json!(credential_id); + } + oe_actions::Action::SetRole { operator, .. } => { + expect["operator"] = serde_json::json!(operator); + } + oe_actions::Action::InviteOperator { .. } => {} } match relay_assertion(&state, &headers, &body, expect).await { Ok(r) => Json(quorum_status(&r.body)).into_response(), @@ -807,7 +821,7 @@ fn action_kind(action: &oe_actions::Action) -> String { } /// La forme du §5 pour une action à plusieurs signatures. -fn quorum_status(body: &serde_json::Value) -> serde_json::Value { +pub(crate) fn quorum_status(body: &serde_json::Value) -> serde_json::Value { let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); serde_json::json!({ "action_id": body.get("action_id"), diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index f855557..1e82d38 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -20,6 +20,7 @@ pub mod http; pub mod login; pub mod purge; pub mod quorum; +pub mod registry_routes; pub mod requests; pub mod session; pub mod webauthn_models; diff --git a/bin/ra-console/src/registry_routes.rs b/bin/ra-console/src/registry_routes.rs new file mode 100644 index 0000000..51dcaab --- /dev/null +++ b/bin/ra-console/src/registry_routes.rs @@ -0,0 +1,138 @@ +//! Gestion du registre des opérateurs depuis la console (docs/WEBUI.md §5, +//! §10) : inviter un opérateur, confirmer ou révoquer une clé, changer un rôle. +//! Même schéma que les décisions et la révocation (§4) : le challenge est +//! préparé par `POST /api/v1/webauthn/challenge` avec l'action voulue, puis +//! l'assertion est relayée ici, **sans corps** ; `ca-server` exécute celui +//! qu'il a figé, après avoir comparé la cible de la route (`expect`) au corps +//! figé. Seul un `admin` signe ces actions, et deux pour créer un +//! administrateur ou changer le rôle de l'un d'eux : c'est la politique de +//! `ca-server`, jamais une décision de la console. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; + +use crate::http::{error, looks_like_a_name, quorum_status, relay_assertion, AppState}; + +pub(crate) fn routes() -> Router> { + Router::new() + .route("/api/v1/operators", post(handle_invite)) + .route( + "/api/v1/credentials/{credential_id}/confirm", + post(handle_confirm_key), + ) + .route( + "/api/v1/credentials/{credential_id}/revoke", + post(handle_revoke_key), + ) + .route("/api/v1/operators/{name}/role", post(handle_set_role)) +} + +/// Un identifiant de clé WebAuthn tel que le registre le range : base64url, +/// borné. Il n'est qu'une attente : `ca-server` le compare au corps figé. +fn looks_like_a_credential_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 1024 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +async fn relay( + state: &AppState, + headers: &HeaderMap, + body: &[u8], + expect: serde_json::Value, +) -> Response { + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// `POST /api/v1/operators` : exécute l'invitation figée +/// (`{"action": "invite_operator", "name", "role"}`). Le jeton d'invitation +/// n'existe que dans `result.invite_token` de la réponse d'exécution : ni +/// `ca-server` ni la console ne le journalisent ni ne le conservent. Une +/// invitation n'a pas de cible dans la route : seul le type est contrôlé. +async fn handle_invite( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "invite_operator" }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/confirm` : active une clé en +/// attente. L'empreinte, transmise hors bande par l'invité (§10), fait partie +/// du corps signé ; `ca-server` la recompare à la clé en attente. +async fn handle_confirm_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "confirm_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/revoke` (perte de clé, départ, +/// §14). `ca-server` refuse de révoquer la dernière clé d'administrateur active. +async fn handle_revoke_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "revoke_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/operators/{name}/role` : l'opérateur est désigné par son +/// **nom**, comme dans le corps signé (`set_role`), pas par un identifiant +/// technique. Élever un opérateur au rôle `admin`, ou changer celui d'un +/// administrateur, exige deux administrateurs : la première signature rend +/// `AWAITING_QUORUM`, la seconde passe par `/api/v1/quorum/{id}/sign`. +async fn handle_set_role( + State(state): State>, + Path(name): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_name(&name) { + return error(StatusCode::BAD_REQUEST, "bad_request", "opérateur invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "set_role", "operator": name }), + ) + .await +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 8b563ae..41f2f56 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -44,8 +44,20 @@ fn origin() -> Url { Url::parse(&format!("https://{HOST}")).unwrap() } +/// Le journal de la console, relu par les tests : un jeton d'invitation ne +/// doit jamais y figurer. +#[derive(Default)] +struct ConsoleJournal(std::sync::Mutex>); + +impl ra_console::audit::Recorder for ConsoleJournal { + fn append(&self, event: &str, data: serde_json::Value) { + self.0.lock().unwrap().push(format!("{event} {data}")); + } +} + struct Env { console: axum::Router, + journal: Arc, registry: Registry, store: Arc, issuer: Arc, @@ -154,6 +166,7 @@ impl Env { let link = CaLink::new(&pki.files(&dir, &client, port)).unwrap(); let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let journal = Arc::new(ConsoleJournal::default()); let console = router(Arc::new(AppState { pool: pool.clone(), link, @@ -164,11 +177,12 @@ impl Env { Arc::new(ra_console::audit::NullRecorder), ), sessions: common::sessions(pool), - journal: Arc::new(ra_console::audit::NullRecorder), + journal: journal.clone(), })); Some(Env { console, + journal, registry, store, issuer, @@ -375,15 +389,15 @@ async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { assert_eq!(status, StatusCode::UNAUTHORIZED, "{err}"); } - // Une action que ca-server saurait exécuter, mais que la console ne propose - // pas encore (gestion du registre : après l'étape 4). + // La gestion du registre est relayée, mais réservée aux administrateurs : + // c'est ca-server qui refuse à un ca_operateur, sans rien figer. for action in [ - serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "admin" }), - serde_json::json!({ "action": "set_role", "operator": "alice", "role": "admin" }), + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "auditeur" }), + serde_json::json!({ "action": "set_role", "operator": "alice", "role": "auditeur" }), ] { let (status, err) = env.challenge(Some(&cookie), action).await; assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); - assert_eq!(err["error"], "action_not_available"); + assert_eq!(err["error"], "denied"); } // Une action inconnue, ou un corps qui n'est pas une action. @@ -882,3 +896,270 @@ async fn a_co_signature_only_counts_for_its_action() { assert_eq!(status, StatusCode::NOT_FOUND, "{id} {err}"); } } + +// --- Gestion du registre depuis la console (invitation, clés, rôles) --- + +impl Env { + /// Prépare `action`, la signe, et la relaie sur `path` : rend la réponse. + async fn sign_and_send( + &mut self, + cookie: &str, + action: serde_json::Value, + path: &str, + ) -> (StatusCode, serde_json::Value) { + let (status, issued) = self.challenge(Some(cookie), action).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = self.sign(&issued); + self.decide(Some(cookie), path, &issued, &assertion).await + } + + async fn role_of(&self, name: &str) -> String { + sqlx::query_scalar("SELECT role FROM operators WHERE name = $1") + .bind(name) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + async fn key_revoked(&self, credential_id: &str) -> bool { + sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM webauthn_credentials WHERE credential_id = $1", + ) + .bind(credential_id) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + /// Une seconde clé pour un opérateur existant, posée dans le registre. + async fn second_key(&mut self, operator: Uuid, name: &str) -> String { + let before = self.credential_ids(operator).await; + let now = time::OffsetDateTime::now_utc(); + let (options, state) = self + .verifier + .start_registration(operator, name, None) + .unwrap(); + let reg = self.authn.do_registration(origin(), options).unwrap(); + let key = self.verifier.finish_registration(®, &state).unwrap(); + self.registry + .add_credential( + NewCredential { + operator_id: operator, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "secours", + initiated_by: "test", + confirmed_by: Some("test"), + }, + now, + ) + .await + .unwrap(); + self.credential_ids(operator) + .await + .into_iter() + .find(|k| !before.contains(k)) + .unwrap() + } +} + +/// Un administrateur invite un opérateur par la console : le jeton n'est rendu +/// qu'une fois, dans la réponse, et ne figure dans aucun journal de la console. +/// L'invité enregistre sa clé par le relais existant ; l'administrateur la +/// confirme en signant son empreinte, transmise hors bande (§10). +#[tokio::test] +async fn an_admin_invites_and_confirms_an_operator_through_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let admin = env.log_in("root").await; + + let invite = + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "ra_operateur" }); + let (status, done) = env.sign_and_send(&admin, invite, "/api/v1/operators").await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + let token = done["result"]["invite_token"].as_str().unwrap().to_string(); + assert!(token.len() > 30); + assert_eq!(env.role_of("eve").await, "ra_operateur"); + let journal = env.journal.0.lock().unwrap().join("\n"); + assert!(journal.contains("ra.action_relayed"), "{journal}"); + assert!( + !journal.contains(&token), + "le jeton est journalisé : {journal}" + ); + + // L'invitée enregistre sa clé : elle attend la confirmation d'un tiers. + let (_, _, begun) = env + .post( + "/api/v1/webauthn/register/begin", + serde_json::json!({ "token": token }), + None, + "application/json", + ) + .await; + let options: oe_webauthn::CreationChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": begun["webauthn"] })).unwrap(); + let credential = env.authn.do_registration(origin(), options).unwrap(); + let (status, _, pending) = env + .post( + "/api/v1/webauthn/register/finish", + serde_json::json!({ "ceremony_id": begun["ceremony_id"], "credential": credential }), + None, + "application/json", + ) + .await; + assert_eq!(status, StatusCode::OK, "{pending}"); + assert_eq!(pending["status"], "pending_confirmation", "{pending}"); + let credential_id = pending["credential_id"].as_str().unwrap().to_string(); + + let confirm = serde_json::json!({ + "action": "confirm_key", + "credential_id": credential_id, + "key_fingerprint": pending["key_fingerprint"], + }); + let (status, done) = env + .sign_and_send( + &admin, + confirm, + &format!("/api/v1/credentials/{credential_id}/confirm"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + // La clé est active : l'invitée peut se connecter. + let eve = env.log_in("eve").await; + assert!(eve.starts_with("session=")); +} + +/// Révocation d'une clé : la cible de la route est contrôlée par ca-server. +/// Les deux clés appartiennent au même opérateur et l'action est la même : +/// seul l'identifiant de la clé distingue, c'est bien lui qui est comparé. +#[tokio::test] +async fn a_key_revocation_only_revokes_the_signed_key() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let carol = env.operator_with_key("carol", Role::RaOperateur).await; + let k1 = env.credential_ids(carol).await.remove(0); + let k2 = env.second_key(carol, "carol").await; + assert_ne!(k1, k2); + let admin = env.log_in("root").await; + + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "revoke_key", "credential_id": k1, "reason": "perdue" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k2}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + assert!(!env.key_revoked(&k1).await && !env.key_revoked(&k2).await); + + let (status, done) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k1}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED"); + assert!(env.key_revoked(&k1).await); + assert!(!env.key_revoked(&k2).await); + + // Un identifiant de clé hors de la forme base64url n'est pas relayé. + let (status, _) = env + .decide( + Some(&admin), + "/api/v1/credentials/cl%C3%A9%20invalide/revoke", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +/// Changement de rôle : la cible (l'opérateur, par son nom) est contrôlée ; +/// élever au rôle admin exige deux administrateurs (co-signature par la salle +/// d'attente). +#[tokio::test] +async fn role_changes_target_the_signed_operator_and_admin_needs_two() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + env.operator_with_key("root2", Role::Admin).await; + env.operator_with_key("carol", Role::RaOperateur).await; + env.operator_with_key("dave", Role::RaOperateur).await; + let admin = env.log_in("root").await; + + // Même action, même rôle : seul l'opérateur visé distingue. + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "set_role", "operator": "carol", "role": "auditeur" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + "/api/v1/operators/dave/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + let (status, done) = env + .decide( + Some(&admin), + "/api/v1/operators/carol/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(env.role_of("carol").await, "auditeur"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + + // Élever dave au rôle admin : une signature ne suffit pas. + let (status, done) = env + .sign_and_send( + &admin, + serde_json::json!({ "action": "set_role", "operator": "dave", "role": "admin" }), + "/api/v1/operators/dave/role", + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "AWAITING_QUORUM", "{done}"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + let action_id = done["action_id"].as_str().unwrap().to_string(); + + let second = env.log_in("root2").await; + let (status, issued) = env + .challenge(Some(&second), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&second), + &format!("/api/v1/quorum/{action_id}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + assert_eq!(env.role_of("dave").await, "admin"); +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index baf0ace..d09ab59 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -209,6 +209,13 @@ pub struct Expect { /// présenté doit avoir été émis pour elle. #[serde(default)] pub action_id: Option, + /// Clé visée par une confirmation ou une révocation de clé. + #[serde(default)] + pub credential_id: Option, + /// Opérateur visé par un changement de rôle (par son nom, celui du corps + /// figé). + #[serde(default)] + pub operator: Option, } impl Expect { @@ -226,32 +233,50 @@ impl Expect { action.kind() ))); } - // 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 { + // La cible que porte le corps figé, par le nom du champ d'attente qui + // la désigne. Une invitation n'a pas de cible (l'opérateur n'existe pas + // encore) : seul le type compte. + let frozen: Option<(&str, &String)> = match action { Action::ApproveRequest { transaction_id, .. } | Action::RejectRequest { transaction_id, .. } => { - (Some(transaction_id), &self.transaction_id, &self.serial) + Some(("transaction_id", transaction_id)) } - Action::RevokeCertificate { serial, .. } => { - (Some(serial), &self.serial, &self.transaction_id) + Action::RevokeCertificate { serial, .. } => Some(("serial", serial)), + Action::ConfirmKey { credential_id, .. } | Action::RevokeKey { credential_id, .. } => { + Some(("credential_id", credential_id)) } - _ => (None, &None, &None), + Action::SetRole { operator, .. } => Some(("operator", operator)), + Action::InviteOperator { .. } => None, }; - if other.is_some() { - return Err(Error::BadRequest( - "cible sans rapport avec ce type d'action".to_string(), - )); + let targets = [ + ("transaction_id", &self.transaction_id), + ("serial", &self.serial), + ("credential_id", &self.credential_id), + ("operator", &self.operator), + ]; + // Tout autre champ d'attente doit rester vide. + for (name, value) in targets { + if value.is_some() && frozen.map(|(f, _)| f) != Some(name) { + return Err(Error::BadRequest( + "cible sans rapport avec ce type d'action".to_string(), + )); + } } - match (frozen, expected) { - (Some(frozen), Some(expected)) if frozen == expected => Ok(()), - (Some(_), None) => Err(Error::BadRequest( + let Some((field, frozen)) = frozen else { + return Ok(()); + }; + let expected = targets + .iter() + .find(|(name, _)| *name == field) + .and_then(|(_, v)| v.as_ref()); + match expected { + Some(expected) if expected == frozen => Ok(()), + None => Err(Error::BadRequest( "la cible visée doit être précisée".to_string(), )), - (Some(frozen), Some(expected)) => Err(Error::Mismatch(format!( + Some(expected) => Err(Error::Mismatch(format!( "cible attendue {expected}, figée {frozen}" ))), - (None, _) => Ok(()), } } } @@ -1013,6 +1038,8 @@ mod expect_tests { transaction_id: tx.map(str::to_string), serial: None, action_id: None, + credential_id: None, + operator: None, } } @@ -1034,12 +1061,67 @@ mod expect_tests { expect("approve_request", None).check(&approve("tx-a"), Uuid::nil()), Err(Error::BadRequest(_)) )); - // Une action sans demande visée : seul le type compte. + // Une invitation n'a pas de cible : seul le type compte. + let invite = Action::InviteOperator { + name: "eve".to_string(), + role: Role::Auditeur, + ttl_minutes: 60, + }; + assert!(expect("invite_operator", None) + .check(&invite, Uuid::nil()) + .is_ok()); + + // Changement de rôle : la cible est l'opérateur, par son nom. let role = Action::SetRole { operator: "alice".to_string(), role: Role::Auditeur, }; - assert!(expect("set_role", None).check(&role, Uuid::nil()).is_ok()); + let for_operator = |o: &str| Expect { + operator: Some(o.to_string()), + ..expect("set_role", None) + }; + assert!(for_operator("alice").check(&role, Uuid::nil()).is_ok()); + assert!(matches!( + for_operator("bob").check(&role, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + assert!(matches!( + expect("set_role", None).check(&role, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Clés : la cible est l'identifiant de la clé. + let revoke_key = Action::RevokeKey { + credential_id: "k1".to_string(), + reason: "perdue".to_string(), + }; + let for_key = |k: &str, action: &str| Expect { + credential_id: Some(k.to_string()), + ..expect(action, None) + }; + assert!(for_key("k1", "revoke_key") + .check(&revoke_key, Uuid::nil()) + .is_ok()); + assert!(matches!( + for_key("k2", "revoke_key").check(&revoke_key, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + let confirm = Action::ConfirmKey { + credential_id: "k1".to_string(), + key_fingerprint: "AA".to_string(), + }; + assert!(for_key("k1", "confirm_key") + .check(&confirm, Uuid::nil()) + .is_ok()); + // Une cible d'un autre type (opérateur sur une clé) est refusée. + let mixed = Expect { + operator: Some("alice".to_string()), + ..for_key("k1", "revoke_key") + }; + assert!(matches!( + mixed.check(&revoke_key, Uuid::nil()), + Err(Error::BadRequest(_)) + )); // Révocation : la cible est le numéro de série, jamais une demande. let revoke = Action::RevokeCertificate { @@ -1052,6 +1134,8 @@ mod expect_tests { transaction_id: None, serial: Some(s.to_string()), action_id: None, + credential_id: None, + operator: None, }; assert!(by_serial("0a1b").check(&revoke, Uuid::nil()).is_ok()); assert!(matches!( diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index a66e9b7..9dd991c 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -66,9 +66,11 @@ exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la (`operator_hint`) vient de la session, jamais du navigateur. L'action est relue dans l'énumération fermée d'`oe_actions` puis resérialisée : un champ en trop ne franchit pas la console. -- 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`. +- Sont préparées toutes les actions d'`oe_actions` : l'approbation et le rejet d'une + demande (§15, étape 3), la révocation d'un certificat (`revoke_certificate`, étape + 4), et la gestion du registre (`invite_operator`, `confirm_key`, `revoke_key`, + `set_role`, voir plus bas). Une action ajoutée plus tard à l'énumération ne sera pas + préparée tant que la console ne la nomme pas (`403 action_not_available`). - Le rôle et l'état de la demande sont jugés par `ca-server` (un administrateur ne peut pas approuver) ; la console relaie son refus. - Chaque préparation est inscrite au journal de la console (`ra.action_challenge` : @@ -137,6 +139,36 @@ lecture sur `actions`, ajouté au script des droits. Rejouer `psql -f crates/oe-castore/sql/ra_console_grants.sql` (idempotent) ; sans cela, `GET /api/v1/quorum` et la co-signature répondent `503`. +## Gestion du registre des opérateurs + +Même schéma : le challenge est préparé avec l'action voulue, puis l'assertion est +relayée à la route correspondante (`{"challenge_id", "assertion"}`), qui rend la forme +d'une action à plusieurs signatures (`status`, `signatures`, `required`, `signed_by`, +`result`). Seul un `admin` signe ces actions ; `ca-server` en décide. + +| Route | Action préparée | Cible contrôlée par `ca-server` | +|---|---|---| +| `POST /api/v1/operators` | `{"action": "invite_operator", "name", "role"}` | le type seulement (l'opérateur n'existe pas encore) | +| `POST /api/v1/credentials/{credential_id}/confirm` | `{"action": "confirm_key", "credential_id", "key_fingerprint"}` | l'identifiant de la clé | +| `POST /api/v1/credentials/{credential_id}/revoke` | `{"action": "revoke_key", "credential_id", "reason"}` | l'identifiant de la clé | +| `POST /api/v1/operators/{name}/role` | `{"action": "set_role", "operator", "role"}` | l'opérateur, par son nom | + +- **Invitation** : le jeton n'existe que dans `result.invite_token` de la réponse + d'exécution, rendu **une seule fois** ; ni `ca-server` ni la console ne le + journalisent ni ne le conservent. L'invité enregistre ensuite sa clé par le relais + d'enregistrement (plus haut) : elle reste en attente, avec une empreinte que l'invité + transmet hors bande. +- **Confirmation** : l'administrateur signe l'empreinte ; `ca-server` la recompare à la + clé en attente et refuse qu'un opérateur confirme sa propre clé. +- **Révocation de clé** : motif obligatoire ; `ca-server` refuse de révoquer la dernière + clé d'administrateur active (la voie de secours est `recover-admin`). +- **Rôle `admin`** : créer un administrateur, élever un opérateur au rôle `admin` ou + changer le rôle d'un administrateur exige **deux administrateurs** — la première + signature rend `AWAITING_QUORUM`, la seconde passe par la salle d'attente + (`/api/v1/quorum/{action_id}/sign`). Un opérateur ne change pas son propre rôle. +- Identifiant de clé : base64url, 1 024 caractères au plus ; nom d'opérateur : 1 à 256 + caractères. Toute autre forme est refusée avant relais. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -171,7 +203,8 @@ lecture sur `actions`, ajouté au script des droits. Rejouer ## Ce qui n'existe pas encore -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 +La liste des clés en attente de confirmation, le libre-service (ajout et retrait de ses +propres clés, §10), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se From 6e3e1c25183e4fe4c7ffa38c3357ca96ece591db Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 12:28:31 +0200 Subject: [PATCH 6/9] =?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 7/9] =?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 From 942f85a407ba512b401cd8989d3892eb1a02d289 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 13:19:31 +0200 Subject: [PATCH 8/9] =?UTF-8?q?feat(ra-console):=20r=C3=A9vocation=20et=20?= =?UTF-8?q?salle=20de=20quorum=20dans=20le=20navigateur=20(=C3=A9tape=206c?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §8, §15 étape 6, docs/UI-UX.md §3.2 : deux opérateurs CA distincts révoquent un certificat depuis la console. - `GET /api/v1/certificates?status=issued|revoked` : certificats émis, en lecture seule sur la table de ca-server, numéro de série sous la forme canonique qu'attend la révocation (test Rust : liste, filtre, passage en « revoked » après double signature, refus sans session). - Navigation entre demandes, certificats et quorum. Écran des certificats : motif RFC 5280 parmi ceux qu'admet ca-server, justification obligatoire, modale de signature ; la première signature part en salle de quorum. - Salle de quorum : corps figé, empreinte, signataires ; co-signature désactivée pour qui a déjà signé (ca-server la refuserait aussi). - Harnais e2e : trois opérateurs (alice RA, bob et carol CA), une CA sur la même base avec son révocateur, deux certificats. Parcours : révocation à deux de bout en bout, refus pour un opérateur RA. Mutation tuée (co-signature par soi-même non désactivée). Co-authored-by: Claude --- bin/ra-console/examples/e2e_console.rs | 185 +++++++++++++++------- bin/ra-console/src/certificates.rs | 62 ++++++++ bin/ra-console/src/http.rs | 38 ++++- bin/ra-console/src/lib.rs | 1 + bin/ra-console/tests/action_challenge.rs | 61 +++++++ bin/ra-console/web/dist/console.css | 58 +++++++ bin/ra-console/web/dist/console.js | 2 +- bin/ra-console/web/e2e/helpers.ts | 17 +- bin/ra-console/web/e2e/revocation.spec.ts | 87 ++++++++++ bin/ra-console/web/src/certificates.ts | 184 +++++++++++++++++++++ bin/ra-console/web/src/quorum.ts | 95 +++++++++++ bin/ra-console/web/src/requests.ts | 10 +- bin/ra-console/web/src/shell.ts | 36 +++-- bin/ra-console/web/src/sign.ts | 24 +-- bin/ra-console/web/src/view.ts | 6 + bin/ra-console/web/static/console.css | 58 +++++++ docs/RA-CONSOLE.md | 11 +- 17 files changed, 847 insertions(+), 88 deletions(-) create mode 100644 bin/ra-console/src/certificates.rs create mode 100644 bin/ra-console/web/e2e/revocation.spec.ts create mode 100644 bin/ra-console/web/src/certificates.ts create mode 100644 bin/ra-console/web/src/quorum.ts create mode 100644 bin/ra-console/web/src/view.ts diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs index 1dd0291..efcb0fe 100644 --- a/bin/ra-console/examples/e2e_console.rs +++ b/bin/ra-console/examples/e2e_console.rs @@ -72,65 +72,139 @@ async fn main() { // 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(); + // Trois opérateurs : alice décide des demandes (étape 6b), bob et carol + // révoquent à deux (étape 6c, double contrôle). + let people = [ + ("alice", Role::RaOperateur), + ("bob", Role::CaOperateur), + ("carol", Role::CaOperateur), + ]; 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). + // la clé des credentials créés 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(); + let mut registered = Vec::new(); + for (who, role) in people { + let id = registry.add_operator(who, role, "e2e", now).await.unwrap(); + let (options, state) = reg_verifier.start_registration(id, who, None).unwrap(); + let reg = authn.do_registration(origin.clone(), options).unwrap(); + let key = reg_verifier.finish_registration(®, &state).unwrap(); + registry + .add_credential( + NewCredential { + operator_id: id, + 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(); + registered.push((who, role, id, reg.raw_id.as_ref().to_vec())); + } 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(); + // Les clés privées du SoftToken (SEC1), converties en PKCS#8 pour le navigateur. 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 mut operators = serde_json::Map::new(); + for (who, role, id, credential_id) in ®istered { + 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(); + operators.insert( + who.to_string(), + serde_json::json!({ + "role": role.as_str(), + "credential": { + "credentialId": b64.encode(credential_id), + "isResidentCredential": false, + "rpId": "localhost", + "privateKey": b64.encode(pkcs8), + "userHandle": b64.encode(id.as_bytes()), + "signCount": counter, + }, + }), + ); + } + + // Une vraie CA sur la même base, et deux certificats émis à révoquer (6c). + 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: "E2E Root CA".into(), + issuing_cn: "E2E Issuing CA".into(), + organization: "Open eIDAS e2e".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: "e2e".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(), + ); + let mut to_revoke = Vec::new(); + for i in 1..=2 { + let key = oe_hsm::testing::SoftwareToken::generate(2048); + let cert = issuer + .issue( + &oe_hsm::SigningToken::public_key_der(&key).unwrap(), + &format!("tsu-rev-{i}.example.test"), + &oe_ca_core::profile::tsa_signer(), + &format!("tx-rev-{i}"), + ) + .await + .unwrap(); + to_revoke.push( + oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(), + ); + } + let pending: Vec = (1..=8).map(|i| format!("tx-e2e-{i}")).collect(); + let alice = operators["alice"].clone(); let out = serde_json::json!({ "origin": origin.as_str().trim_end_matches('/'), "pending": pending, + "certificates": to_revoke, "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, - }, + "credential": alice["credential"], + "operators": operators, }); if let Some(parent) = std::path::Path::new(&fixture).parent() { std::fs::create_dir_all(parent).unwrap(); @@ -160,18 +234,21 @@ async fn main() { // 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 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), + ) + .with_revoker(Arc::new(ca_server::revoker::IssuerRevoker(issuer))), + ); let pki = common::pki().await; let internal = pki .serve_router(ca_server::internal::router(service, 64 * 1024)) diff --git a/bin/ra-console/src/certificates.rs b/bin/ra-console/src/certificates.rs new file mode 100644 index 0000000..f9b611a --- /dev/null +++ b/bin/ra-console/src/certificates.rs @@ -0,0 +1,62 @@ +//! `GET /api/v1/certificates` (docs/WEBUI.md §5, §15 étape 6c) : les +//! certificats émis, en lecture seule sur la table de `ca-server`, pour que +//! l'opérateur choisisse ce qu'il révoque. Les certificats réservés (numéro +//! tiré, émission non aboutie) n'y figurent pas. + +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +/// Les états qu'un filtre peut demander. +pub const STATUSES: &[&str] = &["issued", "revoked"]; + +#[derive(Debug, Serialize)] +pub struct IssuedCertificate { + /// Hexadécimal minuscule : la forme canonique qu'attend la révocation. + pub serial_hex: String, + pub profile: String, + pub subject_dn: String, + #[serde(with = "time::serde::rfc3339::option")] + pub not_before: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub not_after: Option, + pub status: String, + #[serde(with = "time::serde::rfc3339::option")] + pub revoked_at: Option, + pub revocation_reason: i32, + pub request_transaction_id: String, +} + +/// Les certificats émis ou révoqués, les plus récents d'abord ; filtrés par +/// état si demandé. `status` n'est pas revalidé ici : à l'appelant de le +/// confronter à [`STATUSES`]. +pub async fn list( + pool: &PgPool, + status: Option<&str>, +) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT serial_hex, profile, subject_dn, not_before, not_after, status, revoked_at, + revocation_reason, request_transaction_id + FROM certificates + WHERE status <> 'reserved' AND ($1::text IS NULL OR status = $1) + ORDER BY not_before DESC NULLS LAST, serial_hex + LIMIT 1000", + ) + .bind(status) + .fetch_all(pool) + .await?; + Ok(rows + .iter() + .map(|r| IssuedCertificate { + serial_hex: r.get("serial_hex"), + profile: r.get("profile"), + subject_dn: r.get("subject_dn"), + not_before: r.get("not_before"), + not_after: r.get("not_after"), + status: r.get("status"), + revoked_at: r.get("revoked_at"), + revocation_reason: r.get("revocation_reason"), + request_transaction_id: r.get("request_transaction_id"), + }) + .collect()) +} diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 9ec4649..f845d1c 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -18,7 +18,7 @@ use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; -use crate::{quorum, requests}; +use crate::{certificates, quorum, requests}; /// Assez pour un objet d'attestation, pas pour bourrer la mémoire. const MAX_BODY_BYTES: usize = 64 * 1024; @@ -56,6 +56,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/certificates", get(handle_certificates)) .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)) @@ -828,6 +829,41 @@ fn quorum_status(body: &serde_json::Value) -> serde_json::Value { }) } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CertificatesQuery { + status: Option, +} + +/// `GET /api/v1/certificates?status=issued` : les certificats émis, en lecture +/// seule (docs/WEBUI.md §5, §15 étape 6c). Comme pour les demandes, toute +/// session authentifiée peut lire ; la révocation reste jugée par `ca-server`. +async fn handle_certificates( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + if let Some(s) = &q.status { + if !certificates::STATUSES.contains(&s.as_str()) { + return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide"); + } + } + match certificates::list(&state.pool, q.status.as_deref()).await { + Ok(list) => Json(list).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "certificates : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index 28838ae..c1dff8f 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -14,6 +14,7 @@ pub mod audit; pub mod ca_link; +pub mod certificates; pub mod config; pub mod db_guard; pub mod http; diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 8b563ae..cbad1ea 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -882,3 +882,64 @@ async fn a_co_signature_only_counts_for_its_action() { assert_eq!(status, StatusCode::NOT_FOUND, "{id} {err}"); } } + +/// `GET /api/v1/certificates` (étape 6c) : les certificats émis, lus dans la +/// table de `ca-server`, avec leur numéro de série sous la forme canonique +/// qu'attend la révocation ; filtre par état ; rien sans session. +#[tokio::test] +async fn the_console_lists_issued_certificates() { + 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-list").await; + + let (status, issued) = env.get("/api/v1/certificates?status=issued", &alice).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let found = issued + .as_array() + .unwrap() + .iter() + .find(|c| c["serial_hex"] == serial.as_str()) + .unwrap_or_else(|| panic!("{serial} absent de {issued}")); + assert_eq!(found["profile"], "tsa_signer"); + assert_eq!(found["request_transaction_id"], "tx-list"); + + // Révoqué par deux opérateurs : il change de liste. + let action = env.first_signature(&alice, &serial).await; + let (_, issued_c) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action })) + .await; + let assertion = env.sign(&issued_c); + env.decide( + Some(&bob), + &format!("/api/v1/quorum/{action}/sign"), + &issued_c, + &assertion, + ) + .await; + let (_, revoked) = env.get("/api/v1/certificates?status=revoked", &alice).await; + let found = revoked + .as_array() + .unwrap() + .iter() + .find(|c| c["serial_hex"] == serial.as_str()) + .unwrap_or_else(|| panic!("{serial} absent de {revoked}")); + assert_eq!(found["revocation_reason"], 1); + let (_, issued) = env.get("/api/v1/certificates?status=issued", &alice).await; + assert!(issued + .as_array() + .unwrap() + .iter() + .all(|c| c["serial_hex"] != serial.as_str())); + + let (status, _) = env + .get("/api/v1/certificates?status=reserved", &alice) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, _) = env + .get("/api/v1/certificates", "session=n-importe-quoi") + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} diff --git a/bin/ra-console/web/dist/console.css b/bin/ra-console/web/dist/console.css index e3c4755..86b2258 100644 --- a/bin/ra-console/web/dist/console.css +++ b/bin/ra-console/web/dist/console.css @@ -324,3 +324,61 @@ dialog.signature textarea { font-size: 13px; word-spacing: 0.4em; } + +/* --- 6c : navigation, certificats, salle de quorum (UI-UX §2.2, §3.2) --- */ + +button.nav { + width: 100%; + text-align: left; + background: transparent; + border-color: transparent; +} +button.nav[aria-current="true"] { + background: var(--bg-surface-elevated); + border-color: var(--border-subtle); + box-shadow: inset 3px 0 0 var(--border-focus); +} +button.danger { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; + font-weight: 600; +} +button.danger:hover { + background: #ef4444; +} +.badge.valid { + background: #064e3b; + color: #34d399; + border: 1px solid #059669; +} +select { + 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; + width: 100%; +} +.quorum { + display: grid; + gap: 16px; +} +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-left: 3px dashed #d97706; + border-radius: 6px; + padding: 16px; +} +.card pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 200px; + font-size: 12px; +} diff --git a/bin/ra-console/web/dist/console.js b/bin/ra-console/web/dist/console.js index 7cae5be..7f0c3c5 100644 --- a/bin/ra-console/web/dist/console.js +++ b/bin/ra-console/web/dist/console.js @@ -1 +1 @@ -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); +async function v(t,o,n){let i={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(i.headers={"Content-Type":"application/json"},i.body=JSON.stringify(n));let a=await fetch(o,i),s=await a.text(),u=null;if(s!=="")try{u=JSON.parse(s)}catch{u=null}return{status:a.status,body:u}}function b(t){return typeof t=="object"&&t!==null&&"error"in t}var k=["keydown","pointerdown","wheel","touchstart"];function N(t,o){let n=0,i=0,a=()=>{window.clearTimeout(n),window.clearTimeout(i),n=window.setTimeout(t,84e4),i=window.setTimeout(o,9e5)};for(let s of k)window.addEventListener(s,a,{passive:!0});return a(),()=>{window.clearTimeout(n),window.clearTimeout(i);for(let s of k)window.removeEventListener(s,a)}}function e(t,o={},...n){let i=document.createElement(t);for(let[a,s]of Object.entries(o))i.setAttribute(a,s);for(let a of n)a==null||a===!1||i.append(typeof a=="string"?document.createTextNode(a):a);return i}function y(t,...o){t.replaceChildren(...o)}var z={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function S(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},z[t.environment])}function x(t){let o=new Uint8Array(t),n="";for(let i of o)n+=String.fromCharCode(i);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function M(t){let o=t.replace(/-/g,"+").replace(/_/g,"/"),n=o+"=".repeat((4-o.length%4)%4),i=atob(n),a=new Uint8Array(i.length);for(let s=0;s({type:a.type,id:M(a.id),...a.transports?{transports:a.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:o});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let i=n.response;return{id:n.id,rawId:x(n.rawId),type:n.type,response:{clientDataJSON:x(i.clientDataJSON),authenticatorData:x(i.authenticatorData),signature:x(i.signature),userHandle:i.userHandle?x(i.userHandle):null},extensions:{}}}function j(t,o,n,i){let a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},i??""),s=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),u=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),p=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"),s,u,a);p.addEventListener("submit",r=>{r.preventDefault(),J(s.value.trim(),u,a,n)}),y(t,S(o),e("main",{class:"login"},p)),s.focus()}async function J(t,o,n,i){if(t!==""){o.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let a=await v("POST","/api/v1/webauthn/login/begin",{name:t});if(a.status!==200||a.body===null||b(a.body)){n.textContent="Connexion impossible pour le moment.";return}let s=await L(a.body.webauthn),u=await v("POST","/api/v1/webauthn/login/finish",{challenge_id:a.body.challenge_id,credential:s});if(u.status!==200||u.body===null||b(u.body)){n.textContent="Identifiants invalides.";return}i(u.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{o.disabled=!1}}}function E(t){let o=new Date(t);return Number.isNaN(o.getTime())?t:`${o.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var F={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 O(t,o){return b(t)?F[t.error]??t.message:o}function C(t){return new Promise(o=>{let n=!1,i=!1,a=null,s=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),u=e("div",{class:"frozen","data-testid":"frozen"}),p=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),r=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),m=e("dl",{class:"summary"},...t.summary.flatMap(([c,g])=>[e("dt",{},c),e("dd",{},g)])),l=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),m,u,s,e("div",{class:"actions"},r,p)),f=c=>{l.close(),l.remove(),o(c)},h=async()=>{p.disabled=!0,s.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let c=await v("POST","/api/v1/webauthn/challenge",t.action);return c.status!==200||c.body===null||b(c.body)?(s.textContent=O(c.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(a=c.body,y(u,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(a.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(a.body_hash))),s.textContent="",p.disabled=!1,!0)},d=async()=>{if(a===null&&!await h())return;let c=a;if(c!==null){n=!0,p.disabled=!0,r.disabled=!0,s.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let g=await L(c.webauthn),w=await v("POST",t.route,{challenge_id:c.challenge_id,assertion:g});if(w.status===200&&w.body!==null&&!b(w.body)){i=!0;let _=w.body;s.textContent=_.status==="AWAITING_QUORUM"?"\u2713 Signature enregistr\xE9e.":"\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>f(_),800);return}a=null,s.textContent=O(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{s.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,i||(p.disabled=!1,r.disabled=!1)}}};l.addEventListener("cancel",c=>{c.preventDefault(),!n&&!i&&f(null)}),r.addEventListener("click",()=>{n||f(null)}),p.addEventListener("click",()=>{d()}),document.body.append(l),l.showModal(),h().then(()=>p.focus())})}var R=[[1,"keyCompromise \u2014 cl\xE9 priv\xE9e compromise"],[3,"affiliationChanged \u2014 rattachement modifi\xE9"],[4,"superseded \u2014 remplac\xE9"],[5,"cessationOfOperation \u2014 cessation d'activit\xE9"],[9,"privilegeWithdrawn \u2014 privil\xE8ge retir\xE9"]];function T(t){return(t.toUpperCase().match(/.{1,2}/g)??[]).join(":")}function D(t){let o=[],n=0,i=e("tbody",{}),a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"certificates-status"}),s=e("aside",{class:"inspector","aria-label":"Inspecteur"}),u=e("section",{},e("h1",{},"Certificats \xE9mis"),a,e("div",{class:"split"},e("table",{class:"dense","data-testid":"certificates"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Num\xE9ro de s\xE9rie"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"Expire le"))),i),s)),p=()=>{y(i,...o.map((h,d)=>{let c=e("tr",{"aria-selected":String(d===n),"data-testid":`cert-${h.serial_hex}`},e("td",{},e("span",{class:"badge valid"},"\u25CF actif")),e("td",{class:"mono"},T(h.serial_hex)),e("td",{},h.subject_dn),e("td",{},h.profile),e("td",{class:"mono"},h.not_after?E(h.not_after):"\u2014"));return c.addEventListener("click",()=>{n=d,p()}),c}));let l=o[n];if(l===void 0){y(s,e("p",{class:"muted"},"Aucun certificat actif."));return}let f=e("button",{type:"button",class:"danger","data-testid":"revoke"},"R\xE9voquer le certificat\u2026");f.addEventListener("click",()=>{r(l)}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(l.serial_hex)),e("dt",{},"Sujet"),e("dd",{},l.subject_dn),e("dt",{},"Profil"),e("dd",{},l.profile),e("dt",{},"Expire le"),e("dd",{class:"mono"},l.not_after?E(l.not_after):"\u2014")),e("p",{class:"muted"},"La r\xE9vocation exige la signature de deux op\xE9rateurs CA distincts."),e("div",{class:"actions"},f))},r=async l=>{let f=await K();if(f===null)return;let[h,d]=R.find(([g])=>g===f.reason)??[f.reason,String(f.reason)],c=await C({title:"R\xE9vocation de certificat",summary:[["Action","r\xE9voquer d\xE9finitivement le certificat"],["Num\xE9ro de s\xE9rie",T(l.serial_hex)],["Sujet",l.subject_dn],["Motif RFC 5280",`${d} (${h})`],["Justification",f.comment]],action:{action:"revoke_certificate",serial:l.serial_hex,reason:f.reason,comment:f.comment},route:`/api/v1/certificates/${l.serial_hex}/revoke`});c!==null&&(a.textContent=c.status==="AWAITING_QUORUM"?`Signature enregistr\xE9e (${String(c.signatures)} sur ${String(c.required)}) : en attente d'un second op\xE9rateur CA, voir la salle de quorum.`:`Certificat ${T(l.serial_hex)} r\xE9voqu\xE9.`,await m(),t())},m=async()=>{let l=await v("GET","/api/v1/certificates?status=issued");o=Array.isArray(l.body)?l.body:[],b(l.body)&&(a.textContent=l.body.message),n=Math.min(n,Math.max(o.length-1,0)),p()};return m(),{element:u,dispose:()=>{}}}function K(){return new Promise(t=>{let o=e("select",{id:"revocation-reason",required:"","data-testid":"reason"},e("option",{value:""},"\u2014 choisir un motif \u2014"),...R.map(([r,m])=>e("option",{value:String(r)},m))),n=e("textarea",{id:"revocation-comment",rows:"3",maxlength:"1000",required:"","data-testid":"comment"}),i=e("button",{type:"submit",class:"danger","data-testid":"comment-next"},"Pr\xE9parer la signature"),a=e("button",{type:"button"},"Annuler"),s=e("form",{method:"dialog"},e("h2",{},"R\xE9vocation : motif et justification"),e("label",{for:"revocation-reason"},"Motif (RFC 5280)"),o,e("label",{for:"revocation-comment"},"Justification consign\xE9e au journal (obligatoire)"),n,e("div",{class:"actions"},a,i)),u=e("dialog",{class:"signature","data-testid":"revocation-dialog"},s),p=r=>{u.close(),u.remove(),t(r)};s.addEventListener("submit",r=>{r.preventDefault();let m=Number(o.value),l=n.value.trim();!R.some(([f])=>f===m)||l===""||p({reason:m,comment:l})}),a.addEventListener("click",()=>p(null)),u.addEventListener("cancel",r=>{r.preventDefault(),p(null)}),document.body.append(u),u.showModal(),o.focus()})}var q={revoke_certificate:"R\xE9vocation de certificat",set_role:"Changement de r\xF4le",invite_operator:"Invitation d'un op\xE9rateur"};function $(t,o){let n=e("div",{class:"quorum","data-testid":"quorum"}),i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"quorum-status"}),a=e("section",{},e("h1",{},"Salle de quorum"),i,n),s=r=>{let m=r.signed_by.includes(t.operator),l=e("button",{type:"button",class:"primary","data-testid":`cosign-${r.action_id}`,...m?{disabled:""}:{}},"Co-signer avec ma cl\xE9 FIDO2");l.addEventListener("click",()=>{u(r)});let f=typeof r.body.serial=="string"?r.body.serial:null;return e("article",{class:"card","data-testid":`pending-${r.action_id}`},e("h2",{},q[r.action]??r.action),e("dl",{},e("dt",{},"Signatures"),e("dd",{"data-testid":`progress-${r.action_id}`},`${r.signatures} sur ${r.required}`),e("dt",{},"D\xE9j\xE0 sign\xE9 par"),e("dd",{},r.signed_by.join(", ")||"\u2014"),...f?[e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(f))]:[],e("dt",{},"Initi\xE9e le"),e("dd",{class:"mono"},E(r.created_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},E(r.expires_at)),e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},I(r.body_hash))),e("pre",{class:"mono"},JSON.stringify(r.body,null,2)),m?e("p",{class:"muted","data-testid":`own-${r.action_id}`},"Le double contr\xF4le requiert un op\xE9rateur distinct : vous avez d\xE9j\xE0 sign\xE9."):null,e("div",{class:"actions"},l))},u=async r=>{let m=await C({title:`Co-signature : ${q[r.action]??r.action}`,summary:[["Action",q[r.action]??r.action],["D\xE9j\xE0 sign\xE9 par",r.signed_by.join(", ")],["Signatures",`${r.signatures+1} sur ${r.required} apr\xE8s la v\xF4tre`]],action:{action_id:r.action_id},route:`/api/v1/quorum/${r.action_id}/sign`});m!==null&&(i.textContent=m.status==="EXECUTED"?"Action ex\xE9cut\xE9e par l'autorit\xE9.":"Signature enregistr\xE9e.",await p(),o())},p=async()=>{let r=await v("GET","/api/v1/quorum?state=PENDING"),m=Array.isArray(r.body)?r.body:[];b(r.body)&&(i.textContent=r.body.message),y(n,...m.length===0?[e("p",{class:"muted"},"Aucune action en attente de signature.")]:m.map(s))};return p(),{element:a,dispose:()=>{}}}function V(t){let o=[],n=0,i=e("tbody",{}),a=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"))),i),s=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),u=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),p=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"),u,e("div",{class:"split"},a,s)),r=()=>{y(i,...o.map((d,c)=>{let g=e("tr",{"aria-selected":String(c===n),"data-testid":`row-${d.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},d.transaction_id),e("td",{},d.subject_cn),e("td",{},d.profile),e("td",{class:"mono"},E(d.created_at)));return g.addEventListener("click",()=>{n=c,r()}),g})),o.length===0&&y(i,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),m()},m=()=>{let d=o[n];if(d===void 0){y(s,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let c=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),g=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");c.addEventListener("click",()=>{l(d,"approve")}),g.addEventListener("click",()=>{l(d,"reject")}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},d.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},d.subject_cn),e("dt",{},"Profil"),e("dd",{},d.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},E(d.created_at))),e("div",{class:"actions"},c,g))},l=async(d,c)=>{let g=await W(c);if(g===null)return;let w=c==="approve";await C({title:w?`Approbation de la demande ${d.transaction_id}`:`Rejet de la demande ${d.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",d.transaction_id],["Sujet (CN)",d.subject_cn],["Profil",d.profile],["Justification",g===""?"\u2014":g]],action:{action:w?"approve_request":"reject_request",transaction_id:d.transaction_id,comment:g},route:`/api/v1/requests/${encodeURIComponent(d.transaction_id)}/${c}`})!==null&&(u.textContent=w?`Demande ${d.transaction_id} approuv\xE9e.`:`Demande ${d.transaction_id} rejet\xE9e.`,await f(),t())},f=async()=>{let d=await v("GET","/api/v1/requests?state=PENDING");d.status!==200||!Array.isArray(d.body)?(u.textContent=b(d.body)?d.body.message:"File indisponible.",o=[]):o=d.body,n=Math.min(n,Math.max(o.length-1,0)),r()},h=d=>{let c=d.target;if(document.querySelector("dialog[open]")!==null||c!==null&&["INPUT","TEXTAREA","SELECT"].includes(c.tagName))return;let g=o[n];if(d.key==="j"||d.key==="ArrowDown")n=Math.min(n+1,o.length-1),r();else if(d.key==="k"||d.key==="ArrowUp")n=Math.max(n-1,0),r();else if(d.key==="a"&&g!==void 0)l(g,"approve");else if(d.key==="r"&&g!==void 0)l(g,"reject");else return;d.preventDefault()};return window.addEventListener("keydown",h),f(),{element:p,dispose:()=>window.removeEventListener("keydown",h)}}function W(t){return new Promise(o=>{let n=t==="reject",i=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),a=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),s=e("button",{type:"button"},"Annuler"),u=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),i,e("div",{class:"actions"},s,a)),p=e("dialog",{class:"signature","data-testid":"comment-dialog"},u),r=m=>{p.close(),p.remove(),o(m)};u.addEventListener("submit",m=>{m.preventDefault();let l=i.value.trim();if(n&&l===""){i.focus();return}r(l)}),s.addEventListener("click",()=>r(null)),p.addEventListener("cancel",m=>{m.preventDefault(),r(null)}),document.body.append(p),p.showModal(),i.focus()})}var Q={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function H(t,o,n,i){let a=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");a.addEventListener("click",i);let s=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"},Q[n.role])),a),u=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),p=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),r=()=>{X(u,p)},m=e("main",{class:"workspace",tabindex:"-1"}),l=null,f=[["requests","Demandes RA ",u,()=>V(r)],["certificates","Certificats",null,()=>D(r)],["quorum","Quorum ",p,()=>$(n,r)]],h=f.map(([w,_,A,B])=>{let P=e("button",{type:"button",class:"nav","data-testid":`nav-${w}`},_,A);return P.addEventListener("click",()=>d(w,B)),P}),d=(w,_)=>{l?.dispose(),l=_(),y(m,l.element);for(let A of h)A.setAttribute("aria-current",String(A.dataset.testid===`nav-${w}`))},c=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},...h.map(w=>e("li",{},w))));y(t,S(o),s,e("div",{class:"layout"},c,m));let g=f[0];return d(g[0],g[3]),r(),()=>l?.dispose()}async function X(t,o){let[n,i]=await Promise.all([v("GET","/api/v1/requests?state=PENDING"),v("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",o.textContent=Array.isArray(i.body)?`(${i.body.length})`:"(\u2014)",(b(n.body)||b(i.body))&&(t.title=o.title="compteur indisponible")}function G(t){let o=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(o),o}async function Y(t){let o=await v("GET","/api/v1/console"),n=o.body!==null&&!b(o.body)?o.body:{environment:"undeclared",version:"?"},i=null,a=null,s=m=>{i?.(),i=null,a?.(),a=null,j(t,n,p,m)},u=async m=>{await v("POST","/api/v1/logout"),s(m)},p=m=>{a=H(t,n,m,()=>{u()});let l=null;i=N(()=>{l??=G(t)},()=>{u("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let f=()=>{l?.remove(),l=null};window.addEventListener("keydown",f),window.addEventListener("pointerdown",f)},r=await v("GET","/api/v1/me");r.status===200&&r.body!==null&&!b(r.body)?p(r.body):s()}var U=document.getElementById("app");U!==null&&Y(U); diff --git a/bin/ra-console/web/e2e/helpers.ts b/bin/ra-console/web/e2e/helpers.ts index 4cf2557..ac1b92e 100644 --- a/bin/ra-console/web/e2e/helpers.ts +++ b/bin/ra-console/web/e2e/helpers.ts @@ -6,10 +6,14 @@ import { expect, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; import { fixturePath } from "../playwright.config"; +type Credential = Record & { signCount: number }; + export interface Fixture { operator: string; pending: string[]; - credential: Record & { signCount: number }; + certificates: string[]; + credential: Credential; + operators: Record; } export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; @@ -19,7 +23,8 @@ export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8 // titre) un clone. Module partagé : le compteur croît d'un fichier à l'autre. let signCountBase = 1000; -export async function withOperatorKey(page: Page): Promise { +export async function withOperatorKey(page: Page, who?: string): Promise { + const credential = who === undefined ? fixture().credential : fixture().operators[who]!.credential; const cdp = await page.context().newCDPSession(page); await cdp.send("WebAuthn.enable"); const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { @@ -38,7 +43,7 @@ export async function withOperatorKey(page: Page): Promise { signCountBase += 1000; await cdp.send("WebAuthn.addCredential", { authenticatorId, - credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, + credential: { ...credential, signCount: credential.signCount + signCountBase }, } as never); } @@ -53,8 +58,8 @@ export function collectErrors(page: Page): string[] { return errors; } -export async function logIn(page: Page): Promise { - await page.getByTestId("login-name").fill(fixture().operator); +export async function logIn(page: Page, who: string = fixture().operator): Promise { + await page.getByTestId("login-name").fill(who); await page.getByTestId("login-submit").click(); - await expect(page.getByTestId("operator")).toHaveText(fixture().operator); + await expect(page.getByTestId("operator")).toHaveText(who); } diff --git a/bin/ra-console/web/e2e/revocation.spec.ts b/bin/ra-console/web/e2e/revocation.spec.ts new file mode 100644 index 0000000..275c25b --- /dev/null +++ b/bin/ra-console/web/e2e/revocation.spec.ts @@ -0,0 +1,87 @@ +// Révocation à deux dans le navigateur (docs/WEBUI.md §8, §15 étape 6c, +// docs/UI-UX.md §3.2) : bob signe la révocation, rien n'est révoqué ; il ne +// peut pas co-signer lui-même ; carol co-signe, ca-server exécute. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, fixture, logIn, withOperatorKey } from "./helpers"; + +async function statusOf(page: Page, serial: string): Promise { + for (const status of ["issued", "revoked"]) { + const list = (await (await page.request.get(`/api/v1/certificates?status=${status}`)).json()) as { + serial_hex: string; + }[]; + if (list.some((c) => c.serial_hex === serial)) return status; + } + return undefined; +} + +test("deux opérateurs CA distincts révoquent un certificat", async ({ browser }) => { + const serial = fixture().certificates[0]!; + + // bob : première signature. + const bobContext = await browser.newContext(); + const bob = await bobContext.newPage(); + const errors = collectErrors(bob); + await withOperatorKey(bob, "bob"); + await bob.goto("/"); + await logIn(bob, "bob"); + await bob.getByTestId("nav-certificates").click(); + await bob.getByTestId(`cert-${serial}`).click(); + await bob.getByTestId("revoke").click(); + await bob.getByTestId("reason").selectOption("1"); + await bob.getByTestId("comment").fill("clé exposée, ticket CERT-FR #2026-991"); + await bob.getByTestId("comment-next").click(); + const body = bob.getByTestId("frozen-body"); + await expect(body).toContainText(`"serial": "${serial}"`); + await expect(body).toContainText(`"reason": 1`); + await bob.getByTestId("sign").click(); + await expect(bob.getByTestId("sign-dialog")).toBeHidden(); + await expect(bob.getByTestId("certificates-status")).toContainText("1 sur 2"); + expect(await statusOf(bob, serial)).toBe("issued"); + + // bob ne peut pas co-signer sa propre action. + await bob.getByTestId("nav-quorum").click(); + const card = bob.locator("article", { hasText: serial.toUpperCase().match(/.{2}/g)!.join(":") }); + await expect(card).toContainText("1 sur 2"); + await expect(card.getByRole("button", { name: /Co-signer/ })).toBeDisabled(); + await expect(card).toContainText("opérateur distinct"); + expect(errors).toEqual([]); + + // carol co-signe : la révocation s'exécute. + const carolContext = await browser.newContext(); + const carol = await carolContext.newPage(); + await withOperatorKey(carol, "carol"); + await carol.goto("/"); + await logIn(carol, "carol"); + await expect(carol.getByTestId("count-quorum")).toHaveText("(1)"); + await carol.getByTestId("nav-quorum").click(); + const pending = carol.locator("article", { hasText: "bob" }); + await pending.getByRole("button", { name: /Co-signer/ }).click(); + await expect(carol.getByTestId("frozen-body")).toContainText(`"serial": "${serial}"`); + await carol.getByTestId("sign").click(); + await expect(carol.getByTestId("sign-dialog")).toBeHidden(); + await expect(carol.getByTestId("quorum-status")).toContainText("exécutée"); + expect(await statusOf(carol, serial)).toBe("revoked"); + await expect(carol.getByTestId("count-quorum")).toHaveText("(0)"); + + await bobContext.close(); + await carolContext.close(); +}); + +test("un opérateur RA ne peut pas lancer de révocation", async ({ page }) => { + const serial = fixture().certificates[1]!; + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await page.getByTestId("nav-certificates").click(); + await page.getByTestId(`cert-${serial}`).click(); + await page.getByTestId("revoke").click(); + await page.getByTestId("reason").selectOption("4"); + await page.getByTestId("comment").fill("essai"); + await page.getByTestId("comment-next").click(); + // L'autorité refuse de préparer l'action : la modale le dit, rien n'est figé. + await expect(page.getByTestId("sign-status")).toContainText("refusée"); + await expect(page.getByTestId("frozen-body")).toHaveCount(0); + await page.keyboard.press("Escape"); + expect(await statusOf(page, serial)).toBe("issued"); +}); diff --git a/bin/ra-console/web/src/certificates.ts b/bin/ra-console/web/src/certificates.ts new file mode 100644 index 0000000..9ee8f6e --- /dev/null +++ b/bin/ra-console/web/src/certificates.ts @@ -0,0 +1,184 @@ +// Certificats émis et révocation (docs/UI-UX.md §3.1, §4.2, §5 ; docs/WEBUI.md +// §8) : la révocation exige deux ca_operateur distincts, la première signature +// part en salle de quorum. + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Certificate { + serial_hex: string; + profile: string; + subject_dn: string; + not_after: string | null; + status: string; +} + +/// Motifs admis par ca-server (RFC 5280 §5.3.1) ; « unspecified » n'en fait pas partie. +const REASONS: [number, string][] = [ + [1, "keyCompromise — clé privée compromise"], + [3, "affiliationChanged — rattachement modifié"], + [4, "superseded — remplacé"], + [5, "cessationOfOperation — cessation d'activité"], + [9, "privilegeWithdrawn — privilège retiré"], +]; + +/// Numéro de série par paires séparées par deux-points (UI-UX §4.2). +export function pairs(hex: string): string { + return (hex.toUpperCase().match(/.{1,2}/g) ?? []).join(":"); +} + +export function certificatesView(onSigned: () => void): View { + let rows: Certificate[] = []; + let selected = 0; + const body = h("tbody", {}); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "certificates-status" }); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur" }); + const element = h( + "section", + {}, + h("h1", {}, "Certificats émis"), + notice, + h( + "div", + { class: "split" }, + h( + "table", + { class: "dense", "data-testid": "certificates" }, + h("thead", {}, h("tr", {}, h("th", {}, "Statut"), h("th", {}, "Numéro de série"), h("th", {}, "Sujet"), h("th", {}, "Profil"), h("th", {}, "Expire le"))), + body, + ), + inspector, + ), + ); + + const render = (): void => { + replace( + body, + ...rows.map((c, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `cert-${c.serial_hex}` }, + h("td", {}, h("span", { class: "badge valid" }, "● actif")), + h("td", { class: "mono" }, pairs(c.serial_hex)), + h("td", {}, c.subject_dn), + h("td", {}, c.profile), + h("td", { class: "mono" }, c.not_after ? utc(c.not_after) : "—"), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + const c = rows[selected]; + if (c === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucun certificat actif.")); + return; + } + const revoke = h("button", { type: "button", class: "danger", "data-testid": "revoke" }, "Révoquer le certificat…"); + revoke.addEventListener("click", () => void startRevocation(c)); + replace( + inspector, + h("h2", {}, "Général"), + h( + "dl", + {}, + h("dt", {}, "Numéro de série"), + h("dd", { class: "mono" }, pairs(c.serial_hex)), + h("dt", {}, "Sujet"), + h("dd", {}, c.subject_dn), + h("dt", {}, "Profil"), + h("dd", {}, c.profile), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, c.not_after ? utc(c.not_after) : "—"), + ), + h("p", { class: "muted" }, "La révocation exige la signature de deux opérateurs CA distincts."), + h("div", { class: "actions" }, revoke), + ); + }; + + const startRevocation = async (c: Certificate): Promise => { + const choice = await askRevocation(); + if (choice === null) return; + const [code, label] = REASONS.find(([r]) => r === choice.reason) ?? [choice.reason, String(choice.reason)]; + const result = await sign({ + title: "Révocation de certificat", + summary: [ + ["Action", "révoquer définitivement le certificat"], + ["Numéro de série", pairs(c.serial_hex)], + ["Sujet", c.subject_dn], + ["Motif RFC 5280", `${label} (${code})`], + ["Justification", choice.comment], + ], + action: { action: "revoke_certificate", serial: c.serial_hex, reason: choice.reason, comment: choice.comment }, + route: `/api/v1/certificates/${c.serial_hex}/revoke`, + }); + if (result === null) return; + notice.textContent = + result.status === "AWAITING_QUORUM" + ? `Signature enregistrée (${String(result.signatures)} sur ${String(result.required)}) : en attente d'un second opérateur CA, voir la salle de quorum.` + : `Certificat ${pairs(c.serial_hex)} révoqué.`; + await load(); + onSigned(); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/certificates?status=issued"); + rows = Array.isArray(reply.body) ? reply.body : []; + if (isError(reply.body)) notice.textContent = reply.body.message; + selected = Math.min(selected, Math.max(rows.length - 1, 0)); + render(); + }; + + void load(); + return { element, dispose: () => undefined }; +} + +function askRevocation(): Promise<{ reason: number; comment: string } | null> { + return new Promise((resolve) => { + const reason = h( + "select", + { id: "revocation-reason", required: "", "data-testid": "reason" }, + h("option", { value: "" }, "— choisir un motif —"), + ...REASONS.map(([code, label]) => h("option", { value: String(code) }, label)), + ); + const comment = h("textarea", { id: "revocation-comment", rows: "3", maxlength: "1000", required: "", "data-testid": "comment" }); + const next = h("button", { type: "submit", class: "danger", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h( + "form", + { method: "dialog" }, + h("h2", {}, "Révocation : motif et justification"), + h("label", { for: "revocation-reason" }, "Motif (RFC 5280)"), + reason, + h("label", { for: "revocation-comment" }, "Justification consignée au journal (obligatoire)"), + comment, + h("div", { class: "actions" }, cancel, next), + ); + const dialog = h("dialog", { class: "signature", "data-testid": "revocation-dialog" }, form); + const finish = (value: { reason: number; comment: string } | null): void => { + dialog.close(); + dialog.remove(); + resolve(value); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const code = Number(reason.value); + const text = comment.value.trim(); + if (!REASONS.some(([r]) => r === code) || text === "") return; + finish({ reason: code, comment: text }); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + document.body.append(dialog); + dialog.showModal(); + reason.focus(); + }); +} diff --git a/bin/ra-console/web/src/quorum.ts b/bin/ra-console/web/src/quorum.ts new file mode 100644 index 0000000..e35918c --- /dev/null +++ b/bin/ra-console/web/src/quorum.ts @@ -0,0 +1,95 @@ +// Salle d'attente du double contrôle (docs/UI-UX.md §3.2, docs/WEBUI.md §8) : +// ce qui attend une signature de plus, qui a déjà signé, et la co-signature — +// refusée d'avance à qui a déjà signé (l'autorité la refuserait de toute façon). + +import { call, isError, type Me } from "./api"; +import { pairs } from "./certificates"; +import { h, replace } from "./dom"; +import { groupedHash, utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Pending { + action_id: string; + action: string; + body: Record; + body_hash: string; + required: number; + signatures: number; + signed_by: string[]; + created_at: string; + expires_at: string; +} + +const LABELS: Record = { + revoke_certificate: "Révocation de certificat", + set_role: "Changement de rôle", + invite_operator: "Invitation d'un opérateur", +}; + +export function quorumView(me: Me, onSigned: () => void): View { + const list = h("div", { class: "quorum", "data-testid": "quorum" }); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "quorum-status" }); + const element = h("section", {}, h("h1", {}, "Salle de quorum"), notice, list); + + const card = (p: Pending): HTMLElement => { + const mine = p.signed_by.includes(me.operator); + const cosign = h( + "button", + { type: "button", class: "primary", "data-testid": `cosign-${p.action_id}`, ...(mine ? { disabled: "" } : {}) }, + "Co-signer avec ma clé FIDO2", + ); + cosign.addEventListener("click", () => void coSign(p)); + const serial = typeof p.body.serial === "string" ? p.body.serial : null; + return h( + "article", + { class: "card", "data-testid": `pending-${p.action_id}` }, + h("h2", {}, LABELS[p.action] ?? p.action), + h( + "dl", + {}, + h("dt", {}, "Signatures"), + h("dd", { "data-testid": `progress-${p.action_id}` }, `${p.signatures} sur ${p.required}`), + h("dt", {}, "Déjà signé par"), + h("dd", {}, p.signed_by.join(", ") || "—"), + ...(serial ? [h("dt", {}, "Numéro de série"), h("dd", { class: "mono" }, pairs(serial))] : []), + h("dt", {}, "Initiée le"), + h("dd", { class: "mono" }, utc(p.created_at)), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, utc(p.expires_at)), + h("dt", {}, "Empreinte"), + h("dd", { class: "mono hash" }, groupedHash(p.body_hash)), + ), + h("pre", { class: "mono" }, JSON.stringify(p.body, null, 2)), + mine ? h("p", { class: "muted", "data-testid": `own-${p.action_id}` }, "Le double contrôle requiert un opérateur distinct : vous avez déjà signé.") : null, + h("div", { class: "actions" }, cosign), + ); + }; + + const coSign = async (p: Pending): Promise => { + const result = await sign({ + title: `Co-signature : ${LABELS[p.action] ?? p.action}`, + summary: [ + ["Action", LABELS[p.action] ?? p.action], + ["Déjà signé par", p.signed_by.join(", ")], + ["Signatures", `${p.signatures + 1} sur ${p.required} après la vôtre`], + ], + action: { action_id: p.action_id }, + route: `/api/v1/quorum/${p.action_id}/sign`, + }); + if (result === null) return; + notice.textContent = result.status === "EXECUTED" ? "Action exécutée par l'autorité." : "Signature enregistrée."; + await load(); + onSigned(); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/quorum?state=PENDING"); + const pending = Array.isArray(reply.body) ? reply.body : []; + if (isError(reply.body)) notice.textContent = reply.body.message; + replace(list, ...(pending.length === 0 ? [h("p", { class: "muted" }, "Aucune action en attente de signature.")] : pending.map(card))); + }; + + void load(); + return { element, dispose: () => undefined }; +} diff --git a/bin/ra-console/web/src/requests.ts b/bin/ra-console/web/src/requests.ts index 096f0e5..0fd5f4e 100644 --- a/bin/ra-console/web/src/requests.ts +++ b/bin/ra-console/web/src/requests.ts @@ -15,13 +15,9 @@ interface EnrollmentRequest { created_at: string; } -export interface RequestsView { - element: HTMLElement; - /// Retire les raccourcis clavier quand la vue est quittée. - dispose: () => void; -} +import type { View } from "./view"; -export function requestsView(onDecided: () => void): RequestsView { +export function requestsView(onDecided: () => void): View { let rows: EnrollmentRequest[] = []; let selected = 0; @@ -122,7 +118,7 @@ export function requestsView(onDecided: () => void): RequestsView { }, route: `/api/v1/requests/${encodeURIComponent(r.transaction_id)}/${kind}`, }); - if (ok) { + if (ok !== null) { notice.textContent = approving ? `Demande ${r.transaction_id} approuvée.` : `Demande ${r.transaction_id} rejetée.`; await load(); onDecided(); diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts index 2a088b7..a76cb8d 100644 --- a/bin/ra-console/web/src/shell.ts +++ b/bin/ra-console/web/src/shell.ts @@ -6,7 +6,10 @@ import { call, isError, type ConsoleInfo, type Me } from "./api"; import { banner } from "./banner"; import { h, replace } from "./dom"; +import { certificatesView } from "./certificates"; +import { quorumView } from "./quorum"; import { requestsView } from "./requests"; +import type { View } from "./view"; const ROLE_LABELS: Record = { auditeur: "auditeur", @@ -32,16 +35,31 @@ export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogo ); 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 view = requestsView(() => void refreshCounts(requests, quorum)); - const work = h("main", { class: "workspace", tabindex: "-1" }, view.element); + const refresh = (): void => void refreshCounts(requests, quorum); + const work = h("main", { class: "workspace", tabindex: "-1" }); + let current: View | null = null; + const views: [string, string, HTMLElement | null, () => View][] = [ + ["requests", "Demandes RA ", requests, () => requestsView(refresh)], + ["certificates", "Certificats", null, () => certificatesView(refresh)], + ["quorum", "Quorum ", quorum, () => quorumView(me, refresh)], + ]; + const buttons = views.map(([id, label, count, make]) => { + const button = h("button", { type: "button", class: "nav", "data-testid": `nav-${id}` }, label, count); + button.addEventListener("click", () => show(id, make)); + return button; + }); + const show = (id: string, make: () => View): void => { + current?.dispose(); + current = make(); + replace(work, current.element); + for (const b of buttons) b.setAttribute("aria-current", String(b.dataset.testid === `nav-${id}`)); + }; + const nav = h("nav", { class: "sidebar", "aria-label": "Files de travail" }, h("ul", {}, ...buttons.map((b) => h("li", {}, b)))); replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); - void refreshCounts(requests, quorum); - return view.dispose; + const first = views[0]!; + show(first[0], first[3]); + refresh(); + return () => current?.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 index 79e0b56..7db45e9 100644 --- a/bin/ra-console/web/src/sign.ts +++ b/bin/ra-console/web/src/sign.ts @@ -46,8 +46,9 @@ function explain(body: unknown, fallback: string): string { 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 { +/// Ouvre la modale ; rend la réponse de l'autorité si l'action a été signée et +/// relayée avec succès, `null` sinon. +export function sign(signing: Signing): Promise | null> { return new Promise((resolve) => { let busy = false; let done = false; @@ -72,7 +73,7 @@ export function sign(signing: Signing): Promise { h("div", { class: "actions" }, cancel, signButton), ); - const close = (result: boolean): void => { + const close = (result: Record | null): void => { dialog.close(); dialog.remove(); resolve(result); @@ -109,11 +110,16 @@ export function sign(signing: Signing): Promise { 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) { + const reply = await call>("POST", signing.route, { + challenge_id: current.challenge_id, + assertion, + }); + if (reply.status === 200 && reply.body !== null && !isError(reply.body)) { done = true; - status.textContent = "✓ Action signée et exécutée."; - window.setTimeout(() => close(true), 800); + const result = reply.body; + status.textContent = + result.status === "AWAITING_QUORUM" ? "✓ Signature enregistrée." : "✓ Action signée et exécutée."; + window.setTimeout(() => close(result), 800); return; } // Un challenge consommé ou expiré ne resservira pas : on en redemande un. @@ -132,10 +138,10 @@ export function sign(signing: Signing): Promise { dialog.addEventListener("cancel", (event) => { event.preventDefault(); - if (!busy && !done) close(false); + if (!busy && !done) close(null); }); cancel.addEventListener("click", () => { - if (!busy) close(false); + if (!busy) close(null); }); signButton.addEventListener("click", () => void ceremony()); diff --git a/bin/ra-console/web/src/view.ts b/bin/ra-console/web/src/view.ts new file mode 100644 index 0000000..57bebe3 --- /dev/null +++ b/bin/ra-console/web/src/view.ts @@ -0,0 +1,6 @@ +// Une vue de la zone de travail : son élément, et de quoi la quitter proprement +// (raccourcis clavier, minuteries). +export interface View { + element: HTMLElement; + dispose: () => void; +} diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css index e3c4755..86b2258 100644 --- a/bin/ra-console/web/static/console.css +++ b/bin/ra-console/web/static/console.css @@ -324,3 +324,61 @@ dialog.signature textarea { font-size: 13px; word-spacing: 0.4em; } + +/* --- 6c : navigation, certificats, salle de quorum (UI-UX §2.2, §3.2) --- */ + +button.nav { + width: 100%; + text-align: left; + background: transparent; + border-color: transparent; +} +button.nav[aria-current="true"] { + background: var(--bg-surface-elevated); + border-color: var(--border-subtle); + box-shadow: inset 3px 0 0 var(--border-focus); +} +button.danger { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; + font-weight: 600; +} +button.danger:hover { + background: #ef4444; +} +.badge.valid { + background: #064e3b; + color: #34d399; + border: 1px solid #059669; +} +select { + 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; + width: 100%; +} +.quorum { + display: grid; + gap: 16px; +} +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-left: 3px dashed #d97706; + border-radius: 6px; + padding: 16px; +} +.card pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 200px; + font-size: 12px; +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index fc47f4a..f4c99a2 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -162,7 +162,16 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). 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). +- **Certificats et révocation (6c)** : `GET /api/v1/certificates?status=issued|revoked` + (lecture seule de la table de `ca-server`, numéro de série sous la forme canonique + qu'attend la révocation). L'écran liste les certificats actifs ; « Révoquer… » demande + un motif RFC 5280 parmi ceux qu'admet `ca-server` (1, 3, 4, 5, 9) et une + justification obligatoire, puis la modale de signature. La première signature part + en salle de quorum. +- **Salle de quorum (6c)** : les actions en attente, leur corps figé et leur empreinte, + qui a déjà signé ; la co-signature est désactivée pour qui a déjà signé (« le double + contrôle requiert un opérateur distinct » — `ca-server` la refuserait de toute façon). +- L'explorateur d'audit suit (6d, après #51). ## Variables d'environnement From de9d3d00552bebbdb6871ac1c9f1a06185f08e81 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 14:04:32 +0200 Subject: [PATCH 9/9] =?UTF-8?q?feat(ra-console):=20registre=20des=20op?= =?UTF-8?q?=C3=A9rateurs=20dans=20le=20navigateur=20(=C3=A9tape=206e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §10, §15 étape 6 : un administrateur gère le registre depuis la console, chaque écriture étant une action signée que ca-server juge (routes de #69, fusionnées dans cette branche). - `GET /api/v1/operators` : opérateurs, clés, clés en attente, en lecture seule ; l'empreinte d'une clé en attente est recalculée avec oe_actions::key_fingerprint, la fonction même de ca-server (test Rust : identique à celle remise à l'invitée). - Écran des opérateurs : invitation (jeton affiché une seule fois), confirmation d'une clé en attente après déclaration de comparaison de l'empreinte hors bande, révocation de clé (motif obligatoire), changement de rôle (admin : second administrateur en salle de quorum). Boutons désactivés pour les non-administrateurs (affichage seulement). - Harnais e2e : un administrateur et un opérateur de test. Parcours : invitation, changement de rôle, révocation de clé ; consultation seule pour un non-administrateur (mutation tuée). Co-authored-by: Claude --- bin/ra-console/examples/e2e_console.rs | 7 +- bin/ra-console/src/http.rs | 23 +- bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/operators.rs | 119 ++++++++ bin/ra-console/tests/action_challenge.rs | 24 ++ bin/ra-console/web/dist/console.css | 13 + bin/ra-console/web/dist/console.js | 2 +- bin/ra-console/web/e2e/operators.spec.ts | 68 +++++ bin/ra-console/web/src/operators.ts | 341 +++++++++++++++++++++++ bin/ra-console/web/src/shell.ts | 2 + bin/ra-console/web/static/console.css | 13 + docs/RA-CONSOLE.md | 8 + 12 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 bin/ra-console/src/operators.rs create mode 100644 bin/ra-console/web/e2e/operators.spec.ts create mode 100644 bin/ra-console/web/src/operators.ts diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs index efcb0fe..dacbad6 100644 --- a/bin/ra-console/examples/e2e_console.rs +++ b/bin/ra-console/examples/e2e_console.rs @@ -72,12 +72,15 @@ async fn main() { // 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(); - // Trois opérateurs : alice décide des demandes (étape 6b), bob et carol - // révoquent à deux (étape 6c, double contrôle). + // alice décide des demandes (étape 6b), bob et carol révoquent à deux + // (étape 6c, double contrôle). let people = [ ("alice", Role::RaOperateur), ("bob", Role::CaOperateur), ("carol", Role::CaOperateur), + // Registre (6e) : root administre, dave voit son rôle changer et sa clé révoquée. + ("root", Role::Admin), + ("dave", Role::RaOperateur), ]; let reg_verifier = verifier(); // Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 120fbd3..4902d3e 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -18,7 +18,7 @@ use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; -use crate::{certificates, quorum, requests}; +use crate::{certificates, operators, quorum, requests}; /// Assez pour un objet d'attestation, pas pour bourrer la mémoire. const MAX_BODY_BYTES: usize = 64 * 1024; @@ -57,6 +57,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) .route("/api/v1/certificates", get(handle_certificates)) + .route("/api/v1/operators", get(handle_operators)) .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)) @@ -843,6 +844,26 @@ pub(crate) fn quorum_status(body: &serde_json::Value) -> serde_json::Value { }) } +/// `GET /api/v1/operators` : le registre en lecture seule (docs/WEBUI.md §10), +/// pour toute session authentifiée ; les écritures restent des actions signées +/// que `ca-server` juge. +async fn handle_operators(State(state): State>, headers: HeaderMap) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + match operators::list(&state.pool, time::OffsetDateTime::now_utc()).await { + Ok(registry) => Json(registry).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "operators : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CertificatesQuery { diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index faa5ad6..3bae97d 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -19,6 +19,7 @@ pub mod config; pub mod db_guard; pub mod http; pub mod login; +pub mod operators; pub mod purge; pub mod quorum; pub mod registry_routes; diff --git a/bin/ra-console/src/operators.rs b/bin/ra-console/src/operators.rs new file mode 100644 index 0000000..fd5d301 --- /dev/null +++ b/bin/ra-console/src/operators.rs @@ -0,0 +1,119 @@ +//! `GET /api/v1/operators` (docs/WEBUI.md §10, §15 étape 6e) : le registre des +//! opérateurs, leurs clés et les clés en attente de confirmation, en lecture +//! seule sur les tables de `ca-server`. Toute écriture passe par une action +//! signée (`registry_routes`). +//! +//! L'empreinte d'une clé en attente est recalculée ici avec la fonction même +//! de `ca-server` (`oe_actions::key_fingerprint`) : l'administrateur la compare +//! hors bande à celle que l'invité a reçue à l'enregistrement (§10), puis la +//! signe ; `ca-server` la recompare à la clé stockée. Une console qui +//! afficherait une autre empreinte ferait échouer la confirmation, pas passer +//! une autre clé. + +use oe_webauthn::Uuid; +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +#[derive(Debug, Serialize)] +pub struct Credential { + pub credential_id: String, + pub label: String, + #[serde(with = "time::serde::rfc3339")] + pub initiated_at: OffsetDateTime, + pub confirmed_by: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_used_at: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub revoked_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct Operator { + pub name: String, + pub role: String, + pub disabled: bool, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + pub credentials: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PendingKey { + pub credential_id: String, + pub operator: String, + /// `None` si la clé stockée ne se relit pas : rien à confirmer alors. + pub key_fingerprint: Option, + #[serde(with = "time::serde::rfc3339")] + pub registered_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +#[derive(Debug, Serialize)] +pub struct Registry { + pub operators: Vec, + pub pending: Vec, +} + +pub async fn list(pool: &PgPool, now: OffsetDateTime) -> Result { + let ops = + sqlx::query("SELECT id, name, role, created_at, disabled_at FROM operators ORDER BY name") + .fetch_all(pool) + .await?; + let creds = sqlx::query( + "SELECT credential_id, operator_id, label, initiated_at, confirmed_by, last_used_at, revoked_at + FROM webauthn_credentials ORDER BY initiated_at", + ) + .fetch_all(pool) + .await?; + let operators = ops + .iter() + .map(|o| { + let id: Uuid = o.get("id"); + Operator { + name: o.get("name"), + role: o.get("role"), + disabled: o.get::, _>("disabled_at").is_some(), + created_at: o.get("created_at"), + credentials: creds + .iter() + .filter(|c| c.get::("operator_id") == id) + .map(|c| Credential { + credential_id: c.get("credential_id"), + label: c.get("label"), + initiated_at: c.get("initiated_at"), + confirmed_by: c.get("confirmed_by"), + last_used_at: c.get("last_used_at"), + revoked_at: c.get("revoked_at"), + }) + .collect(), + } + }) + .collect(); + + let pending = sqlx::query( + "SELECT p.credential_id, o.name, p.passkey, p.registered_at, p.expires_at + FROM pending_credentials p JOIN operators o ON o.id = p.operator_id + WHERE p.expires_at > $1 + ORDER BY p.registered_at", + ) + .bind(now) + .fetch_all(pool) + .await? + .iter() + .map(|p| { + let passkey: serde_json::Value = p.get("passkey"); + PendingKey { + credential_id: p.get("credential_id"), + operator: p.get("name"), + key_fingerprint: serde_json::from_value::(passkey) + .ok() + .and_then(|k| oe_actions::key_fingerprint(&k).ok()), + registered_at: p.get("registered_at"), + expires_at: p.get("expires_at"), + } + }) + .collect(); + Ok(Registry { operators, pending }) +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 4296dfa..3da0c56 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -1075,6 +1075,19 @@ async fn an_admin_invites_and_confirms_an_operator_through_the_console() { assert_eq!(pending["status"], "pending_confirmation", "{pending}"); let credential_id = pending["credential_id"].as_str().unwrap().to_string(); + // La console montre la clé en attente, avec l'empreinte même que ca-server a + // remise à l'invitée : c'est elle que l'administrateur compare hors bande. + let (status, registry) = env.get("/api/v1/operators", &admin).await; + assert_eq!(status, StatusCode::OK, "{registry}"); + let shown = registry["pending"] + .as_array() + .unwrap() + .iter() + .find(|p| p["credential_id"] == credential_id.as_str()) + .unwrap_or_else(|| panic!("clé en attente absente : {registry}")); + assert_eq!(shown["operator"], "eve"); + assert_eq!(shown["key_fingerprint"], pending["key_fingerprint"]); + let confirm = serde_json::json!({ "action": "confirm_key", "credential_id": credential_id, @@ -1092,6 +1105,17 @@ async fn an_admin_invites_and_confirms_an_operator_through_the_console() { // La clé est active : l'invitée peut se connecter. let eve = env.log_in("eve").await; assert!(eve.starts_with("session=")); + let (_, registry) = env.get("/api/v1/operators", &admin).await; + assert_eq!(registry["pending"], serde_json::json!([]), "{registry}"); + let eve_entry = registry["operators"] + .as_array() + .unwrap() + .iter() + .find(|o| o["name"] == "eve") + .unwrap(); + assert_eq!(eve_entry["credentials"].as_array().unwrap().len(), 1); + let (status, _) = env.get("/api/v1/operators", "session=n-importe-quoi").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); } /// Révocation d'une clé : la cible de la route est contrôlée par ca-server. diff --git a/bin/ra-console/web/dist/console.css b/bin/ra-console/web/dist/console.css index 86b2258..fc629bc 100644 --- a/bin/ra-console/web/dist/console.css +++ b/bin/ra-console/web/dist/console.css @@ -382,3 +382,16 @@ select { max-height: 200px; font-size: 12px; } + +/* --- 6e : opérateurs --- */ +.actions.start { + justify-content: flex-start; + margin-bottom: 12px; +} +.key { + border-top: 1px solid var(--border-subtle); + padding: 8px 0; +} +.key p { + margin: 0 0 4px; +} diff --git a/bin/ra-console/web/dist/console.js b/bin/ra-console/web/dist/console.js index 7f0c3c5..0773654 100644 --- a/bin/ra-console/web/dist/console.js +++ b/bin/ra-console/web/dist/console.js @@ -1 +1 @@ -async function v(t,o,n){let i={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(i.headers={"Content-Type":"application/json"},i.body=JSON.stringify(n));let a=await fetch(o,i),s=await a.text(),u=null;if(s!=="")try{u=JSON.parse(s)}catch{u=null}return{status:a.status,body:u}}function b(t){return typeof t=="object"&&t!==null&&"error"in t}var k=["keydown","pointerdown","wheel","touchstart"];function N(t,o){let n=0,i=0,a=()=>{window.clearTimeout(n),window.clearTimeout(i),n=window.setTimeout(t,84e4),i=window.setTimeout(o,9e5)};for(let s of k)window.addEventListener(s,a,{passive:!0});return a(),()=>{window.clearTimeout(n),window.clearTimeout(i);for(let s of k)window.removeEventListener(s,a)}}function e(t,o={},...n){let i=document.createElement(t);for(let[a,s]of Object.entries(o))i.setAttribute(a,s);for(let a of n)a==null||a===!1||i.append(typeof a=="string"?document.createTextNode(a):a);return i}function y(t,...o){t.replaceChildren(...o)}var z={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function S(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},z[t.environment])}function x(t){let o=new Uint8Array(t),n="";for(let i of o)n+=String.fromCharCode(i);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function M(t){let o=t.replace(/-/g,"+").replace(/_/g,"/"),n=o+"=".repeat((4-o.length%4)%4),i=atob(n),a=new Uint8Array(i.length);for(let s=0;s({type:a.type,id:M(a.id),...a.transports?{transports:a.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:o});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let i=n.response;return{id:n.id,rawId:x(n.rawId),type:n.type,response:{clientDataJSON:x(i.clientDataJSON),authenticatorData:x(i.authenticatorData),signature:x(i.signature),userHandle:i.userHandle?x(i.userHandle):null},extensions:{}}}function j(t,o,n,i){let a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},i??""),s=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),u=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),p=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"),s,u,a);p.addEventListener("submit",r=>{r.preventDefault(),J(s.value.trim(),u,a,n)}),y(t,S(o),e("main",{class:"login"},p)),s.focus()}async function J(t,o,n,i){if(t!==""){o.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let a=await v("POST","/api/v1/webauthn/login/begin",{name:t});if(a.status!==200||a.body===null||b(a.body)){n.textContent="Connexion impossible pour le moment.";return}let s=await L(a.body.webauthn),u=await v("POST","/api/v1/webauthn/login/finish",{challenge_id:a.body.challenge_id,credential:s});if(u.status!==200||u.body===null||b(u.body)){n.textContent="Identifiants invalides.";return}i(u.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{o.disabled=!1}}}function E(t){let o=new Date(t);return Number.isNaN(o.getTime())?t:`${o.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var F={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 O(t,o){return b(t)?F[t.error]??t.message:o}function C(t){return new Promise(o=>{let n=!1,i=!1,a=null,s=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),u=e("div",{class:"frozen","data-testid":"frozen"}),p=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),r=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),m=e("dl",{class:"summary"},...t.summary.flatMap(([c,g])=>[e("dt",{},c),e("dd",{},g)])),l=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),m,u,s,e("div",{class:"actions"},r,p)),f=c=>{l.close(),l.remove(),o(c)},h=async()=>{p.disabled=!0,s.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let c=await v("POST","/api/v1/webauthn/challenge",t.action);return c.status!==200||c.body===null||b(c.body)?(s.textContent=O(c.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(a=c.body,y(u,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(a.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(a.body_hash))),s.textContent="",p.disabled=!1,!0)},d=async()=>{if(a===null&&!await h())return;let c=a;if(c!==null){n=!0,p.disabled=!0,r.disabled=!0,s.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let g=await L(c.webauthn),w=await v("POST",t.route,{challenge_id:c.challenge_id,assertion:g});if(w.status===200&&w.body!==null&&!b(w.body)){i=!0;let _=w.body;s.textContent=_.status==="AWAITING_QUORUM"?"\u2713 Signature enregistr\xE9e.":"\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>f(_),800);return}a=null,s.textContent=O(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{s.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,i||(p.disabled=!1,r.disabled=!1)}}};l.addEventListener("cancel",c=>{c.preventDefault(),!n&&!i&&f(null)}),r.addEventListener("click",()=>{n||f(null)}),p.addEventListener("click",()=>{d()}),document.body.append(l),l.showModal(),h().then(()=>p.focus())})}var R=[[1,"keyCompromise \u2014 cl\xE9 priv\xE9e compromise"],[3,"affiliationChanged \u2014 rattachement modifi\xE9"],[4,"superseded \u2014 remplac\xE9"],[5,"cessationOfOperation \u2014 cessation d'activit\xE9"],[9,"privilegeWithdrawn \u2014 privil\xE8ge retir\xE9"]];function T(t){return(t.toUpperCase().match(/.{1,2}/g)??[]).join(":")}function D(t){let o=[],n=0,i=e("tbody",{}),a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"certificates-status"}),s=e("aside",{class:"inspector","aria-label":"Inspecteur"}),u=e("section",{},e("h1",{},"Certificats \xE9mis"),a,e("div",{class:"split"},e("table",{class:"dense","data-testid":"certificates"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Num\xE9ro de s\xE9rie"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"Expire le"))),i),s)),p=()=>{y(i,...o.map((h,d)=>{let c=e("tr",{"aria-selected":String(d===n),"data-testid":`cert-${h.serial_hex}`},e("td",{},e("span",{class:"badge valid"},"\u25CF actif")),e("td",{class:"mono"},T(h.serial_hex)),e("td",{},h.subject_dn),e("td",{},h.profile),e("td",{class:"mono"},h.not_after?E(h.not_after):"\u2014"));return c.addEventListener("click",()=>{n=d,p()}),c}));let l=o[n];if(l===void 0){y(s,e("p",{class:"muted"},"Aucun certificat actif."));return}let f=e("button",{type:"button",class:"danger","data-testid":"revoke"},"R\xE9voquer le certificat\u2026");f.addEventListener("click",()=>{r(l)}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(l.serial_hex)),e("dt",{},"Sujet"),e("dd",{},l.subject_dn),e("dt",{},"Profil"),e("dd",{},l.profile),e("dt",{},"Expire le"),e("dd",{class:"mono"},l.not_after?E(l.not_after):"\u2014")),e("p",{class:"muted"},"La r\xE9vocation exige la signature de deux op\xE9rateurs CA distincts."),e("div",{class:"actions"},f))},r=async l=>{let f=await K();if(f===null)return;let[h,d]=R.find(([g])=>g===f.reason)??[f.reason,String(f.reason)],c=await C({title:"R\xE9vocation de certificat",summary:[["Action","r\xE9voquer d\xE9finitivement le certificat"],["Num\xE9ro de s\xE9rie",T(l.serial_hex)],["Sujet",l.subject_dn],["Motif RFC 5280",`${d} (${h})`],["Justification",f.comment]],action:{action:"revoke_certificate",serial:l.serial_hex,reason:f.reason,comment:f.comment},route:`/api/v1/certificates/${l.serial_hex}/revoke`});c!==null&&(a.textContent=c.status==="AWAITING_QUORUM"?`Signature enregistr\xE9e (${String(c.signatures)} sur ${String(c.required)}) : en attente d'un second op\xE9rateur CA, voir la salle de quorum.`:`Certificat ${T(l.serial_hex)} r\xE9voqu\xE9.`,await m(),t())},m=async()=>{let l=await v("GET","/api/v1/certificates?status=issued");o=Array.isArray(l.body)?l.body:[],b(l.body)&&(a.textContent=l.body.message),n=Math.min(n,Math.max(o.length-1,0)),p()};return m(),{element:u,dispose:()=>{}}}function K(){return new Promise(t=>{let o=e("select",{id:"revocation-reason",required:"","data-testid":"reason"},e("option",{value:""},"\u2014 choisir un motif \u2014"),...R.map(([r,m])=>e("option",{value:String(r)},m))),n=e("textarea",{id:"revocation-comment",rows:"3",maxlength:"1000",required:"","data-testid":"comment"}),i=e("button",{type:"submit",class:"danger","data-testid":"comment-next"},"Pr\xE9parer la signature"),a=e("button",{type:"button"},"Annuler"),s=e("form",{method:"dialog"},e("h2",{},"R\xE9vocation : motif et justification"),e("label",{for:"revocation-reason"},"Motif (RFC 5280)"),o,e("label",{for:"revocation-comment"},"Justification consign\xE9e au journal (obligatoire)"),n,e("div",{class:"actions"},a,i)),u=e("dialog",{class:"signature","data-testid":"revocation-dialog"},s),p=r=>{u.close(),u.remove(),t(r)};s.addEventListener("submit",r=>{r.preventDefault();let m=Number(o.value),l=n.value.trim();!R.some(([f])=>f===m)||l===""||p({reason:m,comment:l})}),a.addEventListener("click",()=>p(null)),u.addEventListener("cancel",r=>{r.preventDefault(),p(null)}),document.body.append(u),u.showModal(),o.focus()})}var q={revoke_certificate:"R\xE9vocation de certificat",set_role:"Changement de r\xF4le",invite_operator:"Invitation d'un op\xE9rateur"};function $(t,o){let n=e("div",{class:"quorum","data-testid":"quorum"}),i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"quorum-status"}),a=e("section",{},e("h1",{},"Salle de quorum"),i,n),s=r=>{let m=r.signed_by.includes(t.operator),l=e("button",{type:"button",class:"primary","data-testid":`cosign-${r.action_id}`,...m?{disabled:""}:{}},"Co-signer avec ma cl\xE9 FIDO2");l.addEventListener("click",()=>{u(r)});let f=typeof r.body.serial=="string"?r.body.serial:null;return e("article",{class:"card","data-testid":`pending-${r.action_id}`},e("h2",{},q[r.action]??r.action),e("dl",{},e("dt",{},"Signatures"),e("dd",{"data-testid":`progress-${r.action_id}`},`${r.signatures} sur ${r.required}`),e("dt",{},"D\xE9j\xE0 sign\xE9 par"),e("dd",{},r.signed_by.join(", ")||"\u2014"),...f?[e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(f))]:[],e("dt",{},"Initi\xE9e le"),e("dd",{class:"mono"},E(r.created_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},E(r.expires_at)),e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},I(r.body_hash))),e("pre",{class:"mono"},JSON.stringify(r.body,null,2)),m?e("p",{class:"muted","data-testid":`own-${r.action_id}`},"Le double contr\xF4le requiert un op\xE9rateur distinct : vous avez d\xE9j\xE0 sign\xE9."):null,e("div",{class:"actions"},l))},u=async r=>{let m=await C({title:`Co-signature : ${q[r.action]??r.action}`,summary:[["Action",q[r.action]??r.action],["D\xE9j\xE0 sign\xE9 par",r.signed_by.join(", ")],["Signatures",`${r.signatures+1} sur ${r.required} apr\xE8s la v\xF4tre`]],action:{action_id:r.action_id},route:`/api/v1/quorum/${r.action_id}/sign`});m!==null&&(i.textContent=m.status==="EXECUTED"?"Action ex\xE9cut\xE9e par l'autorit\xE9.":"Signature enregistr\xE9e.",await p(),o())},p=async()=>{let r=await v("GET","/api/v1/quorum?state=PENDING"),m=Array.isArray(r.body)?r.body:[];b(r.body)&&(i.textContent=r.body.message),y(n,...m.length===0?[e("p",{class:"muted"},"Aucune action en attente de signature.")]:m.map(s))};return p(),{element:a,dispose:()=>{}}}function V(t){let o=[],n=0,i=e("tbody",{}),a=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"))),i),s=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),u=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),p=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"),u,e("div",{class:"split"},a,s)),r=()=>{y(i,...o.map((d,c)=>{let g=e("tr",{"aria-selected":String(c===n),"data-testid":`row-${d.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},d.transaction_id),e("td",{},d.subject_cn),e("td",{},d.profile),e("td",{class:"mono"},E(d.created_at)));return g.addEventListener("click",()=>{n=c,r()}),g})),o.length===0&&y(i,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),m()},m=()=>{let d=o[n];if(d===void 0){y(s,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let c=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),g=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");c.addEventListener("click",()=>{l(d,"approve")}),g.addEventListener("click",()=>{l(d,"reject")}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},d.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},d.subject_cn),e("dt",{},"Profil"),e("dd",{},d.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},E(d.created_at))),e("div",{class:"actions"},c,g))},l=async(d,c)=>{let g=await W(c);if(g===null)return;let w=c==="approve";await C({title:w?`Approbation de la demande ${d.transaction_id}`:`Rejet de la demande ${d.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",d.transaction_id],["Sujet (CN)",d.subject_cn],["Profil",d.profile],["Justification",g===""?"\u2014":g]],action:{action:w?"approve_request":"reject_request",transaction_id:d.transaction_id,comment:g},route:`/api/v1/requests/${encodeURIComponent(d.transaction_id)}/${c}`})!==null&&(u.textContent=w?`Demande ${d.transaction_id} approuv\xE9e.`:`Demande ${d.transaction_id} rejet\xE9e.`,await f(),t())},f=async()=>{let d=await v("GET","/api/v1/requests?state=PENDING");d.status!==200||!Array.isArray(d.body)?(u.textContent=b(d.body)?d.body.message:"File indisponible.",o=[]):o=d.body,n=Math.min(n,Math.max(o.length-1,0)),r()},h=d=>{let c=d.target;if(document.querySelector("dialog[open]")!==null||c!==null&&["INPUT","TEXTAREA","SELECT"].includes(c.tagName))return;let g=o[n];if(d.key==="j"||d.key==="ArrowDown")n=Math.min(n+1,o.length-1),r();else if(d.key==="k"||d.key==="ArrowUp")n=Math.max(n-1,0),r();else if(d.key==="a"&&g!==void 0)l(g,"approve");else if(d.key==="r"&&g!==void 0)l(g,"reject");else return;d.preventDefault()};return window.addEventListener("keydown",h),f(),{element:p,dispose:()=>window.removeEventListener("keydown",h)}}function W(t){return new Promise(o=>{let n=t==="reject",i=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),a=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),s=e("button",{type:"button"},"Annuler"),u=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),i,e("div",{class:"actions"},s,a)),p=e("dialog",{class:"signature","data-testid":"comment-dialog"},u),r=m=>{p.close(),p.remove(),o(m)};u.addEventListener("submit",m=>{m.preventDefault();let l=i.value.trim();if(n&&l===""){i.focus();return}r(l)}),s.addEventListener("click",()=>r(null)),p.addEventListener("cancel",m=>{m.preventDefault(),r(null)}),document.body.append(p),p.showModal(),i.focus()})}var Q={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function H(t,o,n,i){let a=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");a.addEventListener("click",i);let s=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"},Q[n.role])),a),u=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),p=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),r=()=>{X(u,p)},m=e("main",{class:"workspace",tabindex:"-1"}),l=null,f=[["requests","Demandes RA ",u,()=>V(r)],["certificates","Certificats",null,()=>D(r)],["quorum","Quorum ",p,()=>$(n,r)]],h=f.map(([w,_,A,B])=>{let P=e("button",{type:"button",class:"nav","data-testid":`nav-${w}`},_,A);return P.addEventListener("click",()=>d(w,B)),P}),d=(w,_)=>{l?.dispose(),l=_(),y(m,l.element);for(let A of h)A.setAttribute("aria-current",String(A.dataset.testid===`nav-${w}`))},c=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},...h.map(w=>e("li",{},w))));y(t,S(o),s,e("div",{class:"layout"},c,m));let g=f[0];return d(g[0],g[3]),r(),()=>l?.dispose()}async function X(t,o){let[n,i]=await Promise.all([v("GET","/api/v1/requests?state=PENDING"),v("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",o.textContent=Array.isArray(i.body)?`(${i.body.length})`:"(\u2014)",(b(n.body)||b(i.body))&&(t.title=o.title="compteur indisponible")}function G(t){let o=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(o),o}async function Y(t){let o=await v("GET","/api/v1/console"),n=o.body!==null&&!b(o.body)?o.body:{environment:"undeclared",version:"?"},i=null,a=null,s=m=>{i?.(),i=null,a?.(),a=null,j(t,n,p,m)},u=async m=>{await v("POST","/api/v1/logout"),s(m)},p=m=>{a=H(t,n,m,()=>{u()});let l=null;i=N(()=>{l??=G(t)},()=>{u("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let f=()=>{l?.remove(),l=null};window.addEventListener("keydown",f),window.addEventListener("pointerdown",f)},r=await v("GET","/api/v1/me");r.status===200&&r.body!==null&&!b(r.body)?p(r.body):s()}var U=document.getElementById("app");U!==null&&Y(U); +async function w(t,i,n){let a={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(a.headers={"Content-Type":"application/json"},a.body=JSON.stringify(n));let o=await fetch(i,a),l=await o.text(),u=null;if(l!=="")try{u=JSON.parse(l)}catch{u=null}return{status:o.status,body:u}}function h(t){return typeof t=="object"&&t!==null&&"error"in t}var V=["keydown","pointerdown","wheel","touchstart"];function U(t,i){let n=0,a=0,o=()=>{window.clearTimeout(n),window.clearTimeout(a),n=window.setTimeout(t,84e4),a=window.setTimeout(i,9e5)};for(let l of V)window.addEventListener(l,o,{passive:!0});return o(),()=>{window.clearTimeout(n),window.clearTimeout(a);for(let l of V)window.removeEventListener(l,o)}}function e(t,i={},...n){let a=document.createElement(t);for(let[o,l]of Object.entries(i))a.setAttribute(o,l);for(let o of n)o==null||o===!1||a.append(typeof o=="string"?document.createTextNode(o):o);return a}function b(t,...i){t.replaceChildren(...i)}var Y={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function P(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},Y[t.environment])}function R(t){let i=new Uint8Array(t),n="";for(let a of i)n+=String.fromCharCode(a);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function $(t){let i=t.replace(/-/g,"+").replace(/_/g,"/"),n=i+"=".repeat((4-i.length%4)%4),a=atob(n),o=new Uint8Array(a.length);for(let l=0;l({type:o.type,id:$(o.id),...o.transports?{transports:o.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:i});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let a=n.response;return{id:n.id,rawId:R(n.rawId),type:n.type,response:{clientDataJSON:R(a.clientDataJSON),authenticatorData:R(a.authenticatorData),signature:R(a.signature),userHandle:a.userHandle?R(a.userHandle):null},extensions:{}}}function H(t,i,n,a){let o=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},a??""),l=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),u=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),f=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"),l,u,o);f.addEventListener("submit",r=>{r.preventDefault(),Z(l.value.trim(),u,o,n)}),b(t,P(i),e("main",{class:"login"},f)),l.focus()}async function Z(t,i,n,a){if(t!==""){i.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let o=await w("POST","/api/v1/webauthn/login/begin",{name:t});if(o.status!==200||o.body===null||h(o.body)){n.textContent="Connexion impossible pour le moment.";return}let l=await I(o.body.webauthn),u=await w("POST","/api/v1/webauthn/login/finish",{challenge_id:o.body.challenge_id,credential:l});if(u.status!==200||u.body===null||h(u.body)){n.textContent="Identifiants invalides.";return}a(u.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{i.disabled=!1}}}function x(t){let i=new Date(t);return Number.isNaN(i.getTime())?t:`${i.toISOString().slice(0,19).replace("T"," ")} UTC`}function q(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var ee={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 G(t,i){return h(t)?ee[t.error]??t.message:i}function A(t){return new Promise(i=>{let n=!1,a=!1,o=null,l=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),u=e("div",{class:"frozen","data-testid":"frozen"}),f=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),r=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),m=e("dl",{class:"summary"},...t.summary.flatMap(([p,y])=>[e("dt",{},p),e("dd",{},y)])),d=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),m,u,l,e("div",{class:"actions"},r,f)),g=p=>{d.close(),d.remove(),i(p)},_=async()=>{f.disabled=!0,l.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let p=await w("POST","/api/v1/webauthn/challenge",t.action);return p.status!==200||p.body===null||h(p.body)?(l.textContent=G(p.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(o=p.body,b(u,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(o.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},q(o.body_hash))),l.textContent="",f.disabled=!1,!0)},c=async()=>{if(o===null&&!await _())return;let p=o;if(p!==null){n=!0,f.disabled=!0,r.disabled=!0,l.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let y=await I(p.webauthn),E=await w("POST",t.route,{challenge_id:p.challenge_id,assertion:y});if(E.status===200&&E.body!==null&&!h(E.body)){a=!0;let T=E.body;l.textContent=T.status==="AWAITING_QUORUM"?"\u2713 Signature enregistr\xE9e.":"\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>g(T),800);return}o=null,l.textContent=G(E.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{l.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,a||(f.disabled=!1,r.disabled=!1)}}};d.addEventListener("cancel",p=>{p.preventDefault(),!n&&!a&&g(null)}),r.addEventListener("click",()=>{n||g(null)}),f.addEventListener("click",()=>{c()}),document.body.append(d),d.showModal(),_().then(()=>f.focus())})}var O=[[1,"keyCompromise \u2014 cl\xE9 priv\xE9e compromise"],[3,"affiliationChanged \u2014 rattachement modifi\xE9"],[4,"superseded \u2014 remplac\xE9"],[5,"cessationOfOperation \u2014 cessation d'activit\xE9"],[9,"privilegeWithdrawn \u2014 privil\xE8ge retir\xE9"]];function S(t){return(t.toUpperCase().match(/.{1,2}/g)??[]).join(":")}function K(t){let i=[],n=0,a=e("tbody",{}),o=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"certificates-status"}),l=e("aside",{class:"inspector","aria-label":"Inspecteur"}),u=e("section",{},e("h1",{},"Certificats \xE9mis"),o,e("div",{class:"split"},e("table",{class:"dense","data-testid":"certificates"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Num\xE9ro de s\xE9rie"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"Expire le"))),a),l)),f=()=>{b(a,...i.map((_,c)=>{let p=e("tr",{"aria-selected":String(c===n),"data-testid":`cert-${_.serial_hex}`},e("td",{},e("span",{class:"badge valid"},"\u25CF actif")),e("td",{class:"mono"},S(_.serial_hex)),e("td",{},_.subject_dn),e("td",{},_.profile),e("td",{class:"mono"},_.not_after?x(_.not_after):"\u2014"));return p.addEventListener("click",()=>{n=c,f()}),p}));let d=i[n];if(d===void 0){b(l,e("p",{class:"muted"},"Aucun certificat actif."));return}let g=e("button",{type:"button",class:"danger","data-testid":"revoke"},"R\xE9voquer le certificat\u2026");g.addEventListener("click",()=>{r(d)}),b(l,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},S(d.serial_hex)),e("dt",{},"Sujet"),e("dd",{},d.subject_dn),e("dt",{},"Profil"),e("dd",{},d.profile),e("dt",{},"Expire le"),e("dd",{class:"mono"},d.not_after?x(d.not_after):"\u2014")),e("p",{class:"muted"},"La r\xE9vocation exige la signature de deux op\xE9rateurs CA distincts."),e("div",{class:"actions"},g))},r=async d=>{let g=await te();if(g===null)return;let[_,c]=O.find(([y])=>y===g.reason)??[g.reason,String(g.reason)],p=await A({title:"R\xE9vocation de certificat",summary:[["Action","r\xE9voquer d\xE9finitivement le certificat"],["Num\xE9ro de s\xE9rie",S(d.serial_hex)],["Sujet",d.subject_dn],["Motif RFC 5280",`${c} (${_})`],["Justification",g.comment]],action:{action:"revoke_certificate",serial:d.serial_hex,reason:g.reason,comment:g.comment},route:`/api/v1/certificates/${d.serial_hex}/revoke`});p!==null&&(o.textContent=p.status==="AWAITING_QUORUM"?`Signature enregistr\xE9e (${String(p.signatures)} sur ${String(p.required)}) : en attente d'un second op\xE9rateur CA, voir la salle de quorum.`:`Certificat ${S(d.serial_hex)} r\xE9voqu\xE9.`,await m(),t())},m=async()=>{let d=await w("GET","/api/v1/certificates?status=issued");i=Array.isArray(d.body)?d.body:[],h(d.body)&&(o.textContent=d.body.message),n=Math.min(n,Math.max(i.length-1,0)),f()};return m(),{element:u,dispose:()=>{}}}function te(){return new Promise(t=>{let i=e("select",{id:"revocation-reason",required:"","data-testid":"reason"},e("option",{value:""},"\u2014 choisir un motif \u2014"),...O.map(([r,m])=>e("option",{value:String(r)},m))),n=e("textarea",{id:"revocation-comment",rows:"3",maxlength:"1000",required:"","data-testid":"comment"}),a=e("button",{type:"submit",class:"danger","data-testid":"comment-next"},"Pr\xE9parer la signature"),o=e("button",{type:"button"},"Annuler"),l=e("form",{method:"dialog"},e("h2",{},"R\xE9vocation : motif et justification"),e("label",{for:"revocation-reason"},"Motif (RFC 5280)"),i,e("label",{for:"revocation-comment"},"Justification consign\xE9e au journal (obligatoire)"),n,e("div",{class:"actions"},o,a)),u=e("dialog",{class:"signature","data-testid":"revocation-dialog"},l),f=r=>{u.close(),u.remove(),t(r)};l.addEventListener("submit",r=>{r.preventDefault();let m=Number(i.value),d=n.value.trim();!O.some(([g])=>g===m)||d===""||f({reason:m,comment:d})}),o.addEventListener("click",()=>f(null)),u.addEventListener("cancel",r=>{r.preventDefault(),f(null)}),document.body.append(u),u.showModal(),i.focus()})}var z=[["auditeur","auditeur"],["ra_operateur","op\xE9rateur RA"],["ca_operateur","op\xE9rateur CA"],["admin","administrateur"]];function J(t){let i=t.role==="admin",n=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"operators-status"}),a=e("tbody",{}),o=e("aside",{class:"inspector","aria-label":"Inspecteur"}),l=e("div",{class:"quorum","data-testid":"pending-keys"}),u=e("button",{type:"button",class:"primary","data-testid":"invite",...i?{}:{disabled:""}},"Inviter un op\xE9rateur\u2026"),f=e("section",{},e("h1",{},"Op\xE9rateurs"),i?null:e("p",{class:"muted"},"Consultation seule : la gestion du registre est r\xE9serv\xE9e aux administrateurs."),n,e("div",{class:"actions start"},u),e("div",{class:"split"},e("table",{class:"dense","data-testid":"operators"},e("thead",{},e("tr",{},e("th",{},"Op\xE9rateur"),e("th",{},"R\xF4le"),e("th",{},"Cl\xE9s actives"),e("th",{},"\xC9tat"))),a),o),e("h2",{},"Cl\xE9s en attente de confirmation"),l),r={operators:[],pending:[]},m=0,d=s=>s.credentials.filter(v=>v.revoked_at===null),g=()=>{b(a,...r.operators.map((s,v)=>{let C=e("tr",{"aria-selected":String(v===m),"data-testid":`operator-${s.name}`},e("td",{},s.name),e("td",{},e("span",{class:`role role-${s.role}`},s.role)),e("td",{class:"mono"},String(d(s).length)),e("td",{},s.disabled?"d\xE9sactiv\xE9":"actif"));return C.addEventListener("click",()=>{m=v,g()}),C})),_(),b(l,...r.pending.length===0?[e("p",{class:"muted"},"Aucune cl\xE9 en attente.")]:r.pending.map(s=>{let v=e("button",{type:"button",class:"primary","data-testid":`confirm-${s.operator}`,...i&&s.key_fingerprint?{}:{disabled:""}},"Confirmer la cl\xE9\u2026");return v.addEventListener("click",()=>{T(s)}),e("article",{class:"card"},e("h2",{},`Cl\xE9 de ${s.operator}`),e("dl",{},e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},s.key_fingerprint??"illisible"),e("dt",{},"Enregistr\xE9e le"),e("dd",{class:"mono"},x(s.registered_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},x(s.expires_at))),e("div",{class:"actions"},v))}))},_=()=>{let s=r.operators[m];if(s===void 0){b(o,e("p",{class:"muted"},"Aucun op\xE9rateur."));return}let v=e("select",{id:"new-role","data-testid":"new-role",...i?{}:{disabled:""}},...z.map(([k,M])=>{let D=e("option",{value:k},M);return k===s.role&&(D.selected=!0),D})),C=e("button",{type:"button","data-testid":"change-role",...i?{}:{disabled:""}},"Changer le r\xF4le\u2026");C.addEventListener("click",()=>{y(s,v.value)}),b(o,e("h2",{},s.name),e("label",{for:"new-role"},"R\xF4le"),v,e("div",{class:"actions"},C),e("h2",{},"Cl\xE9s"),...s.credentials.map(k=>{let M=e("button",{type:"button",class:"danger","data-testid":`revoke-key-${s.name}`,...i&&k.revoked_at===null?{}:{disabled:""}},"R\xE9voquer\u2026");return M.addEventListener("click",()=>{E(s,k)}),e("div",{class:"key"},e("p",{class:"mono"},`${k.label} \xB7 ${k.credential_id.slice(0,16)}\u2026`),e("p",{class:"muted"},k.revoked_at?`r\xE9voqu\xE9e le ${x(k.revoked_at)}`:`active depuis le ${x(k.initiated_at)}`),M)}))},c=async()=>{let s=await w("GET","/api/v1/operators");s.status===200&&s.body!==null&&!h(s.body)?r=s.body:h(s.body)&&(n.textContent=s.body.message),m=Math.min(m,Math.max(r.operators.length-1,0)),g()},p=async(s,v)=>{s!==null&&(n.textContent=s.status==="AWAITING_QUORUM"?"Signature enregistr\xE9e : un second administrateur doit co-signer (salle de quorum).":v,await c())},y=async(s,v)=>{if(v===s.role)return;let C=await A({title:`Changement de r\xF4le de ${s.name}`,summary:[["Op\xE9rateur",s.name],["R\xF4le actuel",s.role],["Nouveau r\xF4le",v]],action:{action:"set_role",operator:s.name,role:v},route:`/api/v1/operators/${encodeURIComponent(s.name)}/role`});await p(C,`R\xF4le de ${s.name} : ${v}.`)},E=async(s,v)=>{let C=await re("Motif de la r\xE9vocation de la cl\xE9 (obligatoire)","revoke-reason");if(C===null)return;let k=await A({title:`R\xE9vocation d'une cl\xE9 de ${s.name}`,summary:[["Op\xE9rateur",s.name],["Cl\xE9",`${v.label} \xB7 ${v.credential_id}`],["Motif",C]],action:{action:"revoke_key",credential_id:v.credential_id,reason:C},route:`/api/v1/credentials/${encodeURIComponent(v.credential_id)}/revoke`});await p(k,`Cl\xE9 de ${s.name} r\xE9voqu\xE9e.`)},T=async s=>{if(s.key_fingerprint===null||!await oe(s))return;let C=await A({title:`Confirmation de la cl\xE9 de ${s.operator}`,summary:[["Op\xE9rateur",s.operator],["Empreinte compar\xE9e",s.key_fingerprint]],action:{action:"confirm_key",credential_id:s.credential_id,key_fingerprint:s.key_fingerprint},route:`/api/v1/credentials/${encodeURIComponent(s.credential_id)}/confirm`});await p(C,`Cl\xE9 de ${s.operator} confirm\xE9e.`)};u.addEventListener("click",()=>{L()});let L=async()=>{let s=await ie();if(s===null)return;let v=await A({title:`Invitation de ${s.name}`,summary:[["Op\xE9rateur invit\xE9",s.name],["R\xF4le",s.role]],action:{action:"invite_operator",name:s.name,role:s.role},route:"/api/v1/operators"});if(v===null)return;let C=v.result;v.status==="EXECUTED"&&typeof C?.invite_token=="string"&&await ne(s.name,C.invite_token),await p(v,`Invitation de ${s.name} cr\xE9\xE9e.`)};return c(),{element:f,dispose:()=>{}}}function ne(t,i){return new Promise(n=>{let a=e("button",{type:"button",class:"primary","data-testid":"token-close"},"J'ai transmis le jeton"),o=e("dialog",{class:"signature","data-testid":"token-dialog"},e("h2",{},`Jeton d'invitation de ${t}`),e("p",{},"Affich\xE9 une seule fois. Transmettez-le \xE0 l'invit\xE9 par un canal distinct ; il lui sert \xE0 enregistrer sa cl\xE9 FIDO2."),e("pre",{class:"mono","data-testid":"invite-token"},i),e("div",{class:"actions"},a)),l=()=>{o.close(),o.remove(),n()};a.addEventListener("click",l),o.addEventListener("cancel",u=>{u.preventDefault(),l()}),document.body.append(o),o.showModal()})}function N(t,i,n,a){return new Promise(o=>{let l=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),u=e("button",{type:"button"},"Annuler"),f=e("form",{method:"dialog"},e("h2",{},i),...n,e("div",{class:"actions"},u,l)),r=e("dialog",{class:"signature","data-testid":t},f),m=d=>{r.close(),r.remove(),o(d)};f.addEventListener("submit",d=>{d.preventDefault(),a()&&m(!0)}),u.addEventListener("click",()=>m(!1)),r.addEventListener("cancel",d=>{d.preventDefault(),m(!1)}),document.body.append(r),r.showModal()})}async function re(t,i){let n=e("textarea",{rows:"3",maxlength:"1000",required:"","data-testid":"comment"});return await N(i,t,[n],()=>n.value.trim()!=="")?n.value.trim():null}async function ie(){let t=e("input",{id:"invite-name",required:"",maxlength:"100","data-testid":"invite-name"}),i=e("select",{id:"invite-role","data-testid":"invite-role"},...z.map(([a,o])=>e("option",{value:a},o)));return await N("invite-dialog","Inviter un op\xE9rateur",[e("label",{for:"invite-name"},"Nom"),t,e("label",{for:"invite-role"},"R\xF4le"),i],()=>t.value.trim()!=="")?{name:t.value.trim(),role:i.value}:null}async function oe(t){let i=e("input",{type:"checkbox",id:"compared",required:"","data-testid":"compared"});return N("confirm-dialog",`Confirmer la cl\xE9 de ${t.operator}`,[e("p",{},"Comparez cette empreinte, hors bande, avec celle que l'invit\xE9 a obtenue en enregistrant sa cl\xE9 :"),e("p",{class:"mono hash"},t.key_fingerprint??""),e("label",{for:"compared"},i," J'ai compar\xE9 l'empreinte avec l'invit\xE9 : elle est identique.")],()=>i.checked)}var j={revoke_certificate:"R\xE9vocation de certificat",set_role:"Changement de r\xF4le",invite_operator:"Invitation d'un op\xE9rateur"};function B(t,i){let n=e("div",{class:"quorum","data-testid":"quorum"}),a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"quorum-status"}),o=e("section",{},e("h1",{},"Salle de quorum"),a,n),l=r=>{let m=r.signed_by.includes(t.operator),d=e("button",{type:"button",class:"primary","data-testid":`cosign-${r.action_id}`,...m?{disabled:""}:{}},"Co-signer avec ma cl\xE9 FIDO2");d.addEventListener("click",()=>{u(r)});let g=typeof r.body.serial=="string"?r.body.serial:null;return e("article",{class:"card","data-testid":`pending-${r.action_id}`},e("h2",{},j[r.action]??r.action),e("dl",{},e("dt",{},"Signatures"),e("dd",{"data-testid":`progress-${r.action_id}`},`${r.signatures} sur ${r.required}`),e("dt",{},"D\xE9j\xE0 sign\xE9 par"),e("dd",{},r.signed_by.join(", ")||"\u2014"),...g?[e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},S(g))]:[],e("dt",{},"Initi\xE9e le"),e("dd",{class:"mono"},x(r.created_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},x(r.expires_at)),e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},q(r.body_hash))),e("pre",{class:"mono"},JSON.stringify(r.body,null,2)),m?e("p",{class:"muted","data-testid":`own-${r.action_id}`},"Le double contr\xF4le requiert un op\xE9rateur distinct : vous avez d\xE9j\xE0 sign\xE9."):null,e("div",{class:"actions"},d))},u=async r=>{let m=await A({title:`Co-signature : ${j[r.action]??r.action}`,summary:[["Action",j[r.action]??r.action],["D\xE9j\xE0 sign\xE9 par",r.signed_by.join(", ")],["Signatures",`${r.signatures+1} sur ${r.required} apr\xE8s la v\xF4tre`]],action:{action_id:r.action_id},route:`/api/v1/quorum/${r.action_id}/sign`});m!==null&&(a.textContent=m.status==="EXECUTED"?"Action ex\xE9cut\xE9e par l'autorit\xE9.":"Signature enregistr\xE9e.",await f(),i())},f=async()=>{let r=await w("GET","/api/v1/quorum?state=PENDING"),m=Array.isArray(r.body)?r.body:[];h(r.body)&&(a.textContent=r.body.message),b(n,...m.length===0?[e("p",{class:"muted"},"Aucune action en attente de signature.")]:m.map(l))};return f(),{element:o,dispose:()=>{}}}function F(t){let i=[],n=0,a=e("tbody",{}),o=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"))),a),l=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),u=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),f=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"),u,e("div",{class:"split"},o,l)),r=()=>{b(a,...i.map((c,p)=>{let y=e("tr",{"aria-selected":String(p===n),"data-testid":`row-${c.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},c.transaction_id),e("td",{},c.subject_cn),e("td",{},c.profile),e("td",{class:"mono"},x(c.created_at)));return y.addEventListener("click",()=>{n=p,r()}),y})),i.length===0&&b(a,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),m()},m=()=>{let c=i[n];if(c===void 0){b(l,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let p=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),y=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");p.addEventListener("click",()=>{d(c,"approve")}),y.addEventListener("click",()=>{d(c,"reject")}),b(l,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},c.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},c.subject_cn),e("dt",{},"Profil"),e("dd",{},c.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},x(c.created_at))),e("div",{class:"actions"},p,y))},d=async(c,p)=>{let y=await ae(p);if(y===null)return;let E=p==="approve";await A({title:E?`Approbation de la demande ${c.transaction_id}`:`Rejet de la demande ${c.transaction_id}`,summary:[["Action",E?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",c.transaction_id],["Sujet (CN)",c.subject_cn],["Profil",c.profile],["Justification",y===""?"\u2014":y]],action:{action:E?"approve_request":"reject_request",transaction_id:c.transaction_id,comment:y},route:`/api/v1/requests/${encodeURIComponent(c.transaction_id)}/${p}`})!==null&&(u.textContent=E?`Demande ${c.transaction_id} approuv\xE9e.`:`Demande ${c.transaction_id} rejet\xE9e.`,await g(),t())},g=async()=>{let c=await w("GET","/api/v1/requests?state=PENDING");c.status!==200||!Array.isArray(c.body)?(u.textContent=h(c.body)?c.body.message:"File indisponible.",i=[]):i=c.body,n=Math.min(n,Math.max(i.length-1,0)),r()},_=c=>{let p=c.target;if(document.querySelector("dialog[open]")!==null||p!==null&&["INPUT","TEXTAREA","SELECT"].includes(p.tagName))return;let y=i[n];if(c.key==="j"||c.key==="ArrowDown")n=Math.min(n+1,i.length-1),r();else if(c.key==="k"||c.key==="ArrowUp")n=Math.max(n-1,0),r();else if(c.key==="a"&&y!==void 0)d(y,"approve");else if(c.key==="r"&&y!==void 0)d(y,"reject");else return;c.preventDefault()};return window.addEventListener("keydown",_),g(),{element:f,dispose:()=>window.removeEventListener("keydown",_)}}function ae(t){return new Promise(i=>{let n=t==="reject",a=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),o=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),l=e("button",{type:"button"},"Annuler"),u=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),a,e("div",{class:"actions"},l,o)),f=e("dialog",{class:"signature","data-testid":"comment-dialog"},u),r=m=>{f.close(),f.remove(),i(m)};u.addEventListener("submit",m=>{m.preventDefault();let d=a.value.trim();if(n&&d===""){a.focus();return}r(d)}),l.addEventListener("click",()=>r(null)),f.addEventListener("cancel",m=>{m.preventDefault(),r(null)}),document.body.append(f),f.showModal(),a.focus()})}var se={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function W(t,i,n,a){let o=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");o.addEventListener("click",a);let l=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"},se[n.role])),o),u=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),f=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),r=()=>{le(u,f)},m=e("main",{class:"workspace",tabindex:"-1"}),d=null,g=[["requests","Demandes RA ",u,()=>F(r)],["certificates","Certificats",null,()=>K(r)],["quorum","Quorum ",f,()=>B(n,r)],["operators","Op\xE9rateurs",null,()=>J(n)]],_=g.map(([E,T,L,s])=>{let v=e("button",{type:"button",class:"nav","data-testid":`nav-${E}`},T,L);return v.addEventListener("click",()=>c(E,s)),v}),c=(E,T)=>{d?.dispose(),d=T(),b(m,d.element);for(let L of _)L.setAttribute("aria-current",String(L.dataset.testid===`nav-${E}`))},p=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},..._.map(E=>e("li",{},E))));b(t,P(i),l,e("div",{class:"layout"},p,m));let y=g[0];return c(y[0],y[3]),r(),()=>d?.dispose()}async function le(t,i){let[n,a]=await Promise.all([w("GET","/api/v1/requests?state=PENDING"),w("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",i.textContent=Array.isArray(a.body)?`(${a.body.length})`:"(\u2014)",(h(n.body)||h(a.body))&&(t.title=i.title="compteur indisponible")}function Q(t){let i=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(i),i}async function de(t){let i=await w("GET","/api/v1/console"),n=i.body!==null&&!h(i.body)?i.body:{environment:"undeclared",version:"?"},a=null,o=null,l=m=>{a?.(),a=null,o?.(),o=null,H(t,n,f,m)},u=async m=>{await w("POST","/api/v1/logout"),l(m)},f=m=>{o=W(t,n,m,()=>{u()});let d=null;a=U(()=>{d??=Q(t)},()=>{u("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let g=()=>{d?.remove(),d=null};window.addEventListener("keydown",g),window.addEventListener("pointerdown",g)},r=await w("GET","/api/v1/me");r.status===200&&r.body!==null&&!h(r.body)?f(r.body):l()}var X=document.getElementById("app");X!==null&&de(X); diff --git a/bin/ra-console/web/e2e/operators.spec.ts b/bin/ra-console/web/e2e/operators.spec.ts new file mode 100644 index 0000000..653c38f --- /dev/null +++ b/bin/ra-console/web/e2e/operators.spec.ts @@ -0,0 +1,68 @@ +// Registre des opérateurs dans le navigateur (docs/WEBUI.md §10, §15 étape +// 6e) : invitation (jeton affiché une seule fois), changement de rôle, +// révocation de clé — chaque écriture signée et exécutée par ca-server. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, logIn, withOperatorKey } from "./helpers"; + +interface Registry { + operators: { name: string; role: string; credentials: { revoked_at: string | null }[] }[]; +} + +async function registry(page: Page): Promise { + return (await (await page.request.get("/api/v1/operators")).json()) as Registry; +} + +test("un administrateur invite, change un rôle et révoque une clé", async ({ page }) => { + const errors = collectErrors(page); + await withOperatorKey(page, "root"); + await page.goto("/"); + await logIn(page, "root"); + await page.getByTestId("nav-operators").click(); + + // Invitation : le jeton n'est montré qu'une fois. + await page.getByTestId("invite").click(); + await page.getByTestId("invite-name").fill("frank"); + await page.getByTestId("invite-role").selectOption("ra_operateur"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "invite_operator"`); + await page.getByTestId("sign").click(); + const token = page.getByTestId("invite-token"); + await expect(token).toHaveText(/^\S{30,}$/); + await page.getByTestId("token-close").click(); + await expect(page.getByTestId("token-dialog")).toHaveCount(0); + await expect(page.getByTestId("operator-frank")).toBeVisible(); + + // Rôle de dave : auditeur (pas de double contrôle hors rôle admin). + await page.getByTestId("operator-dave").click(); + await page.getByTestId("new-role").selectOption("auditeur"); + await page.getByTestId("change-role").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"operator": "dave"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("operators-status")).toContainText("auditeur"); + expect((await registry(page)).operators.find((o) => o.name === "dave")?.role).toBe("auditeur"); + + // Révocation de la clé de dave, motif obligatoire. + await page.getByTestId("operator-dave").click(); + await page.getByTestId("revoke-key-dave").click(); + await page.getByTestId("comment").fill("départ de l'association"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "revoke_key"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("operators-status")).toContainText("révoquée"); + const dave = (await registry(page)).operators.find((o) => o.name === "dave"); + expect(dave?.credentials.every((c) => c.revoked_at !== null)).toBe(true); + expect(errors).toEqual([]); +}); + +test("un non-administrateur consulte le registre sans pouvoir l'écrire", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await page.getByTestId("nav-operators").click(); + await expect(page.getByTestId("operator-root")).toBeVisible(); + await expect(page.getByTestId("invite")).toBeDisabled(); + await page.getByTestId("operator-root").click(); + await expect(page.getByTestId("change-role")).toBeDisabled(); + await expect(page.getByTestId("revoke-key-root")).toBeDisabled(); +}); diff --git a/bin/ra-console/web/src/operators.ts b/bin/ra-console/web/src/operators.ts new file mode 100644 index 0000000..58814a1 --- /dev/null +++ b/bin/ra-console/web/src/operators.ts @@ -0,0 +1,341 @@ +// Registre des opérateurs (docs/WEBUI.md §10, §15 étape 6e) : invitations, +// confirmation des clés en attente (empreinte comparée hors bande), révocation +// de clé, changement de rôle. Chaque écriture est une action signée que +// ca-server juge ; les boutons ne sont qu'un affichage pour les non-admins. + +import { call, isError, type Me } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Credential { + credential_id: string; + label: string; + initiated_at: string; + revoked_at: string | null; +} +interface Operator { + name: string; + role: Me["role"]; + disabled: boolean; + credentials: Credential[]; +} +interface PendingKey { + credential_id: string; + operator: string; + key_fingerprint: string | null; + registered_at: string; + expires_at: string; +} +interface Registry { + operators: Operator[]; + pending: PendingKey[]; +} + +const ROLES: [Me["role"], string][] = [ + ["auditeur", "auditeur"], + ["ra_operateur", "opérateur RA"], + ["ca_operateur", "opérateur CA"], + ["admin", "administrateur"], +]; + +export function operatorsView(me: Me): View { + const isAdmin = me.role === "admin"; + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "operators-status" }); + const table = h("tbody", {}); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur" }); + const pendingList = h("div", { class: "quorum", "data-testid": "pending-keys" }); + const invite = h("button", { type: "button", class: "primary", "data-testid": "invite", ...(isAdmin ? {} : { disabled: "" }) }, "Inviter un opérateur…"); + const element = h( + "section", + {}, + h("h1", {}, "Opérateurs"), + isAdmin ? null : h("p", { class: "muted" }, "Consultation seule : la gestion du registre est réservée aux administrateurs."), + notice, + h("div", { class: "actions start" }, invite), + h( + "div", + { class: "split" }, + h( + "table", + { class: "dense", "data-testid": "operators" }, + h("thead", {}, h("tr", {}, h("th", {}, "Opérateur"), h("th", {}, "Rôle"), h("th", {}, "Clés actives"), h("th", {}, "État"))), + table, + ), + inspector, + ), + h("h2", {}, "Clés en attente de confirmation"), + pendingList, + ); + let registry: Registry = { operators: [], pending: [] }; + let selected = 0; + + const active = (o: Operator): Credential[] => o.credentials.filter((c) => c.revoked_at === null); + + const render = (): void => { + replace( + table, + ...registry.operators.map((o, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `operator-${o.name}` }, + h("td", {}, o.name), + h("td", {}, h("span", { class: `role role-${o.role}` }, o.role)), + h("td", { class: "mono" }, String(active(o).length)), + h("td", {}, o.disabled ? "désactivé" : "actif"), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + renderInspector(); + replace( + pendingList, + ...(registry.pending.length === 0 + ? [h("p", { class: "muted" }, "Aucune clé en attente.")] + : registry.pending.map((p) => { + const confirm = h( + "button", + { type: "button", class: "primary", "data-testid": `confirm-${p.operator}`, ...(isAdmin && p.key_fingerprint ? {} : { disabled: "" }) }, + "Confirmer la clé…", + ); + confirm.addEventListener("click", () => void confirmKey(p)); + return h( + "article", + { class: "card" }, + h("h2", {}, `Clé de ${p.operator}`), + h( + "dl", + {}, + h("dt", {}, "Empreinte"), + h("dd", { class: "mono hash" }, p.key_fingerprint ?? "illisible"), + h("dt", {}, "Enregistrée le"), + h("dd", { class: "mono" }, utc(p.registered_at)), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, utc(p.expires_at)), + ), + h("div", { class: "actions" }, confirm), + ); + })), + ); + }; + + const renderInspector = (): void => { + const o = registry.operators[selected]; + if (o === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucun opérateur.")); + return; + } + const roleSelect = h( + "select", + { id: "new-role", "data-testid": "new-role", ...(isAdmin ? {} : { disabled: "" }) }, + ...ROLES.map(([value, label]) => { + const option = h("option", { value }, label); + if (value === o.role) option.selected = true; + return option; + }), + ); + const changeRole = h("button", { type: "button", "data-testid": "change-role", ...(isAdmin ? {} : { disabled: "" }) }, "Changer le rôle…"); + changeRole.addEventListener("click", () => void setRole(o, roleSelect.value as Me["role"])); + replace( + inspector, + h("h2", {}, o.name), + h("label", { for: "new-role" }, "Rôle"), + roleSelect, + h("div", { class: "actions" }, changeRole), + h("h2", {}, "Clés"), + ...o.credentials.map((c) => { + const revoke = h( + "button", + { type: "button", class: "danger", "data-testid": `revoke-key-${o.name}`, ...(isAdmin && c.revoked_at === null ? {} : { disabled: "" }) }, + "Révoquer…", + ); + revoke.addEventListener("click", () => void revokeKey(o, c)); + return h( + "div", + { class: "key" }, + h("p", { class: "mono" }, `${c.label} · ${c.credential_id.slice(0, 16)}…`), + h("p", { class: "muted" }, c.revoked_at ? `révoquée le ${utc(c.revoked_at)}` : `active depuis le ${utc(c.initiated_at)}`), + revoke, + ); + }), + ); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/operators"); + if (reply.status === 200 && reply.body !== null && !isError(reply.body)) registry = reply.body; + else if (isError(reply.body)) notice.textContent = reply.body.message; + selected = Math.min(selected, Math.max(registry.operators.length - 1, 0)); + render(); + }; + + const after = async (result: Record | null, done: string): Promise => { + if (result === null) return; + notice.textContent = + result.status === "AWAITING_QUORUM" ? "Signature enregistrée : un second administrateur doit co-signer (salle de quorum)." : done; + await load(); + }; + + const setRole = async (o: Operator, role: Me["role"]): Promise => { + if (role === o.role) return; + const result = await sign({ + title: `Changement de rôle de ${o.name}`, + summary: [ + ["Opérateur", o.name], + ["Rôle actuel", o.role], + ["Nouveau rôle", role], + ], + action: { action: "set_role", operator: o.name, role }, + route: `/api/v1/operators/${encodeURIComponent(o.name)}/role`, + }); + await after(result, `Rôle de ${o.name} : ${role}.`); + }; + + const revokeKey = async (o: Operator, c: Credential): Promise => { + const reason = await ask("Motif de la révocation de la clé (obligatoire)", "revoke-reason"); + if (reason === null) return; + const result = await sign({ + title: `Révocation d'une clé de ${o.name}`, + summary: [ + ["Opérateur", o.name], + ["Clé", `${c.label} · ${c.credential_id}`], + ["Motif", reason], + ], + action: { action: "revoke_key", credential_id: c.credential_id, reason }, + route: `/api/v1/credentials/${encodeURIComponent(c.credential_id)}/revoke`, + }); + await after(result, `Clé de ${o.name} révoquée.`); + }; + + const confirmKey = async (p: PendingKey): Promise => { + if (p.key_fingerprint === null) return; + const checked = await confirmFingerprint(p); + if (!checked) return; + const result = await sign({ + title: `Confirmation de la clé de ${p.operator}`, + summary: [ + ["Opérateur", p.operator], + ["Empreinte comparée", p.key_fingerprint], + ], + action: { action: "confirm_key", credential_id: p.credential_id, key_fingerprint: p.key_fingerprint }, + route: `/api/v1/credentials/${encodeURIComponent(p.credential_id)}/confirm`, + }); + await after(result, `Clé de ${p.operator} confirmée.`); + }; + + invite.addEventListener("click", () => void inviteOperator()); + const inviteOperator = async (): Promise => { + const who = await askInvite(); + if (who === null) return; + const result = await sign({ + title: `Invitation de ${who.name}`, + summary: [ + ["Opérateur invité", who.name], + ["Rôle", who.role], + ], + action: { action: "invite_operator", name: who.name, role: who.role }, + route: "/api/v1/operators", + }); + if (result === null) return; + const inner = result.result as { invite_token?: string } | null | undefined; + if (result.status === "EXECUTED" && typeof inner?.invite_token === "string") { + await showToken(who.name, inner.invite_token); + } + await after(result, `Invitation de ${who.name} créée.`); + }; + + void load(); + return { element, dispose: () => undefined }; +} + +/// Le jeton d'invitation, affiché une seule fois : il n'est conservé nulle part, +/// ni par la console ni par ca-server (seul son haché l'est). +function showToken(name: string, token: string): Promise { + return new Promise((resolve) => { + const close = h("button", { type: "button", class: "primary", "data-testid": "token-close" }, "J'ai transmis le jeton"); + const dialog = h( + "dialog", + { class: "signature", "data-testid": "token-dialog" }, + h("h2", {}, `Jeton d'invitation de ${name}`), + h("p", {}, "Affiché une seule fois. Transmettez-le à l'invité par un canal distinct ; il lui sert à enregistrer sa clé FIDO2."), + h("pre", { class: "mono", "data-testid": "invite-token" }, token), + h("div", { class: "actions" }, close), + ); + const finish = (): void => { + dialog.close(); + dialog.remove(); + resolve(); + }; + close.addEventListener("click", finish); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +function formDialog(testid: string, title: string, fields: HTMLElement[], valid: () => boolean): Promise { + return new Promise((resolve) => { + 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", {}, title), ...fields, h("div", { class: "actions" }, cancel, next)); + const dialog = h("dialog", { class: "signature", "data-testid": testid }, form); + const finish = (ok: boolean): void => { + dialog.close(); + dialog.remove(); + resolve(ok); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (valid()) finish(true); + }); + cancel.addEventListener("click", () => finish(false)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(false); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +async function ask(title: string, testid: string): Promise { + const input = h("textarea", { rows: "3", maxlength: "1000", required: "", "data-testid": "comment" }); + const ok = await formDialog(testid, title, [input], () => input.value.trim() !== ""); + return ok ? input.value.trim() : null; +} + +async function askInvite(): Promise<{ name: string; role: Me["role"] } | null> { + const name = h("input", { id: "invite-name", required: "", maxlength: "100", "data-testid": "invite-name" }); + const role = h("select", { id: "invite-role", "data-testid": "invite-role" }, ...ROLES.map(([v, l]) => h("option", { value: v }, l))); + const ok = await formDialog( + "invite-dialog", + "Inviter un opérateur", + [h("label", { for: "invite-name" }, "Nom"), name, h("label", { for: "invite-role" }, "Rôle"), role], + () => name.value.trim() !== "", + ); + return ok ? { name: name.value.trim(), role: role.value as Me["role"] } : null; +} + +/// La confirmation n'a de sens que si l'administrateur a comparé l'empreinte +/// avec celle que l'invité lit sur son poste (§10) : il le déclare en cochant. +async function confirmFingerprint(p: PendingKey): Promise { + const box = h("input", { type: "checkbox", id: "compared", required: "", "data-testid": "compared" }); + return formDialog( + "confirm-dialog", + `Confirmer la clé de ${p.operator}`, + [ + h("p", {}, "Comparez cette empreinte, hors bande, avec celle que l'invité a obtenue en enregistrant sa clé :"), + h("p", { class: "mono hash" }, p.key_fingerprint ?? ""), + h("label", { for: "compared" }, box, " J'ai comparé l'empreinte avec l'invité : elle est identique."), + ], + () => box.checked, + ); +} diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts index a76cb8d..eef5850 100644 --- a/bin/ra-console/web/src/shell.ts +++ b/bin/ra-console/web/src/shell.ts @@ -7,6 +7,7 @@ import { call, isError, type ConsoleInfo, type Me } from "./api"; import { banner } from "./banner"; import { h, replace } from "./dom"; import { certificatesView } from "./certificates"; +import { operatorsView } from "./operators"; import { quorumView } from "./quorum"; import { requestsView } from "./requests"; import type { View } from "./view"; @@ -42,6 +43,7 @@ export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogo ["requests", "Demandes RA ", requests, () => requestsView(refresh)], ["certificates", "Certificats", null, () => certificatesView(refresh)], ["quorum", "Quorum ", quorum, () => quorumView(me, refresh)], + ["operators", "Opérateurs", null, () => operatorsView(me)], ]; const buttons = views.map(([id, label, count, make]) => { const button = h("button", { type: "button", class: "nav", "data-testid": `nav-${id}` }, label, count); diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css index 86b2258..fc629bc 100644 --- a/bin/ra-console/web/static/console.css +++ b/bin/ra-console/web/static/console.css @@ -382,3 +382,16 @@ select { max-height: 200px; font-size: 12px; } + +/* --- 6e : opérateurs --- */ +.actions.start { + justify-content: flex-start; + margin-bottom: 12px; +} +.key { + border-top: 1px solid var(--border-subtle); + padding: 8px 0; +} +.key p { + margin: 0 0 4px; +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index b0719a6..866bb9d 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -173,6 +173,14 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). - **Salle de quorum (6c)** : les actions en attente, leur corps figé et leur empreinte, qui a déjà signé ; la co-signature est désactivée pour qui a déjà signé (« le double contrôle requiert un opérateur distinct » — `ca-server` la refuserait de toute façon). +- **Opérateurs (6e)** : `GET /api/v1/operators` (lecture seule : opérateurs, leurs clés, + clés en attente avec leur **empreinte recalculée par `oe_actions::key_fingerprint`**, + la fonction même de `ca-server`). L'écran liste le registre ; un administrateur y + invite (le **jeton s'affiche une seule fois**, à transmettre par un canal distinct), + confirme une clé en attente après avoir **déclaré avoir comparé l'empreinte hors + bande** (§10), révoque une clé (motif obligatoire) et change un rôle (le rôle `admin` + exige un second administrateur, en salle de quorum). Pour les autres rôles, ces + boutons sont désactivés — affichage seulement, `ca-server` juge. - L'explorateur d'audit suit (6d, après #51). ## Gestion du registre des opérateurs