Files
kca/web/src/pages/request_new.rs
T
KCA d5f060cbf0
CI / Build and push docker image (push) Successful in 2m16s
CI / Build and push OCSP image (push) Successful in 34s
Add full XCA curve and digest parity
2026-10-26 03:45:25 +00:00

349 lines
14 KiB
Rust

//! 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::crypto::{
add_key_parameters, apply_signature_hash, key_parameter_input, key_type_select,
signature_hash_input,
};
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{KeySummary, Me, OptionsPage};
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 options = create_signal(None::<OptionsPage>);
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 curve = create_signal("secp256r1".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());
}
}
if let Ok(page) = api::options(id).await {
if options.is_alive() {
options.set(Some(page));
}
}
});
let signing_key_type = move || {
if new_key.get() {
return Some(key_type.get_clone());
}
let selected = key_id.get_clone();
keys.get_clone()
.into_iter()
.find(|key| key.id == selected)
.map(|key| key.key_type)
};
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,
});
apply_signature_hash(
&mut payload,
signing_key_type().as_deref(),
&signature_hash.get_clone(),
);
if new_key.get() {
let mut generated_key = json!({
"key_type": key_type.get_clone(),
"name": format!("{} key", name.get_clone()),
});
add_key_parameters(
&mut generated_key,
&key_type.get_clone(),
&key_size.get_clone(),
&curve.get_clone(),
);
payload["new_key"] = generated_key;
} 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") {
(signature_hash_input(
"Signature hash",
options,
signing_key_type,
signature_hash,
))
}
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" }
(key_type_select(options, key_type))
}
div(class="col-md-4") {
(key_parameter_input(options, key_type, key_size, curve))
}
}
}
} 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()
}