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:
@@ -0,0 +1,322 @@
|
||||
//! Create a PKCS#10 certificate signing request.
|
||||
use serde_json::json;
|
||||
use sycamore::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::nav::{app_nav, whoami};
|
||||
use crate::components::ui::{page_header, toast_error};
|
||||
use crate::model::{KeySummary, Me};
|
||||
|
||||
pub fn RequestNewPage(vault_id: String) -> View {
|
||||
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||
let me = create_signal(None::<Me>);
|
||||
let keys = create_signal(Vec::<KeySummary>::new());
|
||||
let error = create_signal(None::<String>);
|
||||
let saving = create_signal(false);
|
||||
|
||||
let name = create_signal(String::new());
|
||||
let comment = create_signal(String::new());
|
||||
let key_id = create_signal(String::new());
|
||||
let new_key = create_signal(false);
|
||||
let key_type = create_signal("rsa".to_string());
|
||||
let key_size = create_signal("2048".to_string());
|
||||
let signature_hash = create_signal("sha256".to_string());
|
||||
|
||||
let cn = create_signal(String::new());
|
||||
let country = create_signal(String::new());
|
||||
let state = create_signal(String::new());
|
||||
let locality = create_signal(String::new());
|
||||
let organization = create_signal(String::new());
|
||||
let org_unit = create_signal(String::new());
|
||||
let email = create_signal(String::new());
|
||||
|
||||
let san_text = create_signal(String::new());
|
||||
let challenge = create_signal(String::new());
|
||||
let unstructured = create_signal(String::new());
|
||||
let ku_digital = create_signal(true);
|
||||
let ku_encipher = create_signal(false);
|
||||
let ku_cert_sign = create_signal(false);
|
||||
let ku_crl_sign = create_signal(false);
|
||||
let eku_server = create_signal(false);
|
||||
let eku_client = create_signal(false);
|
||||
let eku_code = create_signal(false);
|
||||
let eku_email = create_signal(false);
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(user) = api::me().await {
|
||||
if me.is_alive() {
|
||||
me.set(Some(user));
|
||||
}
|
||||
}
|
||||
if let Ok(page) = api::keys(&id).await {
|
||||
if keys.is_alive() {
|
||||
keys.set(page.results.into_iter().filter(|key| key.has_private).collect());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let submit = move |_| {
|
||||
let request_name = name.get_clone();
|
||||
if request_name.trim().is_empty() {
|
||||
error.set(Some("request name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
let mut subject = Vec::new();
|
||||
for (key, value) in [
|
||||
("C", country.get_clone()),
|
||||
("ST", state.get_clone()),
|
||||
("L", locality.get_clone()),
|
||||
("O", organization.get_clone()),
|
||||
("OU", org_unit.get_clone()),
|
||||
("CN", cn.get_clone()),
|
||||
("emailAddress", email.get_clone()),
|
||||
] {
|
||||
if !value.trim().is_empty() {
|
||||
subject.push(json!({"name": key, "value": value.trim()}));
|
||||
}
|
||||
}
|
||||
if subject.is_empty() {
|
||||
error.set(Some("the subject must not be empty".to_string()));
|
||||
return;
|
||||
}
|
||||
let usages = [
|
||||
(ku_digital.get(), "digital_signature"),
|
||||
(ku_encipher.get(), "key_encipherment"),
|
||||
(ku_cert_sign.get(), "key_cert_sign"),
|
||||
(ku_crl_sign.get(), "crl_sign"),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(checked, _)| *checked)
|
||||
.map(|(_, name)| json!(name))
|
||||
.collect::<Vec<_>>();
|
||||
let eku = [
|
||||
(eku_server.get(), "serverAuth"),
|
||||
(eku_client.get(), "clientAuth"),
|
||||
(eku_code.get(), "codeSigning"),
|
||||
(eku_email.get(), "emailProtection"),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(checked, _)| *checked)
|
||||
.map(|(_, name)| json!(name))
|
||||
.collect::<Vec<_>>();
|
||||
let mut extensions = json!({
|
||||
"key_usage": {"critical": true, "usages": usages},
|
||||
"extended_key_usage": {"critical": false, "usages": eku},
|
||||
});
|
||||
let san = parse_san(&san_text.get_clone());
|
||||
if !san.is_empty() {
|
||||
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
|
||||
}
|
||||
let mut payload = json!({
|
||||
"name": request_name,
|
||||
"comment": comment.get_clone(),
|
||||
"subject": subject,
|
||||
"extensions": extensions,
|
||||
"signature_hash": signature_hash.get_clone(),
|
||||
});
|
||||
if new_key.get() {
|
||||
payload["new_key"] = json!({
|
||||
"key_type": key_type.get_clone(),
|
||||
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
|
||||
"name": format!("{} key", name.get_clone()),
|
||||
});
|
||||
} else if !key_id.get_clone().is_empty() {
|
||||
payload["key_id"] = json!(key_id.get_clone());
|
||||
} else {
|
||||
error.set(Some("select a key or generate a new one".to_string()));
|
||||
return;
|
||||
}
|
||||
let mut attributes = serde_json::Map::new();
|
||||
if !challenge.get_clone().is_empty() {
|
||||
attributes.insert("1.2.840.113549.1.9.7".to_string(), json!(challenge.get_clone()));
|
||||
}
|
||||
if !unstructured.get_clone().is_empty() {
|
||||
attributes.insert("1.2.840.113549.1.9.2".to_string(), json!(unstructured.get_clone()));
|
||||
}
|
||||
payload["attributes"] = serde_json::Value::Object(attributes);
|
||||
saving.set(true);
|
||||
let id = id.clone();
|
||||
spawn_local(async move {
|
||||
match api::create_request(&id, &payload).await {
|
||||
Ok(request) => {
|
||||
saving.set(false);
|
||||
sycamore_router::navigate(&format!("/vaults/{}/requests/{}", id, request.summary.id));
|
||||
return;
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
if saving.is_alive() { saving.set(false); }
|
||||
});
|
||||
};
|
||||
|
||||
let key_options = move || {
|
||||
keys.get_clone()
|
||||
.into_iter()
|
||||
.map(|key| view! { option(value=key.id.clone()) { (key.name.clone()) " (" (key.key_type.clone()) ")" } })
|
||||
.collect::<Vec<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))
|
||||
(page_header("New certificate signing request",
|
||||
"The CSR contains the public key and requested extensions; it is signed by the private key.",
|
||||
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Cancel" } }))
|
||||
div(class="card") {
|
||||
div(class="card-body") {
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "General" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Internal name" }
|
||||
input(class="form-control", bind:value=name)
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Signature hash" }
|
||||
select(class="form-select", bind:value=signature_hash) {
|
||||
option(value="sha256") { "SHA-256" }
|
||||
option(value="sha384") { "SHA-384" }
|
||||
option(value="sha512") { "SHA-512" }
|
||||
option(value="sha1") { "SHA-1" }
|
||||
}
|
||||
}
|
||||
div(class="col-12") {
|
||||
label(class="form-label") { "Comment" }
|
||||
textarea(class="form-control", rows="2", bind:value=comment) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Subject" }
|
||||
div(class="row g-3") {
|
||||
(subject_input("Common name (CN)", cn))
|
||||
(subject_input("Organization (O)", organization))
|
||||
(subject_input("Organizational unit (OU)", org_unit))
|
||||
(subject_input("Country (C)", country))
|
||||
(subject_input("State / province (ST)", state))
|
||||
(subject_input("Locality (L)", locality))
|
||||
(subject_input("E-mail address", email))
|
||||
}
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Private key" }
|
||||
div(class="form-check mb-2") {
|
||||
input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key)
|
||||
label(class="form-check-label", r#for="new-key") { "Generate a new key" }
|
||||
}
|
||||
(if new_key.get() {
|
||||
view! {
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Key type" }
|
||||
select(class="form-select", bind:value=key_type) {
|
||||
option(value="rsa") { "RSA" }
|
||||
option(value="ec") { "EC" }
|
||||
option(value="ed25519") { "ED25519" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Key size" }
|
||||
input(class="form-control", bind:value=key_size)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
label(class="form-label") { "Existing key" }
|
||||
select(class="form-select", bind:value=key_id) {
|
||||
option(value="") { "— select a key —" }
|
||||
(key_options())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Requested extensions" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Subject alternative names" }
|
||||
input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text)
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Challenge password" }
|
||||
input(class="form-control", bind:value=challenge)
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Unstructured name" }
|
||||
input(class="form-control", bind:value=unstructured)
|
||||
}
|
||||
}
|
||||
div(class="row g-3 mt-1") {
|
||||
div(class="col-md-6") {
|
||||
div(class="form-section-title") { "Key usage" }
|
||||
(check("Digital signature", ku_digital))
|
||||
(check("Key encipherment", ku_encipher))
|
||||
(check("Certificate sign", ku_cert_sign))
|
||||
(check("CRL sign", ku_crl_sign))
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
div(class="form-section-title") { "Extended key usage" }
|
||||
(check("TLS server", eku_server))
|
||||
(check("TLS client", eku_client))
|
||||
(check("Code signing", eku_code))
|
||||
(check("E-mail protection", eku_email))
|
||||
}
|
||||
}
|
||||
}
|
||||
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
|
||||
(if saving.get() { "Creating…" } else { "Create request" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subject_input(label: &'static str, value: Signal<String>) -> View {
|
||||
view! {
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { (label) }
|
||||
input(class="form-control", bind:value=value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(label: &'static str, value: Signal<bool>) -> View {
|
||||
view! {
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=value)
|
||||
label(class="form-check-label") { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_san(text: &str) -> Vec<serde_json::Value> {
|
||||
text.split(',')
|
||||
.map(|chunk| chunk.trim())
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| {
|
||||
if let Some((prefix, value)) = chunk.split_once(':') {
|
||||
let kind = match prefix.trim().to_lowercase().as_str() {
|
||||
"ip" => "ip",
|
||||
"uri" => "uri",
|
||||
"email" => "email",
|
||||
"rid" => "rid",
|
||||
"dns" => "dns",
|
||||
_ => "dns",
|
||||
};
|
||||
json!({"type": kind, "value": value.trim()})
|
||||
} else {
|
||||
json!({"type": "dns", "value": chunk})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user