1605 lines
66 KiB
Rust
1605 lines
66 KiB
Rust
//! 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, PartialEq)]
|
|
struct SanRow {
|
|
id: usize,
|
|
kind: Signal<String>,
|
|
value: Signal<String>,
|
|
}
|
|
|
|
impl SanRow {
|
|
fn new(id: usize, kind: &str, value: &str) -> Self {
|
|
Self {
|
|
id,
|
|
kind: create_signal(kind.to_string()),
|
|
value: create_signal(value.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct SanEditorSignals {
|
|
open: Signal<bool>,
|
|
rows: Signal<Vec<SanRow>>,
|
|
next_id: Signal<usize>,
|
|
critical: Signal<bool>,
|
|
copy_cn: Signal<bool>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct CertFormSignals {
|
|
explicit_dn: Signal<Vec<String>>,
|
|
subject_extra: Signal<String>,
|
|
cn: Signal<String>,
|
|
country: Signal<String>,
|
|
state: Signal<String>,
|
|
locality: Signal<String>,
|
|
organization: Signal<String>,
|
|
org_unit: Signal<String>,
|
|
email: Signal<String>,
|
|
ca_mode: Signal<String>,
|
|
path_length: Signal<String>,
|
|
bc_critical: Signal<bool>,
|
|
ku_digital: Signal<bool>,
|
|
ku_encipher: Signal<bool>,
|
|
ku_key_agreement: Signal<bool>,
|
|
ku_cert_sign: Signal<bool>,
|
|
ku_crl_sign: Signal<bool>,
|
|
ku_critical: Signal<bool>,
|
|
eku_server: Signal<bool>,
|
|
eku_client: Signal<bool>,
|
|
eku_code: Signal<bool>,
|
|
eku_email: Signal<bool>,
|
|
eku_time: Signal<bool>,
|
|
eku_ocsp: Signal<bool>,
|
|
eku_critical: Signal<bool>,
|
|
san_text: Signal<String>,
|
|
san_critical: Signal<bool>,
|
|
ski: Signal<bool>,
|
|
aki: Signal<bool>,
|
|
ocsp_staple: Signal<bool>,
|
|
ext: ExtensionSignals,
|
|
validity_mode: Signal<String>,
|
|
validity_amount: Signal<String>,
|
|
validity_unit: Signal<String>,
|
|
midnight: Signal<bool>,
|
|
not_before_text: Signal<String>,
|
|
not_after_text: Signal<String>,
|
|
no_expiration: Signal<bool>,
|
|
}
|
|
|
|
pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
|
let me = create_signal(None::<Me>);
|
|
let keys = create_signal(Vec::<KeySummary>::new());
|
|
let signers = create_signal(Vec::<CertificateSummary>::new());
|
|
let templates = create_signal(Vec::<TemplateSummary>::new());
|
|
let error = create_signal(None::<String>);
|
|
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::<String>);
|
|
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 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::<Vec<_>>(),
|
|
);
|
|
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,
|
|
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 = SanEditorSignals {
|
|
open: create_signal(false),
|
|
rows: create_signal(Vec::new()),
|
|
next_id: create_signal(0),
|
|
critical: create_signal(false),
|
|
copy_cn: create_signal(false),
|
|
};
|
|
let edit_san = move |_| open_san_editor(san_editor, san_text, san_critical);
|
|
|
|
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::<Vec<_>>();
|
|
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::<i64>().unwrap_or(2048),
|
|
"name": format!("{} key", name.get_clone()),
|
|
});
|
|
} else if !key_id.get_clone().is_empty() {
|
|
payload["key_id"] = json!(key_id.get_clone());
|
|
} else {
|
|
error.set(Some("select a key or generate a new one".to_string()));
|
|
return;
|
|
}
|
|
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::<Vec<View>>()
|
|
};
|
|
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::<Vec<View>>()
|
|
};
|
|
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::<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(
|
|
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" }
|
|
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" })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(san_editor_dialog(san_editor, san_text, san_critical))
|
|
}
|
|
}
|
|
|
|
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::<Me>);
|
|
let cert = create_signal(None::<CertificateDetail>);
|
|
let error = create_signal(None::<String>);
|
|
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::<i64>().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<Signal<String>> {
|
|
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<View> {
|
|
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<View> {
|
|
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<Vec<Value>, 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<Vec<Value>, 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<Value, String> {
|
|
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::<i64>().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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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": 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::<i64>().unwrap_or(365),
|
|
"unit": form.validity_unit.get_clone(),
|
|
"midnight": form.midnight.get(),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn build_target(form: &CertFormSignals) -> Result<Value, String> {
|
|
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(san_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 open_san_editor(editor: SanEditorSignals, san_text: Signal<String>, san_critical: Signal<bool>) {
|
|
let mut copy_cn = false;
|
|
let mut rows = Vec::new();
|
|
let mut next_id = 0;
|
|
for entry in parse_san(&san_text.get_clone()) {
|
|
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
|
if matches!(kind, "dns" | "dnsname") && value.eq_ignore_ascii_case("copycn") {
|
|
copy_cn = true;
|
|
continue;
|
|
}
|
|
if let Some(text) = san_entry_to_text(&entry) {
|
|
if let Some((kind, value)) = text.split_once(':') {
|
|
rows.push(SanRow::new(next_id, kind, value));
|
|
next_id += 1;
|
|
}
|
|
}
|
|
}
|
|
editor.rows.set(rows);
|
|
editor.next_id.set(next_id);
|
|
editor.critical.set(san_critical.get());
|
|
editor.copy_cn.set(copy_cn);
|
|
editor.open.set(true);
|
|
}
|
|
|
|
fn san_editor_dialog(
|
|
editor: SanEditorSignals,
|
|
san_text: Signal<String>,
|
|
san_critical: Signal<bool>,
|
|
) -> View {
|
|
let rows = editor.rows;
|
|
let critical = editor.critical;
|
|
let copy_cn = editor.copy_cn;
|
|
let add = move |_| {
|
|
let id = editor.next_id.get();
|
|
editor.next_id.set(id + 1);
|
|
rows.update(|rows| rows.push(SanRow::new(id, "DNS", "")));
|
|
};
|
|
let apply = move |_| {
|
|
let mut entries = Vec::new();
|
|
if copy_cn.get() {
|
|
entries.push("DNS:copycn".to_string());
|
|
}
|
|
for row in rows.get_clone() {
|
|
let value = row.value.get_clone();
|
|
let value = value.trim();
|
|
if !value.is_empty() {
|
|
entries.push(format!("{}:{}", row.kind.get_clone(), value));
|
|
}
|
|
}
|
|
san_text.set(entries.join(", "));
|
|
san_critical.set(critical.get());
|
|
editor.open.set(false);
|
|
};
|
|
let close = move |_| editor.open.set(false);
|
|
let cancel = move |_| editor.open.set(false);
|
|
|
|
view! {
|
|
(if editor.open.get() {
|
|
view! {
|
|
div(class="modal fade show d-block", tabindex="-1", role="dialog", aria-modal="true", aria-labelledby="san-editor-title") {
|
|
div(class="modal-dialog modal-lg modal-dialog-centered") {
|
|
div(class="modal-content") {
|
|
div(class="modal-header") {
|
|
h2(class="modal-title h5", id="san-editor-title") { "Subject alternative names" }
|
|
button(class="btn-close btn-close-white", r#type="button", aria-label="Close", on:click=close)
|
|
}
|
|
div(class="modal-body") {
|
|
div(class="d-flex flex-wrap gap-4 mb-3") {
|
|
div(class="form-check") {
|
|
input(id="san-draft-critical", class="form-check-input", r#type="checkbox", bind:checked=critical)
|
|
label(class="form-check-label", r#for="san-draft-critical") { "Critical" }
|
|
}
|
|
div(class="form-check") {
|
|
input(id="san-copy-cn", class="form-check-input", r#type="checkbox", bind:checked=copy_cn)
|
|
label(class="form-check-label", r#for="san-copy-cn") { "Copy Common Name" }
|
|
}
|
|
}
|
|
div(class="table-responsive") {
|
|
table(class="table table-sm align-middle mb-0") {
|
|
thead {
|
|
tr {
|
|
th(scope="col", style="width: 11rem") { "Type" }
|
|
th(scope="col") { "Content" }
|
|
th(scope="col") { span(class="visually-hidden") { "Actions" } }
|
|
}
|
|
}
|
|
tbody {
|
|
Keyed(
|
|
list=rows,
|
|
view=move |row| {
|
|
let id = row.id;
|
|
let kind = row.kind;
|
|
let value = row.value;
|
|
on_cleanup(move || {
|
|
kind.dispose();
|
|
value.dispose();
|
|
});
|
|
let remove = move |_| {
|
|
rows.update(|rows| rows.retain(|row| row.id != id));
|
|
};
|
|
view! {
|
|
tr(class="san-entry") {
|
|
td {
|
|
select(class="form-select form-select-sm", aria-label="SAN type", bind:value=kind) {
|
|
option(value="DNS") { "DNS" }
|
|
option(value="IP") { "IP" }
|
|
option(value="URI") { "URI" }
|
|
option(value="email") { "email" }
|
|
option(value="RID") { "RID" }
|
|
option(value="UPN") { "UPN" }
|
|
option(value="otherName") { "otherName" }
|
|
}
|
|
}
|
|
td {
|
|
input(class="form-control form-control-sm san-value", aria-label="SAN content", bind:value=value)
|
|
}
|
|
td(class="table-actions") {
|
|
button(class="btn btn-sm btn-outline-danger", r#type="button", on:click=remove) { "Delete" }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
key=|row| row.id,
|
|
)
|
|
(if rows.get_clone().is_empty() {
|
|
view! {
|
|
tr {
|
|
td(colspan="3", class="text-secondary text-center py-3") { "No entries. Click Add to create one." }
|
|
}
|
|
}
|
|
} else {
|
|
view! {}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
div(class="form-text mt-2") {
|
|
"For otherName, use <OID>;UTF8:<content> or <OID>;DER:<hex>."
|
|
}
|
|
button(class="btn btn-outline-light mt-3", r#type="button", on:click=add) { "Add" }
|
|
}
|
|
div(class="modal-footer") {
|
|
button(class="btn btn-outline-light", r#type="button", on:click=cancel) { "Cancel" }
|
|
button(class="btn btn-primary", r#type="button", on:click=apply) { "Apply" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
div(class="modal-backdrop fade show")
|
|
}
|
|
} else {
|
|
view! {}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 = prefix.trim().to_lowercase();
|
|
if kind == "othername" || kind == "other_name" {
|
|
return parse_other_name(value);
|
|
}
|
|
let kind = match kind.as_str() {
|
|
"ip" => "ip",
|
|
"uri" => "uri",
|
|
"email" => "email",
|
|
"rid" => "rid",
|
|
"upn" => "upn",
|
|
"dns" => "dns",
|
|
_ => "dns",
|
|
};
|
|
json!({"type": kind, "value": value.trim()})
|
|
} else {
|
|
json!({"type": "dns", "value": chunk})
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_other_name(value: &str) -> serde_json::Value {
|
|
let (oid, payload) = value.split_once(';').unwrap_or((value, ""));
|
|
let (encoding, content) = payload.split_once(':').unwrap_or(("UTF8", payload));
|
|
let mut entry = json!({"type": "other_name", "oid": oid.trim()});
|
|
if encoding.trim().eq_ignore_ascii_case("DER") {
|
|
entry["der_hex"] = json!(content.trim());
|
|
} else {
|
|
entry["value"] = json!(content.trim());
|
|
}
|
|
entry
|
|
}
|
|
|
|
fn san_entry_to_text(entry: &serde_json::Value) -> Option<String> {
|
|
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 matches!(kind, "othername" | "other_name") {
|
|
let oid = entry.get("oid").and_then(|v| v.as_str()).unwrap_or("");
|
|
if oid.is_empty() {
|
|
return None;
|
|
}
|
|
if !value.is_empty() {
|
|
return Some(format!("otherName:{oid};UTF8:{value}"));
|
|
}
|
|
let der = entry.get("der_hex").and_then(|v| v.as_str()).unwrap_or("");
|
|
return (!der.is_empty()).then(|| format!("otherName:{oid};DER:{der}"));
|
|
}
|
|
if value.is_empty() {
|
|
return None;
|
|
}
|
|
let prefix = match kind {
|
|
"dns" | "dnsname" => "DNS".to_string(),
|
|
"ip" | "ipaddress" => "IP".to_string(),
|
|
"uri" | "uniformresourceidentifier" => "URI".to_string(),
|
|
"email" | "rfc822name" => "email".to_string(),
|
|
"rid" | "registeredid" => "RID".to_string(),
|
|
"upn" => "UPN".to_string(),
|
|
other => other.to_uppercase(),
|
|
};
|
|
Some(format!("{prefix}:{value}"))
|
|
}
|
|
|
|
fn san_entries_to_text(entries: &[serde_json::Value]) -> String {
|
|
entries
|
|
.iter()
|
|
.filter_map(san_entry_to_text)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
|
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<String> {
|
|
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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn san_entries_round_trip_xca_types() {
|
|
let text = "DNS:example.com, IP:192.0.2.1, URI:https://example.com/ca, email:pki@example.com, RID:1.2.3.4, UPN:user@example.com, otherName:1.2.3.4.5;UTF8:device";
|
|
let entries = parse_san(text);
|
|
|
|
assert_eq!(entries.len(), 7);
|
|
assert_eq!(
|
|
entries[5],
|
|
json!({"type": "upn", "value": "user@example.com"})
|
|
);
|
|
assert_eq!(
|
|
entries[6],
|
|
json!({"type": "other_name", "oid": "1.2.3.4.5", "value": "device"})
|
|
);
|
|
assert_eq!(san_entries_to_text(&entries), text);
|
|
}
|
|
|
|
#[test]
|
|
fn san_entries_preserve_copycn_and_der_other_names() {
|
|
let entries = parse_san("DNS:copycn, otherName:1.2.3.4;DER:0c03616263");
|
|
|
|
assert_eq!(
|
|
entries[1],
|
|
json!({"type": "other_name", "oid": "1.2.3.4", "der_hex": "0c03616263"})
|
|
);
|
|
assert_eq!(
|
|
san_entries_to_text(&entries),
|
|
"DNS:copycn, otherName:1.2.3.4;DER:0c03616263"
|
|
);
|
|
}
|
|
}
|