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..dacbad6 --- /dev/null +++ b/bin/ra-console/examples/e2e_console.rs @@ -0,0 +1,339 @@ +//! 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 store: Arc = + Arc::new(oe_castore::Postgres::open(&dsn).await.unwrap()); + let registry = Registry::connect(&dsn).await.unwrap(); + + let (token, root) = SoftToken::new(true).unwrap(); + 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(); + // alice décide des demandes (étape 6b), bob et carol révoquent à deux + // (étape 6c, double contrôle). + let people = [ + ("alice", Role::RaOperateur), + ("bob", Role::CaOperateur), + ("carol", Role::CaOperateur), + // Registre (6e) : root administre, dave voit son rôle changer et sa clé révoquée. + ("root", Role::Admin), + ("dave", Role::RaOperateur), + ]; + let reg_verifier = verifier(); + // Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que + // la clé des credentials créés ci-dessous se relit (aucun accesseur public). + let token_path = std::env::temp_dir().join(format!("{name}.softtoken")); + let token_file = std::fs::File::create(&token_path).unwrap(); + let mut authn = WebauthnAuthenticator::new(SoftTokenFile::new(token, token_file)); + let mut registered = Vec::new(); + for (who, role) in people { + let id = registry.add_operator(who, role, "e2e", now).await.unwrap(); + let (options, state) = reg_verifier.start_registration(id, who, None).unwrap(); + let reg = authn.do_registration(origin.clone(), options).unwrap(); + let key = reg_verifier.finish_registration(®, &state).unwrap(); + registry + .add_credential( + NewCredential { + operator_id: id, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "e2e", + initiated_by: "e2e", + confirmed_by: Some("e2e"), + }, + now, + ) + .await + .unwrap(); + registered.push((who, role, id, reg.raw_id.as_ref().to_vec())); + } + drop(authn); + + // Les clés privées du SoftToken (SEC1), converties en PKCS#8 pour le navigateur. + let soft: serde_cbor_2::Value = + serde_cbor_2::from_slice(&std::fs::read(&token_path).unwrap()).unwrap(); + let _ = std::fs::remove_file(&token_path); + let b64 = base64::engine::general_purpose::STANDARD; + let mut operators = serde_json::Map::new(); + for (who, role, id, credential_id) in ®istered { + let (sec1, counter) = soft_key(&soft, credential_id); + let ec = openssl::ec::EcKey::private_key_from_der(&sec1).unwrap(); + let pkcs8 = openssl::pkey::PKey::from_ec_key(ec) + .unwrap() + .private_key_to_pkcs8() + .unwrap(); + operators.insert( + who.to_string(), + serde_json::json!({ + "role": role.as_str(), + "credential": { + "credentialId": b64.encode(credential_id), + "isResidentCredential": false, + "rpId": "localhost", + "privateKey": b64.encode(pkcs8), + "userHandle": b64.encode(id.as_bytes()), + "signCount": counter, + }, + }), + ); + } + + // Une vraie CA sur la même base, et deux certificats émis à révoquer (6c). + let issuing = Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)); + let h = oe_ca_core::ceremony::run_ceremony(oe_ca_core::ceremony::CeremonyOptions { + root_signer: Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)), + issuing_signer: issuing.clone(), + root_cn: "E2E Root CA".into(), + issuing_cn: "E2E Issuing CA".into(), + organization: "Open eIDAS e2e".into(), + country: "FR".into(), + root_validity: time::Duration::days(3650), + issuing_validity: time::Duration::days(3650), + root_token_label: "r".into(), + root_key_label: "r".into(), + issuing_token_label: "i".into(), + issuing_key_label: "i".into(), + store: store.clone(), + operator: "e2e".into(), + recorder: None, + }) + .await + .unwrap(); + let issuer = Arc::new( + oe_ca_core::Issuer::new(oe_ca_core::Options { + signer: issuing, + certificate: h.issuing, + chain: vec![], + store: store.clone(), + public_url: "https://ca.example.test".into(), + ocsp_url: None, + crl_validity: time::Duration::hours(24), + crl_grace: time::Duration::hours(1), + recorder: None, + }) + .unwrap(), + ); + let mut to_revoke = Vec::new(); + for i in 1..=2 { + let key = oe_hsm::testing::SoftwareToken::generate(2048); + let cert = issuer + .issue( + &oe_hsm::SigningToken::public_key_der(&key).unwrap(), + &format!("tsu-rev-{i}.example.test"), + &oe_ca_core::profile::tsa_signer(), + &format!("tx-rev-{i}"), + ) + .await + .unwrap(); + to_revoke.push( + oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(), + ); + } + + let pending: Vec = (1..=8).map(|i| format!("tx-e2e-{i}")).collect(); + let alice = operators["alice"].clone(); + let out = serde_json::json!({ + "origin": origin.as_str().trim_end_matches('/'), + "pending": pending, + "certificates": to_revoke, + "operator": "alice", + "role": "ra_operateur", + "credential": alice["credential"], + "operators": operators, + }); + 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(); + + // Des demandes d'enrôlement en attente, pour les écrans de décision (6b). + for (i, tx) in pending.iter().enumerate() { + store + .create_request(oe_castore::Request { + transaction_id: tx.clone(), + csr_fingerprint: format!("empreinte-{tx}"), + csr_der: vec![0x30, 0x00], + profile: "tsa_signer".to_string(), + subject_cn: format!("tsu-{}.example.test", i + 1), + state: oe_castore::RequestState::Pending, + created_at: now, + decided_at: None, + operator: String::new(), + comment: String::new(), + issued_at: None, + certificate_serial: None, + }) + .await + .unwrap(); + } + + // Le vrai service d'actions de ca-server, derrière son routeur interne et le + // lien mTLS : les décisions signées dans le navigateur y sont vérifiées. + let service = Arc::new( + oe_actions::Service::new( + registry.clone(), + verifier(), + store.clone(), + oe_raflow::Decider::new(oe_raflow::DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + ) + .with_revoker(Arc::new(ca_server::revoker::IssuerRevoker(issuer))), + ); + let pki = common::pki().await; + let internal = pki + .serve_router(ca_server::internal::router(service, 64 * 1024)) + .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, internal)).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() +} + +struct NullJournal; + +#[async_trait::async_trait] +impl oe_raflow::Recorder for NullJournal { + async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} diff --git a/bin/ra-console/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/certificates.rs b/bin/ra-console/src/certificates.rs new file mode 100644 index 0000000..f9b611a --- /dev/null +++ b/bin/ra-console/src/certificates.rs @@ -0,0 +1,62 @@ +//! `GET /api/v1/certificates` (docs/WEBUI.md §5, §15 étape 6c) : les +//! certificats émis, en lecture seule sur la table de `ca-server`, pour que +//! l'opérateur choisisse ce qu'il révoque. Les certificats réservés (numéro +//! tiré, émission non aboutie) n'y figurent pas. + +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +/// Les états qu'un filtre peut demander. +pub const STATUSES: &[&str] = &["issued", "revoked"]; + +#[derive(Debug, Serialize)] +pub struct IssuedCertificate { + /// Hexadécimal minuscule : la forme canonique qu'attend la révocation. + pub serial_hex: String, + pub profile: String, + pub subject_dn: String, + #[serde(with = "time::serde::rfc3339::option")] + pub not_before: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub not_after: Option, + pub status: String, + #[serde(with = "time::serde::rfc3339::option")] + pub revoked_at: Option, + pub revocation_reason: i32, + pub request_transaction_id: String, +} + +/// Les certificats émis ou révoqués, les plus récents d'abord ; filtrés par +/// état si demandé. `status` n'est pas revalidé ici : à l'appelant de le +/// confronter à [`STATUSES`]. +pub async fn list( + pool: &PgPool, + status: Option<&str>, +) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT serial_hex, profile, subject_dn, not_before, not_after, status, revoked_at, + revocation_reason, request_transaction_id + FROM certificates + WHERE status <> 'reserved' AND ($1::text IS NULL OR status = $1) + ORDER BY not_before DESC NULLS LAST, serial_hex + LIMIT 1000", + ) + .bind(status) + .fetch_all(pool) + .await?; + Ok(rows + .iter() + .map(|r| IssuedCertificate { + serial_hex: r.get("serial_hex"), + profile: r.get("profile"), + subject_dn: r.get("subject_dn"), + not_before: r.get("not_before"), + not_after: r.get("not_after"), + status: r.get("status"), + revoked_at: r.get("revoked_at"), + revocation_reason: r.get("revocation_reason"), + request_transaction_id: r.get("request_transaction_id"), + }) + .collect()) +} diff --git a/bin/ra-console/src/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..4902d3e 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::{certificates, operators, 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,15 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/me", get(handle_me)) .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) + .route("/api/v1/certificates", get(handle_certificates)) + .route("/api/v1/operators", get(handle_operators)) + .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) + .route("/api/v1/requests/{id}/approve", post(handle_approve)) + .route("/api/v1/requests/{id}/reject", post(handle_reject)) + .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) + .route("/api/v1/quorum", get(handle_quorum)) + .route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign)) + .merge(crate::registry_routes::routes()) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -84,7 +104,7 @@ async fn handle_health(State(state): State>) -> Response { } /// `{"error": "", "message": "..."}` (docs/WEBUI.md §5), sans trace interne. -fn error(status: StatusCode, code: &str, message: &str) -> Response { +pub(crate) fn error(status: StatusCode, code: &str, message: &str) -> Response { ( status, Json(serde_json::json!({ "error": code, "message": message })), @@ -242,7 +262,7 @@ struct LoginFinish { /// Un nom d'opérateur : ce qu'un humain saisit, pas un identifiant technique. /// Une longueur bornée suffit à écarter un corps abusif avant toute requête ; /// le reste (existe ou non) ne se voit jamais dans la réponse (§16). -fn looks_like_a_name(s: &str) -> bool { +pub(crate) fn looks_like_a_name(s: &str) -> bool { !s.is_empty() && s.chars().count() <= 256 } @@ -438,6 +458,447 @@ async fn handle_requests( } } +/// Les actions que la console relaie (docs/WEBUI.md §5, §15 étapes 3 et 4) : +/// décider d'une demande d'enrôlement, révoquer un certificat, et gérer le +/// registre des opérateurs (inviter, confirmer ou révoquer une clé, changer un +/// rôle). L'énumération d'`oe_actions` est fermée : il n'en existe pas d'autre +/// aujourd'hui ; une action ajoutée plus tard n'est pas relayée tant qu'elle +/// n'est pas nommée ici. +fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { + matches!( + action, + oe_actions::Action::ApproveRequest { .. } + | oe_actions::Action::RejectRequest { .. } + | oe_actions::Action::RevokeCertificate { .. } + | oe_actions::Action::InviteOperator { .. } + | oe_actions::Action::ConfirmKey { .. } + | oe_actions::Action::RevokeKey { .. } + | oe_actions::Action::SetRole { .. } + ) +} + +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)] +pub(crate) 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); + } + oe_actions::Action::ConfirmKey { credential_id, .. } + | oe_actions::Action::RevokeKey { credential_id, .. } => { + expect["credential_id"] = serde_json::json!(credential_id); + } + oe_actions::Action::SetRole { operator, .. } => { + expect["operator"] = serde_json::json!(operator); + } + oe_actions::Action::InviteOperator { .. } => {} + } + match relay_assertion(&state, &headers, &body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + 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. +pub(crate) fn quorum_status(body: &serde_json::Value) -> serde_json::Value { + let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); + serde_json::json!({ + "action_id": body.get("action_id"), + "status": if executed { "EXECUTED" } else { "AWAITING_QUORUM" }, + "signatures": body.get("signatures"), + "required": body.get("required"), + "signed_by": body.get("operator"), + "result": body.get("result"), + }) +} + +/// `GET /api/v1/operators` : le registre en lecture seule (docs/WEBUI.md §10), +/// pour toute session authentifiée ; les écritures restent des actions signées +/// que `ca-server` juge. +async fn handle_operators(State(state): State>, headers: HeaderMap) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + match operators::list(&state.pool, time::OffsetDateTime::now_utc()).await { + Ok(registry) => Json(registry).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "operators : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CertificatesQuery { + status: Option, +} + +/// `GET /api/v1/certificates?status=issued` : les certificats émis, en lecture +/// seule (docs/WEBUI.md §5, §15 étape 6c). Comme pour les demandes, toute +/// session authentifiée peut lire ; la révocation reste jugée par `ca-server`. +async fn handle_certificates( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + if let Some(s) = &q.status { + if !certificates::STATUSES.contains(&s.as_str()) { + return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide"); + } + } + match certificates::list(&state.pool, q.status.as_deref()).await { + Ok(list) => Json(list).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "certificates : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index ba4a1be..3bae97d 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -14,11 +14,16 @@ pub mod audit; pub mod ca_link; +pub mod certificates; pub mod config; pub mod db_guard; pub mod http; pub mod login; +pub mod operators; pub mod purge; +pub mod quorum; +pub mod registry_routes; 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/operators.rs b/bin/ra-console/src/operators.rs new file mode 100644 index 0000000..fd5d301 --- /dev/null +++ b/bin/ra-console/src/operators.rs @@ -0,0 +1,119 @@ +//! `GET /api/v1/operators` (docs/WEBUI.md §10, §15 étape 6e) : le registre des +//! opérateurs, leurs clés et les clés en attente de confirmation, en lecture +//! seule sur les tables de `ca-server`. Toute écriture passe par une action +//! signée (`registry_routes`). +//! +//! L'empreinte d'une clé en attente est recalculée ici avec la fonction même +//! de `ca-server` (`oe_actions::key_fingerprint`) : l'administrateur la compare +//! hors bande à celle que l'invité a reçue à l'enregistrement (§10), puis la +//! signe ; `ca-server` la recompare à la clé stockée. Une console qui +//! afficherait une autre empreinte ferait échouer la confirmation, pas passer +//! une autre clé. + +use oe_webauthn::Uuid; +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +#[derive(Debug, Serialize)] +pub struct Credential { + pub credential_id: String, + pub label: String, + #[serde(with = "time::serde::rfc3339")] + pub initiated_at: OffsetDateTime, + pub confirmed_by: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_used_at: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub revoked_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct Operator { + pub name: String, + pub role: String, + pub disabled: bool, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + pub credentials: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PendingKey { + pub credential_id: String, + pub operator: String, + /// `None` si la clé stockée ne se relit pas : rien à confirmer alors. + pub key_fingerprint: Option, + #[serde(with = "time::serde::rfc3339")] + pub registered_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +#[derive(Debug, Serialize)] +pub struct Registry { + pub operators: Vec, + pub pending: Vec, +} + +pub async fn list(pool: &PgPool, now: OffsetDateTime) -> Result { + let ops = + sqlx::query("SELECT id, name, role, created_at, disabled_at FROM operators ORDER BY name") + .fetch_all(pool) + .await?; + let creds = sqlx::query( + "SELECT credential_id, operator_id, label, initiated_at, confirmed_by, last_used_at, revoked_at + FROM webauthn_credentials ORDER BY initiated_at", + ) + .fetch_all(pool) + .await?; + let operators = ops + .iter() + .map(|o| { + let id: Uuid = o.get("id"); + Operator { + name: o.get("name"), + role: o.get("role"), + disabled: o.get::, _>("disabled_at").is_some(), + created_at: o.get("created_at"), + credentials: creds + .iter() + .filter(|c| c.get::("operator_id") == id) + .map(|c| Credential { + credential_id: c.get("credential_id"), + label: c.get("label"), + initiated_at: c.get("initiated_at"), + confirmed_by: c.get("confirmed_by"), + last_used_at: c.get("last_used_at"), + revoked_at: c.get("revoked_at"), + }) + .collect(), + } + }) + .collect(); + + let pending = sqlx::query( + "SELECT p.credential_id, o.name, p.passkey, p.registered_at, p.expires_at + FROM pending_credentials p JOIN operators o ON o.id = p.operator_id + WHERE p.expires_at > $1 + ORDER BY p.registered_at", + ) + .bind(now) + .fetch_all(pool) + .await? + .iter() + .map(|p| { + let passkey: serde_json::Value = p.get("passkey"); + PendingKey { + credential_id: p.get("credential_id"), + operator: p.get("name"), + key_fingerprint: serde_json::from_value::(passkey) + .ok() + .and_then(|k| oe_actions::key_fingerprint(&k).ok()), + registered_at: p.get("registered_at"), + expires_at: p.get("expires_at"), + } + }) + .collect(); + Ok(Registry { operators, pending }) +} diff --git a/bin/ra-console/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/registry_routes.rs b/bin/ra-console/src/registry_routes.rs new file mode 100644 index 0000000..51dcaab --- /dev/null +++ b/bin/ra-console/src/registry_routes.rs @@ -0,0 +1,138 @@ +//! Gestion du registre des opérateurs depuis la console (docs/WEBUI.md §5, +//! §10) : inviter un opérateur, confirmer ou révoquer une clé, changer un rôle. +//! Même schéma que les décisions et la révocation (§4) : le challenge est +//! préparé par `POST /api/v1/webauthn/challenge` avec l'action voulue, puis +//! l'assertion est relayée ici, **sans corps** ; `ca-server` exécute celui +//! qu'il a figé, après avoir comparé la cible de la route (`expect`) au corps +//! figé. Seul un `admin` signe ces actions, et deux pour créer un +//! administrateur ou changer le rôle de l'un d'eux : c'est la politique de +//! `ca-server`, jamais une décision de la console. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; + +use crate::http::{error, looks_like_a_name, quorum_status, relay_assertion, AppState}; + +pub(crate) fn routes() -> Router> { + Router::new() + .route("/api/v1/operators", post(handle_invite)) + .route( + "/api/v1/credentials/{credential_id}/confirm", + post(handle_confirm_key), + ) + .route( + "/api/v1/credentials/{credential_id}/revoke", + post(handle_revoke_key), + ) + .route("/api/v1/operators/{name}/role", post(handle_set_role)) +} + +/// Un identifiant de clé WebAuthn tel que le registre le range : base64url, +/// borné. Il n'est qu'une attente : `ca-server` le compare au corps figé. +fn looks_like_a_credential_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 1024 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +async fn relay( + state: &AppState, + headers: &HeaderMap, + body: &[u8], + expect: serde_json::Value, +) -> Response { + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// `POST /api/v1/operators` : exécute l'invitation figée +/// (`{"action": "invite_operator", "name", "role"}`). Le jeton d'invitation +/// n'existe que dans `result.invite_token` de la réponse d'exécution : ni +/// `ca-server` ni la console ne le journalisent ni ne le conservent. Une +/// invitation n'a pas de cible dans la route : seul le type est contrôlé. +async fn handle_invite( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "invite_operator" }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/confirm` : active une clé en +/// attente. L'empreinte, transmise hors bande par l'invité (§10), fait partie +/// du corps signé ; `ca-server` la recompare à la clé en attente. +async fn handle_confirm_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "confirm_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/revoke` (perte de clé, départ, +/// §14). `ca-server` refuse de révoquer la dernière clé d'administrateur active. +async fn handle_revoke_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "revoke_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/operators/{name}/role` : l'opérateur est désigné par son +/// **nom**, comme dans le corps signé (`set_role`), pas par un identifiant +/// technique. Élever un opérateur au rôle `admin`, ou changer celui d'un +/// administrateur, exige deux administrateurs : la première signature rend +/// `AWAITING_QUORUM`, la seconde passe par `/api/v1/quorum/{id}/sign`. +async fn handle_set_role( + State(state): State>, + Path(name): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_name(&name) { + return error(StatusCode::BAD_REQUEST, "bad_request", "opérateur invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "set_role", "operator": name }), + ) + .await +} diff --git a/bin/ra-console/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..3da0c56 --- /dev/null +++ b/bin/ra-console/tests/action_challenge.rs @@ -0,0 +1,1250 @@ +//! 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() +} + +/// Le journal de la console, relu par les tests : un jeton d'invitation ne +/// doit jamais y figurer. +#[derive(Default)] +struct ConsoleJournal(std::sync::Mutex>); + +impl ra_console::audit::Recorder for ConsoleJournal { + fn append(&self, event: &str, data: serde_json::Value) { + self.0.lock().unwrap().push(format!("{event} {data}")); + } +} + +struct Env { + console: axum::Router, + journal: Arc, + registry: Registry, + store: Arc, + issuer: Arc, + 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 journal = Arc::new(ConsoleJournal::default()); + 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: journal.clone(), + })); + + Some(Env { + console, + journal, + 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}"); + } + + // La gestion du registre est relayée, mais réservée aux administrateurs : + // c'est ca-server qui refuse à un ca_operateur, sans rien figer. + for action in [ + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "auditeur" }), + serde_json::json!({ "action": "set_role", "operator": "alice", "role": "auditeur" }), + ] { + let (status, err) = env.challenge(Some(&cookie), action).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(err["error"], "denied"); + } + + // 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}"); + } +} + +/// `GET /api/v1/certificates` (étape 6c) : les certificats émis, lus dans la +/// table de `ca-server`, avec leur numéro de série sous la forme canonique +/// qu'attend la révocation ; filtre par état ; rien sans session. +#[tokio::test] +async fn the_console_lists_issued_certificates() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let alice = env.log_in("alice").await; + let bob = env.log_in("bob").await; + let serial = env.certificate("tx-list").await; + + let (status, issued) = env.get("/api/v1/certificates?status=issued", &alice).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let found = issued + .as_array() + .unwrap() + .iter() + .find(|c| c["serial_hex"] == serial.as_str()) + .unwrap_or_else(|| panic!("{serial} absent de {issued}")); + assert_eq!(found["profile"], "tsa_signer"); + assert_eq!(found["request_transaction_id"], "tx-list"); + + // Révoqué par deux opérateurs : il change de liste. + let action = env.first_signature(&alice, &serial).await; + let (_, issued_c) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action })) + .await; + let assertion = env.sign(&issued_c); + env.decide( + Some(&bob), + &format!("/api/v1/quorum/{action}/sign"), + &issued_c, + &assertion, + ) + .await; + let (_, revoked) = env.get("/api/v1/certificates?status=revoked", &alice).await; + let found = revoked + .as_array() + .unwrap() + .iter() + .find(|c| c["serial_hex"] == serial.as_str()) + .unwrap_or_else(|| panic!("{serial} absent de {revoked}")); + assert_eq!(found["revocation_reason"], 1); + let (_, issued) = env.get("/api/v1/certificates?status=issued", &alice).await; + assert!(issued + .as_array() + .unwrap() + .iter() + .all(|c| c["serial_hex"] != serial.as_str())); + + let (status, _) = env + .get("/api/v1/certificates?status=reserved", &alice) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, _) = env + .get("/api/v1/certificates", "session=n-importe-quoi") + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +// --- Gestion du registre depuis la console (invitation, clés, rôles) --- + +impl Env { + /// Prépare `action`, la signe, et la relaie sur `path` : rend la réponse. + async fn sign_and_send( + &mut self, + cookie: &str, + action: serde_json::Value, + path: &str, + ) -> (StatusCode, serde_json::Value) { + let (status, issued) = self.challenge(Some(cookie), action).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = self.sign(&issued); + self.decide(Some(cookie), path, &issued, &assertion).await + } + + async fn role_of(&self, name: &str) -> String { + sqlx::query_scalar("SELECT role FROM operators WHERE name = $1") + .bind(name) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + async fn key_revoked(&self, credential_id: &str) -> bool { + sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM webauthn_credentials WHERE credential_id = $1", + ) + .bind(credential_id) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + /// Une seconde clé pour un opérateur existant, posée dans le registre. + async fn second_key(&mut self, operator: Uuid, name: &str) -> String { + let before = self.credential_ids(operator).await; + let now = time::OffsetDateTime::now_utc(); + let (options, state) = self + .verifier + .start_registration(operator, name, None) + .unwrap(); + let reg = self.authn.do_registration(origin(), options).unwrap(); + let key = self.verifier.finish_registration(®, &state).unwrap(); + self.registry + .add_credential( + NewCredential { + operator_id: operator, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "secours", + initiated_by: "test", + confirmed_by: Some("test"), + }, + now, + ) + .await + .unwrap(); + self.credential_ids(operator) + .await + .into_iter() + .find(|k| !before.contains(k)) + .unwrap() + } +} + +/// Un administrateur invite un opérateur par la console : le jeton n'est rendu +/// qu'une fois, dans la réponse, et ne figure dans aucun journal de la console. +/// L'invité enregistre sa clé par le relais existant ; l'administrateur la +/// confirme en signant son empreinte, transmise hors bande (§10). +#[tokio::test] +async fn an_admin_invites_and_confirms_an_operator_through_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let admin = env.log_in("root").await; + + let invite = + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "ra_operateur" }); + let (status, done) = env.sign_and_send(&admin, invite, "/api/v1/operators").await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + let token = done["result"]["invite_token"].as_str().unwrap().to_string(); + assert!(token.len() > 30); + assert_eq!(env.role_of("eve").await, "ra_operateur"); + let journal = env.journal.0.lock().unwrap().join("\n"); + assert!(journal.contains("ra.action_relayed"), "{journal}"); + assert!( + !journal.contains(&token), + "le jeton est journalisé : {journal}" + ); + + // L'invitée enregistre sa clé : elle attend la confirmation d'un tiers. + let (_, _, begun) = env + .post( + "/api/v1/webauthn/register/begin", + serde_json::json!({ "token": token }), + None, + "application/json", + ) + .await; + let options: oe_webauthn::CreationChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": begun["webauthn"] })).unwrap(); + let credential = env.authn.do_registration(origin(), options).unwrap(); + let (status, _, pending) = env + .post( + "/api/v1/webauthn/register/finish", + serde_json::json!({ "ceremony_id": begun["ceremony_id"], "credential": credential }), + None, + "application/json", + ) + .await; + assert_eq!(status, StatusCode::OK, "{pending}"); + assert_eq!(pending["status"], "pending_confirmation", "{pending}"); + let credential_id = pending["credential_id"].as_str().unwrap().to_string(); + + // La console montre la clé en attente, avec l'empreinte même que ca-server a + // remise à l'invitée : c'est elle que l'administrateur compare hors bande. + let (status, registry) = env.get("/api/v1/operators", &admin).await; + assert_eq!(status, StatusCode::OK, "{registry}"); + let shown = registry["pending"] + .as_array() + .unwrap() + .iter() + .find(|p| p["credential_id"] == credential_id.as_str()) + .unwrap_or_else(|| panic!("clé en attente absente : {registry}")); + assert_eq!(shown["operator"], "eve"); + assert_eq!(shown["key_fingerprint"], pending["key_fingerprint"]); + + let confirm = serde_json::json!({ + "action": "confirm_key", + "credential_id": credential_id, + "key_fingerprint": pending["key_fingerprint"], + }); + let (status, done) = env + .sign_and_send( + &admin, + confirm, + &format!("/api/v1/credentials/{credential_id}/confirm"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + // La clé est active : l'invitée peut se connecter. + let eve = env.log_in("eve").await; + assert!(eve.starts_with("session=")); + let (_, registry) = env.get("/api/v1/operators", &admin).await; + assert_eq!(registry["pending"], serde_json::json!([]), "{registry}"); + let eve_entry = registry["operators"] + .as_array() + .unwrap() + .iter() + .find(|o| o["name"] == "eve") + .unwrap(); + assert_eq!(eve_entry["credentials"].as_array().unwrap().len(), 1); + let (status, _) = env.get("/api/v1/operators", "session=n-importe-quoi").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +/// Révocation d'une clé : la cible de la route est contrôlée par ca-server. +/// Les deux clés appartiennent au même opérateur et l'action est la même : +/// seul l'identifiant de la clé distingue, c'est bien lui qui est comparé. +#[tokio::test] +async fn a_key_revocation_only_revokes_the_signed_key() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let carol = env.operator_with_key("carol", Role::RaOperateur).await; + let k1 = env.credential_ids(carol).await.remove(0); + let k2 = env.second_key(carol, "carol").await; + assert_ne!(k1, k2); + let admin = env.log_in("root").await; + + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "revoke_key", "credential_id": k1, "reason": "perdue" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k2}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + assert!(!env.key_revoked(&k1).await && !env.key_revoked(&k2).await); + + let (status, done) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k1}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED"); + assert!(env.key_revoked(&k1).await); + assert!(!env.key_revoked(&k2).await); + + // Un identifiant de clé hors de la forme base64url n'est pas relayé. + let (status, _) = env + .decide( + Some(&admin), + "/api/v1/credentials/cl%C3%A9%20invalide/revoke", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +/// Changement de rôle : la cible (l'opérateur, par son nom) est contrôlée ; +/// élever au rôle admin exige deux administrateurs (co-signature par la salle +/// d'attente). +#[tokio::test] +async fn role_changes_target_the_signed_operator_and_admin_needs_two() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + env.operator_with_key("root2", Role::Admin).await; + env.operator_with_key("carol", Role::RaOperateur).await; + env.operator_with_key("dave", Role::RaOperateur).await; + let admin = env.log_in("root").await; + + // Même action, même rôle : seul l'opérateur visé distingue. + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "set_role", "operator": "carol", "role": "auditeur" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + "/api/v1/operators/dave/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + let (status, done) = env + .decide( + Some(&admin), + "/api/v1/operators/carol/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(env.role_of("carol").await, "auditeur"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + + // Élever dave au rôle admin : une signature ne suffit pas. + let (status, done) = env + .sign_and_send( + &admin, + serde_json::json!({ "action": "set_role", "operator": "dave", "role": "admin" }), + "/api/v1/operators/dave/role", + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "AWAITING_QUORUM", "{done}"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + let action_id = done["action_id"].as_str().unwrap().to_string(); + + let second = env.log_in("root2").await; + let (status, issued) = env + .challenge(Some(&second), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&second), + &format!("/api/v1/quorum/{action_id}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + assert_eq!(env.role_of("dave").await, "admin"); +} diff --git a/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..a89cb83 --- /dev/null +++ b/bin/ra-console/web/e2e/console.spec.ts @@ -0,0 +1,50 @@ +// 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 } from "@playwright/test"; +import { collectErrors, logIn, withOperatorKey } from "./helpers"; + +test("chaque réponse porte les en-têtes de sécurité, sans script en ligne", async ({ request }) => { + for (const path of ["/", "/assets/console.js", "/api/v1/console", "/api/v1/me"]) { + 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(/^\(\d+\)$/); + 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/helpers.ts b/bin/ra-console/web/e2e/helpers.ts new file mode 100644 index 0000000..ac1b92e --- /dev/null +++ b/bin/ra-console/web/e2e/helpers.ts @@ -0,0 +1,65 @@ +// Aides communes aux parcours Playwright : la fixture écrite par +// examples/e2e_console.rs, la clé de l'opérateur confiée à l'authentificateur +// WebAuthn virtuel, la connexion. + +import { expect, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fixturePath } from "../playwright.config"; + +type Credential = Record & { signCount: number }; + +export interface Fixture { + operator: string; + pending: string[]; + certificates: string[]; + credential: Credential; + operators: Record; +} + +export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +// Chaque test recrée un authentificateur : son compteur doit dépasser ceux +// déjà vus par la console et par ca-server, sans quoi ils détectent (à juste +// titre) un clone. Module partagé : le compteur croît d'un fichier à l'autre. +let signCountBase = 1000; + +export async function withOperatorKey(page: Page, who?: string): Promise { + const credential = who === undefined ? fixture().credential : fixture().operators[who]!.credential; + const cdp = await page.context().newCDPSession(page); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + 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: { ...credential, signCount: credential.signCount + signCountBase }, + } as never); +} + +/// Exceptions de la page et violations de CSP : aucune n'est admise. Les +/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. +export function collectErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (m) => { + if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); + }); + page.on("pageerror", (e) => errors.push(e.message)); + return errors; +} + +export async function logIn(page: Page, who: string = fixture().operator): Promise { + await page.getByTestId("login-name").fill(who); + await page.getByTestId("login-submit").click(); + await expect(page.getByTestId("operator")).toHaveText(who); +} diff --git a/bin/ra-console/web/e2e/operators.spec.ts b/bin/ra-console/web/e2e/operators.spec.ts new file mode 100644 index 0000000..653c38f --- /dev/null +++ b/bin/ra-console/web/e2e/operators.spec.ts @@ -0,0 +1,68 @@ +// Registre des opérateurs dans le navigateur (docs/WEBUI.md §10, §15 étape +// 6e) : invitation (jeton affiché une seule fois), changement de rôle, +// révocation de clé — chaque écriture signée et exécutée par ca-server. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, logIn, withOperatorKey } from "./helpers"; + +interface Registry { + operators: { name: string; role: string; credentials: { revoked_at: string | null }[] }[]; +} + +async function registry(page: Page): Promise { + return (await (await page.request.get("/api/v1/operators")).json()) as Registry; +} + +test("un administrateur invite, change un rôle et révoque une clé", async ({ page }) => { + const errors = collectErrors(page); + await withOperatorKey(page, "root"); + await page.goto("/"); + await logIn(page, "root"); + await page.getByTestId("nav-operators").click(); + + // Invitation : le jeton n'est montré qu'une fois. + await page.getByTestId("invite").click(); + await page.getByTestId("invite-name").fill("frank"); + await page.getByTestId("invite-role").selectOption("ra_operateur"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "invite_operator"`); + await page.getByTestId("sign").click(); + const token = page.getByTestId("invite-token"); + await expect(token).toHaveText(/^\S{30,}$/); + await page.getByTestId("token-close").click(); + await expect(page.getByTestId("token-dialog")).toHaveCount(0); + await expect(page.getByTestId("operator-frank")).toBeVisible(); + + // Rôle de dave : auditeur (pas de double contrôle hors rôle admin). + await page.getByTestId("operator-dave").click(); + await page.getByTestId("new-role").selectOption("auditeur"); + await page.getByTestId("change-role").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"operator": "dave"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("operators-status")).toContainText("auditeur"); + expect((await registry(page)).operators.find((o) => o.name === "dave")?.role).toBe("auditeur"); + + // Révocation de la clé de dave, motif obligatoire. + await page.getByTestId("operator-dave").click(); + await page.getByTestId("revoke-key-dave").click(); + await page.getByTestId("comment").fill("départ de l'association"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "revoke_key"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("operators-status")).toContainText("révoquée"); + const dave = (await registry(page)).operators.find((o) => o.name === "dave"); + expect(dave?.credentials.every((c) => c.revoked_at !== null)).toBe(true); + expect(errors).toEqual([]); +}); + +test("un non-administrateur consulte le registre sans pouvoir l'écrire", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await page.getByTestId("nav-operators").click(); + await expect(page.getByTestId("operator-root")).toBeVisible(); + await expect(page.getByTestId("invite")).toBeDisabled(); + await page.getByTestId("operator-root").click(); + await expect(page.getByTestId("change-role")).toBeDisabled(); + await expect(page.getByTestId("revoke-key-root")).toBeDisabled(); +}); diff --git a/bin/ra-console/web/e2e/requests.spec.ts b/bin/ra-console/web/e2e/requests.spec.ts new file mode 100644 index 0000000..a5672b4 --- /dev/null +++ b/bin/ra-console/web/e2e/requests.spec.ts @@ -0,0 +1,85 @@ +// Décisions RA signées dans le navigateur (docs/WEBUI.md §15 étape 6b, +// docs/UI-UX.md §3.1, §3.4) : la modale affiche le corps figé par ca-server et +// son empreinte, la clé signe, ca-server exécute ; au clavier comme à la souris. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, fixture, logIn, withOperatorKey } from "./helpers"; + +async function stateOf(page: Page, tx: string): Promise<{ state: string; operator: string | null } | undefined> { + for (const state of ["PENDING", "APPROVED", "REJECTED"]) { + const list = (await (await page.request.get(`/api/v1/requests?state=${state}`)).json()) as { + transaction_id: string; + state: string; + operator: string | null; + }[]; + const found = list.find((r) => r.transaction_id === tx); + if (found) return found; + } + return undefined; +} + +test("approuver une demande : corps figé affiché, signé, exécuté par ca-server", async ({ page }) => { + const errors = collectErrors(page); + const tx = fixture().pending[0]!; + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + + await page.getByTestId(`row-${tx}`).click(); + await page.getByTestId("approve").click(); + await page.getByTestId("comment").fill("identité vérifiée au guichet"); + await page.getByTestId("comment-next").click(); + + // WYSIWYS : ce qui sera exécuté, tel que ca-server l'a figé, et son empreinte. + const body = page.getByTestId("frozen-body"); + await expect(body).toContainText(`"transaction_id": "${tx}"`); + await expect(body).toContainText(`"action": "approve_request"`); + await expect(body).toContainText("identité vérifiée au guichet"); + await expect(page.getByTestId("body-hash")).toHaveText(/^([0-9a-f]{8} ){7}[0-9a-f]{8}$/); + + await page.getByTestId("sign").click(); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + await expect(page.getByTestId("requests-status")).toContainText(`${tx} approuvée`); + await expect(page.getByTestId(`row-${tx}`)).toHaveCount(0); + expect(await stateOf(page, tx)).toMatchObject({ state: "APPROVED", operator: fixture().operator }); + expect(errors).toEqual([]); +}); + +test("rejeter au clavier : motif obligatoire, puis signature", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await expect(page.locator("tr[aria-selected='true']")).toHaveCount(1); + + // j déplace la sélection ; r ouvre le rejet de la demande sélectionnée. + const first = await page.locator("tr[aria-selected='true']").getAttribute("data-testid"); + await page.keyboard.press("j"); + const second = await page.locator("tr[aria-selected='true']").getAttribute("data-testid"); + expect(second).not.toBe(first); + const tx = second!.replace("row-", ""); + await page.keyboard.press("r"); + + // Sans motif, rien ne part. + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("comment-dialog")).toBeVisible(); + await page.getByTestId("comment").fill("sujet non reconnu"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "reject_request"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + expect(await stateOf(page, tx)).toMatchObject({ state: "REJECTED" }); +}); + +test("renoncer avant de signer ne décide rien", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + const tx = fixture().pending[5]!; + await page.getByTestId(`row-${tx}`).click(); + await page.getByTestId("approve").click(); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(tx); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + expect(await stateOf(page, tx)).toMatchObject({ state: "PENDING" }); +}); diff --git a/bin/ra-console/web/e2e/revocation.spec.ts b/bin/ra-console/web/e2e/revocation.spec.ts new file mode 100644 index 0000000..275c25b --- /dev/null +++ b/bin/ra-console/web/e2e/revocation.spec.ts @@ -0,0 +1,87 @@ +// Révocation à deux dans le navigateur (docs/WEBUI.md §8, §15 étape 6c, +// docs/UI-UX.md §3.2) : bob signe la révocation, rien n'est révoqué ; il ne +// peut pas co-signer lui-même ; carol co-signe, ca-server exécute. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, fixture, logIn, withOperatorKey } from "./helpers"; + +async function statusOf(page: Page, serial: string): Promise { + for (const status of ["issued", "revoked"]) { + const list = (await (await page.request.get(`/api/v1/certificates?status=${status}`)).json()) as { + serial_hex: string; + }[]; + if (list.some((c) => c.serial_hex === serial)) return status; + } + return undefined; +} + +test("deux opérateurs CA distincts révoquent un certificat", async ({ browser }) => { + const serial = fixture().certificates[0]!; + + // bob : première signature. + const bobContext = await browser.newContext(); + const bob = await bobContext.newPage(); + const errors = collectErrors(bob); + await withOperatorKey(bob, "bob"); + await bob.goto("/"); + await logIn(bob, "bob"); + await bob.getByTestId("nav-certificates").click(); + await bob.getByTestId(`cert-${serial}`).click(); + await bob.getByTestId("revoke").click(); + await bob.getByTestId("reason").selectOption("1"); + await bob.getByTestId("comment").fill("clé exposée, ticket CERT-FR #2026-991"); + await bob.getByTestId("comment-next").click(); + const body = bob.getByTestId("frozen-body"); + await expect(body).toContainText(`"serial": "${serial}"`); + await expect(body).toContainText(`"reason": 1`); + await bob.getByTestId("sign").click(); + await expect(bob.getByTestId("sign-dialog")).toBeHidden(); + await expect(bob.getByTestId("certificates-status")).toContainText("1 sur 2"); + expect(await statusOf(bob, serial)).toBe("issued"); + + // bob ne peut pas co-signer sa propre action. + await bob.getByTestId("nav-quorum").click(); + const card = bob.locator("article", { hasText: serial.toUpperCase().match(/.{2}/g)!.join(":") }); + await expect(card).toContainText("1 sur 2"); + await expect(card.getByRole("button", { name: /Co-signer/ })).toBeDisabled(); + await expect(card).toContainText("opérateur distinct"); + expect(errors).toEqual([]); + + // carol co-signe : la révocation s'exécute. + const carolContext = await browser.newContext(); + const carol = await carolContext.newPage(); + await withOperatorKey(carol, "carol"); + await carol.goto("/"); + await logIn(carol, "carol"); + await expect(carol.getByTestId("count-quorum")).toHaveText("(1)"); + await carol.getByTestId("nav-quorum").click(); + const pending = carol.locator("article", { hasText: "bob" }); + await pending.getByRole("button", { name: /Co-signer/ }).click(); + await expect(carol.getByTestId("frozen-body")).toContainText(`"serial": "${serial}"`); + await carol.getByTestId("sign").click(); + await expect(carol.getByTestId("sign-dialog")).toBeHidden(); + await expect(carol.getByTestId("quorum-status")).toContainText("exécutée"); + expect(await statusOf(carol, serial)).toBe("revoked"); + await expect(carol.getByTestId("count-quorum")).toHaveText("(0)"); + + await bobContext.close(); + await carolContext.close(); +}); + +test("un opérateur RA ne peut pas lancer de révocation", async ({ page }) => { + const serial = fixture().certificates[1]!; + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await page.getByTestId("nav-certificates").click(); + await page.getByTestId(`cert-${serial}`).click(); + await page.getByTestId("revoke").click(); + await page.getByTestId("reason").selectOption("4"); + await page.getByTestId("comment").fill("essai"); + await page.getByTestId("comment-next").click(); + // L'autorité refuse de préparer l'action : la modale le dit, rien n'est figé. + await expect(page.getByTestId("sign-status")).toContainText("refusée"); + await expect(page.getByTestId("frozen-body")).toHaveCount(0); + await page.keyboard.press("Escape"); + expect(await statusOf(page, serial)).toBe("issued"); +}); diff --git a/bin/ra-console/web/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/certificates.ts b/bin/ra-console/web/src/certificates.ts new file mode 100644 index 0000000..9ee8f6e --- /dev/null +++ b/bin/ra-console/web/src/certificates.ts @@ -0,0 +1,184 @@ +// Certificats émis et révocation (docs/UI-UX.md §3.1, §4.2, §5 ; docs/WEBUI.md +// §8) : la révocation exige deux ca_operateur distincts, la première signature +// part en salle de quorum. + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Certificate { + serial_hex: string; + profile: string; + subject_dn: string; + not_after: string | null; + status: string; +} + +/// Motifs admis par ca-server (RFC 5280 §5.3.1) ; « unspecified » n'en fait pas partie. +const REASONS: [number, string][] = [ + [1, "keyCompromise — clé privée compromise"], + [3, "affiliationChanged — rattachement modifié"], + [4, "superseded — remplacé"], + [5, "cessationOfOperation — cessation d'activité"], + [9, "privilegeWithdrawn — privilège retiré"], +]; + +/// Numéro de série par paires séparées par deux-points (UI-UX §4.2). +export function pairs(hex: string): string { + return (hex.toUpperCase().match(/.{1,2}/g) ?? []).join(":"); +} + +export function certificatesView(onSigned: () => void): View { + let rows: Certificate[] = []; + let selected = 0; + const body = h("tbody", {}); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "certificates-status" }); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur" }); + const element = h( + "section", + {}, + h("h1", {}, "Certificats émis"), + notice, + h( + "div", + { class: "split" }, + h( + "table", + { class: "dense", "data-testid": "certificates" }, + h("thead", {}, h("tr", {}, h("th", {}, "Statut"), h("th", {}, "Numéro de série"), h("th", {}, "Sujet"), h("th", {}, "Profil"), h("th", {}, "Expire le"))), + body, + ), + inspector, + ), + ); + + const render = (): void => { + replace( + body, + ...rows.map((c, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `cert-${c.serial_hex}` }, + h("td", {}, h("span", { class: "badge valid" }, "● actif")), + h("td", { class: "mono" }, pairs(c.serial_hex)), + h("td", {}, c.subject_dn), + h("td", {}, c.profile), + h("td", { class: "mono" }, c.not_after ? utc(c.not_after) : "—"), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + const c = rows[selected]; + if (c === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucun certificat actif.")); + return; + } + const revoke = h("button", { type: "button", class: "danger", "data-testid": "revoke" }, "Révoquer le certificat…"); + revoke.addEventListener("click", () => void startRevocation(c)); + replace( + inspector, + h("h2", {}, "Général"), + h( + "dl", + {}, + h("dt", {}, "Numéro de série"), + h("dd", { class: "mono" }, pairs(c.serial_hex)), + h("dt", {}, "Sujet"), + h("dd", {}, c.subject_dn), + h("dt", {}, "Profil"), + h("dd", {}, c.profile), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, c.not_after ? utc(c.not_after) : "—"), + ), + h("p", { class: "muted" }, "La révocation exige la signature de deux opérateurs CA distincts."), + h("div", { class: "actions" }, revoke), + ); + }; + + const startRevocation = async (c: Certificate): Promise => { + const choice = await askRevocation(); + if (choice === null) return; + const [code, label] = REASONS.find(([r]) => r === choice.reason) ?? [choice.reason, String(choice.reason)]; + const result = await sign({ + title: "Révocation de certificat", + summary: [ + ["Action", "révoquer définitivement le certificat"], + ["Numéro de série", pairs(c.serial_hex)], + ["Sujet", c.subject_dn], + ["Motif RFC 5280", `${label} (${code})`], + ["Justification", choice.comment], + ], + action: { action: "revoke_certificate", serial: c.serial_hex, reason: choice.reason, comment: choice.comment }, + route: `/api/v1/certificates/${c.serial_hex}/revoke`, + }); + if (result === null) return; + notice.textContent = + result.status === "AWAITING_QUORUM" + ? `Signature enregistrée (${String(result.signatures)} sur ${String(result.required)}) : en attente d'un second opérateur CA, voir la salle de quorum.` + : `Certificat ${pairs(c.serial_hex)} révoqué.`; + await load(); + onSigned(); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/certificates?status=issued"); + rows = Array.isArray(reply.body) ? reply.body : []; + if (isError(reply.body)) notice.textContent = reply.body.message; + selected = Math.min(selected, Math.max(rows.length - 1, 0)); + render(); + }; + + void load(); + return { element, dispose: () => undefined }; +} + +function askRevocation(): Promise<{ reason: number; comment: string } | null> { + return new Promise((resolve) => { + const reason = h( + "select", + { id: "revocation-reason", required: "", "data-testid": "reason" }, + h("option", { value: "" }, "— choisir un motif —"), + ...REASONS.map(([code, label]) => h("option", { value: String(code) }, label)), + ); + const comment = h("textarea", { id: "revocation-comment", rows: "3", maxlength: "1000", required: "", "data-testid": "comment" }); + const next = h("button", { type: "submit", class: "danger", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h( + "form", + { method: "dialog" }, + h("h2", {}, "Révocation : motif et justification"), + h("label", { for: "revocation-reason" }, "Motif (RFC 5280)"), + reason, + h("label", { for: "revocation-comment" }, "Justification consignée au journal (obligatoire)"), + comment, + h("div", { class: "actions" }, cancel, next), + ); + const dialog = h("dialog", { class: "signature", "data-testid": "revocation-dialog" }, form); + const finish = (value: { reason: number; comment: string } | null): void => { + dialog.close(); + dialog.remove(); + resolve(value); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const code = Number(reason.value); + const text = comment.value.trim(); + if (!REASONS.some(([r]) => r === code) || text === "") return; + finish({ reason: code, comment: text }); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + document.body.append(dialog); + dialog.showModal(); + reason.focus(); + }); +} diff --git a/bin/ra-console/web/src/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/format.ts b/bin/ra-console/web/src/format.ts new file mode 100644 index 0000000..f3cf765 --- /dev/null +++ b/bin/ra-console/web/src/format.ts @@ -0,0 +1,13 @@ +// Formats d'affichage de docs/UI-UX.md §4.2 : dates ISO 8601 en UTC, +// empreintes groupées, identifiants en monospace (le style s'en charge). + +export function utc(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${d.toISOString().slice(0, 19).replace("T", " ")} UTC`; +} + +/// Empreinte hexadécimale groupée par 8 caractères, lisible à voix haute. +export function groupedHash(hex: string): string { + return (hex.match(/.{1,8}/g) ?? []).join(" "); +} diff --git a/bin/ra-console/web/src/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..a516e6e --- /dev/null +++ b/bin/ra-console/web/src/main.ts @@ -0,0 +1,57 @@ +// 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; + let disposeView: (() => void) | null = null; + + const showLogin = (notice?: string): void => { + stopIdle?.(); + stopIdle = null; + disposeView?.(); + disposeView = 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 => { + disposeView = 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/operators.ts b/bin/ra-console/web/src/operators.ts new file mode 100644 index 0000000..58814a1 --- /dev/null +++ b/bin/ra-console/web/src/operators.ts @@ -0,0 +1,341 @@ +// Registre des opérateurs (docs/WEBUI.md §10, §15 étape 6e) : invitations, +// confirmation des clés en attente (empreinte comparée hors bande), révocation +// de clé, changement de rôle. Chaque écriture est une action signée que +// ca-server juge ; les boutons ne sont qu'un affichage pour les non-admins. + +import { call, isError, type Me } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Credential { + credential_id: string; + label: string; + initiated_at: string; + revoked_at: string | null; +} +interface Operator { + name: string; + role: Me["role"]; + disabled: boolean; + credentials: Credential[]; +} +interface PendingKey { + credential_id: string; + operator: string; + key_fingerprint: string | null; + registered_at: string; + expires_at: string; +} +interface Registry { + operators: Operator[]; + pending: PendingKey[]; +} + +const ROLES: [Me["role"], string][] = [ + ["auditeur", "auditeur"], + ["ra_operateur", "opérateur RA"], + ["ca_operateur", "opérateur CA"], + ["admin", "administrateur"], +]; + +export function operatorsView(me: Me): View { + const isAdmin = me.role === "admin"; + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "operators-status" }); + const table = h("tbody", {}); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur" }); + const pendingList = h("div", { class: "quorum", "data-testid": "pending-keys" }); + const invite = h("button", { type: "button", class: "primary", "data-testid": "invite", ...(isAdmin ? {} : { disabled: "" }) }, "Inviter un opérateur…"); + const element = h( + "section", + {}, + h("h1", {}, "Opérateurs"), + isAdmin ? null : h("p", { class: "muted" }, "Consultation seule : la gestion du registre est réservée aux administrateurs."), + notice, + h("div", { class: "actions start" }, invite), + h( + "div", + { class: "split" }, + h( + "table", + { class: "dense", "data-testid": "operators" }, + h("thead", {}, h("tr", {}, h("th", {}, "Opérateur"), h("th", {}, "Rôle"), h("th", {}, "Clés actives"), h("th", {}, "État"))), + table, + ), + inspector, + ), + h("h2", {}, "Clés en attente de confirmation"), + pendingList, + ); + let registry: Registry = { operators: [], pending: [] }; + let selected = 0; + + const active = (o: Operator): Credential[] => o.credentials.filter((c) => c.revoked_at === null); + + const render = (): void => { + replace( + table, + ...registry.operators.map((o, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `operator-${o.name}` }, + h("td", {}, o.name), + h("td", {}, h("span", { class: `role role-${o.role}` }, o.role)), + h("td", { class: "mono" }, String(active(o).length)), + h("td", {}, o.disabled ? "désactivé" : "actif"), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + renderInspector(); + replace( + pendingList, + ...(registry.pending.length === 0 + ? [h("p", { class: "muted" }, "Aucune clé en attente.")] + : registry.pending.map((p) => { + const confirm = h( + "button", + { type: "button", class: "primary", "data-testid": `confirm-${p.operator}`, ...(isAdmin && p.key_fingerprint ? {} : { disabled: "" }) }, + "Confirmer la clé…", + ); + confirm.addEventListener("click", () => void confirmKey(p)); + return h( + "article", + { class: "card" }, + h("h2", {}, `Clé de ${p.operator}`), + h( + "dl", + {}, + h("dt", {}, "Empreinte"), + h("dd", { class: "mono hash" }, p.key_fingerprint ?? "illisible"), + h("dt", {}, "Enregistrée le"), + h("dd", { class: "mono" }, utc(p.registered_at)), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, utc(p.expires_at)), + ), + h("div", { class: "actions" }, confirm), + ); + })), + ); + }; + + const renderInspector = (): void => { + const o = registry.operators[selected]; + if (o === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucun opérateur.")); + return; + } + const roleSelect = h( + "select", + { id: "new-role", "data-testid": "new-role", ...(isAdmin ? {} : { disabled: "" }) }, + ...ROLES.map(([value, label]) => { + const option = h("option", { value }, label); + if (value === o.role) option.selected = true; + return option; + }), + ); + const changeRole = h("button", { type: "button", "data-testid": "change-role", ...(isAdmin ? {} : { disabled: "" }) }, "Changer le rôle…"); + changeRole.addEventListener("click", () => void setRole(o, roleSelect.value as Me["role"])); + replace( + inspector, + h("h2", {}, o.name), + h("label", { for: "new-role" }, "Rôle"), + roleSelect, + h("div", { class: "actions" }, changeRole), + h("h2", {}, "Clés"), + ...o.credentials.map((c) => { + const revoke = h( + "button", + { type: "button", class: "danger", "data-testid": `revoke-key-${o.name}`, ...(isAdmin && c.revoked_at === null ? {} : { disabled: "" }) }, + "Révoquer…", + ); + revoke.addEventListener("click", () => void revokeKey(o, c)); + return h( + "div", + { class: "key" }, + h("p", { class: "mono" }, `${c.label} · ${c.credential_id.slice(0, 16)}…`), + h("p", { class: "muted" }, c.revoked_at ? `révoquée le ${utc(c.revoked_at)}` : `active depuis le ${utc(c.initiated_at)}`), + revoke, + ); + }), + ); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/operators"); + if (reply.status === 200 && reply.body !== null && !isError(reply.body)) registry = reply.body; + else if (isError(reply.body)) notice.textContent = reply.body.message; + selected = Math.min(selected, Math.max(registry.operators.length - 1, 0)); + render(); + }; + + const after = async (result: Record | null, done: string): Promise => { + if (result === null) return; + notice.textContent = + result.status === "AWAITING_QUORUM" ? "Signature enregistrée : un second administrateur doit co-signer (salle de quorum)." : done; + await load(); + }; + + const setRole = async (o: Operator, role: Me["role"]): Promise => { + if (role === o.role) return; + const result = await sign({ + title: `Changement de rôle de ${o.name}`, + summary: [ + ["Opérateur", o.name], + ["Rôle actuel", o.role], + ["Nouveau rôle", role], + ], + action: { action: "set_role", operator: o.name, role }, + route: `/api/v1/operators/${encodeURIComponent(o.name)}/role`, + }); + await after(result, `Rôle de ${o.name} : ${role}.`); + }; + + const revokeKey = async (o: Operator, c: Credential): Promise => { + const reason = await ask("Motif de la révocation de la clé (obligatoire)", "revoke-reason"); + if (reason === null) return; + const result = await sign({ + title: `Révocation d'une clé de ${o.name}`, + summary: [ + ["Opérateur", o.name], + ["Clé", `${c.label} · ${c.credential_id}`], + ["Motif", reason], + ], + action: { action: "revoke_key", credential_id: c.credential_id, reason }, + route: `/api/v1/credentials/${encodeURIComponent(c.credential_id)}/revoke`, + }); + await after(result, `Clé de ${o.name} révoquée.`); + }; + + const confirmKey = async (p: PendingKey): Promise => { + if (p.key_fingerprint === null) return; + const checked = await confirmFingerprint(p); + if (!checked) return; + const result = await sign({ + title: `Confirmation de la clé de ${p.operator}`, + summary: [ + ["Opérateur", p.operator], + ["Empreinte comparée", p.key_fingerprint], + ], + action: { action: "confirm_key", credential_id: p.credential_id, key_fingerprint: p.key_fingerprint }, + route: `/api/v1/credentials/${encodeURIComponent(p.credential_id)}/confirm`, + }); + await after(result, `Clé de ${p.operator} confirmée.`); + }; + + invite.addEventListener("click", () => void inviteOperator()); + const inviteOperator = async (): Promise => { + const who = await askInvite(); + if (who === null) return; + const result = await sign({ + title: `Invitation de ${who.name}`, + summary: [ + ["Opérateur invité", who.name], + ["Rôle", who.role], + ], + action: { action: "invite_operator", name: who.name, role: who.role }, + route: "/api/v1/operators", + }); + if (result === null) return; + const inner = result.result as { invite_token?: string } | null | undefined; + if (result.status === "EXECUTED" && typeof inner?.invite_token === "string") { + await showToken(who.name, inner.invite_token); + } + await after(result, `Invitation de ${who.name} créée.`); + }; + + void load(); + return { element, dispose: () => undefined }; +} + +/// Le jeton d'invitation, affiché une seule fois : il n'est conservé nulle part, +/// ni par la console ni par ca-server (seul son haché l'est). +function showToken(name: string, token: string): Promise { + return new Promise((resolve) => { + const close = h("button", { type: "button", class: "primary", "data-testid": "token-close" }, "J'ai transmis le jeton"); + const dialog = h( + "dialog", + { class: "signature", "data-testid": "token-dialog" }, + h("h2", {}, `Jeton d'invitation de ${name}`), + h("p", {}, "Affiché une seule fois. Transmettez-le à l'invité par un canal distinct ; il lui sert à enregistrer sa clé FIDO2."), + h("pre", { class: "mono", "data-testid": "invite-token" }, token), + h("div", { class: "actions" }, close), + ); + const finish = (): void => { + dialog.close(); + dialog.remove(); + resolve(); + }; + close.addEventListener("click", finish); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +function formDialog(testid: string, title: string, fields: HTMLElement[], valid: () => boolean): Promise { + return new Promise((resolve) => { + const next = h("button", { type: "submit", class: "primary", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h("form", { method: "dialog" }, h("h2", {}, title), ...fields, h("div", { class: "actions" }, cancel, next)); + const dialog = h("dialog", { class: "signature", "data-testid": testid }, form); + const finish = (ok: boolean): void => { + dialog.close(); + dialog.remove(); + resolve(ok); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (valid()) finish(true); + }); + cancel.addEventListener("click", () => finish(false)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(false); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +async function ask(title: string, testid: string): Promise { + const input = h("textarea", { rows: "3", maxlength: "1000", required: "", "data-testid": "comment" }); + const ok = await formDialog(testid, title, [input], () => input.value.trim() !== ""); + return ok ? input.value.trim() : null; +} + +async function askInvite(): Promise<{ name: string; role: Me["role"] } | null> { + const name = h("input", { id: "invite-name", required: "", maxlength: "100", "data-testid": "invite-name" }); + const role = h("select", { id: "invite-role", "data-testid": "invite-role" }, ...ROLES.map(([v, l]) => h("option", { value: v }, l))); + const ok = await formDialog( + "invite-dialog", + "Inviter un opérateur", + [h("label", { for: "invite-name" }, "Nom"), name, h("label", { for: "invite-role" }, "Rôle"), role], + () => name.value.trim() !== "", + ); + return ok ? { name: name.value.trim(), role: role.value as Me["role"] } : null; +} + +/// La confirmation n'a de sens que si l'administrateur a comparé l'empreinte +/// avec celle que l'invité lit sur son poste (§10) : il le déclare en cochant. +async function confirmFingerprint(p: PendingKey): Promise { + const box = h("input", { type: "checkbox", id: "compared", required: "", "data-testid": "compared" }); + return formDialog( + "confirm-dialog", + `Confirmer la clé de ${p.operator}`, + [ + h("p", {}, "Comparez cette empreinte, hors bande, avec celle que l'invité a obtenue en enregistrant sa clé :"), + h("p", { class: "mono hash" }, p.key_fingerprint ?? ""), + h("label", { for: "compared" }, box, " J'ai comparé l'empreinte avec l'invité : elle est identique."), + ], + () => box.checked, + ); +} diff --git a/bin/ra-console/web/src/quorum.ts b/bin/ra-console/web/src/quorum.ts new file mode 100644 index 0000000..e35918c --- /dev/null +++ b/bin/ra-console/web/src/quorum.ts @@ -0,0 +1,95 @@ +// Salle d'attente du double contrôle (docs/UI-UX.md §3.2, docs/WEBUI.md §8) : +// ce qui attend une signature de plus, qui a déjà signé, et la co-signature — +// refusée d'avance à qui a déjà signé (l'autorité la refuserait de toute façon). + +import { call, isError, type Me } from "./api"; +import { pairs } from "./certificates"; +import { h, replace } from "./dom"; +import { groupedHash, utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Pending { + action_id: string; + action: string; + body: Record; + body_hash: string; + required: number; + signatures: number; + signed_by: string[]; + created_at: string; + expires_at: string; +} + +const LABELS: Record = { + revoke_certificate: "Révocation de certificat", + set_role: "Changement de rôle", + invite_operator: "Invitation d'un opérateur", +}; + +export function quorumView(me: Me, onSigned: () => void): View { + const list = h("div", { class: "quorum", "data-testid": "quorum" }); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "quorum-status" }); + const element = h("section", {}, h("h1", {}, "Salle de quorum"), notice, list); + + const card = (p: Pending): HTMLElement => { + const mine = p.signed_by.includes(me.operator); + const cosign = h( + "button", + { type: "button", class: "primary", "data-testid": `cosign-${p.action_id}`, ...(mine ? { disabled: "" } : {}) }, + "Co-signer avec ma clé FIDO2", + ); + cosign.addEventListener("click", () => void coSign(p)); + const serial = typeof p.body.serial === "string" ? p.body.serial : null; + return h( + "article", + { class: "card", "data-testid": `pending-${p.action_id}` }, + h("h2", {}, LABELS[p.action] ?? p.action), + h( + "dl", + {}, + h("dt", {}, "Signatures"), + h("dd", { "data-testid": `progress-${p.action_id}` }, `${p.signatures} sur ${p.required}`), + h("dt", {}, "Déjà signé par"), + h("dd", {}, p.signed_by.join(", ") || "—"), + ...(serial ? [h("dt", {}, "Numéro de série"), h("dd", { class: "mono" }, pairs(serial))] : []), + h("dt", {}, "Initiée le"), + h("dd", { class: "mono" }, utc(p.created_at)), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, utc(p.expires_at)), + h("dt", {}, "Empreinte"), + h("dd", { class: "mono hash" }, groupedHash(p.body_hash)), + ), + h("pre", { class: "mono" }, JSON.stringify(p.body, null, 2)), + mine ? h("p", { class: "muted", "data-testid": `own-${p.action_id}` }, "Le double contrôle requiert un opérateur distinct : vous avez déjà signé.") : null, + h("div", { class: "actions" }, cosign), + ); + }; + + const coSign = async (p: Pending): Promise => { + const result = await sign({ + title: `Co-signature : ${LABELS[p.action] ?? p.action}`, + summary: [ + ["Action", LABELS[p.action] ?? p.action], + ["Déjà signé par", p.signed_by.join(", ")], + ["Signatures", `${p.signatures + 1} sur ${p.required} après la vôtre`], + ], + action: { action_id: p.action_id }, + route: `/api/v1/quorum/${p.action_id}/sign`, + }); + if (result === null) return; + notice.textContent = result.status === "EXECUTED" ? "Action exécutée par l'autorité." : "Signature enregistrée."; + await load(); + onSigned(); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/quorum?state=PENDING"); + const pending = Array.isArray(reply.body) ? reply.body : []; + if (isError(reply.body)) notice.textContent = reply.body.message; + replace(list, ...(pending.length === 0 ? [h("p", { class: "muted" }, "Aucune action en attente de signature.")] : pending.map(card))); + }; + + void load(); + return { element, dispose: () => undefined }; +} diff --git a/bin/ra-console/web/src/requests.ts b/bin/ra-console/web/src/requests.ts new file mode 100644 index 0000000..0fd5f4e --- /dev/null +++ b/bin/ra-console/web/src/requests.ts @@ -0,0 +1,211 @@ +// File des demandes d'enrôlement (docs/UI-UX.md §3.4, §5.1, §6.1) : tableau +// dense, sélection au clavier (j/k), inspecteur latéral, décisions signées. + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; + +interface EnrollmentRequest { + transaction_id: string; + profile: string; + subject_cn: string; + state: string; + operator: string | null; + created_at: string; +} + +import type { View } from "./view"; + +export function requestsView(onDecided: () => void): View { + let rows: EnrollmentRequest[] = []; + let selected = 0; + + const body = h("tbody", {}); + const table = h( + "table", + { class: "dense", "aria-label": "Demandes en attente", "data-testid": "requests" }, + h( + "thead", + {}, + h("tr", {}, h("th", {}, "Statut"), h("th", {}, "Transaction"), h("th", {}, "Sujet"), h("th", {}, "Profil"), h("th", {}, "Déposée le")), + ), + body, + ); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur", "data-testid": "inspector" }); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "requests-status" }); + const element = h( + "section", + { class: "requests" }, + h("h1", {}, "Demandes d'enrôlement en attente"), + h("p", { class: "muted" }, "j/k : se déplacer · a : approuver · r : rejeter"), + notice, + h("div", { class: "split" }, table, inspector), + ); + + const render = (): void => { + replace( + body, + ...rows.map((r, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `row-${r.transaction_id}` }, + h("td", {}, h("span", { class: "badge pending" }, "⏳ en attente")), + h("td", { class: "mono" }, r.transaction_id), + h("td", {}, r.subject_cn), + h("td", {}, r.profile), + h("td", { class: "mono" }, utc(r.created_at)), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + if (rows.length === 0) { + replace(body, h("tr", {}, h("td", { colspan: "5", class: "muted" }, "Aucune demande en attente."))); + } + renderInspector(); + }; + + const renderInspector = (): void => { + const r = rows[selected]; + if (r === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucune demande sélectionnée.")); + return; + } + const approve = h("button", { type: "button", class: "primary", "data-testid": "approve" }, "Approuver la demande…"); + const reject = h("button", { type: "button", "data-testid": "reject" }, "Rejeter la demande…"); + approve.addEventListener("click", () => void decide(r, "approve")); + reject.addEventListener("click", () => void decide(r, "reject")); + replace( + inspector, + h("h2", {}, "Général"), + h( + "dl", + {}, + h("dt", {}, "Transaction"), + h("dd", { class: "mono" }, r.transaction_id), + h("dt", {}, "Sujet (CN)"), + h("dd", {}, r.subject_cn), + h("dt", {}, "Profil"), + h("dd", {}, r.profile), + h("dt", {}, "Déposée le"), + h("dd", { class: "mono" }, utc(r.created_at)), + ), + h("div", { class: "actions" }, approve, reject), + ); + }; + + const decide = async (r: EnrollmentRequest, kind: "approve" | "reject"): Promise => { + const comment = await askComment(kind); + if (comment === null) return; + const approving = kind === "approve"; + const ok = await sign({ + title: approving ? `Approbation de la demande ${r.transaction_id}` : `Rejet de la demande ${r.transaction_id}`, + summary: [ + ["Action", approving ? "approuver l'émission du certificat" : "rejeter définitivement la demande"], + ["Transaction", r.transaction_id], + ["Sujet (CN)", r.subject_cn], + ["Profil", r.profile], + ["Justification", comment === "" ? "—" : comment], + ], + action: { + action: approving ? "approve_request" : "reject_request", + transaction_id: r.transaction_id, + comment, + }, + route: `/api/v1/requests/${encodeURIComponent(r.transaction_id)}/${kind}`, + }); + if (ok !== null) { + notice.textContent = approving ? `Demande ${r.transaction_id} approuvée.` : `Demande ${r.transaction_id} rejetée.`; + await load(); + onDecided(); + } + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/requests?state=PENDING"); + if (reply.status !== 200 || !Array.isArray(reply.body)) { + notice.textContent = isError(reply.body) ? reply.body.message : "File indisponible."; + rows = []; + } else { + rows = reply.body; + } + selected = Math.min(selected, Math.max(rows.length - 1, 0)); + render(); + }; + + const onKey = (event: KeyboardEvent): void => { + const target = event.target as HTMLElement | null; + if (document.querySelector("dialog[open]") !== null) return; + if (target !== null && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + const r = rows[selected]; + if (event.key === "j" || event.key === "ArrowDown") { + selected = Math.min(selected + 1, rows.length - 1); + render(); + } else if (event.key === "k" || event.key === "ArrowUp") { + selected = Math.max(selected - 1, 0); + render(); + } else if (event.key === "a" && r !== undefined) { + void decide(r, "approve"); + } else if (event.key === "r" && r !== undefined) { + void decide(r, "reject"); + } else { + return; + } + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + void load(); + return { element, dispose: () => window.removeEventListener("keydown", onKey) }; +} + +/// Justification de la décision : obligatoire pour un rejet (UI-UX §3.4), +/// facultative pour une approbation. `null` si l'opérateur renonce. +function askComment(kind: "approve" | "reject"): Promise { + return new Promise((resolve) => { + const required = kind === "reject"; + const input = h("textarea", { + id: "decision-comment", + rows: "3", + maxlength: "1000", + "data-testid": "comment", + ...(required ? { required: "" } : {}), + }); + const next = h("button", { type: "submit", class: "primary", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h( + "form", + { method: "dialog" }, + h("h2", {}, kind === "reject" ? "Motif du rejet (obligatoire)" : "Commentaire (facultatif)"), + h("label", { for: "decision-comment" }, "Justification consignée au journal"), + input, + h("div", { class: "actions" }, cancel, next), + ); + const dialog = h("dialog", { class: "signature", "data-testid": "comment-dialog" }, form); + const finish = (value: string | null): void => { + dialog.close(); + dialog.remove(); + resolve(value); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const value = input.value.trim(); + if (required && value === "") { + input.focus(); + return; + } + finish(value); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + document.body.append(dialog); + dialog.showModal(); + input.focus(); + }); +} diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts new file mode 100644 index 0000000..eef5850 --- /dev/null +++ b/bin/ra-console/web/src/shell.ts @@ -0,0 +1,87 @@ +// 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 +// commencent par la file des demandes (6b) ; révocation, quorum et audit suivent. + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { banner } from "./banner"; +import { h, replace } from "./dom"; +import { certificatesView } from "./certificates"; +import { operatorsView } from "./operators"; +import { quorumView } from "./quorum"; +import { requestsView } from "./requests"; +import type { View } from "./view"; + +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 refresh = (): void => void refreshCounts(requests, quorum); + const work = h("main", { class: "workspace", tabindex: "-1" }); + let current: View | null = null; + const views: [string, string, HTMLElement | null, () => View][] = [ + ["requests", "Demandes RA ", requests, () => requestsView(refresh)], + ["certificates", "Certificats", null, () => certificatesView(refresh)], + ["quorum", "Quorum ", quorum, () => quorumView(me, refresh)], + ["operators", "Opérateurs", null, () => operatorsView(me)], + ]; + const buttons = views.map(([id, label, count, make]) => { + const button = h("button", { type: "button", class: "nav", "data-testid": `nav-${id}` }, label, count); + button.addEventListener("click", () => show(id, make)); + return button; + }); + const show = (id: string, make: () => View): void => { + current?.dispose(); + current = make(); + replace(work, current.element); + for (const b of buttons) b.setAttribute("aria-current", String(b.dataset.testid === `nav-${id}`)); + }; + const nav = h("nav", { class: "sidebar", "aria-label": "Files de travail" }, h("ul", {}, ...buttons.map((b) => h("li", {}, b)))); + replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); + const first = views[0]!; + show(first[0], first[3]); + refresh(); + return () => current?.dispose(); +} + +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/sign.ts b/bin/ra-console/web/src/sign.ts new file mode 100644 index 0000000..7db45e9 --- /dev/null +++ b/bin/ra-console/web/src/sign.ts @@ -0,0 +1,152 @@ +// Cérémonie de signature d'une action (docs/UI-UX.md §3.1, docs/WEBUI.md §4). +// +// 1. La console demande à ca-server de figer l'action : le corps rendu est +// celui qui sera exécuté, affiché tel quel, avec son empreinte SHA-256. +// 2. L'opérateur signe avec sa clé FIDO2. +// 3. L'assertion est relayée ; en cas d'erreur, la modale reste ouverte et +// l'opérateur peut relancer sans ressaisir. +// +// La modale est un natif : focus piégé, Échap ferme — sauf pendant la +// cérémonie matérielle — et un clic extérieur ne la ferme jamais (§5.2). + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { groupedHash } from "./format"; +import { assert } from "./webauthn"; + +interface Issued { + challenge_id: string; + body: Record; + body_hash: string; + webauthn: Parameters[0]; +} + +export interface Signing { + /// Titre de la modale (« Approbation de la demande tx-… »). + title: string; + /// Résumé en clair, ligne par ligne, de ce qui va être signé. + summary: [string, string][]; + /// L'action à figer (forme d'oe_actions). + action: Record; + /// La route qui relaie l'assertion (ex. /api/v1/requests/{id}/approve). + route: string; +} + +const MESSAGES: Record = { + action_mismatch: "La signature ne correspond pas à cette action.", + already_used: "Cette signature a déjà servi : relancez.", + expired: "Le délai de signature est dépassé : relancez.", + denied: "Action refusée par l'autorité (rôle ou état de la demande).", + signature_rejected: "Signature refusée par l'autorité.", + unauthenticated: "Session expirée : reconnectez-vous.", +}; + +function explain(body: unknown, fallback: string): string { + if (isError(body)) return MESSAGES[body.error] ?? body.message; + return fallback; +} + +/// Ouvre la modale ; rend la réponse de l'autorité si l'action a été signée et +/// relayée avec succès, `null` sinon. +export function sign(signing: Signing): Promise | null> { + return new Promise((resolve) => { + let busy = false; + let done = false; + let issued: Issued | null = null; + + const status = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "sign-status" }); + const frozen = h("div", { class: "frozen", "data-testid": "frozen" }); + const signButton = h("button", { type: "button", class: "primary", "data-testid": "sign" }, "Signer avec ma clé FIDO2"); + const cancel = h("button", { type: "button", "data-testid": "sign-cancel" }, "Annuler"); + const summary = h( + "dl", + { class: "summary" }, + ...signing.summary.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)]), + ); + const dialog = h( + "dialog", + { class: "signature", "aria-labelledby": "sign-title", "data-testid": "sign-dialog" }, + h("h2", { id: "sign-title" }, `🔑 ${signing.title}`), + summary, + frozen, + status, + h("div", { class: "actions" }, cancel, signButton), + ); + + const close = (result: Record | null): void => { + dialog.close(); + dialog.remove(); + resolve(result); + }; + + const prepare = async (): Promise => { + signButton.disabled = true; + status.textContent = "Préparation de l'action par l'autorité…"; + const reply = await call("POST", "/api/v1/webauthn/challenge", signing.action); + if (reply.status !== 200 || reply.body === null || isError(reply.body)) { + status.textContent = explain(reply.body, "L'autorité n'a pas pu préparer l'action."); + return false; + } + issued = reply.body; + replace( + frozen, + h("p", { class: "muted" }, "Corps exact qui sera exécuté (figé par l'autorité) :"), + h("pre", { class: "mono", "data-testid": "frozen-body" }, JSON.stringify(issued.body, null, 2)), + h("p", { class: "muted" }, "Empreinte de la requête (SHA-256) :"), + h("p", { class: "mono hash", "data-testid": "body-hash" }, groupedHash(issued.body_hash)), + ); + status.textContent = ""; + signButton.disabled = false; + return true; + }; + + const ceremony = async (): Promise => { + if (issued === null && !(await prepare())) return; + const current = issued; + if (current === null) return; + busy = true; + signButton.disabled = true; + cancel.disabled = true; + status.textContent = "Touchez votre clé de sécurité matérielle…"; + try { + const assertion = await assert(current.webauthn); + const reply = await call>("POST", signing.route, { + challenge_id: current.challenge_id, + assertion, + }); + if (reply.status === 200 && reply.body !== null && !isError(reply.body)) { + done = true; + const result = reply.body; + status.textContent = + result.status === "AWAITING_QUORUM" ? "✓ Signature enregistrée." : "✓ Action signée et exécutée."; + window.setTimeout(() => close(result), 800); + return; + } + // Un challenge consommé ou expiré ne resservira pas : on en redemande un. + issued = null; + status.textContent = explain(reply.body, "L'autorité a refusé l'action."); + } catch { + status.textContent = "La clé n'a pas répondu (annulation ou délai). Réessayez."; + } finally { + busy = false; + if (!done) { + signButton.disabled = false; + cancel.disabled = false; + } + } + }; + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + if (!busy && !done) close(null); + }); + cancel.addEventListener("click", () => { + if (!busy) close(null); + }); + signButton.addEventListener("click", () => void ceremony()); + + document.body.append(dialog); + dialog.showModal(); + void prepare().then(() => signButton.focus()); + }); +} diff --git a/bin/ra-console/web/src/view.ts b/bin/ra-console/web/src/view.ts new file mode 100644 index 0000000..57bebe3 --- /dev/null +++ b/bin/ra-console/web/src/view.ts @@ -0,0 +1,6 @@ +// Une vue de la zone de travail : son élément, et de quoi la quitter proprement +// (raccourcis clavier, minuteries). +export interface View { + element: HTMLElement; + dispose: () => void; +} diff --git a/bin/ra-console/web/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..fc629bc --- /dev/null +++ b/bin/ra-console/web/static/console.css @@ -0,0 +1,397 @@ +/* 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); +} + +/* --- 6b : file des demandes, inspecteur, modale de signature (UI-UX §3.1, §5) --- */ + +.mono { + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.split { + display: grid; + grid-template-columns: 1fr minmax(420px, 34%); + gap: 16px; + align-items: start; +} +table.dense { + width: 100%; + border-collapse: collapse; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); +} +table.dense th { + position: sticky; + top: 0; + background: var(--bg-surface-elevated); + text-align: left; + font-size: 12px; + color: var(--text-muted); +} +table.dense th, +table.dense td { + padding: 8px 10px; + height: 38px; + border-bottom: 1px solid var(--border-subtle); +} +table.dense tr[aria-selected="true"] td { + background: var(--bg-surface-elevated); +} +table.dense tr[aria-selected="true"] td:first-child { + box-shadow: inset 3px 0 0 var(--border-focus); +} +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; +} +.badge.pending { + background: #451a03; + color: #fbbf24; + border: 1px dashed #d97706; +} +.inspector { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 16px; +} +.inspector h2, +dialog.signature h2 { + font-size: 16px; + margin: 0 0 12px; +} +dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0 0 16px; +} +dt { + color: var(--text-muted); +} +dd { + margin: 0; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +dialog.signature { + width: min(640px, 92vw); + background: var(--bg-surface-elevated); + color: var(--text-main); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 24px; +} +dialog.signature::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +dialog.signature textarea { + width: 100%; + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; +} +.frozen pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 240px; + font-size: 12px; +} +.hash { + font-size: 13px; + word-spacing: 0.4em; +} + +/* --- 6c : navigation, certificats, salle de quorum (UI-UX §2.2, §3.2) --- */ + +button.nav { + width: 100%; + text-align: left; + background: transparent; + border-color: transparent; +} +button.nav[aria-current="true"] { + background: var(--bg-surface-elevated); + border-color: var(--border-subtle); + box-shadow: inset 3px 0 0 var(--border-focus); +} +button.danger { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; + font-weight: 600; +} +button.danger:hover { + background: #ef4444; +} +.badge.valid { + background: #064e3b; + color: #34d399; + border: 1px solid #059669; +} +select { + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; + width: 100%; +} +.quorum { + display: grid; + gap: 16px; +} +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-left: 3px dashed #d97706; + border-radius: 6px; + padding: 16px; +} +.card pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 200px; + font-size: 12px; +} + +/* --- 6e : opérateurs --- */ +.actions.start { + justify-content: flex-start; + margin-bottom: 12px; +} +.key { + border-top: 1px solid var(--border-subtle); + padding: 8px 0; +} +.key p { + margin: 0 0 4px; +} diff --git a/bin/ra-console/web/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..d09ab59 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -185,6 +185,100 @@ 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, + /// Clé visée par une confirmation ou une révocation de clé. + #[serde(default)] + pub credential_id: Option, + /// Opérateur visé par un changement de rôle (par son nom, celui du corps + /// figé). + #[serde(default)] + pub operator: Option, +} + +impl Expect { + 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é, par le nom du champ d'attente qui + // la désigne. Une invitation n'a pas de cible (l'opérateur n'existe pas + // encore) : seul le type compte. + let frozen: Option<(&str, &String)> = match action { + Action::ApproveRequest { transaction_id, .. } + | Action::RejectRequest { transaction_id, .. } => { + Some(("transaction_id", transaction_id)) + } + Action::RevokeCertificate { serial, .. } => Some(("serial", serial)), + Action::ConfirmKey { credential_id, .. } | Action::RevokeKey { credential_id, .. } => { + Some(("credential_id", credential_id)) + } + Action::SetRole { operator, .. } => Some(("operator", operator)), + Action::InviteOperator { .. } => None, + }; + let targets = [ + ("transaction_id", &self.transaction_id), + ("serial", &self.serial), + ("credential_id", &self.credential_id), + ("operator", &self.operator), + ]; + // Tout autre champ d'attente doit rester vide. + for (name, value) in targets { + if value.is_some() && frozen.map(|(f, _)| f) != Some(name) { + return Err(Error::BadRequest( + "cible sans rapport avec ce type d'action".to_string(), + )); + } + } + let Some((field, frozen)) = frozen else { + return Ok(()); + }; + let expected = targets + .iter() + .find(|(name, _)| *name == field) + .and_then(|(_, v)| v.as_ref()); + match expected { + Some(expected) if expected == frozen => Ok(()), + None => Err(Error::BadRequest( + "la cible visée doit être précisée".to_string(), + )), + Some(expected) => Err(Error::Mismatch(format!( + "cible attendue {expected}, figée {frozen}" + ))), + } + } } /// Un challenge émis, à présenter à l'opérateur. @@ -618,6 +712,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 +763,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 +794,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 +1019,148 @@ 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, + credential_id: None, + operator: 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 invitation n'a pas de cible : seul le type compte. + let invite = Action::InviteOperator { + name: "eve".to_string(), + role: Role::Auditeur, + ttl_minutes: 60, + }; + assert!(expect("invite_operator", None) + .check(&invite, Uuid::nil()) + .is_ok()); + + // Changement de rôle : la cible est l'opérateur, par son nom. + let role = Action::SetRole { + operator: "alice".to_string(), + role: Role::Auditeur, + }; + let for_operator = |o: &str| Expect { + operator: Some(o.to_string()), + ..expect("set_role", None) + }; + assert!(for_operator("alice").check(&role, Uuid::nil()).is_ok()); + assert!(matches!( + for_operator("bob").check(&role, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + assert!(matches!( + expect("set_role", None).check(&role, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Clés : la cible est l'identifiant de la clé. + let revoke_key = Action::RevokeKey { + credential_id: "k1".to_string(), + reason: "perdue".to_string(), + }; + let for_key = |k: &str, action: &str| Expect { + credential_id: Some(k.to_string()), + ..expect(action, None) + }; + assert!(for_key("k1", "revoke_key") + .check(&revoke_key, Uuid::nil()) + .is_ok()); + assert!(matches!( + for_key("k2", "revoke_key").check(&revoke_key, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + let confirm = Action::ConfirmKey { + credential_id: "k1".to_string(), + key_fingerprint: "AA".to_string(), + }; + assert!(for_key("k1", "confirm_key") + .check(&confirm, Uuid::nil()) + .is_ok()); + // Une cible d'un autre type (opérateur sur une clé) est refusée. + let mixed = Expect { + operator: Some("alice".to_string()), + ..for_key("k1", "revoke_key") + }; + assert!(matches!( + mixed.check(&revoke_key, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Révocation : la cible est le numéro de série, jamais une demande. + let revoke = Action::RevokeCertificate { + 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, + credential_id: None, + operator: 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..866bb9d 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -53,6 +53,165 @@ 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ées toutes les actions d'`oe_actions` : l'approbation et le rejet d'une + demande (§15, étape 3), la révocation d'un certificat (`revoke_certificate`, étape + 4), et la gestion du registre (`invite_operator`, `confirm_key`, `revoke_key`, + `set_role`, voir plus bas). Une action ajoutée plus tard à l'énumération ne sera pas + préparée tant que la console ne la nomme pas (`403 action_not_available`). +- Le rôle et l'état de la demande sont jugés par `ca-server` (un administrateur ne peut + pas approuver) ; la console relaie son refus. +- Chaque préparation est inscrite au journal de la console (`ra.action_challenge` : + 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. +- **File des demandes (6b)** : tableau dense des demandes en attente, sélection au + clavier (`j`/`k`, `a` approuver, `r` rejeter), inspecteur latéral. Une décision passe + par une justification (**obligatoire pour un rejet**, garde d'interface seulement : + `ca-server` ne l'exige pas pour la voie signée), puis par la **modale de signature** + (`` natif) : elle affiche le corps que `ca-server` a figé, tel quel, et son + empreinte SHA-256, avant tout geste sur la clé. Une erreur laisse la modale ouverte ; + un challenge consommé ou expiré est redemandé au besoin. Échap annule, sauf pendant + la cérémonie matérielle. +- **Certificats et révocation (6c)** : `GET /api/v1/certificates?status=issued|revoked` + (lecture seule de la table de `ca-server`, numéro de série sous la forme canonique + qu'attend la révocation). L'écran liste les certificats actifs ; « Révoquer… » demande + un motif RFC 5280 parmi ceux qu'admet `ca-server` (1, 3, 4, 5, 9) et une + justification obligatoire, puis la modale de signature. La première signature part + en salle de quorum. +- **Salle de quorum (6c)** : les actions en attente, leur corps figé et leur empreinte, + qui a déjà signé ; la co-signature est désactivée pour qui a déjà signé (« le double + contrôle requiert un opérateur distinct » — `ca-server` la refuserait de toute façon). +- **Opérateurs (6e)** : `GET /api/v1/operators` (lecture seule : opérateurs, leurs clés, + clés en attente avec leur **empreinte recalculée par `oe_actions::key_fingerprint`**, + la fonction même de `ca-server`). L'écran liste le registre ; un administrateur y + invite (le **jeton s'affiche une seule fois**, à transmettre par un canal distinct), + confirme une clé en attente après avoir **déclaré avoir comparé l'empreinte hors + bande** (§10), révoque une clé (motif obligatoire) et change un rôle (le rôle `admin` + exige un second administrateur, en salle de quorum). Pour les autres rôles, ces + boutons sont désactivés — affichage seulement, `ca-server` juge. +- L'explorateur d'audit suit (6d, après #51). +## Gestion du registre des opérateurs + +Même schéma : le challenge est préparé avec l'action voulue, puis l'assertion est +relayée à la route correspondante (`{"challenge_id", "assertion"}`), qui rend la forme +d'une action à plusieurs signatures (`status`, `signatures`, `required`, `signed_by`, +`result`). Seul un `admin` signe ces actions ; `ca-server` en décide. + +| Route | Action préparée | Cible contrôlée par `ca-server` | +|---|---|---| +| `POST /api/v1/operators` | `{"action": "invite_operator", "name", "role"}` | le type seulement (l'opérateur n'existe pas encore) | +| `POST /api/v1/credentials/{credential_id}/confirm` | `{"action": "confirm_key", "credential_id", "key_fingerprint"}` | l'identifiant de la clé | +| `POST /api/v1/credentials/{credential_id}/revoke` | `{"action": "revoke_key", "credential_id", "reason"}` | l'identifiant de la clé | +| `POST /api/v1/operators/{name}/role` | `{"action": "set_role", "operator", "role"}` | l'opérateur, par son nom | + +- **Invitation** : le jeton n'existe que dans `result.invite_token` de la réponse + d'exécution, rendu **une seule fois** ; ni `ca-server` ni la console ne le + journalisent ni ne le conservent. L'invité enregistre ensuite sa clé par le relais + d'enregistrement (plus haut) : elle reste en attente, avec une empreinte que l'invité + transmet hors bande. +- **Confirmation** : l'administrateur signe l'empreinte ; `ca-server` la recompare à la + clé en attente et refuse qu'un opérateur confirme sa propre clé. +- **Révocation de clé** : motif obligatoire ; `ca-server` refuse de révoquer la dernière + clé d'administrateur active (la voie de secours est `recover-admin`). +- **Rôle `admin`** : créer un administrateur, élever un opérateur au rôle `admin` ou + changer le rôle d'un administrateur exige **deux administrateurs** — la première + signature rend `AWAITING_QUORUM`, la seconde passe par la salle d'attente + (`/api/v1/quorum/{action_id}/sign`). Un opérateur ne change pas son propre rôle. +- Identifiant de clé : base64url, 1 024 caractères au plus ; nom d'opérateur : 1 à 256 + caractères. Toute autre forme est refusée avant relais. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -62,6 +221,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 +247,9 @@ gardé par la console. ## Ce qui n'existe pas encore -La connexion des opérateurs (login, sessions), la -lecture, les actions signées relayées, la révocation, le workflow d'incident et le -frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. L'image, le chart Helm et le +La liste des clés en attente de confirmation, le libre-service (ajout et retrait de ses +propres clés, §10), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas +encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se 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 (