Add XCA-style SAN editor
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
// XCA-style subject alternative name editor on the certificate form.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("XCA-style subject alternative name editor", async ({ page }) => {
|
||||||
|
const user = unique("san");
|
||||||
|
const vaultName = unique("SAN");
|
||||||
|
const certName = unique("san.example");
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
await page.goto(`${vaultUrl}/certificates/new`);
|
||||||
|
await (await field(page, "Internal name")).fill(certName);
|
||||||
|
await (await field(page, "Common name (CN)")).fill(certName);
|
||||||
|
await page.locator("#new-key").check();
|
||||||
|
|
||||||
|
// Cancelling discards the draft rows and leaves the original input untouched.
|
||||||
|
await page.locator("#edit-san").click();
|
||||||
|
let dialog = page.getByRole("dialog", { name: "Subject alternative names" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole("button", { name: "Add", exact: true }).click();
|
||||||
|
await dialog.locator(".san-entry .san-value").fill("discarded.example");
|
||||||
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(page.locator("#san-text")).toHaveValue("");
|
||||||
|
|
||||||
|
await page.locator("#edit-san").click();
|
||||||
|
dialog = page.getByRole("dialog", { name: "Subject alternative names" });
|
||||||
|
await expect(dialog.locator(".san-entry")).toHaveCount(0);
|
||||||
|
await dialog.getByLabel("Critical", { exact: true }).check();
|
||||||
|
await dialog.getByLabel("Copy Common Name", { exact: true }).check();
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Add", exact: true }).click();
|
||||||
|
await dialog.locator(".san-entry").nth(0).locator("select").selectOption("DNS");
|
||||||
|
await dialog.locator(".san-entry").nth(0).locator(".san-value").fill(`www.${certName}`);
|
||||||
|
await dialog.getByRole("button", { name: "Add", exact: true }).click();
|
||||||
|
await dialog.locator(".san-entry").nth(1).locator("select").selectOption("IP");
|
||||||
|
await dialog.locator(".san-entry").nth(1).locator(".san-value").fill("192.0.2.10");
|
||||||
|
|
||||||
|
// Row deletion works without disturbing the remaining entries.
|
||||||
|
await dialog.getByRole("button", { name: "Add", exact: true }).click();
|
||||||
|
await dialog.locator(".san-entry").nth(2).locator(".san-value").fill("discarded.example");
|
||||||
|
await dialog.locator(".san-entry").nth(2).getByRole("button", { name: "Delete", exact: true }).click();
|
||||||
|
await expect(dialog.locator(".san-entry")).toHaveCount(2);
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(page.locator("#san-text")).toHaveValue(
|
||||||
|
`DNS:copycn, DNS:www.${certName}, IP:192.0.2.10`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reopening reconstructs the draft from the applied value and criticality.
|
||||||
|
await page.locator("#edit-san").click();
|
||||||
|
dialog = page.getByRole("dialog", { name: "Subject alternative names" });
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
|
||||||
|
await expect(dialog.getByLabel("Copy Common Name", { exact: true })).toBeChecked();
|
||||||
|
await expect(dialog.locator(".san-entry")).toHaveCount(2);
|
||||||
|
await expect(dialog.locator(".san-entry").nth(0).locator("select")).toHaveValue("DNS");
|
||||||
|
await expect(dialog.locator(".san-entry").nth(0).locator(".san-value")).toHaveValue(`www.${certName}`);
|
||||||
|
await expect(dialog.locator(".san-entry").nth(1).locator("select")).toHaveValue("IP");
|
||||||
|
await expect(dialog.locator(".san-entry").nth(1).locator(".san-value")).toHaveValue("192.0.2.10");
|
||||||
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Validate extensions" }).click();
|
||||||
|
await expect(page.getByText("Extension preview")).toBeVisible();
|
||||||
|
await expect(page.getByText("192.0.2.10", { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(detail.extension_spec.subject_alt_name).toEqual({
|
||||||
|
critical: true,
|
||||||
|
entries: [
|
||||||
|
{ type: "dns", value: certName },
|
||||||
|
{ type: "dns", value: `www.${certName}` },
|
||||||
|
{ type: "ip", value: "192.0.2.10" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(".nav-tabs").getByRole("tab", { name: "Extensions", exact: true }).click();
|
||||||
|
await expect(page.getByText("192.0.2.10", { exact: false })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -54,12 +54,12 @@ test("explicit DN order, mandatory DN and template apply", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`${vaultUrl}/certificates/new`);
|
await page.goto(`${vaultUrl}/certificates/new`);
|
||||||
await expect(page.getByText("Additional subject attributes")).toBeVisible();
|
await expect(page.getByText("Additional subject attributes")).toBeVisible();
|
||||||
|
await expect(page.locator("label").filter({ hasText: "Country (C)" })).toHaveCount(0);
|
||||||
const labels = await page.locator("label").allTextContents();
|
const labels = await page.locator("label").allTextContents();
|
||||||
const cnIndex = labels.findIndex((label) => label.trim() === "Common name (CN)");
|
const cnIndex = labels.findIndex((label) => label.trim() === "Common name (CN)");
|
||||||
const oIndex = labels.findIndex((label) => label.trim() === "Organization (O)");
|
const oIndex = labels.findIndex((label) => label.trim() === "Organization (O)");
|
||||||
expect(cnIndex).toBeGreaterThan(-1);
|
expect(cnIndex).toBeGreaterThan(-1);
|
||||||
expect(oIndex).toBeGreaterThan(cnIndex);
|
expect(oIndex).toBeGreaterThan(cnIndex);
|
||||||
await expect(page.locator("label").filter({ hasText: "Country (C)" })).toHaveCount(0);
|
|
||||||
|
|
||||||
// Applying a template uses the server merge endpoint and updates typed fields.
|
// Applying a template uses the server merge endpoint and updates typed fields.
|
||||||
await page.locator("select").nth(2).selectOption("builtin:tls-server");
|
await page.locator("select").nth(2).selectOption("builtin:tls-server");
|
||||||
|
|||||||
@@ -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"];
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct CertFormSignals {
|
struct CertFormSignals {
|
||||||
explicit_dn: Signal<Vec<String>>,
|
explicit_dn: Signal<Vec<String>>,
|
||||||
@@ -41,6 +67,7 @@ struct CertFormSignals {
|
|||||||
eku_ocsp: Signal<bool>,
|
eku_ocsp: Signal<bool>,
|
||||||
eku_critical: Signal<bool>,
|
eku_critical: Signal<bool>,
|
||||||
san_text: Signal<String>,
|
san_text: Signal<String>,
|
||||||
|
san_critical: Signal<bool>,
|
||||||
ski: Signal<bool>,
|
ski: Signal<bool>,
|
||||||
aki: Signal<bool>,
|
aki: Signal<bool>,
|
||||||
ocsp_staple: 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_ocsp = create_signal(false);
|
||||||
let eku_critical = create_signal(false);
|
let eku_critical = create_signal(false);
|
||||||
let san_text = create_signal(String::new());
|
let san_text = create_signal(String::new());
|
||||||
|
let san_critical = create_signal(false);
|
||||||
let ski = create_signal(true);
|
let ski = create_signal(true);
|
||||||
let aki = create_signal(true);
|
let aki = create_signal(true);
|
||||||
let ocsp_staple = create_signal(false);
|
let ocsp_staple = create_signal(false);
|
||||||
@@ -175,6 +203,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
eku_ocsp,
|
eku_ocsp,
|
||||||
eku_critical,
|
eku_critical,
|
||||||
san_text,
|
san_text,
|
||||||
|
san_critical,
|
||||||
ski,
|
ski,
|
||||||
aki,
|
aki,
|
||||||
ocsp_staple,
|
ocsp_staple,
|
||||||
@@ -187,6 +216,14 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
not_after_text,
|
not_after_text,
|
||||||
no_expiration,
|
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 {
|
spawn_local(async move {
|
||||||
if let Ok(user) = api::me().await {
|
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="col-md-6") {
|
||||||
div(class="form-section-title") { "Subject alternative names" }
|
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="col-md-6") {
|
||||||
div(class="form-section-title") { "Key identifier / OCSP" }
|
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});
|
extensions["extended_key_usage"] = json!({"critical": form.eku_critical.get(), "usages": eku});
|
||||||
let san = parse_san(&form.san_text.get_clone());
|
let san = parse_san(&form.san_text.get_clone());
|
||||||
if !san.is_empty() {
|
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() {
|
if form.ski.get() {
|
||||||
extensions["subject_key_identifier"] = json!({"critical": false});
|
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())
|
.and_then(|v| v.as_array())
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let text = entries
|
form.san_text.set(san_entries_to_text(&entries));
|
||||||
.iter()
|
form.san_critical.set(
|
||||||
.filter_map(|entry| {
|
san.get("critical")
|
||||||
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
|
.and_then(|v| v.as_bool())
|
||||||
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
.unwrap_or(false),
|
||||||
if value.is_empty() {
|
);
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!("{}:{}", kind.to_uppercase(), value))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
form.san_text.set(text);
|
|
||||||
} else {
|
} else {
|
||||||
form.san_text.set(String::new());
|
form.san_text.set(String::new());
|
||||||
|
form.san_critical.set(false);
|
||||||
}
|
}
|
||||||
form.ski.set(spec.get("subject_key_identifier").is_some());
|
form.ski.set(spec.get("subject_key_identifier").is_some());
|
||||||
form.aki.set(spec.get("authority_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 {
|
fn check(label: &'static str, value: Signal<bool>) -> View {
|
||||||
view! {
|
view! {
|
||||||
div(class="form-check") {
|
div(class="form-check") {
|
||||||
@@ -1281,11 +1473,16 @@ fn parse_san(text: &str) -> Vec<serde_json::Value> {
|
|||||||
.filter(|chunk| !chunk.is_empty())
|
.filter(|chunk| !chunk.is_empty())
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
if let Some((prefix, value)) = chunk.split_once(':') {
|
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",
|
"ip" => "ip",
|
||||||
"uri" => "uri",
|
"uri" => "uri",
|
||||||
"email" => "email",
|
"email" => "email",
|
||||||
"rid" => "rid",
|
"rid" => "rid",
|
||||||
|
"upn" => "upn",
|
||||||
"dns" => "dns",
|
"dns" => "dns",
|
||||||
_ => "dns",
|
_ => "dns",
|
||||||
};
|
};
|
||||||
@@ -1297,6 +1494,55 @@ fn parse_san(text: &str) -> Vec<serde_json::Value> {
|
|||||||
.collect()
|
.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> {
|
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
@@ -1320,3 +1566,39 @@ fn query_param(name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
None
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user