Add advanced extension list editors
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod extension_form;
|
||||
pub mod list_editor;
|
||||
pub mod nav;
|
||||
pub mod toast;
|
||||
pub mod ui;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user