From 9e19ad5c429d9e6086848649a83497b0ed04d02a Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 13:19:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(ra-console):=20r=C3=A9vocation=20et=20sall?= =?UTF-8?q?e=20de=20quorum=20dans=20le=20navigateur=20(=C3=A9tape=206c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §8, §15 étape 6, docs/UI-UX.md §3.2 : deux opérateurs CA distincts révoquent un certificat depuis la console. - `GET /api/v1/certificates?status=issued|revoked` : certificats émis, en lecture seule sur la table de ca-server, numéro de série sous la forme canonique qu'attend la révocation (test Rust : liste, filtre, passage en « revoked » après double signature, refus sans session). - Navigation entre demandes, certificats et quorum. Écran des certificats : motif RFC 5280 parmi ceux qu'admet ca-server, justification obligatoire, modale de signature ; la première signature part en salle de quorum. - Salle de quorum : corps figé, empreinte, signataires ; co-signature désactivée pour qui a déjà signé (ca-server la refuserait aussi). - Harnais e2e : trois opérateurs (alice RA, bob et carol CA), une CA sur la même base avec son révocateur, deux certificats. Parcours : révocation à deux de bout en bout, refus pour un opérateur RA. Mutation tuée (co-signature par soi-même non désactivée). Co-authored-by: Claude --- bin/ra-console/examples/e2e_console.rs | 186 +++++++++++++++------- bin/ra-console/src/certificates.rs | 62 ++++++++ bin/ra-console/src/http.rs | 38 ++++- bin/ra-console/src/lib.rs | 1 + bin/ra-console/tests/action_challenge.rs | 61 +++++++ bin/ra-console/web/dist/console.css | 58 +++++++ bin/ra-console/web/dist/console.js | 2 +- bin/ra-console/web/e2e/helpers.ts | 17 +- bin/ra-console/web/e2e/revocation.spec.ts | 87 ++++++++++ bin/ra-console/web/src/certificates.ts | 184 +++++++++++++++++++++ bin/ra-console/web/src/quorum.ts | 95 +++++++++++ bin/ra-console/web/src/requests.ts | 10 +- bin/ra-console/web/src/shell.ts | 36 +++-- bin/ra-console/web/src/sign.ts | 24 +-- bin/ra-console/web/src/view.ts | 6 + bin/ra-console/web/static/console.css | 58 +++++++ docs/RA-CONSOLE.md | 11 +- 17 files changed, 848 insertions(+), 88 deletions(-) create mode 100644 bin/ra-console/src/certificates.rs create mode 100644 bin/ra-console/web/e2e/revocation.spec.ts create mode 100644 bin/ra-console/web/src/certificates.ts create mode 100644 bin/ra-console/web/src/quorum.ts create mode 100644 bin/ra-console/web/src/view.ts diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs index 1dd0291..3f818fe 100644 --- a/bin/ra-console/examples/e2e_console.rs +++ b/bin/ra-console/examples/e2e_console.rs @@ -72,65 +72,140 @@ async fn main() { // L'opérateur et sa clé, comme en production mais sans passer par // l'enregistrement relayé (qui a ses propres tests). let now = time::OffsetDateTime::now_utc(); - let operator = registry - .add_operator("alice", Role::RaOperateur, "e2e", now) - .await - .unwrap(); + // Trois opérateurs : alice décide des demandes (étape 6b), bob et carol + // révoquent à deux (étape 6c, double contrôle). + let people = [ + ("alice", Role::RaOperateur), + ("bob", Role::CaOperateur), + ("carol", Role::CaOperateur), + ]; let reg_verifier = verifier(); // Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que - // la clé du credential créé ci-dessous se relit (aucun accesseur public). + // la clé des credentials créés ci-dessous se relit (aucun accesseur public). let token_path = std::env::temp_dir().join(format!("{name}.softtoken")); let token_file = std::fs::File::create(&token_path).unwrap(); let mut authn = WebauthnAuthenticator::new(SoftTokenFile::new(token, token_file)); - let (options, state) = reg_verifier - .start_registration(operator, "alice", None) - .unwrap(); - let reg = authn.do_registration(origin.clone(), options).unwrap(); + let mut registered = Vec::new(); + for (who, role) in people { + let id = registry.add_operator(who, role, "e2e", now).await.unwrap(); + let (options, state) = reg_verifier.start_registration(id, who, None).unwrap(); + let reg = authn.do_registration(origin.clone(), options).unwrap(); + let key = reg_verifier.finish_registration(®, &state).unwrap(); + registry + .add_credential( + NewCredential { + operator_id: id, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "e2e", + initiated_by: "e2e", + confirmed_by: Some("e2e"), + }, + now, + ) + .await + .unwrap(); + registered.push((who, role, id, reg.raw_id.as_ref().to_vec())); + } drop(authn); - let key = reg_verifier.finish_registration(®, &state).unwrap(); - registry - .add_credential( - NewCredential { - operator_id: operator, - passkey: &key, - aaguid: AAGUID, - attestation_format: "packed", - attestation_object: reg.response.attestation_object.as_ref(), - label: "e2e", - initiated_by: "e2e", - confirmed_by: Some("e2e"), - }, - now, - ) - .await - .unwrap(); - // La clé privée du SoftToken (SEC1), convertie en PKCS#8 pour le navigateur. - let credential_id: Vec = reg.raw_id.as_ref().to_vec(); + // Les clés privées du SoftToken (SEC1), converties en PKCS#8 pour le navigateur. let soft: serde_cbor_2::Value = serde_cbor_2::from_slice(&std::fs::read(&token_path).unwrap()).unwrap(); let _ = std::fs::remove_file(&token_path); - let (sec1, counter) = soft_key(&soft, &credential_id); - let ec = openssl::ec::EcKey::private_key_from_der(&sec1).unwrap(); - let pkcs8 = openssl::pkey::PKey::from_ec_key(ec) - .unwrap() - .private_key_to_pkcs8() - .unwrap(); let b64 = base64::engine::general_purpose::STANDARD; + let mut operators = serde_json::Map::new(); + for (who, role, id, credential_id) in ®istered { + let (sec1, counter) = soft_key(&soft, credential_id); + let ec = openssl::ec::EcKey::private_key_from_der(&sec1).unwrap(); + let pkcs8 = openssl::pkey::PKey::from_ec_key(ec) + .unwrap() + .private_key_to_pkcs8() + .unwrap(); + operators.insert( + who.to_string(), + serde_json::json!({ + "role": role.as_str(), + "credential": { + "credentialId": b64.encode(credential_id), + "isResidentCredential": false, + "rpId": "localhost", + "privateKey": b64.encode(pkcs8), + "userHandle": b64.encode(id.as_bytes()), + "signCount": counter, + }, + }), + ); + } + + // Une vraie CA sur la même base, et deux certificats émis à révoquer (6c). + let issuing = Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)); + let h = oe_ca_core::ceremony::run_ceremony(oe_ca_core::ceremony::CeremonyOptions { + root_signer: Arc::new(oe_hsm::testing::SoftwareToken::generate(2048)), + issuing_signer: issuing.clone(), + root_cn: "E2E Root CA".into(), + issuing_cn: "E2E Issuing CA".into(), + organization: "Open eIDAS e2e".into(), + country: "FR".into(), + root_validity: time::Duration::days(3650), + issuing_validity: time::Duration::days(3650), + root_token_label: "r".into(), + root_key_label: "r".into(), + issuing_token_label: "i".into(), + issuing_key_label: "i".into(), + store: store.clone(), + operator: "e2e".into(), + public_url: "https://ca.example.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(), + ); + let mut to_revoke = Vec::new(); + for i in 1..=2 { + let key = oe_hsm::testing::SoftwareToken::generate(2048); + let cert = issuer + .issue( + &oe_hsm::SigningToken::public_key_der(&key).unwrap(), + &format!("tsu-rev-{i}.example.test"), + &oe_ca_core::profile::tsa_signer(), + &format!("tx-rev-{i}"), + ) + .await + .unwrap(); + to_revoke.push( + oe_ca_core::canonical_serial(cert.tbs_certificate().serial_number()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(), + ); + } + let pending: Vec = (1..=8).map(|i| format!("tx-e2e-{i}")).collect(); + let alice = operators["alice"].clone(); let out = serde_json::json!({ "origin": origin.as_str().trim_end_matches('/'), "pending": pending, + "certificates": to_revoke, "operator": "alice", "role": "ra_operateur", - "credential": { - "credentialId": b64.encode(&credential_id), - "isResidentCredential": false, - "rpId": "localhost", - "privateKey": b64.encode(pkcs8), - "userHandle": b64.encode(operator.as_bytes()), - "signCount": counter, - }, + "credential": alice["credential"], + "operators": operators, }); if let Some(parent) = std::path::Path::new(&fixture).parent() { std::fs::create_dir_all(parent).unwrap(); @@ -160,18 +235,21 @@ async fn main() { // Le vrai service d'actions de ca-server, derrière son routeur interne et le // lien mTLS : les décisions signées dans le navigateur y sont vérifiées. - let service = Arc::new(oe_actions::Service::new( - registry.clone(), - verifier(), - store.clone(), - oe_raflow::Decider::new(oe_raflow::DeciderOptions { - store: store.clone(), - recorder: None, - clock: None, - }), - Arc::new(NullJournal), - Arc::new(time::OffsetDateTime::now_utc), - )); + let service = Arc::new( + oe_actions::Service::new( + registry.clone(), + verifier(), + store.clone(), + oe_raflow::Decider::new(oe_raflow::DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + ) + .with_revoker(Arc::new(ca_server::revoker::IssuerRevoker(issuer))), + ); let pki = common::pki().await; let internal = pki .serve_router(ca_server::internal::router(service, 64 * 1024)) diff --git a/bin/ra-console/src/certificates.rs b/bin/ra-console/src/certificates.rs new file mode 100644 index 0000000..f9b611a --- /dev/null +++ b/bin/ra-console/src/certificates.rs @@ -0,0 +1,62 @@ +//! `GET /api/v1/certificates` (docs/WEBUI.md §5, §15 étape 6c) : les +//! certificats émis, en lecture seule sur la table de `ca-server`, pour que +//! l'opérateur choisisse ce qu'il révoque. Les certificats réservés (numéro +//! tiré, émission non aboutie) n'y figurent pas. + +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +/// Les états qu'un filtre peut demander. +pub const STATUSES: &[&str] = &["issued", "revoked"]; + +#[derive(Debug, Serialize)] +pub struct IssuedCertificate { + /// Hexadécimal minuscule : la forme canonique qu'attend la révocation. + pub serial_hex: String, + pub profile: String, + pub subject_dn: String, + #[serde(with = "time::serde::rfc3339::option")] + pub not_before: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub not_after: Option, + pub status: String, + #[serde(with = "time::serde::rfc3339::option")] + pub revoked_at: Option, + pub revocation_reason: i32, + pub request_transaction_id: String, +} + +/// Les certificats émis ou révoqués, les plus récents d'abord ; filtrés par +/// état si demandé. `status` n'est pas revalidé ici : à l'appelant de le +/// confronter à [`STATUSES`]. +pub async fn list( + pool: &PgPool, + status: Option<&str>, +) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT serial_hex, profile, subject_dn, not_before, not_after, status, revoked_at, + revocation_reason, request_transaction_id + FROM certificates + WHERE status <> 'reserved' AND ($1::text IS NULL OR status = $1) + ORDER BY not_before DESC NULLS LAST, serial_hex + LIMIT 1000", + ) + .bind(status) + .fetch_all(pool) + .await?; + Ok(rows + .iter() + .map(|r| IssuedCertificate { + serial_hex: r.get("serial_hex"), + profile: r.get("profile"), + subject_dn: r.get("subject_dn"), + not_before: r.get("not_before"), + not_after: r.get("not_after"), + status: r.get("status"), + revoked_at: r.get("revoked_at"), + revocation_reason: r.get("revocation_reason"), + request_transaction_id: r.get("request_transaction_id"), + }) + .collect()) +} diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 9ec4649..f845d1c 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -18,7 +18,7 @@ use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; -use crate::{quorum, requests}; +use crate::{certificates, quorum, requests}; /// Assez pour un objet d'attestation, pas pour bourrer la mémoire. const MAX_BODY_BYTES: usize = 64 * 1024; @@ -56,6 +56,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/me", get(handle_me)) .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) + .route("/api/v1/certificates", get(handle_certificates)) .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) .route("/api/v1/requests/{id}/approve", post(handle_approve)) .route("/api/v1/requests/{id}/reject", post(handle_reject)) @@ -828,6 +829,41 @@ fn quorum_status(body: &serde_json::Value) -> serde_json::Value { }) } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CertificatesQuery { + status: Option, +} + +/// `GET /api/v1/certificates?status=issued` : les certificats émis, en lecture +/// seule (docs/WEBUI.md §5, §15 étape 6c). Comme pour les demandes, toute +/// session authentifiée peut lire ; la révocation reste jugée par `ca-server`. +async fn handle_certificates( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + if let Some(s) = &q.status { + if !certificates::STATUSES.contains(&s.as_str()) { + return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide"); + } + } + match certificates::list(&state.pool, q.status.as_deref()).await { + Ok(list) => Json(list).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "certificates : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index 28838ae..c1dff8f 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -14,6 +14,7 @@ pub mod audit; pub mod ca_link; +pub mod certificates; pub mod config; pub mod db_guard; pub mod http; diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 8b563ae..cbad1ea 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -882,3 +882,64 @@ async fn a_co_signature_only_counts_for_its_action() { assert_eq!(status, StatusCode::NOT_FOUND, "{id} {err}"); } } + +/// `GET /api/v1/certificates` (étape 6c) : les certificats émis, lus dans la +/// table de `ca-server`, avec leur numéro de série sous la forme canonique +/// qu'attend la révocation ; filtre par état ; rien sans session. +#[tokio::test] +async fn the_console_lists_issued_certificates() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + env.operator_with_key("bob", Role::CaOperateur).await; + let alice = env.log_in("alice").await; + let bob = env.log_in("bob").await; + let serial = env.certificate("tx-list").await; + + let (status, issued) = env.get("/api/v1/certificates?status=issued", &alice).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let found = issued + .as_array() + .unwrap() + .iter() + .find(|c| c["serial_hex"] == serial.as_str()) + .unwrap_or_else(|| panic!("{serial} absent de {issued}")); + assert_eq!(found["profile"], "tsa_signer"); + assert_eq!(found["request_transaction_id"], "tx-list"); + + // Révoqué par deux opérateurs : il change de liste. + let action = env.first_signature(&alice, &serial).await; + let (_, issued_c) = env + .challenge(Some(&bob), serde_json::json!({ "action_id": action })) + .await; + let assertion = env.sign(&issued_c); + env.decide( + Some(&bob), + &format!("/api/v1/quorum/{action}/sign"), + &issued_c, + &assertion, + ) + .await; + let (_, revoked) = env.get("/api/v1/certificates?status=revoked", &alice).await; + let found = revoked + .as_array() + .unwrap() + .iter() + .find(|c| c["serial_hex"] == serial.as_str()) + .unwrap_or_else(|| panic!("{serial} absent de {revoked}")); + assert_eq!(found["revocation_reason"], 1); + let (_, issued) = env.get("/api/v1/certificates?status=issued", &alice).await; + assert!(issued + .as_array() + .unwrap() + .iter() + .all(|c| c["serial_hex"] != serial.as_str())); + + let (status, _) = env + .get("/api/v1/certificates?status=reserved", &alice) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, _) = env + .get("/api/v1/certificates", "session=n-importe-quoi") + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} diff --git a/bin/ra-console/web/dist/console.css b/bin/ra-console/web/dist/console.css index e3c4755..86b2258 100644 --- a/bin/ra-console/web/dist/console.css +++ b/bin/ra-console/web/dist/console.css @@ -324,3 +324,61 @@ dialog.signature textarea { font-size: 13px; word-spacing: 0.4em; } + +/* --- 6c : navigation, certificats, salle de quorum (UI-UX §2.2, §3.2) --- */ + +button.nav { + width: 100%; + text-align: left; + background: transparent; + border-color: transparent; +} +button.nav[aria-current="true"] { + background: var(--bg-surface-elevated); + border-color: var(--border-subtle); + box-shadow: inset 3px 0 0 var(--border-focus); +} +button.danger { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; + font-weight: 600; +} +button.danger:hover { + background: #ef4444; +} +.badge.valid { + background: #064e3b; + color: #34d399; + border: 1px solid #059669; +} +select { + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; + width: 100%; +} +.quorum { + display: grid; + gap: 16px; +} +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-left: 3px dashed #d97706; + border-radius: 6px; + padding: 16px; +} +.card pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 200px; + font-size: 12px; +} diff --git a/bin/ra-console/web/dist/console.js b/bin/ra-console/web/dist/console.js index 7cae5be..7f0c3c5 100644 --- a/bin/ra-console/web/dist/console.js +++ b/bin/ra-console/web/dist/console.js @@ -1 +1 @@ -async function g(t,a,n){let o={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(o.headers={"Content-Type":"application/json"},o.body=JSON.stringify(n));let r=await fetch(a,o),i=await r.text(),l=null;if(i!=="")try{l=JSON.parse(i)}catch{l=null}return{status:r.status,body:l}}function y(t){return typeof t=="object"&&t!==null&&"error"in t}var L=["keydown","pointerdown","wheel","touchstart"];function S(t,a){let n=0,o=0,r=()=>{window.clearTimeout(n),window.clearTimeout(o),n=window.setTimeout(t,84e4),o=window.setTimeout(a,9e5)};for(let i of L)window.addEventListener(i,r,{passive:!0});return r(),()=>{window.clearTimeout(n),window.clearTimeout(o);for(let i of L)window.removeEventListener(i,r)}}function e(t,a={},...n){let o=document.createElement(t);for(let[r,i]of Object.entries(a))o.setAttribute(r,i);for(let r of n)r==null||r===!1||o.append(typeof r=="string"?document.createTextNode(r):r);return o}function v(t,...a){t.replaceChildren(...a)}var O={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function T(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},O[t.environment])}function h(t){let a=new Uint8Array(t),n="";for(let o of a)n+=String.fromCharCode(o);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function C(t){let a=t.replace(/-/g,"+").replace(/_/g,"/"),n=a+"=".repeat((4-a.length%4)%4),o=atob(n),r=new Uint8Array(o.length);for(let i=0;i({type:r.type,id:C(r.id),...r.transports?{transports:r.transports}:{}})),...t.timeout!==void 0?{timeout:t.timeout}:{},...t.rpId!==void 0?{rpId:t.rpId}:{},...t.userVerification!==void 0?{userVerification:t.userVerification}:{}},n=await navigator.credentials.get({publicKey:a});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let o=n.response;return{id:n.id,rawId:h(n.rawId),type:n.type,response:{clientDataJSON:h(o.clientDataJSON),authenticatorData:h(o.authenticatorData),signature:h(o.signature),userHandle:o.userHandle?h(o.userHandle):null},extensions:{}}}function _(t,a,n,o){let r=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},o??""),i=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),l=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),c=e("form",{class:"login-form","aria-labelledby":"login-title"},e("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),e("label",{for:"operator-name"},"Nom d'op\xE9rateur"),i,l,r);c.addEventListener("submit",u=>{u.preventDefault(),D(i.value.trim(),l,r,n)}),v(t,T(a),e("main",{class:"login"},c)),i.focus()}async function D(t,a,n,o){if(t!==""){a.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let r=await g("POST","/api/v1/webauthn/login/begin",{name:t});if(r.status!==200||r.body===null||y(r.body)){n.textContent="Connexion impossible pour le moment.";return}let i=await x(r.body.webauthn),l=await g("POST","/api/v1/webauthn/login/finish",{challenge_id:r.body.challenge_id,credential:i});if(l.status!==200||l.body===null||y(l.body)){n.textContent="Identifiants invalides.";return}o(l.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{a.disabled=!1}}}function A(t){let a=new Date(t);return Number.isNaN(a.getTime())?t:`${a.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var H={action_mismatch:"La signature ne correspond pas \xE0 cette action.",already_used:"Cette signature a d\xE9j\xE0 servi : relancez.",expired:"Le d\xE9lai de signature est d\xE9pass\xE9 : relancez.",denied:"Action refus\xE9e par l'autorit\xE9 (r\xF4le ou \xE9tat de la demande).",signature_rejected:"Signature refus\xE9e par l'autorit\xE9.",unauthenticated:"Session expir\xE9e : reconnectez-vous."};function M(t,a){return y(t)?H[t.error]??t.message:a}function R(t){return new Promise(a=>{let n=!1,o=!1,r=null,i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),l=e("div",{class:"frozen","data-testid":"frozen"}),c=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),u=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),f=e("dl",{class:"summary"},...t.summary.flatMap(([d,m])=>[e("dt",{},d),e("dd",{},m)])),p=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),f,l,i,e("div",{class:"actions"},u,c)),b=d=>{p.close(),p.remove(),a(d)},E=async()=>{c.disabled=!0,i.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let d=await g("POST","/api/v1/webauthn/challenge",t.action);return d.status!==200||d.body===null||y(d.body)?(i.textContent=M(d.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(r=d.body,v(l,e("p",{class:"muted"},"Corps exact qui sera ex\xE9cut\xE9 (fig\xE9 par l'autorit\xE9) :"),e("pre",{class:"mono","data-testid":"frozen-body"},JSON.stringify(r.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(r.body_hash))),i.textContent="",c.disabled=!1,!0)},s=async()=>{if(r===null&&!await E())return;let d=r;if(d!==null){n=!0,c.disabled=!0,u.disabled=!0,i.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let m=await x(d.webauthn),w=await g("POST",t.route,{challenge_id:d.challenge_id,assertion:m});if(w.status===200){o=!0,i.textContent="\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>b(!0),800);return}r=null,i.textContent=M(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{i.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,o||(c.disabled=!1,u.disabled=!1)}}};p.addEventListener("cancel",d=>{d.preventDefault(),!n&&!o&&b(!1)}),u.addEventListener("click",()=>{n||b(!1)}),c.addEventListener("click",()=>{s()}),document.body.append(p),p.showModal(),E().then(()=>c.focus())})}function N(t){let a=[],n=0,o=e("tbody",{}),r=e("table",{class:"dense","aria-label":"Demandes en attente","data-testid":"requests"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Transaction"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"D\xE9pos\xE9e le"))),o),i=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),l=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),c=e("section",{class:"requests"},e("h1",{},"Demandes d'enr\xF4lement en attente"),e("p",{class:"muted"},"j/k : se d\xE9placer \xB7 a : approuver \xB7 r : rejeter"),l,e("div",{class:"split"},r,i)),u=()=>{v(o,...a.map((s,d)=>{let m=e("tr",{"aria-selected":String(d===n),"data-testid":`row-${s.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},s.transaction_id),e("td",{},s.subject_cn),e("td",{},s.profile),e("td",{class:"mono"},A(s.created_at)));return m.addEventListener("click",()=>{n=d,u()}),m})),a.length===0&&v(o,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),f()},f=()=>{let s=a[n];if(s===void 0){v(i,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let d=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),m=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");d.addEventListener("click",()=>{p(s,"approve")}),m.addEventListener("click",()=>{p(s,"reject")}),v(i,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},s.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},s.subject_cn),e("dt",{},"Profil"),e("dd",{},s.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},A(s.created_at))),e("div",{class:"actions"},d,m))},p=async(s,d)=>{let m=await $(d);if(m===null)return;let w=d==="approve";await R({title:w?`Approbation de la demande ${s.transaction_id}`:`Rejet de la demande ${s.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",s.transaction_id],["Sujet (CN)",s.subject_cn],["Profil",s.profile],["Justification",m===""?"\u2014":m]],action:{action:w?"approve_request":"reject_request",transaction_id:s.transaction_id,comment:m},route:`/api/v1/requests/${encodeURIComponent(s.transaction_id)}/${d}`})&&(l.textContent=w?`Demande ${s.transaction_id} approuv\xE9e.`:`Demande ${s.transaction_id} rejet\xE9e.`,await b(),t())},b=async()=>{let s=await g("GET","/api/v1/requests?state=PENDING");s.status!==200||!Array.isArray(s.body)?(l.textContent=y(s.body)?s.body.message:"File indisponible.",a=[]):a=s.body,n=Math.min(n,Math.max(a.length-1,0)),u()},E=s=>{let d=s.target;if(document.querySelector("dialog[open]")!==null||d!==null&&["INPUT","TEXTAREA","SELECT"].includes(d.tagName))return;let m=a[n];if(s.key==="j"||s.key==="ArrowDown")n=Math.min(n+1,a.length-1),u();else if(s.key==="k"||s.key==="ArrowUp")n=Math.max(n-1,0),u();else if(s.key==="a"&&m!==void 0)p(m,"approve");else if(s.key==="r"&&m!==void 0)p(m,"reject");else return;s.preventDefault()};return window.addEventListener("keydown",E),b(),{element:c,dispose:()=>window.removeEventListener("keydown",E)}}function $(t){return new Promise(a=>{let n=t==="reject",o=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),r=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),i=e("button",{type:"button"},"Annuler"),l=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),o,e("div",{class:"actions"},i,r)),c=e("dialog",{class:"signature","data-testid":"comment-dialog"},l),u=f=>{c.close(),c.remove(),a(f)};l.addEventListener("submit",f=>{f.preventDefault();let p=o.value.trim();if(n&&p===""){o.focus();return}u(p)}),i.addEventListener("click",()=>u(null)),c.addEventListener("cancel",f=>{f.preventDefault(),u(null)}),document.body.append(c),c.showModal(),o.focus()})}var B={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function k(t,a,n,o){let r=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");r.addEventListener("click",o);let i=e("header",{class:"security-bar"},e("span",{class:"brand"},"Open eIDAS"),e("span",{class:"identity"},e("span",{class:"operator","data-testid":"operator"},n.operator),e("span",{class:`role role-${n.role}`,"data-testid":"role"},B[n.role])),r),l=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),c=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),u=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},e("li",{},"Demandes RA ",l),e("li",{},"Quorum ",c))),f=N(()=>{P(l,c)}),p=e("main",{class:"workspace",tabindex:"-1"},f.element);return v(t,T(a),i,e("div",{class:"layout"},u,p)),P(l,c),f.dispose}async function P(t,a){let[n,o]=await Promise.all([g("GET","/api/v1/requests?state=PENDING"),g("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",a.textContent=Array.isArray(o.body)?`(${o.body.length})`:"(\u2014)",(y(n.body)||y(o.body))&&(t.title=a.title="compteur indisponible")}function j(t){let a=e("div",{class:"idle-warning",role:"alert","data-testid":"idle-warning"},"Session inactive : verrouillage dans une minute. Une action au clavier ou \xE0 la souris la prolonge.");return t.prepend(a),a}async function G(t){let a=await g("GET","/api/v1/console"),n=a.body!==null&&!y(a.body)?a.body:{environment:"undeclared",version:"?"},o=null,r=null,i=f=>{o?.(),o=null,r?.(),r=null,_(t,n,c,f)},l=async f=>{await g("POST","/api/v1/logout"),i(f)},c=f=>{r=k(t,n,f,()=>{l()});let p=null;o=S(()=>{p??=j(t)},()=>{l("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let b=()=>{p?.remove(),p=null};window.addEventListener("keydown",b),window.addEventListener("pointerdown",b)},u=await g("GET","/api/v1/me");u.status===200&&u.body!==null&&!y(u.body)?c(u.body):i()}var q=document.getElementById("app");q!==null&&G(q); +async function v(t,o,n){let i={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(i.headers={"Content-Type":"application/json"},i.body=JSON.stringify(n));let a=await fetch(o,i),s=await a.text(),u=null;if(s!=="")try{u=JSON.parse(s)}catch{u=null}return{status:a.status,body:u}}function b(t){return typeof t=="object"&&t!==null&&"error"in t}var k=["keydown","pointerdown","wheel","touchstart"];function N(t,o){let n=0,i=0,a=()=>{window.clearTimeout(n),window.clearTimeout(i),n=window.setTimeout(t,84e4),i=window.setTimeout(o,9e5)};for(let s of k)window.addEventListener(s,a,{passive:!0});return a(),()=>{window.clearTimeout(n),window.clearTimeout(i);for(let s of k)window.removeEventListener(s,a)}}function e(t,o={},...n){let i=document.createElement(t);for(let[a,s]of Object.entries(o))i.setAttribute(a,s);for(let a of n)a==null||a===!1||i.append(typeof a=="string"?document.createTextNode(a):a);return i}function y(t,...o){t.replaceChildren(...o)}var z={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function S(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},z[t.environment])}function x(t){let o=new Uint8Array(t),n="";for(let i of o)n+=String.fromCharCode(i);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function M(t){let o=t.replace(/-/g,"+").replace(/_/g,"/"),n=o+"=".repeat((4-o.length%4)%4),i=atob(n),a=new Uint8Array(i.length);for(let s=0;s({type:a.type,id:M(a.id),...a.transports?{transports:a.transports}:{}})),...t.timeout!==void 0?{timeout:t.timeout}:{},...t.rpId!==void 0?{rpId:t.rpId}:{},...t.userVerification!==void 0?{userVerification:t.userVerification}:{}},n=await navigator.credentials.get({publicKey:o});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let i=n.response;return{id:n.id,rawId:x(n.rawId),type:n.type,response:{clientDataJSON:x(i.clientDataJSON),authenticatorData:x(i.authenticatorData),signature:x(i.signature),userHandle:i.userHandle?x(i.userHandle):null},extensions:{}}}function j(t,o,n,i){let a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},i??""),s=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),u=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),p=e("form",{class:"login-form","aria-labelledby":"login-title"},e("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),e("label",{for:"operator-name"},"Nom d'op\xE9rateur"),s,u,a);p.addEventListener("submit",r=>{r.preventDefault(),J(s.value.trim(),u,a,n)}),y(t,S(o),e("main",{class:"login"},p)),s.focus()}async function J(t,o,n,i){if(t!==""){o.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let a=await v("POST","/api/v1/webauthn/login/begin",{name:t});if(a.status!==200||a.body===null||b(a.body)){n.textContent="Connexion impossible pour le moment.";return}let s=await L(a.body.webauthn),u=await v("POST","/api/v1/webauthn/login/finish",{challenge_id:a.body.challenge_id,credential:s});if(u.status!==200||u.body===null||b(u.body)){n.textContent="Identifiants invalides.";return}i(u.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{o.disabled=!1}}}function E(t){let o=new Date(t);return Number.isNaN(o.getTime())?t:`${o.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var F={action_mismatch:"La signature ne correspond pas \xE0 cette action.",already_used:"Cette signature a d\xE9j\xE0 servi : relancez.",expired:"Le d\xE9lai de signature est d\xE9pass\xE9 : relancez.",denied:"Action refus\xE9e par l'autorit\xE9 (r\xF4le ou \xE9tat de la demande).",signature_rejected:"Signature refus\xE9e par l'autorit\xE9.",unauthenticated:"Session expir\xE9e : reconnectez-vous."};function O(t,o){return b(t)?F[t.error]??t.message:o}function C(t){return new Promise(o=>{let n=!1,i=!1,a=null,s=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),u=e("div",{class:"frozen","data-testid":"frozen"}),p=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),r=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),m=e("dl",{class:"summary"},...t.summary.flatMap(([c,g])=>[e("dt",{},c),e("dd",{},g)])),l=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),m,u,s,e("div",{class:"actions"},r,p)),f=c=>{l.close(),l.remove(),o(c)},h=async()=>{p.disabled=!0,s.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let c=await v("POST","/api/v1/webauthn/challenge",t.action);return c.status!==200||c.body===null||b(c.body)?(s.textContent=O(c.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(a=c.body,y(u,e("p",{class:"muted"},"Corps exact qui sera ex\xE9cut\xE9 (fig\xE9 par l'autorit\xE9) :"),e("pre",{class:"mono","data-testid":"frozen-body"},JSON.stringify(a.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(a.body_hash))),s.textContent="",p.disabled=!1,!0)},d=async()=>{if(a===null&&!await h())return;let c=a;if(c!==null){n=!0,p.disabled=!0,r.disabled=!0,s.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let g=await L(c.webauthn),w=await v("POST",t.route,{challenge_id:c.challenge_id,assertion:g});if(w.status===200&&w.body!==null&&!b(w.body)){i=!0;let _=w.body;s.textContent=_.status==="AWAITING_QUORUM"?"\u2713 Signature enregistr\xE9e.":"\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>f(_),800);return}a=null,s.textContent=O(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{s.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,i||(p.disabled=!1,r.disabled=!1)}}};l.addEventListener("cancel",c=>{c.preventDefault(),!n&&!i&&f(null)}),r.addEventListener("click",()=>{n||f(null)}),p.addEventListener("click",()=>{d()}),document.body.append(l),l.showModal(),h().then(()=>p.focus())})}var R=[[1,"keyCompromise \u2014 cl\xE9 priv\xE9e compromise"],[3,"affiliationChanged \u2014 rattachement modifi\xE9"],[4,"superseded \u2014 remplac\xE9"],[5,"cessationOfOperation \u2014 cessation d'activit\xE9"],[9,"privilegeWithdrawn \u2014 privil\xE8ge retir\xE9"]];function T(t){return(t.toUpperCase().match(/.{1,2}/g)??[]).join(":")}function D(t){let o=[],n=0,i=e("tbody",{}),a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"certificates-status"}),s=e("aside",{class:"inspector","aria-label":"Inspecteur"}),u=e("section",{},e("h1",{},"Certificats \xE9mis"),a,e("div",{class:"split"},e("table",{class:"dense","data-testid":"certificates"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Num\xE9ro de s\xE9rie"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"Expire le"))),i),s)),p=()=>{y(i,...o.map((h,d)=>{let c=e("tr",{"aria-selected":String(d===n),"data-testid":`cert-${h.serial_hex}`},e("td",{},e("span",{class:"badge valid"},"\u25CF actif")),e("td",{class:"mono"},T(h.serial_hex)),e("td",{},h.subject_dn),e("td",{},h.profile),e("td",{class:"mono"},h.not_after?E(h.not_after):"\u2014"));return c.addEventListener("click",()=>{n=d,p()}),c}));let l=o[n];if(l===void 0){y(s,e("p",{class:"muted"},"Aucun certificat actif."));return}let f=e("button",{type:"button",class:"danger","data-testid":"revoke"},"R\xE9voquer le certificat\u2026");f.addEventListener("click",()=>{r(l)}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(l.serial_hex)),e("dt",{},"Sujet"),e("dd",{},l.subject_dn),e("dt",{},"Profil"),e("dd",{},l.profile),e("dt",{},"Expire le"),e("dd",{class:"mono"},l.not_after?E(l.not_after):"\u2014")),e("p",{class:"muted"},"La r\xE9vocation exige la signature de deux op\xE9rateurs CA distincts."),e("div",{class:"actions"},f))},r=async l=>{let f=await K();if(f===null)return;let[h,d]=R.find(([g])=>g===f.reason)??[f.reason,String(f.reason)],c=await C({title:"R\xE9vocation de certificat",summary:[["Action","r\xE9voquer d\xE9finitivement le certificat"],["Num\xE9ro de s\xE9rie",T(l.serial_hex)],["Sujet",l.subject_dn],["Motif RFC 5280",`${d} (${h})`],["Justification",f.comment]],action:{action:"revoke_certificate",serial:l.serial_hex,reason:f.reason,comment:f.comment},route:`/api/v1/certificates/${l.serial_hex}/revoke`});c!==null&&(a.textContent=c.status==="AWAITING_QUORUM"?`Signature enregistr\xE9e (${String(c.signatures)} sur ${String(c.required)}) : en attente d'un second op\xE9rateur CA, voir la salle de quorum.`:`Certificat ${T(l.serial_hex)} r\xE9voqu\xE9.`,await m(),t())},m=async()=>{let l=await v("GET","/api/v1/certificates?status=issued");o=Array.isArray(l.body)?l.body:[],b(l.body)&&(a.textContent=l.body.message),n=Math.min(n,Math.max(o.length-1,0)),p()};return m(),{element:u,dispose:()=>{}}}function K(){return new Promise(t=>{let o=e("select",{id:"revocation-reason",required:"","data-testid":"reason"},e("option",{value:""},"\u2014 choisir un motif \u2014"),...R.map(([r,m])=>e("option",{value:String(r)},m))),n=e("textarea",{id:"revocation-comment",rows:"3",maxlength:"1000",required:"","data-testid":"comment"}),i=e("button",{type:"submit",class:"danger","data-testid":"comment-next"},"Pr\xE9parer la signature"),a=e("button",{type:"button"},"Annuler"),s=e("form",{method:"dialog"},e("h2",{},"R\xE9vocation : motif et justification"),e("label",{for:"revocation-reason"},"Motif (RFC 5280)"),o,e("label",{for:"revocation-comment"},"Justification consign\xE9e au journal (obligatoire)"),n,e("div",{class:"actions"},a,i)),u=e("dialog",{class:"signature","data-testid":"revocation-dialog"},s),p=r=>{u.close(),u.remove(),t(r)};s.addEventListener("submit",r=>{r.preventDefault();let m=Number(o.value),l=n.value.trim();!R.some(([f])=>f===m)||l===""||p({reason:m,comment:l})}),a.addEventListener("click",()=>p(null)),u.addEventListener("cancel",r=>{r.preventDefault(),p(null)}),document.body.append(u),u.showModal(),o.focus()})}var q={revoke_certificate:"R\xE9vocation de certificat",set_role:"Changement de r\xF4le",invite_operator:"Invitation d'un op\xE9rateur"};function $(t,o){let n=e("div",{class:"quorum","data-testid":"quorum"}),i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"quorum-status"}),a=e("section",{},e("h1",{},"Salle de quorum"),i,n),s=r=>{let m=r.signed_by.includes(t.operator),l=e("button",{type:"button",class:"primary","data-testid":`cosign-${r.action_id}`,...m?{disabled:""}:{}},"Co-signer avec ma cl\xE9 FIDO2");l.addEventListener("click",()=>{u(r)});let f=typeof r.body.serial=="string"?r.body.serial:null;return e("article",{class:"card","data-testid":`pending-${r.action_id}`},e("h2",{},q[r.action]??r.action),e("dl",{},e("dt",{},"Signatures"),e("dd",{"data-testid":`progress-${r.action_id}`},`${r.signatures} sur ${r.required}`),e("dt",{},"D\xE9j\xE0 sign\xE9 par"),e("dd",{},r.signed_by.join(", ")||"\u2014"),...f?[e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(f))]:[],e("dt",{},"Initi\xE9e le"),e("dd",{class:"mono"},E(r.created_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},E(r.expires_at)),e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},I(r.body_hash))),e("pre",{class:"mono"},JSON.stringify(r.body,null,2)),m?e("p",{class:"muted","data-testid":`own-${r.action_id}`},"Le double contr\xF4le requiert un op\xE9rateur distinct : vous avez d\xE9j\xE0 sign\xE9."):null,e("div",{class:"actions"},l))},u=async r=>{let m=await C({title:`Co-signature : ${q[r.action]??r.action}`,summary:[["Action",q[r.action]??r.action],["D\xE9j\xE0 sign\xE9 par",r.signed_by.join(", ")],["Signatures",`${r.signatures+1} sur ${r.required} apr\xE8s la v\xF4tre`]],action:{action_id:r.action_id},route:`/api/v1/quorum/${r.action_id}/sign`});m!==null&&(i.textContent=m.status==="EXECUTED"?"Action ex\xE9cut\xE9e par l'autorit\xE9.":"Signature enregistr\xE9e.",await p(),o())},p=async()=>{let r=await v("GET","/api/v1/quorum?state=PENDING"),m=Array.isArray(r.body)?r.body:[];b(r.body)&&(i.textContent=r.body.message),y(n,...m.length===0?[e("p",{class:"muted"},"Aucune action en attente de signature.")]:m.map(s))};return p(),{element:a,dispose:()=>{}}}function V(t){let o=[],n=0,i=e("tbody",{}),a=e("table",{class:"dense","aria-label":"Demandes en attente","data-testid":"requests"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Transaction"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"D\xE9pos\xE9e le"))),i),s=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),u=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),p=e("section",{class:"requests"},e("h1",{},"Demandes d'enr\xF4lement en attente"),e("p",{class:"muted"},"j/k : se d\xE9placer \xB7 a : approuver \xB7 r : rejeter"),u,e("div",{class:"split"},a,s)),r=()=>{y(i,...o.map((d,c)=>{let g=e("tr",{"aria-selected":String(c===n),"data-testid":`row-${d.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},d.transaction_id),e("td",{},d.subject_cn),e("td",{},d.profile),e("td",{class:"mono"},E(d.created_at)));return g.addEventListener("click",()=>{n=c,r()}),g})),o.length===0&&y(i,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),m()},m=()=>{let d=o[n];if(d===void 0){y(s,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let c=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),g=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");c.addEventListener("click",()=>{l(d,"approve")}),g.addEventListener("click",()=>{l(d,"reject")}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},d.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},d.subject_cn),e("dt",{},"Profil"),e("dd",{},d.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},E(d.created_at))),e("div",{class:"actions"},c,g))},l=async(d,c)=>{let g=await W(c);if(g===null)return;let w=c==="approve";await C({title:w?`Approbation de la demande ${d.transaction_id}`:`Rejet de la demande ${d.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",d.transaction_id],["Sujet (CN)",d.subject_cn],["Profil",d.profile],["Justification",g===""?"\u2014":g]],action:{action:w?"approve_request":"reject_request",transaction_id:d.transaction_id,comment:g},route:`/api/v1/requests/${encodeURIComponent(d.transaction_id)}/${c}`})!==null&&(u.textContent=w?`Demande ${d.transaction_id} approuv\xE9e.`:`Demande ${d.transaction_id} rejet\xE9e.`,await f(),t())},f=async()=>{let d=await v("GET","/api/v1/requests?state=PENDING");d.status!==200||!Array.isArray(d.body)?(u.textContent=b(d.body)?d.body.message:"File indisponible.",o=[]):o=d.body,n=Math.min(n,Math.max(o.length-1,0)),r()},h=d=>{let c=d.target;if(document.querySelector("dialog[open]")!==null||c!==null&&["INPUT","TEXTAREA","SELECT"].includes(c.tagName))return;let g=o[n];if(d.key==="j"||d.key==="ArrowDown")n=Math.min(n+1,o.length-1),r();else if(d.key==="k"||d.key==="ArrowUp")n=Math.max(n-1,0),r();else if(d.key==="a"&&g!==void 0)l(g,"approve");else if(d.key==="r"&&g!==void 0)l(g,"reject");else return;d.preventDefault()};return window.addEventListener("keydown",h),f(),{element:p,dispose:()=>window.removeEventListener("keydown",h)}}function W(t){return new Promise(o=>{let n=t==="reject",i=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),a=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),s=e("button",{type:"button"},"Annuler"),u=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),i,e("div",{class:"actions"},s,a)),p=e("dialog",{class:"signature","data-testid":"comment-dialog"},u),r=m=>{p.close(),p.remove(),o(m)};u.addEventListener("submit",m=>{m.preventDefault();let l=i.value.trim();if(n&&l===""){i.focus();return}r(l)}),s.addEventListener("click",()=>r(null)),p.addEventListener("cancel",m=>{m.preventDefault(),r(null)}),document.body.append(p),p.showModal(),i.focus()})}var Q={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function H(t,o,n,i){let a=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");a.addEventListener("click",i);let s=e("header",{class:"security-bar"},e("span",{class:"brand"},"Open eIDAS"),e("span",{class:"identity"},e("span",{class:"operator","data-testid":"operator"},n.operator),e("span",{class:`role role-${n.role}`,"data-testid":"role"},Q[n.role])),a),u=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),p=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),r=()=>{X(u,p)},m=e("main",{class:"workspace",tabindex:"-1"}),l=null,f=[["requests","Demandes RA ",u,()=>V(r)],["certificates","Certificats",null,()=>D(r)],["quorum","Quorum ",p,()=>$(n,r)]],h=f.map(([w,_,A,B])=>{let P=e("button",{type:"button",class:"nav","data-testid":`nav-${w}`},_,A);return P.addEventListener("click",()=>d(w,B)),P}),d=(w,_)=>{l?.dispose(),l=_(),y(m,l.element);for(let A of h)A.setAttribute("aria-current",String(A.dataset.testid===`nav-${w}`))},c=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},...h.map(w=>e("li",{},w))));y(t,S(o),s,e("div",{class:"layout"},c,m));let g=f[0];return d(g[0],g[3]),r(),()=>l?.dispose()}async function X(t,o){let[n,i]=await Promise.all([v("GET","/api/v1/requests?state=PENDING"),v("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",o.textContent=Array.isArray(i.body)?`(${i.body.length})`:"(\u2014)",(b(n.body)||b(i.body))&&(t.title=o.title="compteur indisponible")}function G(t){let o=e("div",{class:"idle-warning",role:"alert","data-testid":"idle-warning"},"Session inactive : verrouillage dans une minute. Une action au clavier ou \xE0 la souris la prolonge.");return t.prepend(o),o}async function Y(t){let o=await v("GET","/api/v1/console"),n=o.body!==null&&!b(o.body)?o.body:{environment:"undeclared",version:"?"},i=null,a=null,s=m=>{i?.(),i=null,a?.(),a=null,j(t,n,p,m)},u=async m=>{await v("POST","/api/v1/logout"),s(m)},p=m=>{a=H(t,n,m,()=>{u()});let l=null;i=N(()=>{l??=G(t)},()=>{u("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let f=()=>{l?.remove(),l=null};window.addEventListener("keydown",f),window.addEventListener("pointerdown",f)},r=await v("GET","/api/v1/me");r.status===200&&r.body!==null&&!b(r.body)?p(r.body):s()}var U=document.getElementById("app");U!==null&&Y(U); diff --git a/bin/ra-console/web/e2e/helpers.ts b/bin/ra-console/web/e2e/helpers.ts index 4cf2557..ac1b92e 100644 --- a/bin/ra-console/web/e2e/helpers.ts +++ b/bin/ra-console/web/e2e/helpers.ts @@ -6,10 +6,14 @@ import { expect, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; import { fixturePath } from "../playwright.config"; +type Credential = Record & { signCount: number }; + export interface Fixture { operator: string; pending: string[]; - credential: Record & { signCount: number }; + certificates: string[]; + credential: Credential; + operators: Record; } export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; @@ -19,7 +23,8 @@ export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8 // titre) un clone. Module partagé : le compteur croît d'un fichier à l'autre. let signCountBase = 1000; -export async function withOperatorKey(page: Page): Promise { +export async function withOperatorKey(page: Page, who?: string): Promise { + const credential = who === undefined ? fixture().credential : fixture().operators[who]!.credential; const cdp = await page.context().newCDPSession(page); await cdp.send("WebAuthn.enable"); const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { @@ -38,7 +43,7 @@ export async function withOperatorKey(page: Page): Promise { signCountBase += 1000; await cdp.send("WebAuthn.addCredential", { authenticatorId, - credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, + credential: { ...credential, signCount: credential.signCount + signCountBase }, } as never); } @@ -53,8 +58,8 @@ export function collectErrors(page: Page): string[] { return errors; } -export async function logIn(page: Page): Promise { - await page.getByTestId("login-name").fill(fixture().operator); +export async function logIn(page: Page, who: string = fixture().operator): Promise { + await page.getByTestId("login-name").fill(who); await page.getByTestId("login-submit").click(); - await expect(page.getByTestId("operator")).toHaveText(fixture().operator); + await expect(page.getByTestId("operator")).toHaveText(who); } diff --git a/bin/ra-console/web/e2e/revocation.spec.ts b/bin/ra-console/web/e2e/revocation.spec.ts new file mode 100644 index 0000000..275c25b --- /dev/null +++ b/bin/ra-console/web/e2e/revocation.spec.ts @@ -0,0 +1,87 @@ +// Révocation à deux dans le navigateur (docs/WEBUI.md §8, §15 étape 6c, +// docs/UI-UX.md §3.2) : bob signe la révocation, rien n'est révoqué ; il ne +// peut pas co-signer lui-même ; carol co-signe, ca-server exécute. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, fixture, logIn, withOperatorKey } from "./helpers"; + +async function statusOf(page: Page, serial: string): Promise { + for (const status of ["issued", "revoked"]) { + const list = (await (await page.request.get(`/api/v1/certificates?status=${status}`)).json()) as { + serial_hex: string; + }[]; + if (list.some((c) => c.serial_hex === serial)) return status; + } + return undefined; +} + +test("deux opérateurs CA distincts révoquent un certificat", async ({ browser }) => { + const serial = fixture().certificates[0]!; + + // bob : première signature. + const bobContext = await browser.newContext(); + const bob = await bobContext.newPage(); + const errors = collectErrors(bob); + await withOperatorKey(bob, "bob"); + await bob.goto("/"); + await logIn(bob, "bob"); + await bob.getByTestId("nav-certificates").click(); + await bob.getByTestId(`cert-${serial}`).click(); + await bob.getByTestId("revoke").click(); + await bob.getByTestId("reason").selectOption("1"); + await bob.getByTestId("comment").fill("clé exposée, ticket CERT-FR #2026-991"); + await bob.getByTestId("comment-next").click(); + const body = bob.getByTestId("frozen-body"); + await expect(body).toContainText(`"serial": "${serial}"`); + await expect(body).toContainText(`"reason": 1`); + await bob.getByTestId("sign").click(); + await expect(bob.getByTestId("sign-dialog")).toBeHidden(); + await expect(bob.getByTestId("certificates-status")).toContainText("1 sur 2"); + expect(await statusOf(bob, serial)).toBe("issued"); + + // bob ne peut pas co-signer sa propre action. + await bob.getByTestId("nav-quorum").click(); + const card = bob.locator("article", { hasText: serial.toUpperCase().match(/.{2}/g)!.join(":") }); + await expect(card).toContainText("1 sur 2"); + await expect(card.getByRole("button", { name: /Co-signer/ })).toBeDisabled(); + await expect(card).toContainText("opérateur distinct"); + expect(errors).toEqual([]); + + // carol co-signe : la révocation s'exécute. + const carolContext = await browser.newContext(); + const carol = await carolContext.newPage(); + await withOperatorKey(carol, "carol"); + await carol.goto("/"); + await logIn(carol, "carol"); + await expect(carol.getByTestId("count-quorum")).toHaveText("(1)"); + await carol.getByTestId("nav-quorum").click(); + const pending = carol.locator("article", { hasText: "bob" }); + await pending.getByRole("button", { name: /Co-signer/ }).click(); + await expect(carol.getByTestId("frozen-body")).toContainText(`"serial": "${serial}"`); + await carol.getByTestId("sign").click(); + await expect(carol.getByTestId("sign-dialog")).toBeHidden(); + await expect(carol.getByTestId("quorum-status")).toContainText("exécutée"); + expect(await statusOf(carol, serial)).toBe("revoked"); + await expect(carol.getByTestId("count-quorum")).toHaveText("(0)"); + + await bobContext.close(); + await carolContext.close(); +}); + +test("un opérateur RA ne peut pas lancer de révocation", async ({ page }) => { + const serial = fixture().certificates[1]!; + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await page.getByTestId("nav-certificates").click(); + await page.getByTestId(`cert-${serial}`).click(); + await page.getByTestId("revoke").click(); + await page.getByTestId("reason").selectOption("4"); + await page.getByTestId("comment").fill("essai"); + await page.getByTestId("comment-next").click(); + // L'autorité refuse de préparer l'action : la modale le dit, rien n'est figé. + await expect(page.getByTestId("sign-status")).toContainText("refusée"); + await expect(page.getByTestId("frozen-body")).toHaveCount(0); + await page.keyboard.press("Escape"); + expect(await statusOf(page, serial)).toBe("issued"); +}); diff --git a/bin/ra-console/web/src/certificates.ts b/bin/ra-console/web/src/certificates.ts new file mode 100644 index 0000000..9ee8f6e --- /dev/null +++ b/bin/ra-console/web/src/certificates.ts @@ -0,0 +1,184 @@ +// Certificats émis et révocation (docs/UI-UX.md §3.1, §4.2, §5 ; docs/WEBUI.md +// §8) : la révocation exige deux ca_operateur distincts, la première signature +// part en salle de quorum. + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Certificate { + serial_hex: string; + profile: string; + subject_dn: string; + not_after: string | null; + status: string; +} + +/// Motifs admis par ca-server (RFC 5280 §5.3.1) ; « unspecified » n'en fait pas partie. +const REASONS: [number, string][] = [ + [1, "keyCompromise — clé privée compromise"], + [3, "affiliationChanged — rattachement modifié"], + [4, "superseded — remplacé"], + [5, "cessationOfOperation — cessation d'activité"], + [9, "privilegeWithdrawn — privilège retiré"], +]; + +/// Numéro de série par paires séparées par deux-points (UI-UX §4.2). +export function pairs(hex: string): string { + return (hex.toUpperCase().match(/.{1,2}/g) ?? []).join(":"); +} + +export function certificatesView(onSigned: () => void): View { + let rows: Certificate[] = []; + let selected = 0; + const body = h("tbody", {}); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "certificates-status" }); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur" }); + const element = h( + "section", + {}, + h("h1", {}, "Certificats émis"), + notice, + h( + "div", + { class: "split" }, + h( + "table", + { class: "dense", "data-testid": "certificates" }, + h("thead", {}, h("tr", {}, h("th", {}, "Statut"), h("th", {}, "Numéro de série"), h("th", {}, "Sujet"), h("th", {}, "Profil"), h("th", {}, "Expire le"))), + body, + ), + inspector, + ), + ); + + const render = (): void => { + replace( + body, + ...rows.map((c, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `cert-${c.serial_hex}` }, + h("td", {}, h("span", { class: "badge valid" }, "● actif")), + h("td", { class: "mono" }, pairs(c.serial_hex)), + h("td", {}, c.subject_dn), + h("td", {}, c.profile), + h("td", { class: "mono" }, c.not_after ? utc(c.not_after) : "—"), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + const c = rows[selected]; + if (c === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucun certificat actif.")); + return; + } + const revoke = h("button", { type: "button", class: "danger", "data-testid": "revoke" }, "Révoquer le certificat…"); + revoke.addEventListener("click", () => void startRevocation(c)); + replace( + inspector, + h("h2", {}, "Général"), + h( + "dl", + {}, + h("dt", {}, "Numéro de série"), + h("dd", { class: "mono" }, pairs(c.serial_hex)), + h("dt", {}, "Sujet"), + h("dd", {}, c.subject_dn), + h("dt", {}, "Profil"), + h("dd", {}, c.profile), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, c.not_after ? utc(c.not_after) : "—"), + ), + h("p", { class: "muted" }, "La révocation exige la signature de deux opérateurs CA distincts."), + h("div", { class: "actions" }, revoke), + ); + }; + + const startRevocation = async (c: Certificate): Promise => { + const choice = await askRevocation(); + if (choice === null) return; + const [code, label] = REASONS.find(([r]) => r === choice.reason) ?? [choice.reason, String(choice.reason)]; + const result = await sign({ + title: "Révocation de certificat", + summary: [ + ["Action", "révoquer définitivement le certificat"], + ["Numéro de série", pairs(c.serial_hex)], + ["Sujet", c.subject_dn], + ["Motif RFC 5280", `${label} (${code})`], + ["Justification", choice.comment], + ], + action: { action: "revoke_certificate", serial: c.serial_hex, reason: choice.reason, comment: choice.comment }, + route: `/api/v1/certificates/${c.serial_hex}/revoke`, + }); + if (result === null) return; + notice.textContent = + result.status === "AWAITING_QUORUM" + ? `Signature enregistrée (${String(result.signatures)} sur ${String(result.required)}) : en attente d'un second opérateur CA, voir la salle de quorum.` + : `Certificat ${pairs(c.serial_hex)} révoqué.`; + await load(); + onSigned(); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/certificates?status=issued"); + rows = Array.isArray(reply.body) ? reply.body : []; + if (isError(reply.body)) notice.textContent = reply.body.message; + selected = Math.min(selected, Math.max(rows.length - 1, 0)); + render(); + }; + + void load(); + return { element, dispose: () => undefined }; +} + +function askRevocation(): Promise<{ reason: number; comment: string } | null> { + return new Promise((resolve) => { + const reason = h( + "select", + { id: "revocation-reason", required: "", "data-testid": "reason" }, + h("option", { value: "" }, "— choisir un motif —"), + ...REASONS.map(([code, label]) => h("option", { value: String(code) }, label)), + ); + const comment = h("textarea", { id: "revocation-comment", rows: "3", maxlength: "1000", required: "", "data-testid": "comment" }); + const next = h("button", { type: "submit", class: "danger", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h( + "form", + { method: "dialog" }, + h("h2", {}, "Révocation : motif et justification"), + h("label", { for: "revocation-reason" }, "Motif (RFC 5280)"), + reason, + h("label", { for: "revocation-comment" }, "Justification consignée au journal (obligatoire)"), + comment, + h("div", { class: "actions" }, cancel, next), + ); + const dialog = h("dialog", { class: "signature", "data-testid": "revocation-dialog" }, form); + const finish = (value: { reason: number; comment: string } | null): void => { + dialog.close(); + dialog.remove(); + resolve(value); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const code = Number(reason.value); + const text = comment.value.trim(); + if (!REASONS.some(([r]) => r === code) || text === "") return; + finish({ reason: code, comment: text }); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + document.body.append(dialog); + dialog.showModal(); + reason.focus(); + }); +} diff --git a/bin/ra-console/web/src/quorum.ts b/bin/ra-console/web/src/quorum.ts new file mode 100644 index 0000000..e35918c --- /dev/null +++ b/bin/ra-console/web/src/quorum.ts @@ -0,0 +1,95 @@ +// Salle d'attente du double contrôle (docs/UI-UX.md §3.2, docs/WEBUI.md §8) : +// ce qui attend une signature de plus, qui a déjà signé, et la co-signature — +// refusée d'avance à qui a déjà signé (l'autorité la refuserait de toute façon). + +import { call, isError, type Me } from "./api"; +import { pairs } from "./certificates"; +import { h, replace } from "./dom"; +import { groupedHash, utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Pending { + action_id: string; + action: string; + body: Record; + body_hash: string; + required: number; + signatures: number; + signed_by: string[]; + created_at: string; + expires_at: string; +} + +const LABELS: Record = { + revoke_certificate: "Révocation de certificat", + set_role: "Changement de rôle", + invite_operator: "Invitation d'un opérateur", +}; + +export function quorumView(me: Me, onSigned: () => void): View { + const list = h("div", { class: "quorum", "data-testid": "quorum" }); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "quorum-status" }); + const element = h("section", {}, h("h1", {}, "Salle de quorum"), notice, list); + + const card = (p: Pending): HTMLElement => { + const mine = p.signed_by.includes(me.operator); + const cosign = h( + "button", + { type: "button", class: "primary", "data-testid": `cosign-${p.action_id}`, ...(mine ? { disabled: "" } : {}) }, + "Co-signer avec ma clé FIDO2", + ); + cosign.addEventListener("click", () => void coSign(p)); + const serial = typeof p.body.serial === "string" ? p.body.serial : null; + return h( + "article", + { class: "card", "data-testid": `pending-${p.action_id}` }, + h("h2", {}, LABELS[p.action] ?? p.action), + h( + "dl", + {}, + h("dt", {}, "Signatures"), + h("dd", { "data-testid": `progress-${p.action_id}` }, `${p.signatures} sur ${p.required}`), + h("dt", {}, "Déjà signé par"), + h("dd", {}, p.signed_by.join(", ") || "—"), + ...(serial ? [h("dt", {}, "Numéro de série"), h("dd", { class: "mono" }, pairs(serial))] : []), + h("dt", {}, "Initiée le"), + h("dd", { class: "mono" }, utc(p.created_at)), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, utc(p.expires_at)), + h("dt", {}, "Empreinte"), + h("dd", { class: "mono hash" }, groupedHash(p.body_hash)), + ), + h("pre", { class: "mono" }, JSON.stringify(p.body, null, 2)), + mine ? h("p", { class: "muted", "data-testid": `own-${p.action_id}` }, "Le double contrôle requiert un opérateur distinct : vous avez déjà signé.") : null, + h("div", { class: "actions" }, cosign), + ); + }; + + const coSign = async (p: Pending): Promise => { + const result = await sign({ + title: `Co-signature : ${LABELS[p.action] ?? p.action}`, + summary: [ + ["Action", LABELS[p.action] ?? p.action], + ["Déjà signé par", p.signed_by.join(", ")], + ["Signatures", `${p.signatures + 1} sur ${p.required} après la vôtre`], + ], + action: { action_id: p.action_id }, + route: `/api/v1/quorum/${p.action_id}/sign`, + }); + if (result === null) return; + notice.textContent = result.status === "EXECUTED" ? "Action exécutée par l'autorité." : "Signature enregistrée."; + await load(); + onSigned(); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/quorum?state=PENDING"); + const pending = Array.isArray(reply.body) ? reply.body : []; + if (isError(reply.body)) notice.textContent = reply.body.message; + replace(list, ...(pending.length === 0 ? [h("p", { class: "muted" }, "Aucune action en attente de signature.")] : pending.map(card))); + }; + + void load(); + return { element, dispose: () => undefined }; +} diff --git a/bin/ra-console/web/src/requests.ts b/bin/ra-console/web/src/requests.ts index 096f0e5..0fd5f4e 100644 --- a/bin/ra-console/web/src/requests.ts +++ b/bin/ra-console/web/src/requests.ts @@ -15,13 +15,9 @@ interface EnrollmentRequest { created_at: string; } -export interface RequestsView { - element: HTMLElement; - /// Retire les raccourcis clavier quand la vue est quittée. - dispose: () => void; -} +import type { View } from "./view"; -export function requestsView(onDecided: () => void): RequestsView { +export function requestsView(onDecided: () => void): View { let rows: EnrollmentRequest[] = []; let selected = 0; @@ -122,7 +118,7 @@ export function requestsView(onDecided: () => void): RequestsView { }, route: `/api/v1/requests/${encodeURIComponent(r.transaction_id)}/${kind}`, }); - if (ok) { + if (ok !== null) { notice.textContent = approving ? `Demande ${r.transaction_id} approuvée.` : `Demande ${r.transaction_id} rejetée.`; await load(); onDecided(); diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts index 2a088b7..a76cb8d 100644 --- a/bin/ra-console/web/src/shell.ts +++ b/bin/ra-console/web/src/shell.ts @@ -6,7 +6,10 @@ import { call, isError, type ConsoleInfo, type Me } from "./api"; import { banner } from "./banner"; import { h, replace } from "./dom"; +import { certificatesView } from "./certificates"; +import { quorumView } from "./quorum"; import { requestsView } from "./requests"; +import type { View } from "./view"; const ROLE_LABELS: Record = { auditeur: "auditeur", @@ -32,16 +35,31 @@ export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogo ); const requests = h("span", { class: "count", "data-testid": "count-requests" }, "…"); const quorum = h("span", { class: "count", "data-testid": "count-quorum" }, "…"); - const nav = h( - "nav", - { class: "sidebar", "aria-label": "Files de travail" }, - h("ul", {}, h("li", {}, "Demandes RA ", requests), h("li", {}, "Quorum ", quorum)), - ); - const view = requestsView(() => void refreshCounts(requests, quorum)); - const work = h("main", { class: "workspace", tabindex: "-1" }, view.element); + const refresh = (): void => void refreshCounts(requests, quorum); + const work = h("main", { class: "workspace", tabindex: "-1" }); + let current: View | null = null; + const views: [string, string, HTMLElement | null, () => View][] = [ + ["requests", "Demandes RA ", requests, () => requestsView(refresh)], + ["certificates", "Certificats", null, () => certificatesView(refresh)], + ["quorum", "Quorum ", quorum, () => quorumView(me, refresh)], + ]; + const buttons = views.map(([id, label, count, make]) => { + const button = h("button", { type: "button", class: "nav", "data-testid": `nav-${id}` }, label, count); + button.addEventListener("click", () => show(id, make)); + return button; + }); + const show = (id: string, make: () => View): void => { + current?.dispose(); + current = make(); + replace(work, current.element); + for (const b of buttons) b.setAttribute("aria-current", String(b.dataset.testid === `nav-${id}`)); + }; + const nav = h("nav", { class: "sidebar", "aria-label": "Files de travail" }, h("ul", {}, ...buttons.map((b) => h("li", {}, b)))); replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); - void refreshCounts(requests, quorum); - return view.dispose; + const first = views[0]!; + show(first[0], first[3]); + refresh(); + return () => current?.dispose(); } async function refreshCounts(requests: HTMLElement, quorum: HTMLElement): Promise { diff --git a/bin/ra-console/web/src/sign.ts b/bin/ra-console/web/src/sign.ts index 79e0b56..7db45e9 100644 --- a/bin/ra-console/web/src/sign.ts +++ b/bin/ra-console/web/src/sign.ts @@ -46,8 +46,9 @@ function explain(body: unknown, fallback: string): string { return fallback; } -/// Ouvre la modale ; rend `true` si l'action a été signée et relayée avec succès. -export function sign(signing: Signing): Promise { +/// Ouvre la modale ; rend la réponse de l'autorité si l'action a été signée et +/// relayée avec succès, `null` sinon. +export function sign(signing: Signing): Promise | null> { return new Promise((resolve) => { let busy = false; let done = false; @@ -72,7 +73,7 @@ export function sign(signing: Signing): Promise { h("div", { class: "actions" }, cancel, signButton), ); - const close = (result: boolean): void => { + const close = (result: Record | null): void => { dialog.close(); dialog.remove(); resolve(result); @@ -109,11 +110,16 @@ export function sign(signing: Signing): Promise { status.textContent = "Touchez votre clé de sécurité matérielle…"; try { const assertion = await assert(current.webauthn); - const reply = await call("POST", signing.route, { challenge_id: current.challenge_id, assertion }); - if (reply.status === 200) { + const reply = await call>("POST", signing.route, { + challenge_id: current.challenge_id, + assertion, + }); + if (reply.status === 200 && reply.body !== null && !isError(reply.body)) { done = true; - status.textContent = "✓ Action signée et exécutée."; - window.setTimeout(() => close(true), 800); + const result = reply.body; + status.textContent = + result.status === "AWAITING_QUORUM" ? "✓ Signature enregistrée." : "✓ Action signée et exécutée."; + window.setTimeout(() => close(result), 800); return; } // Un challenge consommé ou expiré ne resservira pas : on en redemande un. @@ -132,10 +138,10 @@ export function sign(signing: Signing): Promise { dialog.addEventListener("cancel", (event) => { event.preventDefault(); - if (!busy && !done) close(false); + if (!busy && !done) close(null); }); cancel.addEventListener("click", () => { - if (!busy) close(false); + if (!busy) close(null); }); signButton.addEventListener("click", () => void ceremony()); diff --git a/bin/ra-console/web/src/view.ts b/bin/ra-console/web/src/view.ts new file mode 100644 index 0000000..57bebe3 --- /dev/null +++ b/bin/ra-console/web/src/view.ts @@ -0,0 +1,6 @@ +// Une vue de la zone de travail : son élément, et de quoi la quitter proprement +// (raccourcis clavier, minuteries). +export interface View { + element: HTMLElement; + dispose: () => void; +} diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css index e3c4755..86b2258 100644 --- a/bin/ra-console/web/static/console.css +++ b/bin/ra-console/web/static/console.css @@ -324,3 +324,61 @@ dialog.signature textarea { font-size: 13px; word-spacing: 0.4em; } + +/* --- 6c : navigation, certificats, salle de quorum (UI-UX §2.2, §3.2) --- */ + +button.nav { + width: 100%; + text-align: left; + background: transparent; + border-color: transparent; +} +button.nav[aria-current="true"] { + background: var(--bg-surface-elevated); + border-color: var(--border-subtle); + box-shadow: inset 3px 0 0 var(--border-focus); +} +button.danger { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; + font-weight: 600; +} +button.danger:hover { + background: #ef4444; +} +.badge.valid { + background: #064e3b; + color: #34d399; + border: 1px solid #059669; +} +select { + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; + width: 100%; +} +.quorum { + display: grid; + gap: 16px; +} +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-left: 3px dashed #d97706; + border-radius: 6px; + padding: 16px; +} +.card pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 200px; + font-size: 12px; +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index fc47f4a..f4c99a2 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -162,7 +162,16 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). empreinte SHA-256, avant tout geste sur la clé. Une erreur laisse la modale ouverte ; un challenge consommé ou expiré est redemandé au besoin. Échap annule, sauf pendant la cérémonie matérielle. -- Révocation, salle de quorum et audit suivent (étapes 6c et suivantes). +- **Certificats et révocation (6c)** : `GET /api/v1/certificates?status=issued|revoked` + (lecture seule de la table de `ca-server`, numéro de série sous la forme canonique + qu'attend la révocation). L'écran liste les certificats actifs ; « Révoquer… » demande + un motif RFC 5280 parmi ceux qu'admet `ca-server` (1, 3, 4, 5, 9) et une + justification obligatoire, puis la modale de signature. La première signature part + en salle de quorum. +- **Salle de quorum (6c)** : les actions en attente, leur corps figé et leur empreinte, + qui a déjà signé ; la co-signature est désactivée pour qui a déjà signé (« le double + contrôle requiert un opérateur distinct » — `ca-server` la refuserait de toute façon). +- L'explorateur d'audit suit (6d, après #51). ## Variables d'environnement