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
349 changes: 281 additions & 68 deletions bin/ra-console/src/http.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions bin/ra-console/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub mod db_guard;
pub mod http;
pub mod login;
pub mod purge;
pub mod quorum;
pub mod registry_routes;
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())
}
138 changes: 138 additions & 0 deletions bin/ra-console/src/registry_routes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//! Gestion du registre des opérateurs depuis la console (docs/WEBUI.md §5,
//! §10) : inviter un opérateur, confirmer ou révoquer une clé, changer un rôle.
//! Même schéma que les décisions et la révocation (§4) : le challenge est
//! préparé par `POST /api/v1/webauthn/challenge` avec l'action voulue, puis
//! l'assertion est relayée ici, **sans corps** ; `ca-server` exécute celui
//! qu'il a figé, après avoir comparé la cible de la route (`expect`) au corps
//! figé. Seul un `admin` signe ces actions, et deux pour créer un
//! administrateur ou changer le rôle de l'un d'eux : c'est la politique de
//! `ca-server`, jamais une décision de la console.

use std::sync::Arc;

use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};

use crate::http::{error, looks_like_a_name, quorum_status, relay_assertion, AppState};

pub(crate) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/v1/operators", post(handle_invite))
.route(
"/api/v1/credentials/{credential_id}/confirm",
post(handle_confirm_key),
)
.route(
"/api/v1/credentials/{credential_id}/revoke",
post(handle_revoke_key),
)
.route("/api/v1/operators/{name}/role", post(handle_set_role))
}

/// Un identifiant de clé WebAuthn tel que le registre le range : base64url,
/// borné. Il n'est qu'une attente : `ca-server` le compare au corps figé.
fn looks_like_a_credential_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 1024
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}

async fn relay(
state: &AppState,
headers: &HeaderMap,
body: &[u8],
expect: serde_json::Value,
) -> Response {
match relay_assertion(state, headers, body, expect).await {
Ok(r) => Json(quorum_status(&r.body)).into_response(),
Err(resp) => resp,
}
}

/// `POST /api/v1/operators` : exécute l'invitation figée
/// (`{"action": "invite_operator", "name", "role"}`). Le jeton d'invitation
/// n'existe que dans `result.invite_token` de la réponse d'exécution : ni
/// `ca-server` ni la console ne le journalisent ni ne le conservent. Une
/// invitation n'a pas de cible dans la route : seul le type est contrôlé.
async fn handle_invite(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
relay(
&state,
&headers,
&body,
serde_json::json!({ "action": "invite_operator" }),
)
.await
}

/// `POST /api/v1/credentials/{credential_id}/confirm` : active une clé en
/// attente. L'empreinte, transmise hors bande par l'invité (§10), fait partie
/// du corps signé ; `ca-server` la recompare à la clé en attente.
async fn handle_confirm_key(
State(state): State<Arc<AppState>>,
Path(credential_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !looks_like_a_credential_id(&credential_id) {
return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide");
}
relay(
&state,
&headers,
&body,
serde_json::json!({ "action": "confirm_key", "credential_id": credential_id }),
)
.await
}

/// `POST /api/v1/credentials/{credential_id}/revoke` (perte de clé, départ,
/// §14). `ca-server` refuse de révoquer la dernière clé d'administrateur active.
async fn handle_revoke_key(
State(state): State<Arc<AppState>>,
Path(credential_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !looks_like_a_credential_id(&credential_id) {
return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide");
}
relay(
&state,
&headers,
&body,
serde_json::json!({ "action": "revoke_key", "credential_id": credential_id }),
)
.await
}

/// `POST /api/v1/operators/{name}/role` : l'opérateur est désigné par son
/// **nom**, comme dans le corps signé (`set_role`), pas par un identifiant
/// technique. Élever un opérateur au rôle `admin`, ou changer celui d'un
/// administrateur, exige deux administrateurs : la première signature rend
/// `AWAITING_QUORUM`, la seconde passe par `/api/v1/quorum/{id}/sign`.
async fn handle_set_role(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !looks_like_a_name(&name) {
return error(StatusCode::BAD_REQUEST, "bad_request", "opérateur invalide");
}
relay(
&state,
&headers,
&body,
serde_json::json!({ "action": "set_role", "operator": name }),
)
.await
}
Loading
Loading