diff --git a/e2e/tests/key_selection.spec.js b/e2e/tests/key_selection.spec.js new file mode 100644 index 0000000..93ab0c2 --- /dev/null +++ b/e2e/tests/key_selection.spec.js @@ -0,0 +1,84 @@ +// Certificate key selection mirrors XCA's unused-key filtering. +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(); +} + +function existingKeySelect(page) { + return page.locator("label").filter({ hasText: "Existing key" }).locator("xpath=following-sibling::select[1]"); +} + +test("certificate key selection hides used keys unless requested", async ({ page }) => { + const user = unique("key-selection"); + const vaultName = unique("Key Selection Vault"); + const usedKeyName = unique("used-key"); + const unusedKeyName = unique("unused-key"); + const certName = unique("key-selection.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(); + + for (const keyName of [usedKeyName, unusedKeyName]) { + await page.goto(`${vaultUrl}/keys/new`); + await (await field(page, "Internal name")).fill(keyName); + await page.getByRole("button", { name: "Generate key" }).click(); + await expect(page.getByRole("heading", { name: keyName })).toBeVisible(); + } + + // Use one key in a certificate so subsequent forms can distinguish it. + await page.goto(`${vaultUrl}/certificates/new`); + let keySelect = existingKeySelect(page); + const usedKeyOption = keySelect.locator("option", { hasText: usedKeyName }); + await expect(usedKeyOption).toHaveCount(1); + const usedKeyId = await usedKeyOption.getAttribute("value"); + expect(usedKeyId).toBeTruthy(); + await keySelect.selectOption(usedKeyId); + await (await field(page, "Internal name")).fill(certName); + await (await field(page, "Common name (CN)")).fill(certName); + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByRole("heading", { name: certName })).toBeVisible(); + const certUrl = page.url(); + + // Copying a certificate includes used keys and retains the original key. + await page.goto(`${certUrl}/similar`); + keySelect = existingKeySelect(page); + let includeUsed = page.getByLabel("Include used keys", { exact: true }); + await expect(includeUsed).toBeChecked(); + await expect(keySelect).toHaveValue(usedKeyId); + await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(1); + + // New certificates show only unused private keys by default. + await page.goto(`${vaultUrl}/certificates/new`); + keySelect = existingKeySelect(page); + includeUsed = page.getByLabel("Include used keys", { exact: true }); + await expect(includeUsed).not.toBeChecked(); + await expect(keySelect.locator("option", { hasText: unusedKeyName })).toHaveCount(1); + await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(0); + + await includeUsed.check(); + await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(1); + await expect(keySelect.locator("option", { hasText: unusedKeyName })).toHaveCount(1); + + // Hiding a selected used key also clears the form value. + await keySelect.selectOption(usedKeyId); + await includeUsed.uncheck(); + await expect(keySelect).toHaveValue(""); + await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(0); +}); diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index fa3e392..4653174 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -31,6 +31,10 @@ struct ExtraSubjectRow { value: Signal, } +fn key_is_selectable(key: &KeySummary, include_used: bool) -> bool { + key.has_private && (include_used || key.use_count == 0) +} + #[derive(Clone, Copy)] struct CertFormSignals { explicit_dn: Signal>, @@ -94,6 +98,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let comment = create_signal(String::new()); let signer_id = create_signal("self".to_string()); let key_id = create_signal(String::new()); + let include_used_keys = create_signal(false); let new_key = create_signal(false); let key_type = create_signal("rsa".to_string()); let key_size = create_signal("2048".to_string()); @@ -298,6 +303,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { name.set(format!("{} copy", value.summary.name)); comment.set(value.summary.comment.clone()); if let Some(key) = value.summary.key_id.clone() { + // XCA includes used keys when copying a certificate so + // the original key remains available and selected. + include_used_keys.set(true); key_id.set(key); } if let Some(issuer) = value.summary.issuer_id.clone() { @@ -594,9 +602,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { .collect::>() }; let key_options = move || { + let include_used = include_used_keys.get(); keys.get_clone() .into_iter() - .filter(|key| key.has_private) + .filter(|key| key_is_selectable(key, include_used)) .map(|key| { view! { option(value=key.id.clone()) { (key.name.clone()) } @@ -604,6 +613,22 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { }) .collect::>() }; + create_effect(move || { + if include_used_keys.get() { + return; + } + let selected = key_id.get_clone(); + if selected.is_empty() { + return; + } + let selected_is_used = keys + .get_clone() + .into_iter() + .any(|key| key.id == selected && key.use_count > 0); + if selected_is_used { + key_id.set(String::new()); + } + }); let template_options = move || { templates.get_clone().into_iter().map(|template| view! { option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) } @@ -728,6 +753,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { option(value="") { "— select a key —" } (key_options()) } + div(class="form-check mt-2") { + input(class="form-check-input", r#type="checkbox", id="include-used-keys", bind:checked=include_used_keys) + label(class="form-check-label", r#for="include-used-keys") { "Include used keys" } + } } }) } @@ -1502,3 +1531,31 @@ fn query_param(name: &str) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + + fn key(has_private: bool, use_count: i64) -> KeySummary { + KeySummary { + has_private, + use_count, + ..KeySummary::default() + } + } + + #[test] + fn key_selection_hides_used_and_public_keys_by_default() { + assert!(key_is_selectable(&key(true, 0), false)); + assert!(!key_is_selectable(&key(true, 1), false)); + assert!(!key_is_selectable(&key(false, 0), false)); + } + + #[test] + fn key_selection_includes_used_private_keys_when_requested() { + assert!(key_is_selectable(&key(true, 0), true)); + assert!(key_is_selectable(&key(true, 2), true)); + assert!(!key_is_selectable(&key(false, 0), true)); + assert!(!key_is_selectable(&key(false, 1), true)); + } +}