diff --git a/e2e/tests/san.spec.js b/e2e/tests/san.spec.js new file mode 100644 index 0000000..26cb027 --- /dev/null +++ b/e2e/tests/san.spec.js @@ -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(); +}); diff --git a/e2e/tests/settings.spec.js b/e2e/tests/settings.spec.js index a4d412c..82a2295 100644 --- a/e2e/tests/settings.spec.js +++ b/e2e/tests/settings.spec.js @@ -54,12 +54,12 @@ test("explicit DN order, mandatory DN and template apply", async ({ page }) => { await page.goto(`${vaultUrl}/certificates/new`); 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 cnIndex = labels.findIndex((label) => label.trim() === "Common name (CN)"); const oIndex = labels.findIndex((label) => label.trim() === "Organization (O)"); expect(cnIndex).toBeGreaterThan(-1); 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. await page.locator("select").nth(2).selectOption("builtin:tls-server"); diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index 92d71cf..b3c391c 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -13,6 +13,32 @@ use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, Templa const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"]; +#[derive(Clone, PartialEq)] +struct SanRow { + id: usize, + kind: Signal, + 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>, @@ -41,6 +67,7 @@ struct CertFormSignals { eku_ocsp: Signal, eku_critical: Signal, san_text: Signal, + san_critical: Signal, ski: Signal, aki: Signal, ocsp_staple: Signal, @@ -105,6 +132,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let eku_ocsp = create_signal(false); let eku_critical = create_signal(false); let san_text = create_signal(String::new()); + let san_critical = create_signal(false); let ski = create_signal(true); let aki = create_signal(true); let ocsp_staple = create_signal(false); @@ -175,6 +203,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { eku_ocsp, eku_critical, san_text, + san_critical, ski, aki, ocsp_staple, @@ -187,6 +216,14 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { not_after_text, no_expiration, }; + let san_editor = SanEditorSignals { + open: create_signal(false), + rows: create_signal(Vec::new()), + next_id: create_signal(0), + critical: create_signal(false), + copy_cn: create_signal(false), + }; + let edit_san = move |_| open_san_editor(san_editor, san_text, san_critical); spawn_local(async move { if let Ok(user) = api::me().await { @@ -700,7 +737,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { } div(class="col-md-6") { div(class="form-section-title") { "Subject alternative names" } - input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text) + div(class="input-group") { + input(id="san-text", class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text) + button(id="edit-san", class="btn btn-outline-light", r#type="button", on:click=edit_san) { "Edit" } + } } div(class="col-md-6") { div(class="form-section-title") { "Key identifier / OCSP" } @@ -778,6 +818,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { } } } + (san_editor_dialog(san_editor, san_text, san_critical)) } } @@ -1102,7 +1143,8 @@ fn build_extensions(form: &CertFormSignals) -> Result { extensions["extended_key_usage"] = json!({"critical": form.eku_critical.get(), "usages": eku}); let san = parse_san(&form.san_text.get_clone()); if !san.is_empty() { - extensions["subject_alt_name"] = json!({"critical": false, "entries": san}); + extensions["subject_alt_name"] = + json!({"critical": form.san_critical.get(), "entries": san}); } if form.ski.get() { extensions["subject_key_identifier"] = json!({"critical": false}); @@ -1234,22 +1276,15 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) { .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); - let text = entries - .iter() - .filter_map(|entry| { - let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns"); - let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or(""); - if value.is_empty() { - None - } else { - Some(format!("{}:{}", kind.to_uppercase(), value)) - } - }) - .collect::>() - .join(", "); - form.san_text.set(text); + form.san_text.set(san_entries_to_text(&entries)); + form.san_critical.set( + san.get("critical") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ); } else { form.san_text.set(String::new()); + form.san_critical.set(false); } form.ski.set(spec.get("subject_key_identifier").is_some()); form.aki.set(spec.get("authority_key_identifier").is_some()); @@ -1266,6 +1301,163 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) { ); } +fn open_san_editor(editor: SanEditorSignals, san_text: Signal, 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") { @@ -1281,11 +1473,16 @@ fn parse_san(text: &str) -> Vec { .filter(|chunk| !chunk.is_empty()) .map(|chunk| { if let Some((prefix, value)) = chunk.split_once(':') { - let kind = match prefix.trim().to_lowercase().as_str() { + let kind = prefix.trim().to_lowercase(); + if kind == "othername" || kind == "other_name" { + return parse_other_name(value); + } + let kind = match kind.as_str() { "ip" => "ip", "uri" => "uri", "email" => "email", "rid" => "rid", + "upn" => "upn", "dns" => "dns", _ => "dns", }; @@ -1297,6 +1494,55 @@ fn parse_san(text: &str) -> Vec { .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()) @@ -1320,3 +1566,39 @@ 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" + ); + } +}