//! 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::extension_form::{ build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals, }; use crate::components::nav::{app_nav, whoami}; use crate::components::ui::{page_header, toast_error}; use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary}; const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"]; #[derive(Clone, Copy)] struct CertFormSignals { explicit_dn: Signal>, subject_extra: 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, 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 id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = 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 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 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 ski = create_signal(true); let aki = create_signal(true); let ocsp_staple = create_signal(false); let crl_urls = create_signal(String::new()); 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 policy_oids = create_signal(String::new()); let policy_cps = create_signal(String::new()); 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, aia_ocsp, aia_ca_issuers, nc_permitted, nc_excluded, policy_oids, policy_cps, 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(String::new()); let form = CertFormSignals { explicit_dn, subject_extra, 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, ski, aki, ocsp_staple, ext, validity_mode, validity_amount, validity_unit, midnight, not_before_text, not_after_text, no_expiration, }; 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(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 !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!([])); 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)); let subject_json = serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); apply_subject_json(&form, &subject_json); } } }); 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!([])); 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, "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "validity": validity_value(&form), }); 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() { payload["new_key"] = json!({ "key_type": key_type.get_clone(), "size": key_size.get_clone().parse::().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; } 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 payload = json!({ "name": if name.get_clone().is_empty() { "preview".to_string() } else { name.get_clone() }, "subject": subject, "extensions": extensions, "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "validity": validity_value(&form), }); let mut payload = payload; 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") { 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-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)) div(class="col-12") { label(class="form-label") { "Additional subject attributes (JSON)" } textarea(class="form-control mono", rows="2", placeholder=r#"[{"name":"serialNumber","value":"1234"}]"#, bind:value=subject_extra) {} div(class="form-text") { "Used for attributes not shown above; the order above follows the vault's explicit DN setting." } } } } (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" } 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") { "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" } input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text) } 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" }) } } } } } } 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 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(entry), } } if extras.is_empty() { form.subject_extra.set(String::new()); } else { form.subject_extra .set(serde_json::to_string_pretty(&Value::Array(extras)).unwrap_or_default()); } } fn extra_subject_entries(form: &CertFormSignals) -> Result, String> { let text = form.subject_extra.get_clone(); if text.trim().is_empty() { return Ok(Vec::new()); } let value: Value = serde_json::from_str(&text) .map_err(|_| "additional subject attributes must be valid JSON".to_string())?; let entries = value .as_array() .ok_or_else(|| "additional subject attributes must be a JSON array".to_string())?; let mut result = Vec::new(); for entry in entries { let name = entry .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); let value = entry .get("value") .and_then(|v| v.as_str()) .unwrap_or("") .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_san(&form.san_text.get_clone()); if !san.is_empty() { extensions["subject_alt_name"] = json!({"critical": false, "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(); let text = entries .iter() .filter_map(|entry| { let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns"); let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or(""); if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) } }) .collect::>() .join(", "); form.san_text.set(text); } else { form.san_text.set(String::new()); } 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 parse_san(text: &str) -> Vec { 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() } 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 }