Add XCA-style SAN editor

This commit is contained in:
2026-10-26 12:06:17 +08:00
parent dd6d20e3a3
commit c8031f5876
3 changed files with 406 additions and 18 deletions
+299 -17
View File
@@ -13,6 +13,32 @@ use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, Templa
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>>,
@@ -41,6 +67,7 @@ struct CertFormSignals {
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>,
@@ -105,6 +132,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
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);
@@ -175,6 +203,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
eku_ocsp,
eku_critical,
san_text,
san_critical,
ski,
aki,
ocsp_staple,
@@ -187,6 +216,14 @@ 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 edit_san = move |_| open_san_editor(san_editor, san_text, san_critical);
spawn_local(async move {
if let Ok(user) = api::me().await {
@@ -700,7 +737,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
}
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="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" }
@@ -778,6 +818,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
}
}
}
(san_editor_dialog(san_editor, san_text, san_critical))
}
}
@@ -1102,7 +1143,8 @@ fn build_extensions(form: &CertFormSignals) -> Result<Value, String> {
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});
extensions["subject_alt_name"] =
json!({"critical": form.san_critical.get(), "entries": san});
}
if form.ski.get() {
extensions["subject_key_identifier"] = json!({"critical": false});
@@ -1234,22 +1276,15 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) {
.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::<Vec<_>>()
.join(", ");
form.san_text.set(text);
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());
@@ -1266,6 +1301,163 @@ 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") {
@@ -1281,11 +1473,16 @@ fn parse_san(text: &str) -> Vec<serde_json::Value> {
.filter(|chunk| !chunk.is_empty())
.map(|chunk| {
if let Some((prefix, value)) = chunk.split_once(':') {
let kind = match prefix.trim().to_lowercase().as_str() {
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",
};
@@ -1297,6 +1494,55 @@ fn parse_san(text: &str) -> Vec<serde_json::Value> {
.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())
@@ -1320,3 +1566,39 @@ 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"
);
}
}