Filter certificate key selection to unused keys
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -31,6 +31,10 @@ struct ExtraSubjectRow {
|
|||||||
value: Signal<String>,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct CertFormSignals {
|
struct CertFormSignals {
|
||||||
explicit_dn: Signal<Vec<String>>,
|
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 comment = create_signal(String::new());
|
||||||
let signer_id = create_signal("self".to_string());
|
let signer_id = create_signal("self".to_string());
|
||||||
let key_id = create_signal(String::new());
|
let key_id = create_signal(String::new());
|
||||||
|
let include_used_keys = create_signal(false);
|
||||||
let new_key = create_signal(false);
|
let new_key = create_signal(false);
|
||||||
let key_type = create_signal("rsa".to_string());
|
let key_type = create_signal("rsa".to_string());
|
||||||
let key_size = create_signal("2048".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));
|
name.set(format!("{} copy", value.summary.name));
|
||||||
comment.set(value.summary.comment.clone());
|
comment.set(value.summary.comment.clone());
|
||||||
if let Some(key) = value.summary.key_id.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);
|
key_id.set(key);
|
||||||
}
|
}
|
||||||
if let Some(issuer) = value.summary.issuer_id.clone() {
|
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>>()
|
.collect::<Vec<View>>()
|
||||||
};
|
};
|
||||||
let key_options = move || {
|
let key_options = move || {
|
||||||
|
let include_used = include_used_keys.get();
|
||||||
keys.get_clone()
|
keys.get_clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|key| key.has_private)
|
.filter(|key| key_is_selectable(key, include_used))
|
||||||
.map(|key| {
|
.map(|key| {
|
||||||
view! {
|
view! {
|
||||||
option(value=key.id.clone()) { (key.name.clone()) }
|
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>>()
|
.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 || {
|
let template_options = move || {
|
||||||
templates.get_clone().into_iter().map(|template| view! {
|
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() }) }
|
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 —" }
|
option(value="") { "— select a key —" }
|
||||||
(key_options())
|
(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
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user