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:
commit
4f3a632f4b
109 files changed
+21048
No files matched your search
@@ -0,0 +1,232 @@
|
||||
//! Certificate signing request detail.
|
||||
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::{empty_state, page_header, toast_error};
|
||||
use crate::dom;
|
||||
use crate::model::{fmt_fp, Me, RequestDetail};
|
||||
|
||||
pub fn RequestDetailPage(vault_id: String, request_id: String) -> View {
|
||||
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||
let req_id: &'static str = Box::leak(request_id.clone().into_boxed_str());
|
||||
let me = create_signal(None::<Me>);
|
||||
let request = create_signal(None::<RequestDetail>);
|
||||
let error = create_signal(None::<String>);
|
||||
let loading = create_signal(true);
|
||||
let name = create_signal(String::new());
|
||||
let comment = create_signal(String::new());
|
||||
let request_name = create_signal(String::new());
|
||||
let request_subject = create_signal(String::new());
|
||||
let signature_label = create_signal(String::new());
|
||||
let export_format = create_signal("pem".to_string());
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(user) = api::me().await {
|
||||
me.set(Some(user));
|
||||
}
|
||||
match api::request(id, req_id).await {
|
||||
Ok(value) => {
|
||||
name.set(value.summary.name.clone());
|
||||
comment.set(value.summary.comment.clone());
|
||||
request_name.set(value.summary.name.clone());
|
||||
request_subject.set(value.summary.subject.clone());
|
||||
signature_label.set(if value.signature_valid { "PKCS#10 signature valid".to_string() } else { "PKCS#10 signature INVALID".to_string() });
|
||||
request.set(Some(value));
|
||||
}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
|
||||
let save = move |_| {
|
||||
let value = request.get_clone();
|
||||
let Some(value) = value else { return };
|
||||
let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version});
|
||||
spawn_local(async move {
|
||||
match api::update_request(id, req_id, &payload).await {
|
||||
Ok(updated) => {
|
||||
request_name.set(updated.summary.name.clone());
|
||||
request.set(Some(updated));
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let export = move |_| {
|
||||
let fmt = export_format.get_clone();
|
||||
spawn_local(async move {
|
||||
match api::export_request(id, req_id, &json!({"format": fmt})).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 remove = move |_| {
|
||||
if !dom::confirm("Delete this certificate signing request?") {
|
||||
return;
|
||||
}
|
||||
spawn_local(async move {
|
||||
match api::delete_request(id, req_id).await {
|
||||
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/requests")),
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let extract = move |_| {
|
||||
spawn_local(async move {
|
||||
match api::transform_request(id, req_id, "open-key").await {
|
||||
Ok(value) => {
|
||||
if let Some(key_id) = value.get("id").and_then(|v| v.as_str()) {
|
||||
sycamore_router::navigate(&format!("/vaults/{id}/keys/{key_id}"));
|
||||
}
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let to_template = move |_| {
|
||||
spawn_local(async move {
|
||||
match api::transform_request(id, req_id, "template").await {
|
||||
Ok(value) => {
|
||||
if let Some(template_id) = value.get("id").and_then(|v| v.as_str()) {
|
||||
sycamore_router::navigate(&format!("/vaults/{id}/templates/{template_id}"));
|
||||
}
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let content = move || {
|
||||
if loading.get() {
|
||||
return spinner();
|
||||
}
|
||||
let Some(value) = request.get_clone() else {
|
||||
return empty_state("Request not found.");
|
||||
};
|
||||
let mut html = String::new();
|
||||
html.push_str("<table class=\"table table-sm mb-0\"><tbody>");
|
||||
html.push_str(&format!("<tr><td class=\"text-secondary\">Subject</td><td class=\"mono\">{}</td></tr>", escape(&value.subject_rfc4514)));
|
||||
html.push_str(&format!("<tr><td class=\"text-secondary\">Signature</td><td>{}</td></tr>", escape(&value.signature_algorithm)));
|
||||
html.push_str(&format!("<tr><td class=\"text-secondary\">Fingerprint</td><td class=\"fingerprint\">{}</td></tr>", fmt_fp(&value.summary.fingerprint_sha256)));
|
||||
html.push_str(&format!("<tr><td class=\"text-secondary\">Certificates issued</td><td>{}</td></tr>", value.summary.certificate_count));
|
||||
html.push_str("</tbody></table>");
|
||||
let mut subject_html = String::new();
|
||||
for entry in &value.subject_entries {
|
||||
subject_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&entry.name), escape(&entry.value)));
|
||||
}
|
||||
let mut ext_html = String::new();
|
||||
for ext in &value.extensions {
|
||||
ext_html.push_str(&format!(
|
||||
"<tr><td>{}{}</td><td class=\"extension-value\">{}</td></tr>",
|
||||
escape(&ext.name),
|
||||
if ext.critical { " <span class=\"badge badge-critical\">critical</span>" } else { "" },
|
||||
escape(&ext.value),
|
||||
));
|
||||
}
|
||||
let mut attr_html = String::new();
|
||||
for attr in &value.attributes {
|
||||
attr_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&attr.name), escape(&attr.values.join(", "))));
|
||||
}
|
||||
view! {
|
||||
(page_header(request_name.get_clone(), request_subject.get_clone(), view! {
|
||||
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new?request={req_id}")) { "Sign request" }
|
||||
a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Back" }
|
||||
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
|
||||
}))
|
||||
div(class="alert alert-info") { (signature_label.get_clone()) }
|
||||
div(class="row g-3") {
|
||||
div(class="col-12 col-lg-7") {
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header") { "Request" }
|
||||
div(class="card-body", dangerously_set_inner_html=html) {}
|
||||
}
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header") { "Subject" }
|
||||
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=subject_html) {} }
|
||||
}
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header") { "Requested extensions" }
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-sm ext-table mb-0") {
|
||||
thead() { tr() { th() { "Extension" } th() { "Value" } } }
|
||||
tbody(dangerously_set_inner_html=ext_html) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Attributes" }
|
||||
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=attr_html) {} }
|
||||
}
|
||||
}
|
||||
div(class="col-12 col-lg-5") {
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header") { "Edit" }
|
||||
div(class="card-body") {
|
||||
div(class="mb-2") {
|
||||
label(class="form-label") { "Name" }
|
||||
input(class="form-control", bind:value=name)
|
||||
}
|
||||
div(class="mb-2") {
|
||||
label(class="form-label") { "Comment" }
|
||||
textarea(class="form-control", rows="3", bind:value=comment) {}
|
||||
}
|
||||
button(class="btn btn-primary", on:click=save) { "Save" }
|
||||
}
|
||||
}
|
||||
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="der") { "DER" }
|
||||
option(value="openssl-config") { "OpenSSL config" }
|
||||
}
|
||||
button(class="btn btn-primary", on:click=export) { "Download" }
|
||||
}
|
||||
}
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Transform" }
|
||||
div(class="card-body d-grid gap-2") {
|
||||
button(class="btn btn-outline-light", on:click=extract) { "Extract public key" }
|
||||
button(class="btn btn-outline-light", on:click=to_template) { "Create template" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 escape(value: &str) -> String {
|
||||
value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
Reference in new issue
Block a user