Implement KCA: web reimplementation of XCA PKI workflows

- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models
- AES-256-GCM private key storage and full certificate/CSR/CRL domain logic
- Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates
- imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
woggioni committed 2026-10-23 02:06:55 +00:00
commit 4f3a632f4b
109 files changed
+21048

No files matched your search

+421
View File
@@ -0,0 +1,421 @@
//! Certificate detail, validation and CA actions.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{copy_button, empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, CertificateDetail, Me, Revocation};
pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let cert = create_signal(None::<CertificateDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let export_format = create_signal("pem".to_string());
let export_password = create_signal(String::new());
let revocations = create_signal(Vec::<Revocation>::new());
let subject_entries = create_signal(Vec::<crate::model::DnEntry>::new());
let issuer_entries = create_signal(Vec::<crate::model::DnEntry>::new());
let extensions = create_signal(Vec::<crate::model::ExtensionRow>::new());
let validation_errors = create_signal(Vec::<crate::model::ValidationError>::new());
let purposes = create_signal(Vec::<String>::new());
let chain = create_signal(Vec::<crate::model::ChainItem>::new());
let rev_reason = create_signal("unspecified".to_string());
let rev_serial = create_signal(String::new());
let ca_days = create_signal("30".to_string());
let crl_days = create_signal("30".to_string());
let crl_hash = create_signal("sha256".to_string());
let tls_auth_text = create_signal(String::new());
let new_name = create_signal(String::new());
let new_comment = create_signal(String::new());
let load = move || {
spawn_local(async move {
match api::certificate(id, cert_id_value).await {
Ok(value) => {
new_name.set(value.summary.name.clone());
new_comment.set(value.summary.comment.clone());
if let Some(days) = value.authority.as_ref().and_then(|a| a.crl_days) {
ca_days.set(days.to_string());
crl_days.set(days.to_string());
}
subject_entries.set(value.subject_entries.clone());
issuer_entries.set(value.issuer_entries.clone());
extensions.set(value.extensions.clone());
validation_errors.set(value.validation_errors.clone());
purposes.set(value.purposes.clone());
chain.set(value.chain.clone());
cert.set(Some(value));
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
}
Err(message) => error.set(Some(message)),
}
});
};
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
load();
let do_export = move |_| {
let mut payload = json!({"format": export_format.get_clone()});
if !export_password.get_clone().is_empty() {
payload["password"] = json!(export_password.get_clone());
}
spawn_local(async move {
match api::export_certificate(id, cert_id_value, &payload).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let revoke = move |_| {
let payload = json!({"reason": rev_reason.get_clone()});
spawn_local(async move {
match api::revoke_certificate(id, cert_id_value, &payload).await {
Ok(_) => load(),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this certificate?") {
return;
}
spawn_local(async move {
match api::delete_certificate(id, cert_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/certificates")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let save_meta = move |_| {
let value = cert.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_certificate(id, cert_id_value, &payload).await {
Ok(updated) => cert.set(Some(updated)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let transform = move |action: &'static str| {
spawn_local(async move {
match api::transform_certificate(id, cert_id_value, action).await {
Ok(value) => {
if let Some(item_id) = value.get("id").and_then(|v| v.as_str()) {
let route = match action {
"open-key" => "keys",
"request" => "requests",
"template" => "templates",
_ => "certificates",
};
sycamore_router::navigate(&format!("/vaults/{id}/{route}/{item_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let generate_crl = move |_| {
let payload = json!({
"days": crl_days.get_clone().parse::<i64>().unwrap_or(30),
"signature_hash": crl_hash.get_clone(),
"with_reason": true,
"include_aki": true,
});
spawn_local(async move {
match api::generate_crl(id, cert_id_value, &payload).await {
Ok(crl) => sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let add_revocation = move |_| {
let payload = json!({"reason": rev_reason.get_clone(), "serial_hex": rev_serial.get_clone()});
spawn_local(async move {
match api::add_revocation(id, cert_id_value, &payload).await {
Ok(_) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
rev_serial.set(String::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let delete_revocation = move |rev_id: String| {
spawn_local(async move {
match api::delete_revocation(id, cert_id_value, &rev_id).await {
Ok(()) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let make_tls_auth = move |_| {
spawn_local(async move {
match api::tls_auth(id, cert_id_value, &json!({})).await {
Ok(value) => {
if let Some(key) = value.get("key").and_then(|v| v.as_str()) {
tls_auth_text.set(key.to_string());
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = cert.get_clone() else {
return empty_state("Certificate not found.");
};
let summary = value.summary.clone();
let is_ca = summary.is_ca;
let header_serial = summary.serial_hex.clone();
view! {
(page_header(
summary.name.clone(),
format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" }
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" }
(if !summary.revoked && !is_ca {
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } }
} else { view! {} })
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
},
))
div(class="card mb-3") {
div(class="card-body") {
div(class="row g-2") {
div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } }
div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
}
}
}
div(class="row g-3 mb-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Subject" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
}
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Issuer" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
}
}
div(class="card mb-3") {
div(class="card-header d-flex justify-content-between align-items-center") {
span() { "Extensions" }
(copy_button(value.openssl_config.clone(), "Copy OpenSSL config"))
}
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) }
}
}
}
div(class="card mb-3") {
div(class="card-header") { "Validation" }
div(class="card-body") {
(if validation_errors.get_clone().is_empty() {
view! { div(class="alert alert-success mb-2") { "No verification errors found." } }
} else {
view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } }
})
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
}
}
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card mb-3") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=export_format) {
option(value="pem") { "PEM" }
option(value="pem-chain") { "PEM chain" }
option(value="pem-key") { "PEM + key" }
option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" }
option(value="pkcs7") { "PKCS#7" }
option(value="pkcs7-chain") { "PKCS#7 chain" }
option(value="der") { "DER" }
option(value="pkcs12") { "PKCS#12" }
option(value="pkcs12-chain") { "PKCS#12 chain" }
option(value="openvpn") { "OpenVPN" }
option(value="openssl-config") { "OpenSSL config" }
option(value="index") { "Certificate index" }
option(value="vcalendar") { "vCalendar" }
option(value="jwk") { "JWK" }
option(value="jwk-chain") { "JWK chain" }
option(value="tls-auth") { "OpenVPN tls-auth key" }
}
div(class="mb-2") {
label(class="form-label") { "Password (encrypted formats)" }
input(class="form-control", r#type="password", bind:value=export_password)
}
button(class="btn btn-primary", on:click=do_export) { "Download" }
}
}
div(class="card") {
div(class="card-header") { "Metadata" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=new_name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=new_comment) {}
}
button(class="btn btn-primary", on:click=save_meta) { "Save" }
}
}
}
div(class="col-12 col-lg-6") {
div(class="card mb-3") {
div(class="card-header") { "Revoke" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) }
button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" }
}
}
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" }
button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" }
button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" }
}
}
}
}
(if is_ca {
view! {
div(class="card mt-3") {
div(class="card-header") { "CA operations" }
div(class="card-body") {
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CRL days" }
input(class="form-control", bind:value=crl_days)
}
div(class="col-md-3") {
label(class="form-label") { "Hash" }
select(class="form-select", bind:value=crl_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
}
}
div(class="col-md-3") {
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
}
div(class="col-md-3") {
button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" }
}
}
(if tls_auth_text.get_clone().is_empty() {
view! {}
} else {
view! { pre(class="pem") { (tls_auth_text.get_clone()) } }
})
h3(class="h6 mt-3") { "Revocations" }
div(class="row g-2 align-items-end mb-2") {
div(class="col-md-4") {
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
}
div(class="col-md-4") {
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
}
div(class="col-md-4") {
button(class="btn btn-outline-light", on:click=add_revocation) { "Add revocation" }
}
}
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() {} } }
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } td() { (fmt_date(&rev.revoked_at)) } td() { (rev.reason) } td() { button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } } } } }) }
}
}
}
}
} else {
view! {}
})
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn reason_options() -> Vec<View> {
["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}