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/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/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/audit.rs b/bin/ra-console/src/audit.rs index 703a4dc..bab082f 100644 --- a/bin/ra-console/src/audit.rs +++ b/bin/ra-console/src/audit.rs @@ -17,6 +17,15 @@ pub const EVENT_LOGIN_SUCCEEDED: &str = "ra.login_succeeded"; pub const EVENT_LOGIN_REFUSED: &str = "ra.login_refused"; pub const EVENT_SESSION_OPENED: &str = "ra.session_opened"; pub const EVENT_SESSION_CLOSED: &str = "ra.session_closed"; +/// Une action signée préparée par `ca-server` à la demande d'un opérateur +/// (docs/WEBUI.md §4, étapes 1 à 3) : l'identifiant de l'action et +/// l'empreinte du corps figé, pour rapprocher ce journal de celui de +/// `ca-server`, qui fait foi. +pub const EVENT_ACTION_CHALLENGE: &str = "ra.action_challenge"; +/// Une assertion d'opérateur relayée pour exécution (docs/WEBUI.md §4, étapes +/// 5 à 7), avec la réponse de `ca-server` : qui a signé selon son registre, +/// et le statut rendu. +pub const EVENT_ACTION_RELAYED: &str = "ra.action_relayed"; /// Même forme que `oe_ca_core::Recorder` / `oe_raflow::Recorder`, dupliquée /// plutôt que partagée (ce sont des traits d'un seul étage, la duplication diff --git a/bin/ra-console/src/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 4d468a5..9ec4649 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::{DefaultBodyLimit, Query, State}; +use axum::extract::{DefaultBodyLimit, Path, Query, State}; use axum::http::header::{COOKIE, SET_COOKIE}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; @@ -14,10 +14,11 @@ 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; 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; @@ -27,6 +28,16 @@ pub struct AppState { pub link: CaLink, pub login: LoginService, pub sessions: Sessions, + 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 { @@ -45,6 +56,12 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/me", get(handle_me)) .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) + .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) + .route("/api/v1/requests/{id}/approve", post(handle_approve)) + .route("/api/v1/requests/{id}/reject", post(handle_reject)) + .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) } @@ -438,6 +455,379 @@ 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. +fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { + matches!( + action, + oe_actions::Action::ApproveRequest { .. } + | oe_actions::Action::RejectRequest { .. } + | oe_actions::Action::RevokeCertificate { .. } + ) +} + +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` +/// 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, + }; + let value: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"), + }; + // 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, + serde_json::json!({ + "operator": who.operator, + "action": r.body.get("body").and_then(|b| b.get("action")), + "action_id": r.body.get("action_id"), + "body_hash": r.body.get("body_hash"), + "status": r.status, + "error": r.body.get("error"), + }), + ); + } + relayed(result) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Signed { + challenge_id: String, + /// La sortie brute de `navigator.credentials.get` : relayée telle quelle, + /// vérifiée par `ca-server` seul (§4, étape 6). + assertion: serde_json::Value, +} + +/// Un identifiant de transaction tel que `ca-server` les émet : borné, sans +/// caractère de contrôle. Il n'est qu'une attente : `ca-server` le compare au +/// corps qu'il a figé. +fn looks_like_a_transaction(s: &str) -> bool { + !s.is_empty() && s.len() <= 128 && s.chars().all(|c| c.is_ascii_graphic()) +} + +async fn handle_approve( + State(state): State>, + Path(id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay_decision(&state, "approve_request", &id, &headers, &body).await +} + +async fn handle_reject( + State(state): State>, + Path(id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay_decision(&state, "reject_request", &id, &headers, &body).await +} + +/// 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, + headers: &HeaderMap, + body: &[u8], + expect: serde_json::Value, +) -> Result { + if !is_json(headers) { + return Err(unsupported_media_type()); + } + 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 + .post( + "/internal/v1/actions", + &serde_json::json!({ + "challenge_id": req.challenge_id, + "assertion": req.assertion, + "expect": expect, + }), + ) + .await; + if let Ok(r) = &result { + state.journal.append( + audit::EVENT_ACTION_RELAYED, + serde_json::json!({ + "session_operator": who.operator, + "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 { + Ok(r) if r.status == 200 => Ok(r), + other => Err(relayed(other)), + } +} + +/// `POST /api/v1/requests/{id}/approve|reject` (docs/WEBUI.md §5) : la décision +/// signée sur une demande d'enrôlement. `decided_by` est l'opérateur dont la clé +/// a signé, lu dans le registre de `ca-server`, pas celui de la session. +async fn relay_decision( + state: &AppState, + action: &str, + transaction_id: &str, + headers: &HeaderMap, + body: &[u8], +) -> Response { + if !looks_like_a_transaction(transaction_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "demande invalide"); + } + let expect = serde_json::json!({ "action": action, "transaction_id": transaction_id }); + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(serde_json::json!({ + "transaction_id": transaction_id, + "state": if action == "approve_request" { "APPROVED" } else { "REJECTED" }, + "decided_by": r.body.get("operator"), + "action_id": r.body.get("action_id"), + })) + .into_response(), + Err(resp) => resp, + } +} + +/// Un numéro de série dans la forme canonique du corps figé : hexadécimal +/// minuscule, sans préfixe, 20 octets au plus (RFC 5280 §4.1.2.2). +fn looks_like_a_serial(s: &str) -> bool { + !s.is_empty() && s.len() <= 40 && s.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) +} + +/// `POST /api/v1/certificates/{serial}/revoke` (docs/WEBUI.md §5, §8, §15 étape +/// 4) : une signature de plus sur la révocation figée. `ca-server` exige, par +/// sa propre politique, deux `ca_operateur` distincts : tant que le seuil n'est +/// pas atteint, la signature est enregistrée et rien n'est révoqué +/// (`AWAITING_QUORUM`) ; la dernière signature exécute (`EXECUTED`). +async fn handle_revoke( + State(state): State>, + Path(serial): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_serial(&serial) { + return error( + StatusCode::BAD_REQUEST, + "bad_request", + "numéro de série invalide", + ); + } + let expect = serde_json::json!({ "action": "revoke_certificate", "serial": serial }); + match relay_assertion(&state, &headers, &body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct QuorumQuery { + state: Option, +} + +/// `GET /api/v1/quorum?state=PENDING` (docs/WEBUI.md §5, §8) : les actions à +/// plusieurs signatures ni exécutées ni expirées, avec qui a déjà signé. En +/// lecture seule sur l'état de `ca-server`, qui fait foi. +async fn handle_quorum( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + if q.state.as_deref().is_some_and(|s| s != "PENDING") { + return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide"); + } + match quorum::pending(&state.pool, time::OffsetDateTime::now_utc()).await { + Ok(list) => Json(list).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "quorum : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + +/// `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"); + serde_json::json!({ + "action_id": body.get("action_id"), + "status": if executed { "EXECUTED" } else { "AWAITING_QUORUM" }, + "signatures": body.get("signatures"), + "required": body.get("required"), + "signed_by": body.get("operator"), + "result": body.get("result"), + }) +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index ba4a1be..28838ae 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -19,6 +19,8 @@ pub mod db_guard; pub mod http; pub mod login; pub mod purge; +pub mod quorum; pub mod 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 3de65fb..b600b85 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -93,18 +93,25 @@ 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. purge::spawn_periodic(pool.clone(), cfg.purge_interval); - let app = http::router(Arc::new(http::AppState { - pool, - link, - login, - sessions, - })); + 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/quorum.rs b/bin/ra-console/src/quorum.rs new file mode 100644 index 0000000..b768d13 --- /dev/null +++ b/bin/ra-console/src/quorum.rs @@ -0,0 +1,93 @@ +//! Salle d'attente des actions à plusieurs signatures (docs/WEBUI.md §8, §15 +//! étape 4b), en lecture seule sur les tables de `ca-server`. +//! +//! Le §8 prévoyait des tables de collecte propres à la console, qui auraient +//! conservé les assertions jusqu'au seuil. Ce n'est pas ce qui est construit : +//! `ca-server` enregistre chaque signature au fil de l'eau (`decision_evidence`) +//! et n'exécute qu'au seuil. La console lit donc l'état qui fait foi, sans en +//! tenir de copie qui pourrait diverger, et ne garde jamais d'assertion. + +use oe_webauthn::Uuid; +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +/// Une action figée par `ca-server`, telle que la route de signature en a +/// besoin pour dire ce qu'elle attend (`expect`). +pub struct Frozen { + pub body: serde_json::Value, + pub executed: bool, +} + +/// Une action en attente de signatures, pour l'affichage (« 1 signature sur +/// 2 »). Le seuil qui fait foi reste celui de la politique de `ca-server`, +/// relu à l'exécution. +#[derive(Serialize)] +pub struct Pending { + pub action_id: Uuid, + pub action: String, + /// Le corps figé, à afficher tel quel à qui va co-signer (WYSIWYS). + pub body: serde_json::Value, + pub body_hash: String, + pub required: i32, + pub signatures: usize, + /// Qui a déjà signé, lu dans le registre de `ca-server`. + pub signed_by: Vec, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +pub async fn frozen(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query("SELECT body, executed_at FROM actions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| Frozen { + body: r.get("body"), + executed: r.get::, _>("executed_at").is_some(), + })) +} + +/// Les actions à plusieurs signatures ni exécutées ni expirées, des plus +/// anciennes aux plus récentes. +pub async fn pending(pool: &PgPool, now: OffsetDateTime) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT a.id, a.body, a.body_hash, a.required_signatures, a.created_at, a.expires_at, + COALESCE(array_agg(o.name ORDER BY e.verified_at) + FILTER (WHERE o.name IS NOT NULL), '{}') AS signed_by + FROM actions a + LEFT JOIN decision_evidence e ON e.action_id = a.id + LEFT JOIN operators o ON o.id = e.operator_id + WHERE a.executed_at IS NULL AND a.expires_at > $1 AND a.required_signatures > 1 + GROUP BY a.id + ORDER BY a.created_at", + ) + .bind(now) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| { + let body: serde_json::Value = r.get("body"); + let signed_by: Vec = r.get("signed_by"); + let hash: Vec = r.get("body_hash"); + Pending { + action_id: r.get("id"), + action: body + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_string(), + body, + body_hash: hash.iter().map(|b| format!("{b:02x}")).collect(), + required: r.get("required_signatures"), + signatures: signed_by.len(), + signed_by, + created_at: r.get("created_at"), + expires_at: r.get("expires_at"), + } + }) + .collect()) +} diff --git a/bin/ra-console/src/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/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/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs new file mode 100644 index 0000000..8b563ae --- /dev/null +++ b/bin/ra-console/tests/action_challenge.rs @@ -0,0 +1,884 @@ +//! Actions signées approve/reject (docs/WEBUI.md §4, §15 étape 3 : 3a +//! préparation, 3b exécution) de bout en bout : un navigateur factice connecté → `ra-console` → le +//! lien mTLS → le **vrai** service d'actions de `ca-server`, sur un vrai +//! PostgreSQL. Ce que le test prouve : le challenge est émis pour l'opérateur +//! de la session et pour personne d'autre, la console ne prépare que les +//! actions de l'étape 3, et c'est `ca-server` qui juge du rôle. +//! +//! DSN dans `OE_CASTORE_TEST_DSN` ; test ignoré si elle n'est pas définie. + +mod common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::{pki, tempdir::Dir, Pki}; +use http_body_util::BodyExt; +use oe_actions::{NewCredential, Registry, Role, Service}; +use oe_castore::{Postgres, RequestState, Store}; +use oe_raflow::{Decider, DeciderOptions, Recorder}; +use oe_webauthn::{trusted_models, TrustedModel, Url, Uuid, Verifier}; +use ra_console::ca_link::CaLink; +use ra_console::http::{router, AppState}; +use ra_console::login::LoginService; +use sqlx::postgres::PgPoolOptions; +use tower::ServiceExt; +use webauthn_authenticator_rs::softtoken::{SoftToken, AAGUID}; +use webauthn_authenticator_rs::WebauthnAuthenticator; + +const HOST: &str = "console.example.com"; +const LOGIN_BEGIN: &str = "/api/v1/webauthn/login/begin"; +const LOGIN_FINISH: &str = "/api/v1/webauthn/login/finish"; +const CHALLENGE: &str = "/api/v1/webauthn/challenge"; + +struct NullJournal; +#[async_trait::async_trait] +impl Recorder for NullJournal { + async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} + +fn origin() -> Url { + Url::parse(&format!("https://{HOST}")).unwrap() +} + +struct Env { + console: axum::Router, + registry: Registry, + store: Arc, + issuer: 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(); + + // 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(); + 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), + ) + .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)) + .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, + issuer, + 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 (gestion du registre : après l'étape 4). + for action in [ + 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; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(err["error"], "action_not_available"); + } + + // Une action inconnue, ou un corps qui n'est pas une action. + for body in [ + serde_json::json!({ "action": "delete_everything" }), + serde_json::json!({ "transaction_id": "tx-1" }), + ] { + let (status, err) = env.challenge(Some(&cookie), body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{err}"); + } + + // Un corps qui ne se déclare pas JSON. + let (status, _, _) = env + .post(CHALLENGE, approve("tx-1"), Some(&cookie), "text/plain") + .await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + + assert_eq!( + env.actions_frozen().await, + 0, + "rien n'a été figé côté ca-server" + ); +} + +/// Le rôle affiché par la console n'est pas une barrière (§3) : c'est +/// `ca-server` qui refuse une approbation à un administrateur, et la console +/// relaie son refus. +#[tokio::test] +async fn ca_server_judges_the_role_not_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let cookie = env.log_in("root").await; + env.pending_request("tx-1").await; + + let (status, err) = env.challenge(Some(&cookie), approve("tx-1")).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(env.actions_frozen().await, 0); + + // Et une demande qui n'existe pas n'est pas figée non plus. + let mut env2 = env!(); + env2.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env2.log_in("alice").await; + let (status, err) = env2.challenge(Some(&cookie), approve("tx-inconnue")).await; + assert!(status.is_client_error(), "{status} {err}"); + assert_eq!(env2.actions_frozen().await, 0); +} + +impl Env { + /// L'opérateur touche sa clé : l'assertion du challenge rendu par la console. + fn sign(&mut self, issued: &serde_json::Value) -> serde_json::Value { + let options: oe_webauthn::RequestChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": issued["webauthn"] })).unwrap(); + serde_json::to_value(self.authn.do_authentication(origin(), options).unwrap()).unwrap() + } + + async fn decide( + &self, + cookie: Option<&str>, + path: &str, + issued: &serde_json::Value, + assertion: &serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let (status, _, body) = self + .post( + path, + serde_json::json!({ "challenge_id": issued["challenge_id"], "assertion": assertion }), + cookie, + "application/json", + ) + .await; + (status, body) + } + + async fn state_of(&self, tx: &str) -> (RequestState, String) { + let r = self.store.request_by_transaction_id(tx).await.unwrap(); + (r.state, r.operator) + } +} + +fn reject(tx: &str) -> serde_json::Value { + serde_json::json!({ "action": "reject_request", "transaction_id": tx, "comment": "sujet non reconnu" }) +} + +#[tokio::test] +async fn an_operator_approves_and_rejects_through_the_console() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + env.pending_request("tx-2").await; + + let (_, issued) = env.challenge(Some(&cookie), approve("tx-1")).await; + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-1/approve", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["transaction_id"], "tx-1"); + assert_eq!(done["state"], "APPROVED"); + // L'identité qui a décidé est celle du registre de ca-server. + assert_eq!(done["decided_by"], "alice"); + assert_eq!( + env.state_of("tx-1").await, + (RequestState::Approved, "alice".to_string()) + ); + + // Rejouer la même assertion ne décide rien de plus. + let (status, again) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-1/approve", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{again}"); + assert_eq!(again["error"], "already_used"); + + let (_, issued) = env.challenge(Some(&cookie), reject("tx-2")).await; + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-2/reject", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["state"], "REJECTED"); + assert_eq!(env.state_of("tx-2").await.0, RequestState::Rejected); +} + +/// Une signature obtenue pour une demande ne décide jamais d'une autre, ni +/// l'inverse de ce qui a été signé : `ca-server` compare la cible de la route +/// au corps figé **avant** de rien consommer, si bien que la même assertion +/// reste utilisable sur la bonne route. +#[tokio::test] +async fn a_signature_only_decides_what_was_signed() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-a").await; + env.pending_request("tx-b").await; + + let (_, issued) = env.challenge(Some(&cookie), approve("tx-a")).await; + let assertion = env.sign(&issued); + + for path in [ + "/api/v1/requests/tx-b/approve", + "/api/v1/requests/tx-a/reject", + ] { + let (status, err) = env.decide(Some(&cookie), path, &issued, &assertion).await; + assert_eq!(status, StatusCode::CONFLICT, "{path} {err}"); + assert_eq!(err["error"], "action_mismatch", "{path}"); + } + assert_eq!(env.state_of("tx-a").await.0, RequestState::Pending); + assert_eq!(env.state_of("tx-b").await.0, RequestState::Pending); + + let (status, done) = env + .decide( + Some(&cookie), + "/api/v1/requests/tx-a/approve", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(env.state_of("tx-a").await.0, RequestState::Approved); + assert_eq!(env.state_of("tx-b").await.0, RequestState::Pending); +} + +#[tokio::test] +async fn the_console_relays_no_decision_it_has_not_validated() { + let mut env = env!(); + env.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + let (_, issued) = env.challenge(Some(&cookie), approve("tx-1")).await; + let assertion = env.sign(&issued); + let path = "/api/v1/requests/tx-1/approve"; + + // Sans session. + let (status, _) = env.decide(None, path, &issued, &assertion).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // Des corps que la console ne relaie pas : identifiant de challenge qui + // n'est pas un UUID, assertion absente, et un corps d'action glissé en plus. + for body in [ + serde_json::json!({ "challenge_id": "pas-un-uuid", "assertion": assertion }), + serde_json::json!({ "challenge_id": issued["challenge_id"] }), + serde_json::json!({ "challenge_id": issued["challenge_id"], "assertion": assertion, "body": approve("tx-1") }), + ] { + let (status, _, err) = env + .post(path, body, Some(&cookie), "application/json") + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{err}"); + } + let (status, _, _) = env + .post(path, serde_json::json!({}), Some(&cookie), "text/plain") + .await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + + // Rien n'a été décidé, et l'assertion reste utilisable. + assert_eq!(env.state_of("tx-1").await.0, RequestState::Pending); + let (status, done) = env.decide(Some(&cookie), path, &issued, &assertion).await; + assert_eq!(status, StatusCode::OK, "{done}"); +} + +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); +} + +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/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/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/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index 6006b91..baf0ace 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -185,6 +185,75 @@ 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, + /// Demande visée par une décision d'enrôlement. + #[serde(default)] + pub transaction_id: Option, + /// Certificat visé par une révocation (hexadécimal minuscule, forme + /// canonique du corps figé). + #[serde(default)] + pub serial: Option, + /// Action visée par une co-signature (double contrôle, §8) : le challenge + /// présenté doit avoir été émis pour elle. + #[serde(default)] + pub action_id: Option, +} + +impl Expect { + 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é {}", + self.action, + 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 { + Action::ApproveRequest { transaction_id, .. } + | 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), + }; + 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 cible visée doit être précisée".to_string(), + )), + (Some(frozen), Some(expected)) => Err(Error::Mismatch(format!( + "cible attendue {expected}, figée {frozen}" + ))), + (None, _) => Ok(()), + } + } } /// Un challenge émis, à présenter à l'opérateur. @@ -618,6 +687,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 +738,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, action_id)?; + } // Une seule tentative par cérémonie : l'état sort de la mémoire quoi // qu'il arrive ensuite. @@ -671,8 +769,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 +994,89 @@ 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), + 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"), Uuid::nil()) + .is_ok()); + assert!(matches!( + 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"), 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"), Uuid::nil()), + 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, Uuid::nil()).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()), + action_id: None, + }; + assert!(by_serial("0a1b").check(&revoke, Uuid::nil()).is_ok()); + assert!(matches!( + by_serial("0a1c").check(&revoke, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + let mixed = Expect { + transaction_id: Some("tx".to_string()), + ..by_serial("0a1b") + }; + assert!(matches!( + mixed.check(&revoke, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Co-signature : le challenge doit avoir été émis pour l'action visée. + let target = Uuid::from_u128(7); + let for_target = Expect { + action_id: Some(target), + ..by_serial("0a1b") + }; + assert!(for_target.check(&revoke, target).is_ok()); + assert!(matches!( + for_target.check(&revoke, Uuid::from_u128(8)), + Err(Error::Mismatch(_)) + )); + } +} diff --git a/crates/oe-castore/sql/ra_console_grants.sql b/crates/oe-castore/sql/ra_console_grants.sql index 07d10f9..db57cd0 100644 --- a/crates/oe-castore/sql/ra_console_grants.sql +++ b/crates/oe-castore/sql/ra_console_grants.sql @@ -23,7 +23,12 @@ GRANT SELECT ON operators, webauthn_credentials, pending_credentials, - decision_evidence + decision_evidence, + -- Les actions figées (corps, empreinte, seuil, échéance) : la salle + -- d'attente des actions à plusieurs signatures (docs/WEBUI.md §8) les lit + -- ici plutôt que d'en tenir une copie. Aucun secret n'y figure : le jeton + -- d'une invitation n'est rendu que dans le résultat de l'exécution. + actions TO openeidas_ra_console; -- Pour les clés étrangères des tables propres à ra-console. diff --git a/crates/oe-castore/tests/operators_schema.rs b/crates/oe-castore/tests/operators_schema.rs index 7c03d4d..de7ea59 100644 --- a/crates/oe-castore/tests/operators_schema.rs +++ b/crates/oe-castore/tests/operators_schema.rs @@ -318,11 +318,10 @@ async fn ra_console_role_cannot_write_ca_tables() { ); } - // Aucune lecture des tables sans droit : hachés de jetons, actions, - // challenges, autorités, CRL. + // Aucune lecture des tables sans droit : hachés de jetons, challenges, + // autorités, CRL. for table in [ "operator_invites", - "actions", "action_challenges", "authorities", "crls", @@ -336,6 +335,8 @@ async fn ra_console_role_cannot_write_ca_tables() { // La lecture, elle, fonctionne : c'est ce dont la console a besoin. for table in [ + // La salle d'attente des actions à plusieurs signatures (§8). + "actions", "enrollment_requests", "certificates", "operators", diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 4ca5ea1..5c11e76 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -53,6 +53,110 @@ 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. +- 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` : + opérateur, action, `action_id`, `body_hash`, statut), rapprochable du journal de + `ca-server`, qui fait foi. + +## Exécution d'une décision signée (approuver, rejeter) + +`POST /api/v1/requests/{id}/approve` ou `/reject`, avec une session ouverte : +`{"challenge_id": "…", "assertion": {…}}`, l'assertion étant la sortie brute de +`navigator.credentials.get` sur les options du challenge. La console relaie à +`ca-server` (`/internal/v1/actions`) l'identifiant du challenge et l'assertion — +**jamais de corps** : `ca-server` exécute celui qu'il a figé (docs/WEBUI.md §4, +étapes 5 à 7). Réponse : `{"transaction_id", "state": "APPROVED" | "REJECTED", +"decided_by", "action_id"}`, où `decided_by` est l'opérateur **dont la clé a signé**, +lu dans le registre de `ca-server`, pas celui de la session. + +- La console joint ce que la route promet (`expect` : l'action et la demande du + chemin). `ca-server` le compare au corps figé **avant** toute vérification ou + consommation, et refuse (`409 action_mismatch`) s'il diffère : une signature obtenue + pour une demande ne décide jamais d'une autre, ni l'inverse de ce qui a été signé, + et l'assertion reste utilisable sur la bonne route. +- Une assertion déjà utilisée est refusée (`409 already_used`) : le rejeu est + impossible par construction. +- Chaque relais est inscrit au journal de la console (`ra.action_relayed` : opérateur + de la session, action, demande, `action_id`, signataire selon `ca-server`, statut). +- Le certificat n'est pas émis à ce moment : comme avec `ca-server ra approve`, il l'est + au prochain appel du demandeur à l'enrôlement. + +## Révocation d'un certificat (première signature) + +`POST /api/v1/certificates/{serial}/revoke`, avec une session ouverte et la même forme +de corps qu'une décision (`{"challenge_id", "assertion"}`), le challenge ayant été +préparé pour `{"action": "revoke_certificate", "serial": "…", "reason": …, +"comment": "…"}`. Le numéro de série est en hexadécimal minuscule, sans préfixe (la +forme canonique du corps figé) ; toute autre forme est refusée avant relais. + +- La révocation exige, par la politique de `ca-server`, **deux `ca_operateur` + distincts** (docs/WEBUI.md §8). La première signature est enregistrée par + `ca-server` et **rien n'est révoqué** : réponse `{"status": "AWAITING_QUORUM", + "signatures": 1, "required": 2, "action_id", "signed_by"}`. La signature suivante + (co-signature) passe par la salle d'attente, ci-dessous. +- La cible est contrôlée par `ca-server` comme pour une décision (`expect` porte le + numéro de série) : une signature ne révoque jamais un autre certificat. +- Un `ra_operateur` ne peut pas préparer de révocation : `ca-server` refuse. + +## Double contrôle : salle d'attente et co-signature + +- `GET /api/v1/quorum?state=PENDING` (session) : les actions à plusieurs signatures ni + exécutées ni expirées — identifiant, type, **corps figé** (à afficher tel quel à qui + va co-signer), empreinte, signatures recueillies et exigées, **qui a déjà signé**. + La console lit l'état qui fait foi, dans la table `actions` et `decision_evidence` + de `ca-server`, en lecture seule ; elle n'en tient aucune copie et ne conserve + jamais d'assertion. +- Co-signer : `POST /api/v1/webauthn/challenge` avec `{"action_id": "…"}` (la console + vérifie que l'action existe, n'est pas exécutée et relève des actions proposées), + puis `POST /api/v1/quorum/{action_id}/sign` avec `{"challenge_id", "assertion"}`. + La console joint à `expect` l'identifiant de l'action et sa cible : une + co-signature ne compte que pour l'action pour laquelle son challenge a été émis. +- `ca-server` n'accepte qu'une signature par opérateur, relit le rôle de chacun et + exécute **une seule fois**, au seuil fixé par sa politique : la dernière signature + rend `{"status": "EXECUTED", "signatures": 2, "required": 2, …}`. + +**Mise à jour d'un déploiement existant** : la salle d'attente exige le droit de +lecture sur `actions`, ajouté au script des droits. Rejouer +`psql -f crates/oe-castore/sql/ra_console_grants.sql` (idempotent) ; sans cela, +`GET /api/v1/quorum` et la co-signature répondent `503`. + +## 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 | @@ -62,6 +166,7 @@ gardé par la console. | `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 | @@ -87,8 +192,8 @@ gardé par la console. ## Ce qui n'existe pas encore -La connexion des opérateurs (login, sessions), la -lecture, les actions signées relayées, la révocation, le workflow d'incident et le -frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. L'image, le chart Helm et le +La 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 renouvelle à la main pour l'instant. 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 (