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
181 changes: 161 additions & 20 deletions bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ use sqlx::PgPool;
use crate::audit::{self, Recorder};
use crate::ca_link::{CaLink, Relayed};
use crate::login::{LoginError, LoginService};
use crate::requests;
use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL};
use crate::{quorum, requests};

/// Assez pour un objet d'attestation, pas pour bourrer la mémoire.
const MAX_BODY_BYTES: usize = 64 * 1024;
Expand Down Expand Up @@ -51,6 +51,8 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/v1/requests/{id}/approve", post(handle_approve))
.route("/api/v1/requests/{id}/reject", post(handle_reject))
.route("/api/v1/certificates/{serial}/revoke", post(handle_revoke))
.route("/api/v1/quorum", get(handle_quorum))
.route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state)
}
Expand Down Expand Up @@ -457,6 +459,57 @@ fn relayed_at_this_stage(action: &oe_actions::Action) -> bool {
)
}

fn not_available() -> Response {
error(
StatusCode::FORBIDDEN,
"action_not_available",
"cette action n'est pas encore proposée par la console",
)
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CoSign {
action_id: String,
}

/// Une action déjà figée par `ca-server`, que la console propose à ce stade et
/// qui attend encore des signatures. Lue dans la table `actions`, en lecture
/// seule : rien n'est décidé ici, `ca-server` recontrôle tout.
///
/// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]).
#[allow(clippy::result_large_err)]
async fn frozen_at_this_stage(
state: &AppState,
action_id: &str,
) -> Result<(oe_webauthn::Uuid, oe_actions::Action), Response> {
let unknown = || error(StatusCode::NOT_FOUND, "unknown_action", "action inconnue");
let id: oe_webauthn::Uuid = action_id.parse().map_err(|_| unknown())?;
let frozen = quorum::frozen(&state.pool, id)
.await
.map_err(|e| {
tracing::error!(erreur = %e, "quorum : base indisponible");
error(
StatusCode::SERVICE_UNAVAILABLE,
"unavailable",
"service indisponible",
)
})?
.ok_or_else(unknown)?;
if frozen.executed {
return Err(error(
StatusCode::CONFLICT,
"already_executed",
"action déjà exécutée",
));
}
let action: oe_actions::Action = serde_json::from_value(frozen.body).map_err(|_| unknown())?;
if !relayed_at_this_stage(&action) {
return Err(not_available());
}
Ok((id, action))
}

/// `POST /api/v1/webauthn/challenge` (docs/WEBUI.md §4 étapes 1 à 3, §5) :
/// l'opérateur connecté demande à `ca-server` de figer une action et d'émettre
/// le challenge qu'il signera. Le corps rendu est celui que `ca-server`
Expand All @@ -478,27 +531,35 @@ async fn handle_action_challenge(
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,
let value: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
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;
// Deux formes (§8) : une action nouvelle, ou `{"action_id"}` pour signer
// une action déjà figée (double contrôle). Dans les deux cas, l'action est
// relue dans l'énumération fermée d'`oe_actions` : un champ en trop (un
// `operator_hint` glissé par le navigateur, par exemple) ne franchit
// jamais la console.
let relay = if value.get("action_id").is_some() {
let Ok(CoSign { action_id }) = serde_json::from_value::<CoSign>(value) else {
return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide");
};
let (id, _) = match frozen_at_this_stage(&state, &action_id).await {
Ok(f) => f,
Err(resp) => return resp,
};
serde_json::json!({ "action_id": id, "operator_hint": who.operator_id })
} else {
let action: oe_actions::Action = match serde_json::from_value(value) {
Ok(a) => a,
Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"),
};
if !relayed_at_this_stage(&action) {
return not_available();
}
serde_json::json!({ "body": action, "operator_hint": who.operator_id })
};
let result = state.link.post("/internal/v1/challenge", &relay).await;
if let Ok(r) = &result {
state.journal.append(
audit::EVENT_ACTION_CHALLENGE,
Expand Down Expand Up @@ -665,6 +726,86 @@ async fn handle_revoke(
}
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct QuorumQuery {
state: Option<String>,
}

/// `GET /api/v1/quorum?state=PENDING` (docs/WEBUI.md §5, §8) : les actions à
/// plusieurs signatures ni exécutées ni expirées, avec qui a déjà signé. En
/// lecture seule sur l'état de `ca-server`, qui fait foi.
async fn handle_quorum(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<QuorumQuery>,
) -> Response {
if let Err(resp) = authenticate(&state, &headers).await {
return resp;
}
if q.state.as_deref().is_some_and(|s| s != "PENDING") {
return error(StatusCode::BAD_REQUEST, "bad_request", "état invalide");
}
match quorum::pending(&state.pool, time::OffsetDateTime::now_utc()).await {
Ok(list) => Json(list).into_response(),
Err(e) => {
tracing::error!(erreur = %e, "quorum : base indisponible");
error(
StatusCode::SERVICE_UNAVAILABLE,
"unavailable",
"service indisponible",
)
}
}
}

/// `POST /api/v1/quorum/{action_id}/sign` (docs/WEBUI.md §5, §8) : une
/// signature de plus sur une action figée, challenge obtenu par
/// `POST /api/v1/webauthn/challenge` avec `{"action_id"}`. `ca-server`
/// n'accepte qu'une signature par opérateur et exécute au seuil, une seule
/// fois ; la console lui dit ce qu'elle attend (l'action de la route et sa
/// cible), qu'il compare avant toute consommation.
async fn handle_quorum_sign(
State(state): State<Arc<AppState>>,
Path(action_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !is_json(&headers) {
return unsupported_media_type();
}
if let Err(resp) = authenticate(&state, &headers).await {
return resp;
}
let (id, action) = match frozen_at_this_stage(&state, &action_id).await {
Ok(f) => f,
Err(resp) => return resp,
};
let mut expect = serde_json::json!({ "action": action_kind(&action), "action_id": id });
match &action {
oe_actions::Action::ApproveRequest { transaction_id, .. }
| oe_actions::Action::RejectRequest { transaction_id, .. } => {
expect["transaction_id"] = serde_json::json!(transaction_id);
}
oe_actions::Action::RevokeCertificate { serial, .. } => {
expect["serial"] = serde_json::json!(serial);
}
_ => {}
}
match relay_assertion(&state, &headers, &body, expect).await {
Ok(r) => Json(quorum_status(&r.body)).into_response(),
Err(resp) => resp,
}
}

/// Le nom sérialisé d'une action (`approve_request`…), celui du corps figé.
fn action_kind(action: &oe_actions::Action) -> String {
serde_json::to_value(action)
.ok()
.and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string))
.unwrap_or_default()
}

/// La forme du §5 pour une action à plusieurs signatures.
fn quorum_status(body: &serde_json::Value) -> serde_json::Value {
let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed");
Expand Down
1 change: 1 addition & 0 deletions bin/ra-console/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod db_guard;
pub mod http;
pub mod login;
pub mod purge;
pub mod quorum;
pub mod requests;
pub mod session;
pub mod webauthn_models;
93 changes: 93 additions & 0 deletions bin/ra-console/src/quorum.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! Salle d'attente des actions à plusieurs signatures (docs/WEBUI.md §8, §15
//! étape 4b), en lecture seule sur les tables de `ca-server`.
//!
//! Le §8 prévoyait des tables de collecte propres à la console, qui auraient
//! conservé les assertions jusqu'au seuil. Ce n'est pas ce qui est construit :
//! `ca-server` enregistre chaque signature au fil de l'eau (`decision_evidence`)
//! et n'exécute qu'au seuil. La console lit donc l'état qui fait foi, sans en
//! tenir de copie qui pourrait diverger, et ne garde jamais d'assertion.

use oe_webauthn::Uuid;
use serde::Serialize;
use sqlx::{PgPool, Row};
use time::OffsetDateTime;

/// Une action figée par `ca-server`, telle que la route de signature en a
/// besoin pour dire ce qu'elle attend (`expect`).
pub struct Frozen {
pub body: serde_json::Value,
pub executed: bool,
}

/// Une action en attente de signatures, pour l'affichage (« 1 signature sur
/// 2 »). Le seuil qui fait foi reste celui de la politique de `ca-server`,
/// relu à l'exécution.
#[derive(Serialize)]
pub struct Pending {
pub action_id: Uuid,
pub action: String,
/// Le corps figé, à afficher tel quel à qui va co-signer (WYSIWYS).
pub body: serde_json::Value,
pub body_hash: String,
pub required: i32,
pub signatures: usize,
/// Qui a déjà signé, lu dans le registre de `ca-server`.
pub signed_by: Vec<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}

pub async fn frozen(pool: &PgPool, id: Uuid) -> Result<Option<Frozen>, sqlx::Error> {
let row = sqlx::query("SELECT body, executed_at FROM actions WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| Frozen {
body: r.get("body"),
executed: r.get::<Option<OffsetDateTime>, _>("executed_at").is_some(),
}))
}

/// Les actions à plusieurs signatures ni exécutées ni expirées, des plus
/// anciennes aux plus récentes.
pub async fn pending(pool: &PgPool, now: OffsetDateTime) -> Result<Vec<Pending>, sqlx::Error> {
let rows = sqlx::query(
"SELECT a.id, a.body, a.body_hash, a.required_signatures, a.created_at, a.expires_at,
COALESCE(array_agg(o.name ORDER BY e.verified_at)
FILTER (WHERE o.name IS NOT NULL), '{}') AS signed_by
FROM actions a
LEFT JOIN decision_evidence e ON e.action_id = a.id
LEFT JOIN operators o ON o.id = e.operator_id
WHERE a.executed_at IS NULL AND a.expires_at > $1 AND a.required_signatures > 1
GROUP BY a.id
ORDER BY a.created_at",
)
.bind(now)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
let body: serde_json::Value = r.get("body");
let signed_by: Vec<String> = r.get("signed_by");
let hash: Vec<u8> = r.get("body_hash");
Pending {
action_id: r.get("id"),
action: body
.get("action")
.and_then(|a| a.as_str())
.unwrap_or_default()
.to_string(),
body,
body_hash: hash.iter().map(|b| format!("{b:02x}")).collect(),
required: r.get("required_signatures"),
signatures: signed_by.len(),
signed_by,
created_at: r.get("created_at"),
expires_at: r.get("expires_at"),
}
})
.collect())
}
Loading
Loading