Add advanced extension list editors

This commit is contained in:
2026-10-26 12:06:17 +08:00
parent c8031f5876
commit f9e8915b77
6 changed files with 713 additions and 335 deletions
+105 -30
View File
@@ -8,15 +8,23 @@
use serde_json::{json, Map, Value};
use sycamore::prelude::*;
use crate::components::list_editor::{
general_name_entries_to_text, list_editor_dialog, open_list_editor, parse_other_name,
split_list, ListEditorConfig, ListEditorFormat, ListEditorSignals,
};
#[derive(Clone, Copy)]
pub struct ExtensionSignals {
pub crl_urls: Signal<String>,
pub crl_critical: Signal<bool>,
pub aia_ocsp: Signal<String>,
pub aia_ca_issuers: Signal<String>,
pub nc_permitted: Signal<String>,
pub nc_excluded: Signal<String>,
pub nc_critical: Signal<bool>,
pub policy_oids: Signal<String>,
pub policy_cps: Signal<String>,
pub policy_critical: Signal<bool>,
pub policy_require: Signal<String>,
pub policy_inhibit: Signal<String>,
pub inhibit_any: Signal<String>,
@@ -35,13 +43,57 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
let policy_inhibit = s.policy_inhibit;
let inhibit_any = s.inhibit_any;
let advanced_json = s.advanced_json;
let crl_editor = ListEditorSignals::new();
let nc_permitted_editor = ListEditorSignals::new();
let nc_excluded_editor = ListEditorSignals::new();
let policy_cps_editor = ListEditorSignals::new();
let crl_config = ListEditorConfig {
id: "crl-urls",
title: "CRL distribution point URLs",
format: ListEditorFormat::UriList("\n"),
critical: Some(s.crl_critical),
copy_common_name: false,
};
let nc_permitted_config = ListEditorConfig {
id: "nc-permitted",
title: "Name constraints · permitted",
format: ListEditorFormat::GeneralNames,
critical: Some(s.nc_critical),
copy_common_name: false,
};
let nc_excluded_config = ListEditorConfig {
id: "nc-excluded",
title: "Name constraints · excluded",
format: ListEditorFormat::GeneralNames,
critical: Some(s.nc_critical),
copy_common_name: false,
};
let policy_cps_config = ListEditorConfig {
id: "policy-cps",
title: "Policy CPS URIs",
format: ListEditorFormat::UriList(", "),
critical: Some(s.policy_critical),
copy_common_name: false,
};
let edit_crl = move |_| open_list_editor(crl_editor, crl_urls, crl_config);
let edit_nc_permitted =
move |_| open_list_editor(nc_permitted_editor, nc_permitted, nc_permitted_config);
let edit_nc_excluded =
move |_| open_list_editor(nc_excluded_editor, nc_excluded, nc_excluded_config);
let edit_policy_cps =
move |_| open_list_editor(policy_cps_editor, policy_cps, policy_cps_config);
view! {
div(class="form-section") {
div(class="form-section-title") { "Advanced extensions" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "CRL distribution point URLs" }
textarea(class="form-control", rows="2", placeholder="http://example.com/ca.crl", bind:value=crl_urls) {}
div(class="input-group") {
textarea(id="crl-urls", class="form-control", rows="2", placeholder="http://example.com/ca.crl", bind:value=crl_urls) {}
button(id="edit-crl-urls", class="btn btn-outline-light", r#type="button", on:click=edit_crl) { "Edit" }
}
}
div(class="col-md-3") {
label(class="form-label") { "AIA · OCSP" }
@@ -53,11 +105,17 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
}
div(class="col-md-6") {
label(class="form-label") { "Name constraints · permitted" }
textarea(class="form-control", rows="2", placeholder="DNS:.example.com, IP:10.0.0.0/8", bind:value=nc_permitted) {}
div(class="input-group") {
textarea(id="nc-permitted", class="form-control", rows="2", placeholder="DNS:.example.com, IP:10.0.0.0/8", bind:value=nc_permitted) {}
button(id="edit-nc-permitted", class="btn btn-outline-light", r#type="button", on:click=edit_nc_permitted) { "Edit" }
}
}
div(class="col-md-6") {
label(class="form-label") { "Name constraints · excluded" }
textarea(class="form-control", rows="2", placeholder="DNS:.bad.example", bind:value=nc_excluded) {}
div(class="input-group") {
textarea(id="nc-excluded", class="form-control", rows="2", placeholder="DNS:.bad.example", bind:value=nc_excluded) {}
button(id="edit-nc-excluded", class="btn btn-outline-light", r#type="button", on:click=edit_nc_excluded) { "Edit" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Certificate policy OIDs" }
@@ -65,7 +123,10 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
}
div(class="col-md-4") {
label(class="form-label") { "Policy CPS URIs" }
input(class="form-control", placeholder="https://example.com/cps", bind:value=policy_cps)
div(class="input-group") {
input(id="policy-cps", class="form-control", placeholder="https://example.com/cps", bind:value=policy_cps)
button(id="edit-policy-cps", class="btn btn-outline-light", r#type="button", on:click=edit_policy_cps) { "Edit" }
}
}
div(class="col-md-2") {
label(class="form-label") { "Require explicit policy" }
@@ -90,6 +151,22 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
}
}
}
(list_editor_dialog(crl_editor, crl_urls, crl_config))
(list_editor_dialog(
nc_permitted_editor,
nc_permitted,
nc_permitted_config,
))
(list_editor_dialog(
nc_excluded_editor,
nc_excluded,
nc_excluded_config,
))
(list_editor_dialog(
policy_cps_editor,
policy_cps,
policy_cps_config,
))
}
}
@@ -100,7 +177,7 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
if !crl_urls.is_empty() {
extensions.insert(
"crl_distribution_points".to_string(),
json!({"critical": false, "urls": crl_urls}),
json!({"critical": s.crl_critical.get(), "urls": crl_urls}),
);
}
@@ -123,7 +200,7 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
if !permitted.is_empty() || !excluded.is_empty() {
extensions.insert(
"name_constraints".to_string(),
json!({"critical": true, "permitted": permitted, "excluded": excluded}),
json!({"critical": s.nc_critical.get(), "permitted": permitted, "excluded": excluded}),
);
}
@@ -136,7 +213,7 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
.collect::<Vec<_>>();
extensions.insert(
"certificate_policies".to_string(),
json!({"critical": false, "policies": policies}),
json!({"critical": s.policy_critical.get(), "policies": policies}),
);
}
@@ -185,6 +262,12 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
})
.unwrap_or_default(),
);
s.crl_critical.set(
spec.get("crl_distribution_points")
.and_then(|v| v.get("critical"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
let descriptions = spec
.get("authority_information_access")
.and_then(|v| v.get("descriptions"))
@@ -215,11 +298,22 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
s.aia_ca_issuers.set(ca_issuers);
let nc = spec.get("name_constraints");
s.nc_critical.set(
nc.and_then(|v| v.get("critical"))
.and_then(|v| v.as_bool())
.unwrap_or(true),
);
s.nc_permitted
.set(names_to_text(nc.and_then(|v| v.get("permitted"))));
s.nc_excluded
.set(names_to_text(nc.and_then(|v| v.get("excluded"))));
s.policy_critical.set(
spec.get("certificate_policies")
.and_then(|v| v.get("critical"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
let policies = spec
.get("certificate_policies")
.and_then(|v| v.get("policies"))
@@ -293,13 +387,6 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
}
}
fn split_list(text: &str) -> Vec<String> {
text.split(|c| c == ',' || c == '\n')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect()
}
fn optional_int(text: &str, field: &str) -> Result<Option<i64>, String> {
let text = text.trim();
if text.is_empty() {
@@ -316,6 +403,9 @@ fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
.map(|item| {
if let Some((prefix, value)) = item.split_once(':') {
let prefix = prefix.trim().to_lowercase();
if prefix == "othername" || prefix == "other_name" {
return Ok(parse_other_name(value));
}
let kind = match prefix.as_str() {
"dns" => "dns",
"ip" => "ip",
@@ -323,7 +413,6 @@ fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
"email" => "email",
"rid" => "rid",
"upn" => "upn",
"othername" | "other_name" => "other_name",
_ => return Err(format!("unknown general-name type {prefix:?}")),
};
if kind == "upn" {
@@ -341,20 +430,6 @@ fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
fn names_to_text(value: Option<&Value>) -> String {
value
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value = item.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() {
None
} else {
Some(format!("{}:{}", kind.to_uppercase(), value))
}
})
.collect::<Vec<_>>()
.join(", ")
})
.map(|items| general_name_entries_to_text(items))
.unwrap_or_default()
}
+428
View File
@@ -0,0 +1,428 @@
//! XCA-style modal editor for general-name and URI-list extension fields.
use serde_json::{json, Value};
use sycamore::prelude::*;
#[derive(Clone, PartialEq)]
struct ListRow {
id: usize,
kind: Signal<String>,
value: Signal<String>,
}
impl ListRow {
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)]
pub struct ListEditorSignals {
open: Signal<bool>,
rows: Signal<Vec<ListRow>>,
next_id: Signal<usize>,
critical: Signal<bool>,
copy_cn: Signal<bool>,
}
impl ListEditorSignals {
pub fn new() -> Self {
Self {
open: create_signal(false),
rows: create_signal(Vec::new()),
next_id: create_signal(0),
critical: create_signal(false),
copy_cn: create_signal(false),
}
}
}
#[derive(Clone, Copy)]
pub enum ListEditorFormat {
GeneralNames,
UriList(&'static str),
}
#[derive(Clone, Copy)]
pub struct ListEditorConfig {
pub id: &'static str,
pub title: &'static str,
pub format: ListEditorFormat,
pub critical: Option<Signal<bool>>,
pub copy_common_name: bool,
}
pub fn open_list_editor(editor: ListEditorSignals, text: Signal<String>, config: ListEditorConfig) {
let mut copy_cn = false;
let mut rows = Vec::new();
let mut next_id = 0;
match config.format {
ListEditorFormat::GeneralNames => {
for entry in parse_general_name_entries(&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 config.copy_common_name
&& matches!(kind, "dns" | "dnsname")
&& value.eq_ignore_ascii_case("copycn")
{
copy_cn = true;
continue;
}
if let Some(text) = general_name_entry_to_text(&entry) {
if let Some((kind, value)) = text.split_once(':') {
rows.push(ListRow::new(next_id, kind, value));
next_id += 1;
}
}
}
}
ListEditorFormat::UriList(_) => {
for chunk in split_list(&text.get_clone()) {
let value = uri_value(&chunk);
if !value.is_empty() {
rows.push(ListRow::new(next_id, "URI", &value));
next_id += 1;
}
}
}
}
editor.rows.set(rows);
editor.next_id.set(next_id);
editor.critical.set(
config
.critical
.map(|critical| critical.get())
.unwrap_or(false),
);
editor.copy_cn.set(copy_cn);
editor.open.set(true);
}
pub fn list_editor_dialog(
editor: ListEditorSignals,
text: Signal<String>,
config: ListEditorConfig,
) -> View {
let rows = editor.rows;
let critical = editor.critical;
let copy_cn = editor.copy_cn;
let title_id: &'static str = Box::leak(format!("{}-editor-title", config.id).into_boxed_str());
let critical_id: &'static str =
Box::leak(format!("{}-draft-critical", config.id).into_boxed_str());
let copy_cn_id: &'static str = Box::leak(format!("{}-copy-cn", config.id).into_boxed_str());
let has_options = config.critical.is_some() || config.copy_common_name;
let add = move |_| {
let id = editor.next_id.get();
editor.next_id.set(id + 1);
let kind = match config.format {
ListEditorFormat::GeneralNames => "DNS",
ListEditorFormat::UriList(_) => "URI",
};
rows.update(|rows| rows.push(ListRow::new(id, kind, "")));
};
let apply = move |_| {
let mut entries = Vec::new();
if config.copy_common_name && 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() {
continue;
}
entries.push(match config.format {
ListEditorFormat::GeneralNames => {
format!("{}:{}", row.kind.get_clone(), value)
}
ListEditorFormat::UriList(_) => value.to_string(),
});
}
let separator = match config.format {
ListEditorFormat::GeneralNames => ", ",
ListEditorFormat::UriList(separator) => separator,
};
text.set(entries.join(separator));
if let Some(target) = config.critical {
target.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=title_id) {
div(class="modal-dialog modal-lg modal-dialog-centered") {
div(class="modal-content") {
div(class="modal-header") {
h2(class="modal-title h5", id=title_id) { (config.title) }
button(class="btn-close btn-close-white", r#type="button", aria-label="Close", on:click=close)
}
div(class="modal-body") {
(if has_options {
view! {
div(class="d-flex flex-wrap gap-4 mb-3") {
(if config.critical.is_some() {
view! {
div(class="form-check") {
input(id=critical_id, class="form-check-input", r#type="checkbox", bind:checked=critical)
label(class="form-check-label", r#for=critical_id) { "Critical" }
}
}
} else {
view! {}
})
(if config.copy_common_name {
view! {
div(class="form-check") {
input(id=copy_cn_id, class="form-check-input", r#type="checkbox", bind:checked=copy_cn)
label(class="form-check-label", r#for=copy_cn_id) { "Copy Common Name" }
}
}
} else {
view! {}
})
}
}
} else {
view! {}
})
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 list-entry") {
td {
select(class="form-select form-select-sm", aria-label="Entry type", bind:value=kind) {
(list_type_options(config.format))
}
}
td {
input(class="form-control form-control-sm san-value list-value", aria-label="Entry 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! {}
})
}
}
}
(if matches!(config.format, ListEditorFormat::GeneralNames) {
view! {
div(class="form-text mt-2") {
"For otherName, use <OID>;UTF8:<content> or <OID>;DER:<hex>."
}
}
} else {
view! {}
})
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 list_type_options(format: ListEditorFormat) -> View {
match format {
ListEditorFormat::GeneralNames => view! {
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" }
},
ListEditorFormat::UriList(_) => view! {
option(value="URI") { "URI" }
},
}
}
pub fn split_list(text: &str) -> Vec<String> {
text.split(|c| c == ',' || c == '\n')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect()
}
fn uri_value(text: &str) -> String {
if let Some((prefix, value)) = text.split_once(':') {
if prefix.trim().eq_ignore_ascii_case("URI") {
return value.trim().to_string();
}
}
text.to_string()
}
pub fn parse_general_name_entries(text: &str) -> Vec<Value> {
split_list(text)
.iter()
.map(|chunk| parse_general_name_chunk(chunk))
.collect()
}
fn parse_general_name_chunk(chunk: &str) -> Value {
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})
}
}
pub fn parse_other_name(value: &str) -> 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
}
pub fn general_name_entry_to_text(entry: &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}"))
}
pub fn general_name_entries_to_text(entries: &[Value]) -> String {
entries
.iter()
.filter_map(general_name_entry_to_text)
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn general_names_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_general_name_entries(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!(general_name_entries_to_text(&entries), text);
}
#[test]
fn general_names_preserve_copycn_and_der_other_names() {
let entries = parse_general_name_entries("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!(
general_name_entries_to_text(&entries),
"DNS:copycn, otherName:1.2.3.4;DER:0c03616263"
);
}
#[test]
fn uri_lists_split_lines_and_accept_optional_prefixes() {
let items = split_list("https://one.example/crl\nURI:https://two.example/crl");
assert_eq!(items.len(), 2);
assert_eq!(uri_value(&items[0]), "https://one.example/crl");
assert_eq!(uri_value(&items[1]), "https://two.example/crl");
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod extension_form;
pub mod list_editor;
pub mod nav;
pub mod toast;
pub mod ui;
+21 -305
View File
@@ -7,38 +7,16 @@ use crate::api;
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, 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>>,
@@ -137,24 +115,30 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
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,
@@ -216,14 +200,15 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
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 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_san_editor(san_editor, san_text, san_critical);
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 {
@@ -818,7 +803,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
}
}
}
(san_editor_dialog(san_editor, san_text, san_critical))
(list_editor_dialog(san_editor, san_text, san_editor_config))
}
}
@@ -1141,7 +1126,7 @@ fn build_extensions(form: &CertFormSignals) -> Result<Value, String> {
.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());
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});
@@ -1276,7 +1261,7 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) {
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
form.san_text.set(san_entries_to_text(&entries));
form.san_text.set(general_name_entries_to_text(&entries));
form.san_critical.set(
san.get("critical")
.and_then(|v| v.as_bool())
@@ -1301,163 +1286,6 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) {
);
}
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") {
@@ -1467,82 +1295,6 @@ fn check(label: &'static str, value: Signal<bool>) -> View {
}
}
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())
@@ -1566,39 +1318,3 @@ fn query_param(name: &str) -> Option<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"
);
}
}
+6
View File
@@ -51,24 +51,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
let ski = create_signal(true);
let aki = create_signal(true);
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,