Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion bin/ca-server/src/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<oe_actions::Expect>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -160,7 +166,15 @@ async fn handle_actions(State(service): State<Arc<Service>>, 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,
Expand Down
6 changes: 6 additions & 0 deletions bin/ra-console/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
211 changes: 211 additions & 0 deletions bin/ra-console/examples/e2e_console.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! Harnais des tests de bout en bout du frontend (bin/ra-console/web/e2e,
//! Playwright) : une console réelle (`ra_console::http::app`, assets embarqués
//! et en-têtes de sécurité compris) sur un vrai PostgreSQL, un opérateur
//! enregistré, et sa clé privée exportée pour l'authentificateur virtuel du
//! navigateur (CDP `WebAuthn.addCredential`).
//!
//! La liste blanche de modèles de clés refuserait l'attestation d'un
//! authentificateur virtuel : l'opérateur est donc enregistré avec le
//! `SoftToken` des tests Rust, dont la clé est ensuite confiée au navigateur.
//!
//! Variables : `OE_CASTORE_TEST_DSN` (obligatoire), `E2E_PORT` (défaut 8431),
//! `E2E_FIXTURE` (défaut `target/e2e-fixture.json`).
//! Ne sert qu'aux tests : jamais construit dans l'image.

#[path = "../tests/common/mod.rs"]
mod common;

use std::sync::Arc;

use base64::Engine;
use oe_actions::{NewCredential, Registry, Role};
use oe_webauthn::{trusted_models, TrustedModel, Url, Verifier};
use ra_console::ca_link::CaLink;
use ra_console::http::{app, AppState};
use ra_console::login::LoginService;
use sqlx::postgres::PgPoolOptions;
use webauthn_authenticator_rs::softtoken::{SoftToken, SoftTokenFile, AAGUID};
use webauthn_authenticator_rs::WebauthnAuthenticator;

#[tokio::main]
async fn main() {
let base = std::env::var("OE_CASTORE_TEST_DSN").expect("OE_CASTORE_TEST_DSN est obligatoire");
let port: u16 = std::env::var("E2E_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8431);
let fixture = std::env::var("E2E_FIXTURE").unwrap_or_else(|_| "target/e2e-fixture.json".into());
let origin = Url::parse(&format!("http://localhost:{port}")).unwrap();

let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let name = format!("e2e_{nanos}");
let admin = PgPoolOptions::new().connect(&base).await.unwrap();
sqlx::query(&format!("CREATE DATABASE {name}"))
.execute(&admin)
.await
.unwrap();
let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0);
let _ = oe_castore::Postgres::open(&dsn).await.unwrap();
let registry = Registry::connect(&dsn).await.unwrap();

let (token, root) = SoftToken::new(true).unwrap();
let root_pem = root.to_pem().unwrap();
let verifier = || {
Verifier::new(
"localhost",
&origin,
"Open eIDAS Console — e2e",
trusted_models(&[TrustedModel {
root_pem: &root_pem,
aaguid: AAGUID,
description: "SoftToken (e2e)",
}])
.unwrap(),
)
.unwrap()
};

// L'opérateur et sa clé, comme en production mais sans passer par
// l'enregistrement relayé (qui a ses propres tests).
let now = time::OffsetDateTime::now_utc();
let operator = registry
.add_operator("alice", Role::RaOperateur, "e2e", now)
.await
.unwrap();
let reg_verifier = verifier();
// Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que
// la clé du credential créé ci-dessous se relit (aucun accesseur public).
let token_path = std::env::temp_dir().join(format!("{name}.softtoken"));
let token_file = std::fs::File::create(&token_path).unwrap();
let mut authn = WebauthnAuthenticator::new(SoftTokenFile::new(token, token_file));
let (options, state) = reg_verifier
.start_registration(operator, "alice", None)
.unwrap();
let reg = authn.do_registration(origin.clone(), options).unwrap();
drop(authn);
let key = reg_verifier.finish_registration(&reg, &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<u8> = reg.raw_id.as_ref().to_vec();
let soft: serde_cbor_2::Value =
serde_cbor_2::from_slice(&std::fs::read(&token_path).unwrap()).unwrap();
let _ = std::fs::remove_file(&token_path);
let (sec1, counter) = soft_key(&soft, &credential_id);
let ec = openssl::ec::EcKey::private_key_from_der(&sec1).unwrap();
let pkcs8 = openssl::pkey::PKey::from_ec_key(ec)
.unwrap()
.private_key_to_pkcs8()
.unwrap();
let b64 = base64::engine::general_purpose::STANDARD;
let out = serde_json::json!({
"origin": origin.as_str().trim_end_matches('/'),
"operator": "alice",
"role": "ra_operateur",
"credential": {
"credentialId": b64.encode(&credential_id),
"isResidentCredential": false,
"rpId": "localhost",
"privateKey": b64.encode(pkcs8),
"userHandle": b64.encode(operator.as_bytes()),
"signCount": counter,
},
});
if let Some(parent) = std::path::Path::new(&fixture).parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&fixture, serde_json::to_vec_pretty(&out).unwrap()).unwrap();

// Un lien vers ca-server injoignable : la connexion et le poste n'en ont
// pas besoin (seul /healthz s'en soucie).
let pki = common::pki().await;
let dir = common::tempdir::Dir::new();
let client = pki
.cert(&oe_ca_core::profile::internal_client(), "ra-console")
.await;
let link = CaLink::new(&pki.files(&dir, &client, 9)).unwrap();
let pool = PgPoolOptions::new().connect(&dsn).await.unwrap();
let console = app(
Arc::new(AppState {
pool: pool.clone(),
link,
login: LoginService::new(
registry.clone(),
verifier(),
b"secret-de-test-au-moins-16-octets".to_vec(),
Arc::new(ra_console::audit::NullRecorder),
),
sessions: common::sessions(pool),
journal: Arc::new(ra_console::audit::NullRecorder),
}),
ra_console::web::Console {
environment: ra_console::web::Environment::Staging,
},
);
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
.await
.unwrap();
eprintln!("e2e : console prête sur {origin}, fixture {fixture}");
axum::serve(listener, console).await.unwrap();
}

/// La clé privée (DER SEC1) d'un credential, et le compteur de signatures.
fn soft_key(token: &serde_cbor_2::Value, credential_id: &[u8]) -> (Vec<u8>, 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<u8> {
values
.iter()
.map(|v| match v {
serde_cbor_2::Value::Integer(n) => *n as u8,
_ => panic!("octet attendu"),
})
.collect()
}
9 changes: 9 additions & 0 deletions bin/ra-console/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions bin/ra-console/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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(),
)?,
})
}

Expand Down
Loading
Loading