Filter certificate key selection to unused keys

This commit is contained in:
2026-10-27 23:34:25 +00:00
parent 356e3beccd
commit 3918a2ac71
2 changed files with 142 additions and 1 deletions
+84
View File
@@ -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);
});
+58 -1
View File
@@ -31,6 +31,10 @@ struct ExtraSubjectRow {
value: Signal<String>,
}
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<Vec<String>>,
@@ -94,6 +98,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> 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<String>) -> 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<String>) -> View {
.collect::<Vec<View>>()
};
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<String>) -> View {
})
.collect::<Vec<View>>()
};
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<String>) -> 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<String> {
}
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));
}
}