From 41b1258825fbc1050baca1490d1af9384cf37e01 Mon Sep 17 00:00:00 2001 From: KCA Date: Mon, 26 Oct 2026 01:12:31 +0000 Subject: [PATCH] Add advanced extension list editors --- e2e/tests/extension_editors.spec.js | 152 ++++++++++ web/src/components/extension_form.rs | 135 +++++++-- web/src/components/list_editor.rs | 428 +++++++++++++++++++++++++++ web/src/components/mod.rs | 1 + web/src/pages/certificate_new.rs | 326 ++------------------ web/src/pages/template_editor.rs | 6 + 6 files changed, 713 insertions(+), 335 deletions(-) create mode 100644 e2e/tests/extension_editors.spec.js create mode 100644 web/src/components/list_editor.rs diff --git a/e2e/tests/extension_editors.spec.js b/e2e/tests/extension_editors.spec.js new file mode 100644 index 0000000..2d8469f --- /dev/null +++ b/e2e/tests/extension_editors.spec.js @@ -0,0 +1,152 @@ +// XCA-style editors for advanced extension list fields. +const { test, expect } = require("@playwright/test"); + +function unique(prefix) { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; +} + +async function field(page, label) { + return page.locator("label").filter({ hasText: label }).first().locator("..").locator("input, select, textarea").first(); +} + +async function login(page, username) { + await page.goto("/login"); + await page.getByRole("link", { name: "Sign in with default" }).click(); + await page.getByPlaceholder("Enter any user/subject").fill(username); + await page.getByRole("button", { name: "Sign-in" }).click(); + await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible(); +} + +async function openEditor(page, buttonId, title) { + await page.locator(buttonId).click(); + const dialog = page.getByRole("dialog", { name: title, exact: true }); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function addRow(dialog, type, value) { + const index = await dialog.locator(".list-entry").count(); + await dialog.getByRole("button", { name: "Add", exact: true }).click(); + const row = dialog.locator(".list-entry").nth(index); + await row.locator("select").selectOption(type); + await row.locator(".list-value").fill(value); + return row; +} + +test("advanced extension list editors", async ({ page }) => { + const user = unique("list-editor"); + const vaultName = unique("List Editors"); + const certName = unique("constraints.example"); + const crlUrls = ["https://ca.example.com/root.crl", "https://backup.example.com/root.crl"]; + const cpsUrls = ["https://example.com/cps", "https://example.com/policy"]; + + await login(page, user); + await page.getByRole("button", { name: "New vault" }).click(); + await page.getByPlaceholder("Production PKI").fill(vaultName); + await page.getByRole("button", { name: "Create vault" }).click(); + await page.getByRole("link", { name: vaultName }).click(); + const vaultUrl = page.url(); + + const settingsResponse = page.waitForResponse( + (response) => response.url().includes("/api/vaults/") && response.url().endsWith("/settings") && response.ok() + ); + await page.goto(`${vaultUrl}/certificates/new`); + await settingsResponse; + await (await field(page, "Internal name")).fill(certName); + await (await field(page, "Common name (CN)")).fill(certName); + await (await field(page, "Basic constraints")).selectOption("ca"); + await page.locator("#new-key").check(); + + // URI-list edits can be cancelled without changing the original textarea. + let dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs"); + await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked(); + await addRow(dialog, "URI", "https://discarded.example.com/root.crl"); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator("#crl-urls")).toHaveValue(""); + + dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs"); + await expect(dialog.locator(".list-entry")).toHaveCount(0); + await addRow(dialog, "URI", crlUrls[0]); + await addRow(dialog, "URI", crlUrls[1]); + await dialog.getByLabel("Critical", { exact: true }).check(); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("#crl-urls")).toHaveValue(crlUrls.join("\n")); + + dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs"); + await expect(dialog.locator(".list-entry")).toHaveCount(2); + await expect(dialog.locator(".list-entry").nth(1).locator(".list-value")).toHaveValue(crlUrls[1]); + await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + + // Name constraints use typed rows and share one extension-level critical flag. + dialog = await openEditor(page, "#edit-nc-permitted", "Name constraints · permitted"); + await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked(); + await addRow(dialog, "DNS", ".example.com"); + await addRow(dialog, "DNS", "example.net"); + await dialog.getByLabel("Critical", { exact: true }).uncheck(); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("#nc-permitted")).toHaveValue("DNS:.example.com, DNS:example.net"); + + dialog = await openEditor(page, "#edit-nc-excluded", "Name constraints · excluded"); + await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked(); + await dialog.getByLabel("Critical", { exact: true }).check(); + await addRow(dialog, "DNS", ".bad.example"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("#nc-excluded")).toHaveValue("DNS:.bad.example"); + + dialog = await openEditor(page, "#edit-nc-permitted", "Name constraints · permitted"); + await expect(dialog.locator(".list-entry")).toHaveCount(2); + await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + + // CPS URIs use comma-separated plain URLs and support row deletion. + await (await field(page, "Certificate policy OIDs")).fill("1.3.6.1.4.1.1"); + dialog = await openEditor(page, "#edit-policy-cps", "Policy CPS URIs"); + await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked(); + await dialog.getByLabel("Critical", { exact: true }).check(); + await addRow(dialog, "URI", cpsUrls[0]); + await addRow(dialog, "URI", cpsUrls[1]); + const discarded = await addRow(dialog, "URI", "https://discarded.example.com/cps"); + await discarded.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog.locator(".list-entry")).toHaveCount(2); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("#policy-cps")).toHaveValue(cpsUrls.join(", ")); + + await expect(await field(page, "Common name (CN)")).toHaveValue(certName); + await expect(page.locator("#nc-excluded")).toHaveValue("DNS:.bad.example"); + await page.getByRole("button", { name: "Validate extensions" }).click(); + await expect(page.getByText("Extension preview")).toBeVisible(); + await expect(page.locator("pre.pem")).toContainText("Name Constraints"); + await expect(page.locator("pre.pem")).toContainText("Certificate Policies"); + + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByRole("heading", { name: certName })).toBeVisible(); + const certApiUrl = page.url().replace(/\/vaults\//, "/api/vaults/"); + const detail = await page.evaluate(async (url) => { + const response = await fetch(url); + return response.json(); + }, certApiUrl); + const extensions = detail.extension_spec; + expect(extensions.crl_distribution_points).toEqual({ critical: true, urls: crlUrls }); + expect(extensions.name_constraints).toEqual({ + critical: true, + permitted: [ + { type: "dns", value: ".example.com" }, + { type: "dns", value: "example.net" }, + ], + excluded: [{ type: "dns", value: ".bad.example" }], + }); + expect(extensions.certificate_policies).toEqual({ + critical: true, + policies: [{ oid: "1.3.6.1.4.1.1", cps: cpsUrls }], + }); + + // The shared advanced-extension component also works in template forms. + await page.goto(`${vaultUrl}/templates/new`); + await expect(page.getByRole("heading", { name: "New template" })).toBeVisible(); + dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs"); + await addRow(dialog, "URI", "https://template.example.com/root.crl"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("#crl-urls")).toHaveValue("https://template.example.com/root.crl"); +}); diff --git a/web/src/components/extension_form.rs b/web/src/components/extension_form.rs index 911991e..420de57 100644 --- a/web/src/components/extension_form.rs +++ b/web/src/components/extension_form.rs @@ -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, + pub crl_critical: Signal, pub aia_ocsp: Signal, pub aia_ca_issuers: Signal, pub nc_permitted: Signal, pub nc_excluded: Signal, + pub nc_critical: Signal, pub policy_oids: Signal, pub policy_cps: Signal, + pub policy_critical: Signal, pub policy_require: Signal, pub policy_inhibit: Signal, pub inhibit_any: Signal, @@ -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 { 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 { 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 { .collect::>(); 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 { - 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, String> { let text = text.trim(); if text.is_empty() { @@ -316,6 +403,9 @@ fn parse_general_names(text: &str) -> Result, 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, 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, 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::>() - .join(", ") - }) + .map(|items| general_name_entries_to_text(items)) .unwrap_or_default() } diff --git a/web/src/components/list_editor.rs b/web/src/components/list_editor.rs new file mode 100644 index 0000000..bf8f752 --- /dev/null +++ b/web/src/components/list_editor.rs @@ -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, + value: Signal, +} + +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, + rows: Signal>, + next_id: Signal, + critical: Signal, + copy_cn: Signal, +} + +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>, + pub copy_common_name: bool, +} + +pub fn open_list_editor(editor: ListEditorSignals, text: Signal, 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, + 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 ;UTF8: or ;DER:." + } + } + } 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 { + 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 { + 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 { + 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::>() + .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"); + } +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index bc9f0e2..a74446c 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,4 +1,5 @@ pub mod extension_form; +pub mod list_editor; pub mod nav; pub mod toast; pub mod ui; diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index b3c391c..5950f54 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -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, - value: Signal, -} - -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, - rows: Signal>, - next_id: Signal, - critical: Signal, - copy_cn: Signal, -} - #[derive(Clone, Copy)] struct CertFormSignals { explicit_dn: Signal>, @@ -137,24 +115,30 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> 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) -> 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) -> 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 { .map(|(_, name)| json!(name)) .collect::>(); extensions["extended_key_usage"] = json!({"critical": form.eku_critical.get(), "usages": eku}); - let san = parse_san(&form.san_text.get_clone()); + 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, san_critical: Signal) { - 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, - san_critical: Signal, -) -> 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 ;UTF8: or ;DER:." - } - 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) -> View { view! { div(class="form-check") { @@ -1467,82 +1295,6 @@ fn check(label: &'static str, value: Signal) -> View { } } -fn parse_san(text: &str) -> Vec { - text.split(',') - .map(|chunk| chunk.trim()) - .filter(|chunk| !chunk.is_empty()) - .map(|chunk| { - if let Some((prefix, value)) = chunk.split_once(':') { - let kind = 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 { - 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::>() - .join(", ") -} - fn string_array(value: Option<&serde_json::Value>) -> Vec { value .and_then(|v| v.as_array()) @@ -1566,39 +1318,3 @@ fn query_param(name: &str) -> Option { } 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" - ); - } -} diff --git a/web/src/pages/template_editor.rs b/web/src/pages/template_editor.rs index 6c2216e..5ae41eb 100644 --- a/web/src/pages/template_editor.rs +++ b/web/src/pages/template_editor.rs @@ -51,24 +51,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> 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,