349 lines
14 KiB
Rust
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()
|
|
}
|