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
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
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
184 changes: 183 additions & 1 deletion bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -14,6 +14,7 @@ use axum::{Json, Router};
use serde::Deserialize;
use sqlx::PgPool;

use crate::audit::{self, Recorder};
use crate::ca_link::{CaLink, Relayed};
use crate::login::{LoginError, LoginService};
use crate::requests;
Expand All @@ -27,6 +28,7 @@ pub struct AppState {
pub link: CaLink,
pub login: LoginService,
pub sessions: Sessions,
pub journal: Arc<dyn Recorder>,
}

pub fn router(state: Arc<AppState>) -> Router {
Expand All @@ -45,6 +47,9 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/v1/me", get(handle_me))
.route("/api/v1/logout", post(handle_logout))
.route("/api/v1/requests", get(handle_requests))
.route("/api/v1/webauthn/challenge", post(handle_action_challenge))
.route("/api/v1/requests/{id}/approve", post(handle_approve))
.route("/api/v1/requests/{id}/reject", post(handle_reject))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state)
}
Expand Down Expand Up @@ -438,6 +443,183 @@ async fn handle_requests(
}
}

/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étape 3) :
/// décider d'une demande d'enrôlement. La révocation (étape 4) et la gestion
/// du registre suivront ; d'ici là, la console refuse de les préparer, même si
/// `ca-server` saurait les exécuter.
fn relayed_at_this_stage(action: &oe_actions::Action) -> bool {
matches!(
action,
oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. }
)
}

/// `POST /api/v1/webauthn/challenge` (docs/WEBUI.md §4 étapes 1 à 3, §5) :
/// l'opérateur connecté demande à `ca-server` de figer une action et d'émettre
/// le challenge qu'il signera. Le corps rendu est celui que `ca-server`
/// exécutera, à afficher tel quel.
///
/// Ce que la console décide : que la session est valide, et **pour qui** le
/// challenge est émis — l'opérateur de la session, jamais une valeur du
/// navigateur. Ce qu'elle ne décide pas : le rôle suffisant, l'état de la
/// demande, le corps final. `ca-server` en juge.
async fn handle_action_challenge(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !is_json(&headers) {
return unsupported_media_type();
}
let who = match authenticate(&state, &headers).await {
Ok(a) => a,
Err(resp) => return resp,
};
// Relue dans l'énumération fermée d'`oe_actions`, puis resérialisée : un
// champ en trop (un `operator_hint` glissé par le navigateur, par exemple)
// ne franchit jamais la console.
let action: oe_actions::Action = match serde_json::from_slice(&body) {
Ok(a) => a,
Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"),
};
if !relayed_at_this_stage(&action) {
return error(
StatusCode::FORBIDDEN,
"action_not_available",
"cette action n'est pas encore proposée par la console",
);
}
let result = state
.link
.post(
"/internal/v1/challenge",
&serde_json::json!({ "body": action, "operator_hint": who.operator_id }),
)
.await;
if let Ok(r) = &result {
state.journal.append(
audit::EVENT_ACTION_CHALLENGE,
serde_json::json!({
"operator": who.operator,
"action": r.body.get("body").and_then(|b| b.get("action")),
"action_id": r.body.get("action_id"),
"body_hash": r.body.get("body_hash"),
"status": r.status,
"error": r.body.get("error"),
}),
);
}
relayed(result)
}

#[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<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
relay_decision(&state, "approve_request", &id, &headers, &body).await
}

async fn handle_reject(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
relay_decision(&state, "reject_request", &id, &headers, &body).await
}

/// `POST /api/v1/requests/{id}/approve|reject` (docs/WEBUI.md §4 étapes 5 à 7,
/// §5) : la console relaie l'identifiant du challenge et l'assertion brute —
/// **jamais de corps** : `ca-server` exécute celui qu'il a figé. Elle y joint
/// ce que la route promet (`expect` : l'action et la demande du chemin), que
/// `ca-server` compare au corps figé avant toute vérification : une signature
/// obtenue pour une demande ne décide jamais d'une autre.
async fn relay_decision(
state: &AppState,
action: &str,
transaction_id: &str,
headers: &HeaderMap,
body: &[u8],
) -> Response {
if !is_json(headers) {
return unsupported_media_type();
}
let who = match authenticate(state, headers).await {
Ok(a) => a,
Err(resp) => return resp,
};
let req: Signed = match serde_json::from_slice(body) {
Ok(r) => r,
Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide"),
};
if !looks_like_uuid(&req.challenge_id)
|| !req.assertion.is_object()
|| !looks_like_a_transaction(transaction_id)
{
return error(StatusCode::BAD_REQUEST, "bad_request", "corps invalide");
}
let result = state
.link
.post(
"/internal/v1/actions",
&serde_json::json!({
"challenge_id": req.challenge_id,
"assertion": req.assertion,
"expect": { "action": action, "transaction_id": transaction_id },
}),
)
.await;
if let Ok(r) = &result {
state.journal.append(
audit::EVENT_ACTION_RELAYED,
serde_json::json!({
"session_operator": who.operator,
"action": action,
"transaction_id": transaction_id,
"action_id": r.body.get("action_id"),
"signed_by": r.body.get("operator"),
"status": r.status,
"error": r.body.get("error"),
}),
);
}
match result {
// La forme du §5 : l'identité qui a décidé est celle que `ca-server` a
// lue dans son registre, pas celle de la session.
Ok(r)
if r.status == 200
&& r.body.get("status").and_then(|s| s.as_str()) == Some("executed") =>
{
Json(serde_json::json!({
"transaction_id": transaction_id,
"state": if action == "approve_request" { "APPROVED" } else { "REJECTED" },
"decided_by": r.body.get("operator"),
"action_id": r.body.get("action_id"),
}))
.into_response()
}
other => relayed(other),
}
}

/// `POST /api/v1/logout` : révoque la session sans attendre son expiration.
/// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans
/// tous les cas, plus aucune session valide n'existe ensuite.
Expand Down
3 changes: 2 additions & 1 deletion bin/ra-console/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async fn run_serve() {
cfg.webauthn.login_decoy_secret.into_bytes(),
journal.clone(),
);
let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal);
let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal.clone());

// Purge périodique des sessions et challenges expirés (§15 étape 1c-2b) :
// aucune opération manuelle, arrêtée par le même signal que le serveur.
Expand All @@ -104,6 +104,7 @@ async fn run_serve() {
link,
login,
sessions,
journal,
}));
let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen))
.await
Expand Down
4 changes: 4 additions & 0 deletions bin/ra-console/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -118,6 +121,7 @@ impl Sessions {
.execute(self.registry.pool())
.await;
Ok(Authenticated {
operator_id: operator.id,
operator: operator.name,
role: operator.role,
})
Expand Down
Loading
Loading