Skip to content
Open
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
186 changes: 132 additions & 54 deletions bin/ra-console/examples/e2e_console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&reg, &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(&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();
// 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 &registered {
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::<String>(),
);
}

let pending: Vec<String> = (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();
Expand Down Expand Up @@ -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))
Expand Down
62 changes: 62 additions & 0 deletions bin/ra-console/src/certificates.rs
Original file line number Diff line number Diff line change
@@ -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<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub not_after: Option<OffsetDateTime>,
pub status: String,
#[serde(with = "time::serde::rfc3339::option")]
pub revoked_at: Option<OffsetDateTime>,
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<Vec<IssuedCertificate>, 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())
}
38 changes: 37 additions & 1 deletion bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,6 +56,7 @@ 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/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))
Expand Down Expand Up @@ -828,6 +829,41 @@ fn quorum_status(body: &serde_json::Value) -> serde_json::Value {
})
}

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

/// `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<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<CertificatesQuery>,
) -> 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.
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 @@ -14,6 +14,7 @@

pub mod audit;
pub mod ca_link;
pub mod certificates;
pub mod config;
pub mod db_guard;
pub mod http;
Expand Down
Loading
Loading