//! Certificate creation wizard and renewal form. use serde_json::{json, Value}; 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::extension_form::{ build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals, }; use crate::components::list_editor::{ general_name_entries_to_text, list_editor_dialog, open_list_editor, parse_general_name_entries, ListEditorConfig, ListEditorFormat, ListEditorSignals, }; use crate::components::nav::{app_nav, whoami}; use crate::components::ui::{page_header, toast_error}; use crate::model::{ CertificateDetail, CertificateSummary, DnAttribute, KeySummary, Me, OptionsPage, TemplateSummary, }; const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"]; #[derive(Debug, Clone, PartialEq, Eq)] struct ExtraSubjectRow { id: u64, name: Signal, value: Signal, } #[derive(Clone, Copy)] struct CertFormSignals { explicit_dn: Signal>, subject_extra: Signal>, next_extra_subject_id: Signal, cn: Signal, country: Signal, state: Signal, locality: Signal, organization: Signal, org_unit: Signal, email: Signal, ca_mode: Signal, path_length: Signal, bc_critical: Signal, ku_digital: Signal, ku_encipher: Signal, ku_key_agreement: Signal, ku_cert_sign: Signal, ku_crl_sign: Signal, ku_critical: Signal, eku_server: Signal, eku_client: Signal, eku_code: Signal, eku_email: Signal, eku_time: Signal, eku_ocsp: Signal, eku_critical: Signal, san_text: Signal, san_critical: Signal, ski: Signal, aki: Signal, ocsp_staple: Signal, ext: ExtensionSignals, validity_mode: Signal, validity_amount: Signal, validity_unit: Signal, midnight: Signal, not_before_text: Signal, not_after_text: Signal, no_expiration: Signal, } pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let reactive_root = use_global_scope(); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); let options = create_signal(None::); let keys = create_signal(Vec::::new()); let signers = create_signal(Vec::::new()); let templates = create_signal(Vec::::new()); let error = create_signal(None::); let saving = create_signal(false); let request_id = query_param("request").unwrap_or_default(); let request_mode = !request_id.is_empty(); let request_id_for_load = request_id.clone(); let request_key_id = create_signal(String::new()); let name = create_signal(String::new()); let comment = create_signal(String::new()); let signer_id = create_signal("self".to_string()); 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 template_id = create_signal(String::new()); let template_apply_mode = create_signal("all".to_string()); let ca_default_template = create_signal(None::); let ca_default_name = create_signal(String::new()); 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 ca_mode = create_signal("end".to_string()); let path_length = create_signal(String::new()); let bc_critical = create_signal(true); let ku_digital = create_signal(true); let ku_encipher = create_signal(false); let ku_key_agreement = create_signal(false); let ku_cert_sign = create_signal(false); let ku_crl_sign = create_signal(false); let ku_critical = create_signal(true); let eku_server = create_signal(false); let eku_client = create_signal(false); let eku_code = create_signal(false); let eku_email = create_signal(false); let eku_time = create_signal(false); let eku_ocsp = create_signal(false); let eku_critical = create_signal(false); let san_text = create_signal(String::new()); let san_critical = create_signal(false); let ski = create_signal(true); let aki = create_signal(true); let ocsp_staple = create_signal(false); let crl_urls = create_signal(String::new()); let crl_critical = create_signal(false); let aia_ocsp = create_signal(String::new()); let aia_ca_issuers = create_signal(String::new()); let nc_permitted = create_signal(String::new()); let nc_excluded = create_signal(String::new()); let nc_critical = create_signal(true); let policy_oids = create_signal(String::new()); let policy_cps = create_signal(String::new()); let policy_critical = create_signal(false); let policy_require = create_signal(String::new()); let policy_inhibit = create_signal(String::new()); let inhibit_any = create_signal(String::new()); let advanced_json = create_signal(String::new()); let ext = ExtensionSignals { crl_urls, crl_critical, aia_ocsp, aia_ca_issuers, nc_permitted, nc_excluded, nc_critical, policy_oids, policy_cps, policy_critical, policy_require, policy_inhibit, inhibit_any, advanced_json, }; let preview_text = create_signal(String::new()); let validity_mode = create_signal("duration".to_string()); let validity_amount = create_signal("365".to_string()); let validity_unit = create_signal("days".to_string()); let midnight = create_signal(false); let not_before_text = create_signal(String::new()); let not_after_text = create_signal(String::new()); let no_expiration = create_signal(false); let explicit_dn = create_signal( DEFAULT_EXPLICIT_DN .iter() .map(|v| v.to_string()) .collect::>(), ); let subject_extra = create_signal(Vec::::new()); let next_extra_subject_id = create_signal(0_u64); let dn_attributes = create_signal(Vec::::new()); let form = CertFormSignals { explicit_dn, subject_extra, next_extra_subject_id, cn, country, state, locality, organization, org_unit, email, ca_mode, path_length, bc_critical, ku_digital, ku_encipher, ku_key_agreement, ku_cert_sign, ku_crl_sign, ku_critical, eku_server, eku_client, eku_code, eku_email, eku_time, eku_ocsp, eku_critical, san_text, san_critical, ski, aki, ocsp_staple, ext, validity_mode, validity_amount, validity_unit, midnight, not_before_text, not_after_text, no_expiration, }; let san_editor = ListEditorSignals::new(); let san_editor_config = ListEditorConfig { id: "san", title: "Subject alternative names", format: ListEditorFormat::GeneralNames, critical: Some(san_critical), copy_common_name: true, }; let edit_san = move |_| open_list_editor(san_editor, san_text, san_editor_config); 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 { keys.set(page.results); } if let Ok(page) = api::certificates(&id).await { signers.set( page.results .into_iter() .filter(|cert| cert.is_ca && cert.has_private_key) .collect(), ); } if let Ok(page) = api::templates(&id).await { templates.set(page.results); } if let Ok(value) = api::vault_settings(&id).await { if let Some(settings) = value.get("settings") { if let Some(hash) = settings.get("default_hash").and_then(|v| v.as_str()) { signature_hash.set(hash.to_string()); } if let Some(key) = settings.get("default_key") { if let Some(kind) = key.get("type").and_then(|v| v.as_str()) { key_type.set(kind.to_string()); } if let Some(size) = key.get("size").and_then(|v| v.as_i64()) { key_size.set(size.to_string()); } if let Some(value) = key.get("curve").and_then(|v| v.as_str()) { curve.set(value.to_string()); } } if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) { let names = items .iter() .filter_map(|v| v.as_str()) .map(|v| v.to_string()) .collect::>(); if !names.is_empty() { explicit_dn.set(names); } } if let Some(validity) = settings.get("default_validity") { if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) { validity_amount.set(amount.to_string()); } if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) { validity_unit.set(unit.to_string()); } } } } if let Ok(page) = api::options(id).await { dn_attributes.set(page.dn_attributes.clone()); options.set(Some(page)); } if !request_mode { if let Some(cert_id) = from_cert.clone() { if let Ok(value) = api::certificate(&id, &cert_id).await { name.set(format!("{} copy", value.summary.name)); comment.set(value.summary.comment.clone()); if let Some(key) = value.summary.key_id.clone() { key_id.set(key); } if let Some(issuer) = value.summary.issuer_id.clone() { if issuer != value.summary.id { signer_id.set(issuer); } } if let Some(spec) = value .cert_spec .get("signature_hash") .and_then(|v| v.as_str()) { signature_hash.set(spec.to_string()); } let subject_json = serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); reactive_root.run_in(|| apply_subject_json(&form, &subject_json)); apply_extension_spec(&form, &value.extension_spec); no_expiration.set(value.summary.no_expiration); } } } if request_mode { if let Ok(value) = api::request(&id, &request_id_for_load).await { name.set(format!("{} certificate", value.summary.name)); request_key_id.set(value.summary.key_id.clone().unwrap_or_default()); let subject_json = serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); reactive_root.run_in(|| apply_subject_json(&form, &subject_json)); } } }); let signing_key_type = move || { let keys = keys.get_clone(); let signer = signer_id.get_clone(); if signer != "self" { let issuer_key_id = signers .get_clone() .into_iter() .find(|cert| cert.id == signer) .and_then(|cert| cert.key_id); return issuer_key_id.and_then(|key_id| { keys.into_iter() .find(|key| key.id == key_id) .map(|key| key.key_type) }); } if !request_mode && new_key.get() { return Some(key_type.get_clone()); } let subject_key_id = if request_mode { request_key_id.get_clone() } else { key_id.get_clone() }; keys.into_iter() .find(|key| key.id == subject_key_id) .map(|key| key.key_type) }; let apply_template_id = move |template: String, mode: String| { if template.is_empty() { return; } let target = match build_target(&form) { Ok(target) => target, Err(message) => { error.set(Some(message)); return; } }; let payload = json!({ "target": target, "apply_subject": mode == "all" || mode == "subject", "apply_extensions": mode == "all" || mode == "extensions", }); spawn_local(async move { match api::apply_template(&id, &template, &payload).await { Ok(merged) => { if mode == "all" || mode == "subject" { let subject = merged.get("subject").cloned().unwrap_or_else(|| json!([])); reactive_root.run_in(|| apply_subject_json(&form, &subject)); } if mode == "all" || mode == "extensions" { let extensions = merged .get("extensions") .cloned() .unwrap_or_else(|| json!({})); apply_extension_spec(&form, &extensions); if let Some(validity) = merged.get("validity") { apply_validity_json(&form, validity); } } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let apply_template = move |_| { apply_template_id(template_id.get_clone(), template_apply_mode.get_clone()); }; let apply_ca_default = move |_: web_sys::MouseEvent| { if let Some(template) = ca_default_template.get_clone() { template_id.set(template.clone()); template_apply_mode.set("all".to_string()); apply_template_id(template, "all".to_string()); } }; create_effect(move || { let signer = signer_id.get_clone(); if signer == "self" { ca_default_template.set(None); ca_default_name.set(String::new()); return; } spawn_local(async move { if let Ok(value) = api::ca_info(&id, &signer).await { let template = value .get("default_template") .and_then(|v| v.as_str()) .map(|v| v.to_string()); let label = match template.as_deref() { Some("builtin:ca") => "CA".to_string(), Some("builtin:tls-server") => "TLS server".to_string(), Some("builtin:tls-client") => "TLS client".to_string(), Some(other) => match api::template(&id, other).await { Ok(template) => template.name, Err(_) => other.to_string(), }, None => String::new(), }; ca_default_template.set(template); ca_default_name.set(label); } }); }); let submit = { let id = id.clone(); let request_id = request_id.clone(); move |_| { let cert_name = name.get_clone(); if cert_name.trim().is_empty() { error.set(Some("certificate name is required".to_string())); return; } let subject = match build_subject(&form) { Ok(subject) => subject, Err(message) => { error.set(Some(message)); return; } }; if subject.is_empty() { error.set(Some("the subject must not be empty".to_string())); return; } let extensions = match build_extensions(&form) { Ok(extensions) => extensions, Err(message) => { error.set(Some(message)); return; } }; let signer = signer_id.get_clone(); let mut payload = json!({ "name": cert_name, "comment": comment.get_clone(), "subject": subject, "extensions": extensions, "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "validity": validity_value(&form), }); apply_signature_hash( &mut payload, signing_key_type().as_deref(), &signature_hash.get_clone(), ); if validity_mode.get_clone() == "absolute" && !no_expiration.get() { payload["not_before"] = json!(not_before_text.get_clone()); payload["not_after"] = json!(not_after_text.get_clone()); } if !request_id.is_empty() { payload["request_id"] = json!(request_id); payload["modify_subject"] = json!(true); payload["copy_request_extensions"] = json!(false); } else 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; } saving.set(true); let id = id.clone(); spawn_local(async move { match api::create_certificate(&id, &payload).await { Ok(cert) => { saving.set(false); sycamore_router::navigate(&format!( "/vaults/{}/certificates/{}", id, cert.summary.id )); return; } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } if saving.is_alive() { saving.set(false); } }); } }; let validate = { let id = id.clone(); move |_| { let subject = match build_subject(&form) { Ok(subject) => subject, Err(message) => { error.set(Some(message)); return; } }; let extensions = match build_extensions(&form) { Ok(extensions) => extensions, Err(message) => { error.set(Some(message)); return; } }; let signer = signer_id.get_clone(); let mut payload = json!({ "name": if name.get_clone().is_empty() { "preview".to_string() } else { name.get_clone() }, "subject": subject, "extensions": extensions, "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "validity": validity_value(&form), }); apply_signature_hash( &mut payload, signing_key_type().as_deref(), &signature_hash.get_clone(), ); if validity_mode.get_clone() == "absolute" && !no_expiration.get() { payload["not_before"] = json!(not_before_text.get_clone()); payload["not_after"] = json!(not_after_text.get_clone()); } let id = id.clone(); spawn_local(async move { match api::preview_certificate(&id, &payload).await { Ok(value) => { let mut text = String::new(); if let Some(rows) = value.get("extensions").and_then(|v| v.as_array()) { for row in rows { let name = row.get("name").and_then(|v| v.as_str()).unwrap_or(""); let value = row.get("value").and_then(|v| v.as_str()).unwrap_or(""); text.push_str(&format!("{name}: {value}\n")); } } preview_text.set(text); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); } }; let signer_options = move || { signers .get_clone() .into_iter() .map(|cert| { view! { option(value=cert.id.clone()) { (cert.name.clone()) } } }) .collect::>() }; let key_options = move || { keys.get_clone() .into_iter() .filter(|key| key.has_private) .map(|key| { view! { option(value=key.id.clone()) { (key.name.clone()) } } }) .collect::>() }; let template_options = move || { templates.get_clone().into_iter().map(|template| view! { option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) } }).collect::>() }; 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( if request_mode { "Sign certificate request" } else { "New certificate" }, "Certificates can be self-signed or issued by a CA with a private key in this vault.", view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Cancel" } }, )) (if request_mode { view! { div(class="alert alert-info") { "The subject and public key come from the selected request; the fields below override the subject." } } } else { view! {} }) (if let Some(default) = ca_default_template.get_clone() { if default != template_id.get_clone() { view! { div(class="alert alert-info d-flex align-items-center justify-content-between") { span() { (format!("CA default template: {}", ca_default_name.get_clone())) } button(class="btn btn-sm btn-outline-light", on:click=apply_ca_default) { "Apply CA default" } } } } else { view! {} } } else { view! {} }) div(class="card") { div(class="card-body") { div(class="form-section") { div(class="form-section-title") { "Source" } div(class="row g-3") { div(class="col-md-6") { label(class="form-label") { "Issuer" } select(class="form-select", bind:value=signer_id) { option(value="self") { "Self-signed" } (signer_options()) } } div(class="col-md-6") { (signature_hash_input( "Signature hash", options, signing_key_type, signature_hash, )) } div(class="col-md-6") { label(class="form-label") { "Template" } div(class="input-group") { select(class="form-select", bind:value=template_id) { option(value="") { "— no template —" } (template_options()) } select(class="form-select", style="max-width: 11rem", bind:value=template_apply_mode) { option(value="all") { "Apply all" } option(value="subject") { "Subject only" } option(value="extensions") { "Extensions + validity" } } button(class="btn btn-outline-light", on:click=apply_template) { "Apply" } } } div(class="col-md-6") { label(class="form-label") { "Internal name" } input(class="form-control", bind:value=name) } 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_fields(&form)) (extra_subject_editor( form.subject_extra, form.next_extra_subject_id, dn_attributes, )) } } (if request_mode { view! {} } else { view! { 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") { "Extensions" } div(class="row g-3" ) { div(class="col-md-4") { label(class="form-label") { "Basic constraints" } select(class="form-select", bind:value=ca_mode) { option(value="end") { "End entity (CA:FALSE)" } option(value="ca") { "Certification authority (CA:TRUE)" } option(value="none") { "Not defined" } } } div(class="col-md-3") { label(class="form-label") { "Path length" } input(class="form-control", placeholder="unlimited", bind:value=path_length) } div(class="col-md-3 d-flex align-items-end") { div(class="form-check") { input(class="form-check-input", r#type="checkbox", bind:checked=bc_critical) label(class="form-check-label") { "Critical" } } } div(class="col-md-2 d-flex align-items-end") { div(class="form-check") { input(class="form-check-input", r#type="checkbox", bind:checked=ski) label(class="form-check-label") { "SKI" } } } } 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("Key agreement", ku_key_agreement)) (check("Certificate sign", ku_cert_sign)) (check("CRL sign", ku_crl_sign)) (check("Critical", ku_critical)) } 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)) (check("Time stamping", eku_time)) (check("OCSP signing", eku_ocsp)) (check("Critical", eku_critical)) } div(class="col-md-6") { div(class="form-section-title") { "Subject alternative names" } div(class="input-group") { input(id="san-text", class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text) button(id="edit-san", class="btn btn-outline-light", r#type="button", on:click=edit_san) { "Edit" } } } div(class="col-md-6") { div(class="form-section-title") { "Key identifier / OCSP" } (check("Authority key identifier", aki)) (check("OCSP stapling", ocsp_staple)) } } } (extension_fields(&ext)) div(class="form-section") { div(class="form-section-title") { "Validity" } div(class="row g-3") { div(class="col-md-3") { label(class="form-label") { "Mode" } select(class="form-select", bind:value=validity_mode) { option(value="duration") { "Duration" } option(value="absolute") { "Absolute dates" } } } (if validity_mode.get_clone() == "duration" { view! { div(class="col-md-3") { label(class="form-label") { "Duration" } input(class="form-control", disabled=no_expiration.get(), bind:value=validity_amount) } div(class="col-md-3") { label(class="form-label") { "Unit" } select(class="form-select", disabled=no_expiration.get(), bind:value=validity_unit) { option(value="days") { "Days" } option(value="months") { "Months" } option(value="years") { "Years" } } } div(class="col-md-3 d-flex align-items-end") { div(class="form-check") { input(class="form-check-input", r#type="checkbox", disabled=no_expiration.get(), bind:checked=midnight) label(class="form-check-label") { "Round to midnight" } } } } } else { view! { div(class="col-md-4") { label(class="form-label") { "Not before" } input(class="form-control", r#type="datetime-local", disabled=no_expiration.get(), bind:value=not_before_text) } div(class="col-md-4") { label(class="form-label") { "Not after" } input(class="form-control", r#type="datetime-local", disabled=no_expiration.get(), bind:value=not_after_text) } } }) div(class="col-md-3 d-flex align-items-end") { div(class="form-check") { input(class="form-check-input", r#type="checkbox", bind:checked=no_expiration) label(class="form-check-label") { "No well-defined expiration" } } } } } (if preview_text.get_clone().is_empty() { view! {} } else { view! { div(class="card mb-3") { div(class="card-header") { "Extension preview" } div(class="card-body") { pre(class="pem mb-0") { (preview_text.get_clone()) } } } } }) button(class="btn btn-outline-light btn-lg me-2", on:click=validate) { "Validate extensions" } button(class="btn btn-primary btn-lg", disabled=saving.get(), on:click=submit) { (if saving.get() { "Issuing…" } else if request_mode { "Issue certificate" } else { "Create certificate" }) } } } } (list_editor_dialog(san_editor, san_text, san_editor_config)) } } pub fn CertificateRenewPage(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::); let cert = create_signal(None::); let error = create_signal(None::); let loading = create_signal(true); let saving = create_signal(false); let name = create_signal(String::new()); let amount = create_signal("365".to_string()); let unit = create_signal("days".to_string()); let keep_serial = create_signal(false); let revoke_old = create_signal(false); let replace_old = create_signal(false); spawn_local(async move { match api::me().await { Ok(user) => { me.set(Some(user)); match api::certificate(&id, &cert_id_value).await { Ok(value) => { name.set(value.summary.name.clone()); cert.set(Some(value)); } Err(message) => error.set(Some(message)), } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } loading.set(false); }); let submit = { let id = id.clone(); let cert_id_value = cert_id_value.clone(); move |_| { let id = id.clone(); let cert_id_value = cert_id_value.clone(); let payload = json!({ "name": name.get_clone(), "validity": {"amount": amount.get_clone().parse::().unwrap_or(365), "unit": unit.get_clone()}, "keep_serial": keep_serial.get(), "revoke_old": revoke_old.get(), "replace_old": replace_old.get(), }); saving.set(true); spawn_local(async move { match api::renew_certificate(&id, &cert_id_value, &payload).await { Ok(value) => { saving.set(false); sycamore_router::navigate(&format!( "/vaults/{}/certificates/{}", id, value.summary.id )); return; } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } if saving.is_alive() { saving.set(false); } }); } }; let content = move || { if loading.get() { return crate::components::ui::spinner(); } let Some(value) = cert.get_clone() else { return crate::components::ui::empty_state("Certificate not found."); }; view! { (page_header("Renew certificate", &format!("Issued by {}", value.issuer_name), view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}")) { "Cancel" } })) div(class="card") { div(class="card-body") { div(class="row g-3") { div(class="col-md-8") { label(class="form-label") { "New internal name" } input(class="form-control", bind:value=name) } div(class="col-md-2") { label(class="form-label") { "Duration" } input(class="form-control", bind:value=amount) } div(class="col-md-2") { label(class="form-label") { "Unit" } select(class="form-select", bind:value=unit) { option(value="days") { "Days" } option(value="months") { "Months" } option(value="years") { "Years" } } } } div(class="form-check mt-3") { input(class="form-check-input", r#type="checkbox", id="keep-serial", bind:checked=keep_serial) label(class="form-check-label", r#for="keep-serial") { "Keep serial number" } } div(class="form-check") { input(class="form-check-input", r#type="checkbox", id="revoke-old", bind:checked=revoke_old) label(class="form-check-label", r#for="revoke-old") { "Revoke old certificate" } } div(class="form-check mb-3") { input(class="form-check-input", r#type="checkbox", id="replace-old", bind:checked=replace_old) label(class="form-check-label", r#for="replace-old") { "Replace old certificate" } } button(class="btn btn-primary", disabled=saving.get(), on:click=submit) { (if saving.get() { "Renewing…" } else { "Renew certificate" }) } } } } }; 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 extra_subject_editor( rows: Signal>, next_id: Signal, attributes: Signal>, ) -> View { let add_row = move |_| { let available = attributes.get_clone(); let default_name = available .first() .map(|attribute| { if attribute.name.is_empty() { attribute.oid.clone() } else { attribute.name.clone() } }) .unwrap_or_default(); let row = new_extra_subject_row(next_id, default_name, String::new()); rows.update(move |rows| rows.push(row)); }; view! { div(class="col-12 additional-subject-attributes") { div(class="d-flex align-items-center justify-content-between mb-2") { label(class="form-label mb-0") { "Additional subject attributes" } button( class="btn btn-sm btn-outline-light", r#type="button", disabled=attributes.with(|attributes| attributes.is_empty()), on:click=add_row, ) { "Add" } } div(class="table-responsive") { table(class="table table-sm align-middle mb-2") { thead() { tr() { th(scope="col") { "Type" } th(scope="col") { "Content" } th(scope="col") {} } } tbody() { Keyed( list=rows, view=move |row| { let row_id = row.id; let name = row.name; let value = row.value; on_cleanup(move || { name.dispose(); value.dispose(); }); let remove_row = move |_| { rows.update(|rows| rows.retain(|row| row.id != row_id)); }; view! { tr(class="subject-extra-row") { td(style="width: 16rem") { select( class="form-select form-select-sm", aria-label="Additional subject attribute type", bind:value=name, ) { (move || extra_subject_type_options(attributes, name)) } } td() { input( class="form-control form-control-sm", aria-label="Additional subject attribute value", bind:value=value, ) } td(class="table-actions") { button( class="btn btn-sm btn-outline-danger", r#type="button", on:click=remove_row, ) { "Delete" } } } } }, key=|row| row.id, ) } } } div(class="form-text") { "Used for attributes not shown above; the order above follows the vault's explicit DN setting." } } } } fn new_extra_subject_row(next_id: Signal, name: String, value: String) -> ExtraSubjectRow { let id = next_id.get(); next_id.set(id + 1); ExtraSubjectRow { id, name: create_signal(name), value: create_signal(value), } } fn extra_subject_type_options( attributes: Signal>, selected: Signal, ) -> Vec { let selected = selected.get_clone(); let attributes = attributes.get_clone(); let has_selected = attributes .iter() .any(|attribute| attribute.name.as_str() == selected); let mut options = attributes .into_iter() .map(|attribute| { let value = if attribute.name.is_empty() { attribute.oid } else { attribute.name }; let label = if attribute.long_name.is_empty() || attribute.long_name == value { value.clone() } else { format!("{} ({})", attribute.long_name, value) }; view! { option(value=value) { (label) } } }) .collect::>(); if !selected.is_empty() && !has_selected { let value = selected.clone(); let label = selected.clone(); options.push(view! { option(value=value) { (label) } }); } options } fn subject_label(name: &str) -> Option<&'static str> { match name { "CN" => Some("Common name (CN)"), "O" => Some("Organization (O)"), "OU" => Some("Organizational unit (OU)"), "C" => Some("Country (C)"), "ST" => Some("State / province (ST)"), "L" => Some("Locality (L)"), "emailAddress" => Some("E-mail address"), _ => None, } } fn subject_signal(name: &str, form: &CertFormSignals) -> Option> { match name { "CN" => Some(form.cn), "O" => Some(form.organization), "OU" => Some(form.org_unit), "C" => Some(form.country), "ST" => Some(form.state), "L" => Some(form.locality), "emailAddress" => Some(form.email), _ => None, } } fn subject_field(name: &str, form: &CertFormSignals) -> Option { let label = subject_label(name)?; let value = subject_signal(name, form)?; Some(view! { div(class="col-md-6") { label(class="form-label") { (label) } input(class="form-control", bind:value=value) } }) } fn subject_fields(form: &CertFormSignals) -> Vec { let mut names = form.explicit_dn.get_clone(); if names.is_empty() { names = DEFAULT_EXPLICIT_DN.iter().map(|v| v.to_string()).collect(); } names .iter() .filter_map(|name| subject_field(name, form)) .collect() } fn apply_subject_json(form: &CertFormSignals, subject: &Value) { for signal in [ form.cn, form.organization, form.org_unit, form.country, form.state, form.locality, form.email, ] { signal.set(String::new()); } let entries = subject.as_array().cloned().unwrap_or_default(); let mut extras = Vec::new(); for entry in entries { let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() || value.is_empty() { continue; } match subject_signal(name, form) { Some(signal) if signal.get_clone().is_empty() => signal.set(value.to_string()), _ => extras.push((name.to_string(), value.to_string())), } } form.subject_extra.set( extras .into_iter() .map(|(name, value)| new_extra_subject_row(form.next_extra_subject_id, name, value)) .collect(), ); } fn extra_subject_entries(form: &CertFormSignals) -> Result, String> { let mut result = Vec::new(); for row in form.subject_extra.get_clone() { let name = row.name.get_clone(); let value = row.value.get_clone(); let name = name.trim(); let value = value.trim(); if name.is_empty() { return Err("each additional subject attribute needs a name".to_string()); } if value.is_empty() { continue; } result.push(json!({"name": name, "value": value})); } Ok(result) } fn build_subject(form: &CertFormSignals) -> Result, String> { let extras = extra_subject_entries(form)?; let mut used = vec![false; extras.len()]; let mut result = Vec::new(); let mut names = form.explicit_dn.get_clone(); if names.is_empty() { names = DEFAULT_EXPLICIT_DN.iter().map(|v| v.to_string()).collect(); } for name in names { if let Some(signal) = subject_signal(&name, form) { let value = signal.get_clone(); if !value.trim().is_empty() { result.push(json!({"name": name, "value": value.trim()})); } continue; } for (index, entry) in extras.iter().enumerate() { if used[index] { continue; } if entry.get("name").and_then(|v| v.as_str()) == Some(name.as_str()) { used[index] = true; result.push(entry.clone()); break; } } } for (index, entry) in extras.into_iter().enumerate() { if !used[index] { result.push(entry); } } Ok(result) } fn build_extensions(form: &CertFormSignals) -> Result { let mut extensions = json!({}); let mode = form.ca_mode.get_clone(); if mode == "ca" { extensions["basic_constraints"] = json!({ "critical": form.bc_critical.get(), "ca": true, "path_length": form.path_length.get_clone().parse::().ok(), }); } else if mode == "end" { extensions["basic_constraints"] = json!({"critical": form.bc_critical.get(), "ca": false, "path_length": null}); } let usages = [ (form.ku_digital.get(), "digital_signature"), (form.ku_encipher.get(), "key_encipherment"), (form.ku_key_agreement.get(), "key_agreement"), (form.ku_cert_sign.get(), "key_cert_sign"), (form.ku_crl_sign.get(), "crl_sign"), ] .iter() .filter(|(checked, _)| *checked) .map(|(_, name)| json!(name)) .collect::>(); extensions["key_usage"] = json!({"critical": form.ku_critical.get(), "usages": usages}); let eku = [ (form.eku_server.get(), "serverAuth"), (form.eku_client.get(), "clientAuth"), (form.eku_code.get(), "codeSigning"), (form.eku_email.get(), "emailProtection"), (form.eku_time.get(), "timeStamping"), (form.eku_ocsp.get(), "OCSPSigning"), ] .iter() .filter(|(checked, _)| *checked) .map(|(_, name)| json!(name)) .collect::>(); extensions["extended_key_usage"] = json!({"critical": form.eku_critical.get(), "usages": eku}); let san = parse_general_name_entries(&form.san_text.get_clone()); if !san.is_empty() { extensions["subject_alt_name"] = json!({"critical": form.san_critical.get(), "entries": san}); } if form.ski.get() { extensions["subject_key_identifier"] = json!({"critical": false}); } if form.aki.get() { extensions["authority_key_identifier"] = json!({"critical": false}); } if form.ocsp_staple.get() { extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]}); } let extra = build_extra_extensions(&form.ext)?; if let Some(object) = extra.as_object() { for (key, value) in object { extensions[key] = value.clone(); } } Ok(extensions) } fn validity_value(form: &CertFormSignals) -> Value { if form.no_expiration.get() { json!({"no_expiration": true}) } else if form.validity_mode.get_clone() == "absolute" { json!({}) } else { json!({ "amount": form.validity_amount.get_clone().parse::().unwrap_or(365), "unit": form.validity_unit.get_clone(), "midnight": form.midnight.get(), }) } } fn build_target(form: &CertFormSignals) -> Result { let subject = build_subject(form)?; let mut target = json!({ "subject": subject, "extensions": build_extensions(form)?, "validity": validity_value(form), }); if form.validity_mode.get_clone() == "absolute" && !form.no_expiration.get() { target["not_before"] = json!(form.not_before_text.get_clone()); target["not_after"] = json!(form.not_after_text.get_clone()); } Ok(target) } fn apply_validity_json(form: &CertFormSignals, value: &Value) { if value.is_null() { return; } let amount = value.get("amount").and_then(|v| v.as_i64()); let unit = value.get("unit").and_then(|v| v.as_str()); let midnight = value.get("midnight").and_then(|v| v.as_bool()); let no_expiration = value .get("no_expiration") .and_then(|v| v.as_bool()) .unwrap_or(false); if let Some(amount) = amount { form.validity_amount.set(amount.to_string()); } if let Some(unit) = unit { form.validity_unit.set(unit.to_string()); } if let Some(midnight) = midnight { form.midnight.set(midnight); } form.no_expiration.set(no_expiration); if !no_expiration { form.validity_mode.set("duration".to_string()); } } fn apply_extension_spec(form: &CertFormSignals, spec: &Value) { load_extra_extensions(&form.ext, spec); if let Some(bc) = spec.get("basic_constraints") { if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) { form.ca_mode.set("ca".to_string()); form.path_length.set( bc.get("path_length") .and_then(|v| v.as_i64()) .map(|v| v.to_string()) .unwrap_or_default(), ); } else if bc.get("ca").is_some() { form.ca_mode.set("end".to_string()); form.path_length.set(String::new()); } form.bc_critical .set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); } else { form.ca_mode.set("none".to_string()); } if let Some(ku) = spec.get("key_usage") { let usages = string_array(ku.get("usages")); form.ku_digital .set(usages.iter().any(|s| s == "digital_signature")); form.ku_encipher .set(usages.iter().any(|s| s == "key_encipherment")); form.ku_key_agreement .set(usages.iter().any(|s| s == "key_agreement")); form.ku_cert_sign .set(usages.iter().any(|s| s == "key_cert_sign")); form.ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign")); form.ku_critical .set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); } if let Some(eku) = spec.get("extended_key_usage") { let usages = string_array(eku.get("usages")); form.eku_server .set(usages.iter().any(|s| s == "serverAuth")); form.eku_client .set(usages.iter().any(|s| s == "clientAuth")); form.eku_code.set(usages.iter().any(|s| s == "codeSigning")); form.eku_email .set(usages.iter().any(|s| s == "emailProtection")); form.eku_time .set(usages.iter().any(|s| s == "timeStamping")); form.eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning")); form.eku_critical.set( eku.get("critical") .and_then(|v| v.as_bool()) .unwrap_or(false), ); } if let Some(san) = spec.get("subject_alt_name") { let entries = san .get("entries") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); form.san_text.set(general_name_entries_to_text(&entries)); form.san_critical.set( san.get("critical") .and_then(|v| v.as_bool()) .unwrap_or(false), ); } else { form.san_text.set(String::new()); form.san_critical.set(false); } form.ski.set(spec.get("subject_key_identifier").is_some()); form.aki.set(spec.get("authority_key_identifier").is_some()); let features = spec .get("tls_feature") .and_then(|v| v.get("features")) .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); form.ocsp_staple.set( features .iter() .any(|v| v.as_str() == Some("status_request")), ); } fn check(label: &'static str, value: Signal) -> View { view! { div(class="form-check") { input(class="form-check-input", r#type="checkbox", bind:checked=value) label(class="form-check-label") { (label) } } } } fn string_array(value: Option<&serde_json::Value>) -> Vec { value .and_then(|v| v.as_array()) .map(|items| { items .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default() } fn query_param(name: &str) -> Option { let search = web_sys::window()?.location().search().ok()?; let search = search.trim_start_matches('?'); for pair in search.split('&') { let (key, value) = pair.split_once('=').unwrap_or((pair, "")); if key == name { return Some(value.to_string()); } } None }