diff --git a/README.md b/README.md index 4252aa1..12d6c46 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,10 @@ explicit/mandatory DN entries, default key type/size, default validity). - Issue self-signed certificates or certificates signed by a CA in the same vault, from scratch or from a request. Imported CSRs are marked **signed** once a certificate is issued from them. -- The certificate wizard covers subject entries, validity, basic constraints, - key usage, extended key usage, SAN/IAN, subject/authority key identifiers, +- The certificate wizard covers subject entries in the vault's explicit DN + order, additional subject attributes, mandatory DN enforcement, validity, + basic constraints, key usage, extended key usage, SAN/IAN, + subject/authority key identifiers, CRL distribution points, authority information access, name constraints, certificate policies, policy constraints, inhibit-any-policy, OCSP no-check, TLS feature (OCSP stapling), Netscape legacy extensions and raw @@ -87,14 +89,18 @@ explicit/mandatory DN entries, default key type/size, default validity). advanced extensions plus an advanced JSON editor for raw/less common extensions, and a server-side **Validate extensions** action. OpenSSL config text is available on export. -- Certificate details show subject/issuer, serial, validity, signatures, - MD5/SHA-1/SHA-256 fingerprints, parsed extensions, OpenSSL validation - errors, purposes and the issuer chain. +- Certificate details are split into Overview, Extensions, Validation, Export, + Metadata, Revoke, Transform and CA tabs, with hash deep links. They show + subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256 + fingerprints, parsed extensions, OpenSSL validation errors, purposes and the + issuer chain. - Renew with a new or kept serial, optionally revoke/replace the old certificate. Revoke with a reason; manage the CA's revocation list manually, - including revocation dates and invalidity dates. CA properties (CRL days and - default signing template, including built-ins) are editable from the CA - detail page. + including revocation dates and invalidity dates. Certificate lists support + bulk revocation with a shared reason and invalidity date. CA properties + (CRL days and default signing template, including built-ins) are editable + from the CA detail page, and the wizard offers the CA default template + explicitly without overwriting edits. - Absolute validity dates and XCA's "no well-defined expiration" mode are supported alongside duration-based validity. - Transform certificates to a public key, a CSR or a template. @@ -114,8 +120,9 @@ DER or vCalendar. Built-in **CA**, **TLS server** and **TLS client** templates mirror XCA's presets. User templates store subject defaults, extension defaults and a relative validity range. XCA's binary template format is supported for import -and export, and templates can be applied to a certificate/request spec -server-side. +and export, and templates are applied to a certificate/request spec +server-side with **Apply all**, **Subject only**, and **Extensions + validity** +modes. ### Import and migration @@ -148,7 +155,7 @@ OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`. | Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search`, `POST /api/vaults/{id}/export` | | Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` | | Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` | -| Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms | +| Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `POST /api/vaults/{id}/certificates/bulk-revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms | | CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` | | Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` | | Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) | diff --git a/e2e/tests/advanced.spec.js b/e2e/tests/advanced.spec.js index 8d40613..57feb54 100644 --- a/e2e/tests/advanced.spec.js +++ b/e2e/tests/advanced.spec.js @@ -49,6 +49,9 @@ test("advanced extensions, validation and vault export", async ({ page }) => { await page.getByRole("button", { name: "Create certificate" }).click(); await expect(page.getByRole("heading", { name: certName })).toBeVisible(); + const certUrl = page.url(); + const certApiUrl = certUrl.replace(/\/vaults\//, "/api/vaults/"); + await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click(); await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible(); await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible(); @@ -59,4 +62,24 @@ test("advanced extensions, validation and vault export", async ({ page }) => { await page.getByRole("button", { name: "Export ZIP" }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/\.zip$/); + + // Import preview selection: a duplicate is deselected by default, but can + // be selected explicitly and then reported as skipped by the commit. + await page.goto(`${vaultUrl}/import`); + await expect(page.getByRole("heading", { name: "Import" })).toBeVisible(); + const pem = await page.evaluate(async (url) => { + const response = await fetch(`${url}/export`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ format: "pem" }), + }); + const payload = await response.json(); + return atob(payload.content_base64); + }, certApiUrl); + await page.locator("textarea[placeholder='-----BEGIN CERTIFICATE-----']").fill(pem); + await page.getByRole("button", { name: "Preview", exact: true }).click(); + await expect(page.getByText("duplicate", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "All", exact: true }).click(); + await page.getByRole("button", { name: "Import", exact: true }).click(); + await expect(page.getByText(/skipped 1/)).toBeVisible(); }); diff --git a/e2e/tests/bulk.spec.js b/e2e/tests/bulk.spec.js new file mode 100644 index 0000000..10d32e8 --- /dev/null +++ b/e2e/tests/bulk.spec.js @@ -0,0 +1,115 @@ +// Bulk revocation through the certificate list. +const { test, expect } = require("@playwright/test"); + +function unique(prefix) { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; +} + +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(); +} + +async function api(page, method, url, body) { + return page.evaluate( + async ({ method, url, body }) => { + const response = await fetch(url, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { status: response.status, json: text ? JSON.parse(text) : null }; + }, + { method, url, body } + ); +} + +test("bulk revocation from the certificate list", async ({ page }) => { + const user = unique("bulk"); + const vaultName = unique("Bulk Vault"); + await login(page, user); + + const vault = await api(page, "POST", "/api/vaults", { name: vaultName }); + expect(vault.status).toBe(201); + const vaultId = vault.json.id; + + const caKey = await api(page, "POST", `/api/vaults/${vaultId}/keys`, { + name: "Bulk CA key", + key_type: "rsa", + size: 2048, + }); + expect(caKey.status).toBe(201); + const now = new Date(); + const later = new Date(now.getTime() + 365 * 24 * 3600 * 1000); + const ca = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, { + name: "Bulk CA", + key_id: caKey.json.id, + subject: [ + { name: "C", value: "DE" }, + { name: "O", value: "KCA E2E" }, + { name: "CN", value: "Bulk CA" }, + ], + not_before: now.toISOString(), + not_after: later.toISOString(), + extensions: { + basic_constraints: { critical: true, ca: true, path_length: null }, + key_usage: { critical: true, usages: ["key_cert_sign", "crl_sign"] }, + subject_key_identifier: { critical: false }, + authority_key_identifier: { critical: false }, + }, + }); + expect(ca.status).toBe(201); + const caId = ca.json.id; + + async function leaf(name) { + const key = await api(page, "POST", `/api/vaults/${vaultId}/keys`, { + name: `${name} key`, + key_type: "rsa", + size: 2048, + }); + expect(key.status).toBe(201); + const cert = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, { + name, + key_id: key.json.id, + signer_id: caId, + subject: [ + { name: "C", value: "DE" }, + { name: "O", value: "KCA E2E" }, + { name: "CN", value: name }, + ], + not_before: now.toISOString(), + not_after: later.toISOString(), + extensions: { + basic_constraints: { critical: true, ca: false, path_length: null }, + key_usage: { critical: true, usages: ["digital_signature"] }, + subject_alt_name: { critical: false, entries: [{ type: "dns", value: name }] }, + subject_key_identifier: { critical: false }, + authority_key_identifier: { critical: false }, + }, + }); + expect(cert.status).toBe(201); + return cert.json; + } + + await leaf("bulk-one.example"); + await leaf("bulk-two.example"); + + await page.goto(`/vaults/${vaultId}/certificates`); + await expect(page.getByRole("heading", { name: "Certificates" })).toBeVisible(); + await page.evaluate(() => { + window.confirm = () => true; + }); + await page.getByRole("button", { name: "Select all revocable" }).click(); + await expect(page.getByText("2 selected")).toBeVisible(); + await page.locator("select").first().selectOption("keyCompromise"); + await page.getByRole("button", { name: "Revoke selected" }).click(); + await expect(page.getByText(/Revoked 2 certificate\(s\); 0 already revoked; 0 error\(s\)/)).toBeVisible(); + + const revocations = await api(page, "GET", `/api/vaults/${vaultId}/certificates`); + const revoked = revocations.json.results.filter((cert) => cert.revoked); + expect(revoked).toHaveLength(2); +}); diff --git a/e2e/tests/core.spec.js b/e2e/tests/core.spec.js index b214a85..0dc5ef9 100644 --- a/e2e/tests/core.spec.js +++ b/e2e/tests/core.spec.js @@ -54,6 +54,10 @@ test("core PKI workflow", async ({ page }) => { await page.getByRole("button", { name: "Create certificate" }).click(); await expect(page.getByText("CA certificate", { exact: false })).toBeVisible(); const caUrl = page.url(); + await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click(); + await (await field(page, "CA · default signing template")).selectOption("builtin:tls-server"); + await page.getByRole("button", { name: "Save CA" }).click(); + await expect(page.getByText("CA properties saved.")).toBeVisible(); // Leaf signed by the CA await page.goto(`${vaultUrl}/certificates/new`); @@ -63,10 +67,13 @@ test("core PKI workflow", async ({ page }) => { await (await field(page, "Organization (O)")).fill("KCA E2E"); await (await field(page, "Country (C)")).fill("DE"); await page.locator("#new-key").check(); + await expect(page.getByText("CA default template: TLS server")).toBeVisible(); + await page.getByRole("button", { name: "Apply CA default" }).click(); + await expect(page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]")).toBeChecked(); await page.locator("input[placeholder*='DNS:example.com']").fill(`DNS:${leafName}`); - await page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]").check(); await page.getByRole("button", { name: "Create certificate" }).click(); await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); + await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click(); await expect(page.getByText("serverAuth", { exact: false })).toBeVisible(); // Search @@ -77,12 +84,14 @@ test("core PKI workflow", async ({ page }) => { // Revoke await page.getByRole("link", { name: leafName, exact: true }).click(); + await page.locator(".nav-tabs").getByRole("button", { name: "Revoke", exact: true }).click(); await page.getByRole("button", { name: "Revoke certificate" }).click(); await expect(page.getByText("revoked", { exact: true })).toBeVisible(); // CRL from the CA await page.goto(caUrl); await expect(page.getByRole("heading", { name: caName })).toBeVisible(); + await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click(); await page.getByRole("button", { name: "Generate CRL" }).click(); await expect(page).toHaveURL(/\/crls\//); await expect(page.getByText(/CRL number \d+ · 1 revoked/)).toBeVisible(); diff --git a/e2e/tests/settings.spec.js b/e2e/tests/settings.spec.js new file mode 100644 index 0000000..a4d412c --- /dev/null +++ b/e2e/tests/settings.spec.js @@ -0,0 +1,85 @@ +// Vault settings: explicit DN order, mandatory DN and template application. +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(); +} + +async function api(page, method, url, body) { + return page.evaluate( + async ({ method, url, body }) => { + const response = await fetch(url, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { status: response.status, json: text ? JSON.parse(text) : null }; + }, + { method, url, body } + ); +} + +test("explicit DN order, mandatory DN and template apply", async ({ page }) => { + const user = unique("settings"); + const vaultName = unique("Settings Vault"); + const certName = unique("ordered.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(); + const vaultId = vaultUrl.split("/").pop(); + + const settings = await api(page, "GET", `/api/vaults/${vaultId}/settings`); + const updated = await api(page, "PATCH", `/api/vaults/${vaultId}/settings`, { + version: settings.json.version, + settings: { explicit_dn: ["CN", "O"], mandatory_dn: ["CN"] }, + }); + expect(updated.status).toBe(200); + + await page.goto(`${vaultUrl}/certificates/new`); + await expect(page.getByText("Additional subject attributes")).toBeVisible(); + 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"); + await page.locator("select").nth(3).selectOption("extensions"); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]")).toBeChecked(); + + await (await field(page, "Internal name")).fill(certName); + await (await field(page, "Organization (O)")).fill("KCA E2E"); + await page.locator("#new-key").check(); + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByText("missing mandatory subject entries: CN")).toBeVisible(); + + await (await field(page, "Common name (CN)")).fill(certName); + await page.locator("label").filter({ hasText: "No well-defined expiration" }).locator("..").locator("input[type=checkbox]").check(); + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByRole("heading", { name: certName })).toBeVisible(); + await expect(page.getByText("No expiration", { exact: true })).toBeVisible(); + const cert = await api(page, "GET", `/api/vaults/${vaultId}/certificates`); + const created = cert.json.results.find((item) => item.name === certName); + // RFC 4514 renders the most specific RDN first, so CN follows O. + expect(created.subject).toBe(`O=KCA E2E,CN=${certName}`); +}); diff --git a/server/packages/kca-app/src/kca/routes/certificates.py b/server/packages/kca-app/src/kca/routes/certificates.py index 45f03a8..5512257 100644 --- a/server/packages/kca-app/src/kca/routes/certificates.py +++ b/server/packages/kca-app/src/kca/routes/certificates.py @@ -26,6 +26,7 @@ from ..service import ( chain_for_certificate, delete_certificate_cascade, generate_crl, + bulk_revoke_certificates, issue_certificate, load_private_key, preview_certificate, @@ -97,6 +98,25 @@ def register(app: KayaApp, kca: "Kca") -> None: return await send_json(ctx, 201, await serialize_certificate_detail(cert, vault)) + @app.POST("/api/vaults/${vault_id}/certificates/bulk-revoke") + @operation(summary="Revoke several certificates", tags=["certificates"]) + @require_auth(kca.oidc) + async def bulk_revoke(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + result = await bulk_revoke_certificates(vault, body) + except (ValidationError, ValueError) as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, result) + @app.POST("/api/vaults/${vault_id}/certificates/preview") @operation(summary="Preview/validate a certificate payload", tags=["certificates"]) @require_auth(kca.oidc) diff --git a/server/packages/kca-app/src/kca/service.py b/server/packages/kca-app/src/kca/service.py index 3208a42..f3a6b4c 100644 --- a/server/packages/kca-app/src/kca/service.py +++ b/server/packages/kca-app/src/kca/service.py @@ -73,6 +73,17 @@ def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict if not (32 <= bits <= 160): raise ValidationError("serial_len_bits must be between 32 and 160") merged["serial_len_bits"] = bits + for key in ("explicit_dn", "mandatory_dn"): + entries = merged.get(key) or [] + if not isinstance(entries, list): + raise ValidationError(f"{key} must be a list") + for entry in entries: + if not isinstance(entry, str) or not entry.strip(): + raise ValidationError(f"{key} entries must be non-empty strings") + try: + name_core.resolve_oid(entry) + except ValueError as exc: + raise ValidationError(f"unknown subject attribute {entry!r}") from exc return merged @@ -965,6 +976,63 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s return row +async def bulk_revoke_certificates(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]: + """Revoke several certificates in one request, reporting per-item failures.""" + raw_ids = payload.get("certificate_ids") or payload.get("ids") or [] + if not isinstance(raw_ids, list): + raise ValidationError("certificate_ids must be a list") + seen: set[str] = set() + ids: list[str] = [] + for raw in raw_ids: + text = str(raw) + if text in seen: + continue + seen.add(text) + ids.append(text) + if not ids: + raise ValidationError("select at least one certificate") + if len(ids) > 200: + raise ValidationError("at most 200 certificates can be revoked at once") + reason = str(payload.get("reason") or "unspecified") + if reason not in {item["name"] for item in crl_core.CRL_REASONS}: + raise ValidationError("unknown revocation reason") + invalidity = payload.get("invalidity_date") + if payload.get("revoked_at"): + parse_datetime(payload["revoked_at"], "revoked_at") + if invalidity: + parse_datetime(invalidity, "invalidity_date") + revocation_payload = { + "reason": reason, + "revoked_at": payload.get("revoked_at"), + "invalidity_date": invalidity, + } + revoked: list[Dict[str, Any]] = [] + skipped: list[Dict[str, Any]] = [] + errors: list[Dict[str, Any]] = [] + for raw_id in ids: + parsed_id = parse_uuid(raw_id) + if parsed_id is None: + errors.append({"id": raw_id, "error": "invalid certificate id"}) + continue + cert = await Certificate.get_or_none(id=parsed_id, vault_id=vault.id) + if cert is None: + errors.append({"id": raw_id, "error": "certificate not found"}) + continue + existing = None + if cert.issuer_id is not None: + existing = await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex) + if existing is not None: + skipped.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "reason": "already revoked"}) + continue + try: + row = await revoke_certificate(vault, cert, revocation_payload) + except (ValidationError, ValueError) as exc: + errors.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "error": str(exc)}) + continue + revoked.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "revocation_id": str(row.id)}) + return {"revoked": revoked, "skipped": skipped, "errors": errors} + + async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]) -> Crl: if not ca.is_ca: raise ValidationError("the selected certificate is not a CA") @@ -1323,6 +1391,7 @@ def _reason_from_bit(bit: int) -> str: __all__ = [ "DEFAULT_VAULT_SETTINGS", "ValidationError", + "bulk_revoke_certificates", "chain_for_certificate", "extension_search_text", "delete_certificate_cascade", diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index 6928a0c..2c48ab4 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -152,6 +152,30 @@ class RouteExtraTests(unittest.TestCase): ) assert response.status_code == 200 assert response.json()["extensions"]["basic_constraints"]["ca"] is True + response = await client.post( + f"/api/vaults/{vault_id}/templates/builtin:tls-server/apply", + json={ + "target": { + "subject": [{"name": "CN", "value": "keep.example.com"}], + "extensions": {}, + }, + "apply_subject": False, + "apply_extensions": True, + }, + ) + assert response.status_code == 200 + merged = response.json() + assert merged["subject"] == [{"name": "CN", "value": "keep.example.com"}] + assert "serverAuth" in merged["extensions"]["extended_key_usage"]["usages"] + settings_response = await client.get(f"/api/vaults/{vault_id}/settings") + response = await client.patch( + f"/api/vaults/{vault_id}/settings", + json={ + "settings": {"explicit_dn": ["CN", "not-a-real-attribute"]}, + "version": settings_response.json()["version"], + }, + ) + assert response.status_code == 400 @async_test async def test_revocation_and_crl(self) -> None: @@ -303,6 +327,55 @@ class VaultExportTests(unittest.TestCase): names = archive.namelist() assert "manifest.json" in names assert any(name.startswith("certificates/") for name in names) - assert any(name.startswith("keys/") for name in names) + key_files = [name for name in names if name.startswith("keys/") and name.endswith(".key")] + assert key_files + assert b"BEGIN ENCRYPTED PRIVATE KEY" in archive.read(key_files[0]) manifest = archive.read("manifest.json") assert b"Zip Cert" in manifest + + +class BulkRevocationTests(unittest.TestCase): + @async_test + async def test_bulk_revoke_reports_skips_and_errors(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "bulk ca") + ca = await _cert(client, vault_id, ca_key["id"], "Bulk CA", ca=True) + leaf1_key = await _key(client, vault_id, "leaf1") + leaf1 = await _cert(client, vault_id, leaf1_key["id"], "Leaf 1", signer_id=ca["id"]) + leaf2_key = await _key(client, vault_id, "leaf2") + leaf2 = await _cert(client, vault_id, leaf2_key["id"], "Leaf 2", signer_id=ca["id"]) + self_key = await _key(client, vault_id, "self") + self_signed = await _cert(client, vault_id, self_key["id"], "Self") + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={ + "certificate_ids": [leaf1["id"], leaf2["id"], self_signed["id"]], + "reason": "keyCompromise", + }, + ) + assert response.status_code == 200, response.text + result = response.json() + assert len(result["revoked"]) == 2 + assert len(result["errors"]) == 1 + assert result["errors"][0]["id"] == self_signed["id"] + response = await client.get(f"/api/vaults/{vault_id}/certificates/{leaf1['id']}") + assert response.json()["revoked"] is True + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={"certificate_ids": [leaf1["id"], leaf2["id"]], "reason": "superseded"}, + ) + assert response.status_code == 200, response.text + assert len(response.json()["skipped"]) == 2 + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={"certificate_ids": [], "reason": "superseded"}, + ) + assert response.status_code == 400 + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={"certificate_ids": ["not-a-uuid"], "reason": "superseded"}, + ) + assert response.status_code == 200 + assert response.json()["errors"][0]["error"] == "invalid certificate id" diff --git a/web/src/api.rs b/web/src/api.rs index e3d701d..48f6fb2 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -297,6 +297,11 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/bulk-revoke"), Some(payload)).await?; + check(response).await +} + pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result { let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?; check(response).await diff --git a/web/src/model.rs b/web/src/model.rs index 5985b97..9909cf0 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -1,6 +1,6 @@ //! JSON models mirroring the KCA API and small display helpers. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -265,7 +265,7 @@ pub struct CertificateDetail { pub cert_spec: Value, } -#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)] pub struct DnEntry { #[serde(default)] pub oid: String, diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs index 6a468b8..f9c4df2 100644 --- a/web/src/pages/certificate_detail.rs +++ b/web/src/pages/certificate_detail.rs @@ -37,6 +37,9 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let tls_auth_text = create_signal(String::new()); let new_name = create_signal(String::new()); let new_comment = create_signal(String::new()); + let active_tab = create_signal(initial_tab()); + let info = create_signal(None::); + let openssl_config = create_signal(String::new()); let load = move || { spawn_local(async move { @@ -62,6 +65,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { validation_errors.set(value.validation_errors.clone()); purposes.set(value.purposes.clone()); chain.set(value.chain.clone()); + openssl_config.set(value.openssl_config.clone()); cert.set(Some(value)); if is_ca { if let Ok(page) = api::templates(id).await { @@ -111,7 +115,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let payload = json!({"reason": rev_reason.get_clone()}); spawn_local(async move { match api::revoke_certificate(id, cert_id_value, &payload).await { - Ok(_) => load(), + Ok(_) => { + info.set(Some("Certificate revoked.".to_string())); + load(); + } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } @@ -137,7 +144,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version}); spawn_local(async move { match api::update_certificate(id, cert_id_value, &payload).await { - Ok(updated) => cert.set(Some(updated)), + Ok(updated) => { + cert.set(Some(updated)); + info.set(Some("Certificate metadata saved.".to_string())); + } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } @@ -171,7 +181,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }); spawn_local(async move { match api::update_ca(id, cert_id_value, &payload).await { - Ok(_) => {} + Ok(_) => info.set(Some("CA properties saved.".to_string())), Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } @@ -217,6 +227,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { rev_serial.set(String::new()); rev_invalidity.set(String::new()); editing_rev.set(String::new()); + info.set(Some("Revocation list saved.".to_string())); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), @@ -264,6 +275,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }); }; + let tab = move |key: &'static str, label: &'static str| -> View { + view! { + li(class="nav-item") { + button(class=if active_tab.get_clone() == key { "nav-link active" } else { "nav-link" }, + on:click=move |_| { + active_tab.set(key.to_string()); + if let Some(window) = web_sys::window() { + let _ = window.location().set_hash(key); + } + }) { (label) } + } + } + }; + let content = move || { if loading.get() { return spinner(); @@ -275,233 +300,281 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let is_ca = summary.is_ca; let header_serial = summary.serial_hex.clone(); view! { - (page_header( - summary.name.clone(), - format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial), - view! { - a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } - a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" } - (if !summary.revoked && !is_ca { - view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } } - } else { view! {} }) - button(class="btn btn-outline-danger", on:click=remove) { "Delete" } - }, - )) - div(class="card mb-3") { - div(class="card-body") { - div(class="row g-2") { - div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } } - div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } } - div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } } - div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } } - div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } } - div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } } - div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } } - div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) } - } - } - } - div(class="row g-3 mb-3") { - div(class="col-12 col-lg-6") { - div(class="card") { - div(class="card-header") { "Subject" } - table(class="table table-sm dn-table mb-0") { - tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) } - } - } - } - div(class="col-12 col-lg-6") { - div(class="card") { - div(class="card-header") { "Issuer" } - table(class="table table-sm dn-table mb-0") { - tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) } - } - } - } - } - div(class="card mb-3") { - div(class="card-header d-flex justify-content-between align-items-center") { - span() { "Extensions" } - (copy_button(value.openssl_config.clone(), "Copy OpenSSL config")) - } - div(class="table-responsive") { - table(class="table table-sm ext-table mb-0") { - thead() { tr() { th() { "Extension" } th() { "Value" } } } - tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) } - } - } - } - div(class="card mb-3") { - div(class="card-header") { "Validation" } - div(class="card-body") { - (if validation_errors.get_clone().is_empty() { - view! { div(class="alert alert-success mb-2") { "No verification errors found." } } - } else { - view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } } - }) - div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) } - } - } - div(class="row g-3") { - div(class="col-12 col-lg-6") { + (page_header( + summary.name.clone(), + format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial), + view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } + a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" } + (if !summary.revoked && !is_ca { + view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } } + } else { view! {} }) + button(class="btn btn-outline-danger", on:click=remove) { "Delete" } + }, + )) div(class="card mb-3") { - div(class="card-header") { "Export" } div(class="card-body") { - select(class="form-select mb-2", bind:value=export_format) { - option(value="pem") { "PEM" } - option(value="pem-chain") { "PEM chain" } - option(value="pem-key") { "PEM + key" } - option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" } - option(value="pkcs7") { "PKCS#7" } - option(value="pkcs7-chain") { "PKCS#7 chain" } - option(value="der") { "DER" } - option(value="pkcs12") { "PKCS#12" } - option(value="pkcs12-chain") { "PKCS#12 chain" } - option(value="openvpn") { "OpenVPN" } - option(value="openssl-config") { "OpenSSL config" } - option(value="index") { "Certificate index" } - option(value="vcalendar") { "vCalendar" } - option(value="jwk") { "JWK" } - option(value="jwk-chain") { "JWK chain" } - option(value="tls-auth") { "OpenVPN tls-auth key" } + div(class="row g-2") { + div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } } + div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } } + div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } } + div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } } + div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } } + div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (if summary.no_expiration { "No expiration".to_string() } else { fmt_date(&summary.not_after) }) } } + div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } } + div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) } } - div(class="mb-2") { - label(class="form-label") { "Password (encrypted formats)" } - input(class="form-control", r#type="password", bind:value=export_password) - } - button(class="btn btn-primary", on:click=do_export) { "Download" } } } - div(class="card") { - div(class="card-header") { "Metadata" } - div(class="card-body") { - div(class="mb-2") { - label(class="form-label") { "Name" } - input(class="form-control", bind:value=new_name) + ul(class="nav nav-tabs mb-3") { + (tab("overview", "Overview")) + (tab("extensions", "Extensions")) + (tab("validation", "Validation")) + (tab("export", "Export")) + (tab("metadata", "Metadata")) + (tab("revoke", "Revoke")) + (tab("transform", "Transform")) + (if is_ca { tab("ca", "CA") } else { view! {} }) + } + (if active_tab.get_clone() == "overview" { + view! { + div(class="row g-3 mb-3") { + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Subject" } + table(class="table table-sm dn-table mb-0") { + tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) } + } } - div(class="mb-2") { - label(class="form-label") { "Comment" } - textarea(class="form-control", rows="3", bind:value=new_comment) {} + } + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Issuer" } + table(class="table table-sm dn-table mb-0") { + tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) } + } } - button(class="btn btn-primary", on:click=save_meta) { "Save" } } } - } - div(class="col-12 col-lg-6") { - div(class="card mb-3") { - div(class="card-header") { "Revoke" } - div(class="card-body") { - select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) } - button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" } + + } + } else { view! {} }) (if active_tab.get_clone() == "extensions" { + view! { + div(class="card mb-3") { + div(class="card-header d-flex justify-content-between align-items-center") { + span() { "Extensions" } + (copy_button(openssl_config.get_clone(), "Copy OpenSSL config")) + } + div(class="table-responsive") { + table(class="table table-sm ext-table mb-0") { + thead() { tr() { th() { "Extension" } th() { "Value" } } } + tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) } + } } } - div(class="card") { - div(class="card-header") { "Transform" } - div(class="card-body d-grid gap-2") { - button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" } - button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" } - button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" } - a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" } + } - } - } - } - (if is_ca { - view! { - div(class="card mt-3") { - div(class="card-header") { "CA operations" } + } else { view! {} }) (if active_tab.get_clone() == "validation" { + view! { + div(class="card mb-3") { + div(class="card-header") { "Validation" } div(class="card-body") { - div(class="row g-2 align-items-end mb-3") { - div(class="col-md-3") { - label(class="form-label") { "CA · CRL days" } - input(class="form-control", bind:value=ca_days) - } - div(class="col-md-5") { - label(class="form-label") { "CA · default signing template" } - select(class="form-select", bind:value=ca_template) { - option(value="") { "— none —" } - (templates.get_clone().into_iter().map(|template| view! { - option(value=template.id.clone()) { (template.name.clone()) } - }).collect::>()) - } - } - div(class="col-md-2") { - button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" } - } - } - div(class="row g-2 align-items-end mb-3") { - div(class="col-md-3") { - label(class="form-label") { "CRL days" } - input(class="form-control", bind:value=crl_days) - } - div(class="col-md-3") { - label(class="form-label") { "Hash" } - select(class="form-select", bind:value=crl_hash) { - option(value="sha256") { "SHA-256" } - option(value="sha384") { "SHA-384" } - option(value="sha512") { "SHA-512" } - } - } - div(class="col-md-3") { - button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" } - } - div(class="col-md-3") { - button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" } - } - } - (if tls_auth_text.get_clone().is_empty() { - view! {} + (if validation_errors.get_clone().is_empty() { + view! { div(class="alert alert-success mb-2") { "No verification errors found." } } } else { - view! { pre(class="pem") { (tls_auth_text.get_clone()) } } + view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } } }) - h3(class="h6 mt-3") { "Revocations" } - div(class="row g-2 align-items-end mb-2") { - div(class="col-md-3") { - input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial) - } - div(class="col-md-3") { - select(class="form-select", bind:value=rev_reason) { (reason_options()) } - } - div(class="col-md-3") { - input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity) - } - div(class="col-md-3 d-flex gap-2") { - button(class="btn btn-outline-light", on:click=save_revocation) { - (if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" }) - } - (if !editing_rev.get_clone().is_empty() { - view! { - button(class="btn btn-outline-secondary", on:click=move |_| { - editing_rev.set(String::new()); - rev_serial.set(String::new()); - rev_invalidity.set(String::new()); - }) { "Cancel" } - } - } else { view! {} }) - } - } - table(class="table table-sm mb-0") { - thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } } - tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! { - tr() { - td(class="fingerprint") { (rev.serial_hex) } - td() { (fmt_date(&rev.revoked_at)) } - td() { (rev.reason) } - td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) } - td(class="table-actions") { - button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" } - button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } - } - } - } }) } - } + div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) } } } + + } + } else { view! {} }) (if active_tab.get_clone() == "export" { + view! { + div(class="row g-3") { + div(class="col-12 col-lg-6") { + div(class="card mb-3") { + div(class="card-header") { "Export" } + div(class="card-body") { + select(class="form-select mb-2", bind:value=export_format) { + option(value="pem") { "PEM" } + option(value="pem-chain") { "PEM chain" } + option(value="pem-key") { "PEM + key" } + option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" } + option(value="pkcs7") { "PKCS#7" } + option(value="pkcs7-chain") { "PKCS#7 chain" } + option(value="der") { "DER" } + option(value="pkcs12") { "PKCS#12" } + option(value="pkcs12-chain") { "PKCS#12 chain" } + option(value="openvpn") { "OpenVPN" } + option(value="openssl-config") { "OpenSSL config" } + option(value="index") { "Certificate index" } + option(value="vcalendar") { "vCalendar" } + option(value="jwk") { "JWK" } + option(value="jwk-chain") { "JWK chain" } + option(value="tls-auth") { "OpenVPN tls-auth key" } + } + div(class="mb-2") { + label(class="form-label") { "Password (encrypted formats)" } + input(class="form-control", r#type="password", bind:value=export_password) + } + button(class="btn btn-primary", on:click=do_export) { "Download" } + } + } + } + } + } + } else { view! {} }) + (if active_tab.get_clone() == "metadata" { + view! { + div(class="row g-3") { + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Metadata" } + div(class="card-body") { + div(class="mb-2") { + label(class="form-label") { "Name" } + input(class="form-control", bind:value=new_name) + } + div(class="mb-2") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="3", bind:value=new_comment) {} + } + button(class="btn btn-primary", on:click=save_meta) { "Save" } + } + } + } + } + } + } else { view! {} }) + (if active_tab.get_clone() == "revoke" { + view! { + div(class="row g-3") { + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Revoke" } + div(class="card-body") { + select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) } + button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" } + } + } + } + } + } + } else { view! {} }) + (if active_tab.get_clone() == "transform" { + view! { + div(class="row g-3") { + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Transform" } + div(class="card-body d-grid gap-2") { + button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" } + button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" } + button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" } + } + } + } + } + } + } else { view! {} }) + (if is_ca && active_tab.get_clone() == "ca" { + view! { + div(class="card mt-3") { + div(class="card-header") { "CA operations" } + div(class="card-body") { + div(class="row g-2 align-items-end mb-3") { + div(class="col-md-3") { + label(class="form-label") { "CA · CRL days" } + input(class="form-control", bind:value=ca_days) + } + div(class="col-md-5") { + label(class="form-label") { "CA · default signing template" } + select(class="form-select", bind:value=ca_template) { + option(value="") { "— none —" } + (templates.get_clone().into_iter().map(|template| view! { + option(value=template.id.clone()) { (template.name.clone()) } + }).collect::>()) + } + } + div(class="col-md-2") { + button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" } + } + } + div(class="row g-2 align-items-end mb-3") { + div(class="col-md-3") { + label(class="form-label") { "CRL days" } + input(class="form-control", bind:value=crl_days) + } + div(class="col-md-3") { + label(class="form-label") { "Hash" } + select(class="form-select", bind:value=crl_hash) { + option(value="sha256") { "SHA-256" } + option(value="sha384") { "SHA-384" } + option(value="sha512") { "SHA-512" } + } + } + div(class="col-md-3") { + button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" } + } + div(class="col-md-3") { + button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" } + } + } + (if tls_auth_text.get_clone().is_empty() { + view! {} + } else { + view! { pre(class="pem") { (tls_auth_text.get_clone()) } } + }) + h3(class="h6 mt-3") { "Revocations" } + div(class="row g-2 align-items-end mb-2") { + div(class="col-md-3") { + input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial) + } + div(class="col-md-3") { + select(class="form-select", bind:value=rev_reason) { (reason_options()) } + } + div(class="col-md-3") { + input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity) + } + div(class="col-md-3 d-flex gap-2") { + button(class="btn btn-outline-light", on:click=save_revocation) { + (if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" }) + } + (if !editing_rev.get_clone().is_empty() { + view! { + button(class="btn btn-outline-secondary", on:click=move |_| { + editing_rev.set(String::new()); + rev_serial.set(String::new()); + rev_invalidity.set(String::new()); + }) { "Cancel" } + } + } else { view! {} }) + } + } + table(class="table table-sm mb-0") { + thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } } + tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! { + tr() { + td(class="fingerprint") { (rev.serial_hex) } + td() { (fmt_date(&rev.revoked_at)) } + td() { (rev.reason) } + td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) } + td(class="table-actions") { + button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" } + button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } + } + } + } }) } + } + } + } + } + } else { + view! {} + }) } - } else { - view! {} - }) - } }; let nav = move || match me.get_clone() { @@ -513,11 +586,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { (app_nav("/", Some(id), nav)) main(class="container page-shell py-4") { (toast_error(error)) + (crate::components::toast::toast(info, true)) (content) } } } +fn initial_tab() -> String { + let hash = web_sys::window() + .and_then(|window| window.location().hash().ok()) + .unwrap_or_default(); + let hash = hash.trim_start_matches('#'); + match hash { + "overview" | "extensions" | "validation" | "export" | "metadata" | "revoke" + | "transform" | "ca" => hash.to_string(), + _ => "overview".to_string(), + } +} + fn reason_options() -> Vec { [ "unspecified", diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index 22df31e..92d71cf 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -1,5 +1,5 @@ //! Certificate creation wizard and renewal form. -use serde_json::json; +use serde_json::{json, Value}; use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; @@ -11,6 +11,49 @@ use crate::components::nav::{app_nav, whoami}; use crate::components::ui::{page_header, toast_error}; use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary}; +const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"]; + +#[derive(Clone, Copy)] +struct CertFormSignals { + explicit_dn: Signal>, + subject_extra: Signal, + cn: Signal, + country: Signal, + state: Signal, + locality: Signal, + organization: Signal, + org_unit: Signal, + email: Signal, + ca_mode: Signal, + path_length: Signal, + bc_critical: Signal, + ku_digital: Signal, + ku_encipher: Signal, + ku_key_agreement: Signal, + ku_cert_sign: Signal, + ku_crl_sign: Signal, + ku_critical: Signal, + eku_server: Signal, + eku_client: Signal, + eku_code: Signal, + eku_email: Signal, + eku_time: Signal, + eku_ocsp: Signal, + eku_critical: Signal, + san_text: Signal, + ski: Signal, + aki: Signal, + ocsp_staple: Signal, + ext: ExtensionSignals, + validity_mode: Signal, + validity_amount: Signal, + validity_unit: Signal, + midnight: Signal, + not_before_text: Signal, + not_after_text: Signal, + no_expiration: Signal, +} + pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); @@ -32,6 +75,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let key_type = create_signal("rsa".to_string()); let key_size = create_signal("2048".to_string()); let template_id = create_signal(String::new()); + let template_apply_mode = create_signal("all".to_string()); + let ca_default_template = create_signal(None::); + let ca_default_name = create_signal(String::new()); let signature_hash = create_signal("sha256".to_string()); let cn = create_signal(String::new()); @@ -95,6 +141,52 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let not_before_text = create_signal(String::new()); let not_after_text = create_signal(String::new()); let no_expiration = create_signal(false); + let explicit_dn = create_signal( + DEFAULT_EXPLICIT_DN + .iter() + .map(|v| v.to_string()) + .collect::>(), + ); + let subject_extra = create_signal(String::new()); + let form = CertFormSignals { + explicit_dn, + subject_extra, + cn, + country, + state, + locality, + organization, + org_unit, + email, + ca_mode, + path_length, + bc_critical, + ku_digital, + ku_encipher, + ku_key_agreement, + ku_cert_sign, + ku_crl_sign, + ku_critical, + eku_server, + eku_client, + eku_code, + eku_email, + eku_time, + eku_ocsp, + eku_critical, + san_text, + ski, + aki, + ocsp_staple, + ext, + validity_mode, + validity_amount, + validity_unit, + midnight, + not_before_text, + not_after_text, + no_expiration, + }; spawn_local(async move { if let Ok(user) = api::me().await { @@ -129,6 +221,16 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { key_size.set(size.to_string()); } } + if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) { + let names = items + .iter() + .filter_map(|v| v.as_str()) + .map(|v| v.to_string()) + .collect::>(); + if !names.is_empty() { + explicit_dn.set(names); + } + } if let Some(validity) = settings.get("default_validity") { if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) { validity_amount.set(amount.to_string()); @@ -147,6 +249,11 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { if let Some(key) = value.summary.key_id.clone() { key_id.set(key); } + if let Some(issuer) = value.summary.issuer_id.clone() { + if issuer != value.summary.id { + signer_id.set(issuer); + } + } if let Some(spec) = value .cert_spec .get("signature_hash") @@ -154,82 +261,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { { signature_hash.set(spec.to_string()); } - for entry in &value.subject_entries { - match entry.name.as_str() { - "CN" => cn.set(entry.value.clone()), - "O" => organization.set(entry.value.clone()), - "OU" => org_unit.set(entry.value.clone()), - "C" => country.set(entry.value.clone()), - "ST" => state.set(entry.value.clone()), - "L" => locality.set(entry.value.clone()), - "emailAddress" => email.set(entry.value.clone()), - _ => {} - } - } - let spec = &value.extension_spec; - load_extra_extensions(&ext, spec); - if let Some(bc) = spec.get("basic_constraints") { - if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) { - ca_mode.set("ca".to_string()); - if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) { - path_length.set(len.to_string()); - } - } else if bc.get("ca").is_some() { - ca_mode.set("end".to_string()); - } - bc_critical - .set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); - } - if let Some(ku) = spec.get("key_usage") { - let usages = string_array(ku.get("usages")); - ku_digital.set(usages.iter().any(|s| s == "digital_signature")); - ku_encipher.set(usages.iter().any(|s| s == "key_encipherment")); - ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement")); - ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign")); - ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign")); - ku_critical - .set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); - } - if let Some(eku) = spec.get("extended_key_usage") { - let usages = string_array(eku.get("usages")); - eku_server.set(usages.iter().any(|s| s == "serverAuth")); - eku_client.set(usages.iter().any(|s| s == "clientAuth")); - eku_code.set(usages.iter().any(|s| s == "codeSigning")); - eku_email.set(usages.iter().any(|s| s == "emailProtection")); - eku_time.set(usages.iter().any(|s| s == "timeStamping")); - eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning")); - eku_critical.set( - eku.get("critical") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - ); - } - if let Some(san) = spec.get("subject_alt_name") { - let entries = san - .get("entries") - .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(", "); - san_text.set(text); - } - ski.set(spec.get("subject_key_identifier").is_some()); - aki.set(spec.get("authority_key_identifier").is_some()); - ocsp_staple.set(spec.get("tls_feature").is_some()); + let subject_json = + serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); + apply_subject_json(&form, &subject_json); + apply_extension_spec(&form, &value.extension_spec); no_expiration.set(value.summary.no_expiration); } } @@ -237,160 +272,92 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { if request_mode { if let Ok(value) = api::request(&id, &request_id_for_load).await { name.set(format!("{} certificate", value.summary.name)); - if let Some(first) = value.subject_entries.iter().find(|e| e.name == "CN") { - cn.set(first.value.clone()); - } - if let Some(first) = value.subject_entries.iter().find(|e| e.name == "O") { - organization.set(first.value.clone()); - } - if let Some(first) = value.subject_entries.iter().find(|e| e.name == "OU") { - org_unit.set(first.value.clone()); - } - if let Some(first) = value.subject_entries.iter().find(|e| e.name == "C") { - country.set(first.value.clone()); - } - if let Some(first) = value.subject_entries.iter().find(|e| e.name == "ST") { - state.set(first.value.clone()); - } - if let Some(first) = value.subject_entries.iter().find(|e| e.name == "L") { - locality.set(first.value.clone()); - } - if let Some(first) = value - .subject_entries - .iter() - .find(|e| e.name == "emailAddress") - { - email.set(first.value.clone()); - } + let subject_json = + serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); + apply_subject_json(&form, &subject_json); } } }); - let apply_template = { - let id = id.clone(); - move |_| { - let template = template_id.get_clone(); - if template.is_empty() { + let apply_template_id = move |template: String, mode: String| { + if template.is_empty() { + return; + } + let target = match build_target(&form) { + Ok(target) => target, + Err(message) => { + error.set(Some(message)); return; } - let id = id.clone(); - spawn_local(async move { - match api::template(&id, &template).await { - Ok(template) => { - if let Some(settings) = template.settings.get("extensions") { - load_extra_extensions(&ext, settings); - if let Some(bc) = settings.get("basic_constraints") { - if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) { - ca_mode.set("ca".to_string()); - if let Some(len) = - bc.get("path_length").and_then(|v| v.as_i64()) - { - path_length.set(len.to_string()); - } - } else if bc.get("ca").is_some() { - ca_mode.set("end".to_string()); - } - bc_critical.set( - bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true), - ); - } - if let Some(ku) = settings.get("key_usage") { - let usages = string_array(ku.get("usages")); - ku_digital.set(usages.iter().any(|s| s == "digital_signature")); - ku_encipher.set(usages.iter().any(|s| s == "key_encipherment")); - ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement")); - ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign")); - ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign")); - ku_critical.set( - ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true), - ); - } - if let Some(eku) = settings.get("extended_key_usage") { - let usages = string_array(eku.get("usages")); - eku_server.set(usages.iter().any(|s| s == "serverAuth")); - eku_client.set(usages.iter().any(|s| s == "clientAuth")); - eku_code.set(usages.iter().any(|s| s == "codeSigning")); - eku_email.set(usages.iter().any(|s| s == "emailProtection")); - eku_time.set(usages.iter().any(|s| s == "timeStamping")); - eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning")); - eku_critical.set( - eku.get("critical") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - ); - } - if let Some(san) = settings.get("subject_alt_name") { - let entries = san - .get("entries") - .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(", "); - san_text.set(text); - } - ski.set(settings.get("subject_key_identifier").is_some()); - aki.set(settings.get("authority_key_identifier").is_some()); - } - if let Some(subject) = template.subject.as_array() { - for entry in subject { - let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let value = entry - .get("value") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - match key { - "CN" => cn.set(value), - "O" => organization.set(value), - "OU" => org_unit.set(value), - "C" => country.set(value), - "ST" => state.set(value), - "L" => locality.set(value), - "emailAddress" => email.set(value), - _ => {} - } - } - } - if let Some(validity) = template.settings.get("validity") { - if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) { - validity_amount.set(amount.to_string()); - } - if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) { - validity_unit.set(unit.to_string()); - } - midnight.set( - validity - .get("midnight") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - ); + }; + let payload = json!({ + "target": target, + "apply_subject": mode == "all" || mode == "subject", + "apply_extensions": mode == "all" || mode == "extensions", + }); + spawn_local(async move { + match api::apply_template(&id, &template, &payload).await { + Ok(merged) => { + if mode == "all" || mode == "subject" { + let subject = merged.get("subject").cloned().unwrap_or_else(|| json!([])); + apply_subject_json(&form, &subject); + } + if mode == "all" || mode == "extensions" { + let extensions = merged + .get("extensions") + .cloned() + .unwrap_or_else(|| json!({})); + apply_extension_spec(&form, &extensions); + if let Some(validity) = merged.get("validity") { + apply_validity_json(&form, validity); } } - Err(message) if message == api::LOGIN_REQUIRED => {} - Err(message) => error.set(Some(message)), } - }); + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + let apply_template = move |_| { + apply_template_id(template_id.get_clone(), template_apply_mode.get_clone()); + }; + let apply_ca_default = move |_: web_sys::MouseEvent| { + if let Some(template) = ca_default_template.get_clone() { + template_id.set(template.clone()); + template_apply_mode.set("all".to_string()); + apply_template_id(template, "all".to_string()); } }; + create_effect(move || { + let signer = signer_id.get_clone(); + if signer == "self" { + ca_default_template.set(None); + ca_default_name.set(String::new()); + return; + } + spawn_local(async move { + if let Ok(value) = api::ca_info(&id, &signer).await { + let template = value + .get("default_template") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let label = match template.as_deref() { + Some("builtin:ca") => "CA".to_string(), + Some("builtin:tls-server") => "TLS server".to_string(), + Some("builtin:tls-client") => "TLS client".to_string(), + Some(other) => match api::template(&id, other).await { + Ok(template) => template.name, + Err(_) => other.to_string(), + }, + None => String::new(), + }; + ca_default_template.set(template); + ca_default_name.set(label); + } + }); + }); + let submit = { let id = id.clone(); let request_id = request_id.clone(); @@ -400,85 +367,24 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { error.set(Some("certificate name is required".to_string())); return; } - let subject = build_subject(&[ - ("C", country.get_clone()), - ("ST", state.get_clone()), - ("L", locality.get_clone()), - ("O", organization.get_clone()), - ("OU", org_unit.get_clone()), - ("CN", cn.get_clone()), - ("emailAddress", email.get_clone()), - ]); - if subject.is_empty() { - error.set(Some("the subject must not be empty".to_string())); - return; - } - let mut extensions = json!({}); - let mode = ca_mode.get_clone(); - let crit = bc_critical.get(); - if mode == "ca" { - extensions["basic_constraints"] = json!({ - "critical": crit, - "ca": true, - "path_length": path_length.get_clone().parse::().ok(), - }); - } else if mode == "end" { - extensions["basic_constraints"] = - json!({"critical": crit, "ca": false, "path_length": null}); - } - let usages = [ - (ku_digital.get(), "digital_signature"), - (ku_encipher.get(), "key_encipherment"), - (ku_key_agreement.get(), "key_agreement"), - (ku_cert_sign.get(), "key_cert_sign"), - (ku_crl_sign.get(), "crl_sign"), - ] - .iter() - .filter(|(checked, _)| *checked) - .map(|(_, name)| json!(name)) - .collect::>(); - extensions["key_usage"] = json!({"critical": ku_critical.get(), "usages": usages}); - let eku = [ - (eku_server.get(), "serverAuth"), - (eku_client.get(), "clientAuth"), - (eku_code.get(), "codeSigning"), - (eku_email.get(), "emailProtection"), - (eku_time.get(), "timeStamping"), - (eku_ocsp.get(), "OCSPSigning"), - ] - .iter() - .filter(|(checked, _)| *checked) - .map(|(_, name)| json!(name)) - .collect::>(); - extensions["extended_key_usage"] = - json!({"critical": eku_critical.get(), "usages": eku}); - let san = parse_san(&san_text.get_clone()); - if !san.is_empty() { - extensions["subject_alt_name"] = json!({"critical": false, "entries": san}); - } - if ski.get() { - extensions["subject_key_identifier"] = json!({"critical": false}); - } - if aki.get() { - extensions["authority_key_identifier"] = json!({"critical": false}); - } - if ocsp_staple.get() { - extensions["tls_feature"] = - json!({"critical": false, "features": ["status_request"]}); - } - match build_extra_extensions(&ext) { - Ok(extra) => { - if let Some(object) = extra.as_object() { - for (key, value) in object { - extensions[key] = value.clone(); - } - } - } + let subject = match build_subject(&form) { + Ok(subject) => subject, Err(message) => { error.set(Some(message)); return; } + }; + if subject.is_empty() { + error.set(Some("the subject must not be empty".to_string())); + return; } + let extensions = match build_extensions(&form) { + Ok(extensions) => extensions, + Err(message) => { + error.set(Some(message)); + return; + } + }; let signer = signer_id.get_clone(); let mut payload = json!({ "name": cert_name, @@ -487,17 +393,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { "extensions": extensions, "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, - "validity": if no_expiration.get() { - json!({"no_expiration": true}) - } else if validity_mode.get_clone() == "absolute" { - json!({}) - } else { - json!({ - "amount": validity_amount.get_clone().parse::().unwrap_or(365), - "unit": validity_unit.get_clone(), - "midnight": midnight.get(), - }) - }, + "validity": validity_value(&form), }); if validity_mode.get_clone() == "absolute" && !no_expiration.get() { payload["not_before"] = json!(not_before_text.get_clone()); @@ -544,77 +440,20 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let validate = { let id = id.clone(); move |_| { - let subject = build_subject(&[ - ("C", country.get_clone()), - ("ST", state.get_clone()), - ("L", locality.get_clone()), - ("O", organization.get_clone()), - ("OU", org_unit.get_clone()), - ("CN", cn.get_clone()), - ("emailAddress", email.get_clone()), - ]); - let mut extensions = json!({}); - let mode = ca_mode.get_clone(); - let crit = bc_critical.get(); - if mode == "ca" { - extensions["basic_constraints"] = json!({"critical": crit, "ca": true, "path_length": path_length.get_clone().parse::().ok()}); - } else if mode == "end" { - extensions["basic_constraints"] = - json!({"critical": crit, "ca": false, "path_length": null}); - } - let usages = [ - (ku_digital.get(), "digital_signature"), - (ku_encipher.get(), "key_encipherment"), - (ku_key_agreement.get(), "key_agreement"), - (ku_cert_sign.get(), "key_cert_sign"), - (ku_crl_sign.get(), "crl_sign"), - ] - .iter() - .filter(|(checked, _)| *checked) - .map(|(_, name)| json!(name)) - .collect::>(); - extensions["key_usage"] = json!({"critical": ku_critical.get(), "usages": usages}); - let eku = [ - (eku_server.get(), "serverAuth"), - (eku_client.get(), "clientAuth"), - (eku_code.get(), "codeSigning"), - (eku_email.get(), "emailProtection"), - (eku_time.get(), "timeStamping"), - (eku_ocsp.get(), "OCSPSigning"), - ] - .iter() - .filter(|(checked, _)| *checked) - .map(|(_, name)| json!(name)) - .collect::>(); - extensions["extended_key_usage"] = - json!({"critical": eku_critical.get(), "usages": eku}); - let san = parse_san(&san_text.get_clone()); - if !san.is_empty() { - extensions["subject_alt_name"] = json!({"critical": false, "entries": san}); - } - if ski.get() { - extensions["subject_key_identifier"] = json!({"critical": false}); - } - if aki.get() { - extensions["authority_key_identifier"] = json!({"critical": false}); - } - if ocsp_staple.get() { - extensions["tls_feature"] = - json!({"critical": false, "features": ["status_request"]}); - } - match build_extra_extensions(&ext) { - Ok(extra) => { - if let Some(object) = extra.as_object() { - for (key, value) in object { - extensions[key] = value.clone(); - } - } - } + let subject = match build_subject(&form) { + Ok(subject) => subject, Err(message) => { error.set(Some(message)); return; } - } + }; + let extensions = match build_extensions(&form) { + Ok(extensions) => extensions, + Err(message) => { + error.set(Some(message)); + return; + } + }; let signer = signer_id.get_clone(); let payload = json!({ "name": if name.get_clone().is_empty() { "preview".to_string() } else { name.get_clone() }, @@ -622,13 +461,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { "extensions": extensions, "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, - "validity": if no_expiration.get() { - json!({"no_expiration": true}) - } else if validity_mode.get_clone() == "absolute" { - json!({}) - } else { - json!({"amount": validity_amount.get_clone().parse::().unwrap_or(365), "unit": validity_unit.get_clone(), "no_expiration": false}) - }, + "validity": validity_value(&form), }); let mut payload = payload; if validity_mode.get_clone() == "absolute" && !no_expiration.get() { @@ -703,6 +536,20 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { } else { view! {} }) + (if let Some(default) = ca_default_template.get_clone() { + if default != template_id.get_clone() { + view! { + div(class="alert alert-info d-flex align-items-center justify-content-between") { + span() { (format!("CA default template: {}", ca_default_name.get_clone())) } + button(class="btn btn-sm btn-outline-light", on:click=apply_ca_default) { "Apply CA default" } + } + } + } else { + view! {} + } + } else { + view! {} + }) div(class="card") { div(class="card-body") { div(class="form-section") { @@ -731,6 +578,11 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { option(value="") { "— no template —" } (template_options()) } + select(class="form-select", style="max-width: 11rem", bind:value=template_apply_mode) { + option(value="all") { "Apply all" } + option(value="subject") { "Subject only" } + option(value="extensions") { "Extensions + validity" } + } button(class="btn btn-outline-light", on:click=apply_template) { "Apply" } } } @@ -747,13 +599,16 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { div(class="form-section") { div(class="form-section-title") { "Subject" } div(class="row g-3") { - (subject_input("Common name (CN)", cn)) - (subject_input("Organization (O)", organization)) - (subject_input("Organizational unit (OU)", org_unit)) - (subject_input("Country (C)", country)) - (subject_input("State / province (ST)", state)) - (subject_input("Locality (L)", locality)) - (subject_input("E-mail address", email)) + (subject_fields(&form)) + div(class="col-12") { + label(class="form-label") { "Additional subject attributes (JSON)" } + textarea(class="form-control mono", rows="2", + placeholder=r#"[{"name":"serialNumber","value":"1234"}]"#, + bind:value=subject_extra) {} + div(class="form-text") { + "Used for attributes not shown above; the order above follows the vault's explicit DN setting." + } + } } } (if request_mode { @@ -1058,13 +913,357 @@ pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View { } } -fn subject_input(label: &'static str, value: Signal) -> View { - view! { +fn subject_label(name: &str) -> Option<&'static str> { + match name { + "CN" => Some("Common name (CN)"), + "O" => Some("Organization (O)"), + "OU" => Some("Organizational unit (OU)"), + "C" => Some("Country (C)"), + "ST" => Some("State / province (ST)"), + "L" => Some("Locality (L)"), + "emailAddress" => Some("E-mail address"), + _ => None, + } +} + +fn subject_signal(name: &str, form: &CertFormSignals) -> Option> { + match name { + "CN" => Some(form.cn), + "O" => Some(form.organization), + "OU" => Some(form.org_unit), + "C" => Some(form.country), + "ST" => Some(form.state), + "L" => Some(form.locality), + "emailAddress" => Some(form.email), + _ => None, + } +} + +fn subject_field(name: &str, form: &CertFormSignals) -> Option { + let label = subject_label(name)?; + let value = subject_signal(name, form)?; + Some(view! { div(class="col-md-6") { label(class="form-label") { (label) } input(class="form-control", bind:value=value) } + }) +} + +fn subject_fields(form: &CertFormSignals) -> Vec { + let mut names = form.explicit_dn.get_clone(); + if names.is_empty() { + names = DEFAULT_EXPLICIT_DN.iter().map(|v| v.to_string()).collect(); } + names + .iter() + .filter_map(|name| subject_field(name, form)) + .collect() +} + +fn apply_subject_json(form: &CertFormSignals, subject: &Value) { + for signal in [ + form.cn, + form.organization, + form.org_unit, + form.country, + form.state, + form.locality, + form.email, + ] { + signal.set(String::new()); + } + let entries = subject.as_array().cloned().unwrap_or_default(); + let mut extras = Vec::new(); + for entry in entries { + let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() || value.is_empty() { + continue; + } + match subject_signal(name, form) { + Some(signal) if signal.get_clone().is_empty() => signal.set(value.to_string()), + _ => extras.push(entry), + } + } + if extras.is_empty() { + form.subject_extra.set(String::new()); + } else { + form.subject_extra + .set(serde_json::to_string_pretty(&Value::Array(extras)).unwrap_or_default()); + } +} + +fn extra_subject_entries(form: &CertFormSignals) -> Result, String> { + let text = form.subject_extra.get_clone(); + if text.trim().is_empty() { + return Ok(Vec::new()); + } + let value: Value = serde_json::from_str(&text) + .map_err(|_| "additional subject attributes must be valid JSON".to_string())?; + let entries = value + .as_array() + .ok_or_else(|| "additional subject attributes must be a JSON array".to_string())?; + let mut result = Vec::new(); + for entry in entries { + let name = entry + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let value = entry + .get("value") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if name.is_empty() { + return Err("each additional subject attribute needs a name".to_string()); + } + if value.is_empty() { + continue; + } + result.push(json!({"name": name, "value": value})); + } + Ok(result) +} + +fn build_subject(form: &CertFormSignals) -> Result, String> { + let extras = extra_subject_entries(form)?; + let mut used = vec![false; extras.len()]; + let mut result = Vec::new(); + let mut names = form.explicit_dn.get_clone(); + if names.is_empty() { + names = DEFAULT_EXPLICIT_DN.iter().map(|v| v.to_string()).collect(); + } + for name in names { + if let Some(signal) = subject_signal(&name, form) { + let value = signal.get_clone(); + if !value.trim().is_empty() { + result.push(json!({"name": name, "value": value.trim()})); + } + continue; + } + for (index, entry) in extras.iter().enumerate() { + if used[index] { + continue; + } + if entry.get("name").and_then(|v| v.as_str()) == Some(name.as_str()) { + used[index] = true; + result.push(entry.clone()); + break; + } + } + } + for (index, entry) in extras.into_iter().enumerate() { + if !used[index] { + result.push(entry); + } + } + Ok(result) +} + +fn build_extensions(form: &CertFormSignals) -> Result { + let mut extensions = json!({}); + let mode = form.ca_mode.get_clone(); + if mode == "ca" { + extensions["basic_constraints"] = json!({ + "critical": form.bc_critical.get(), + "ca": true, + "path_length": form.path_length.get_clone().parse::().ok(), + }); + } else if mode == "end" { + extensions["basic_constraints"] = + json!({"critical": form.bc_critical.get(), "ca": false, "path_length": null}); + } + let usages = [ + (form.ku_digital.get(), "digital_signature"), + (form.ku_encipher.get(), "key_encipherment"), + (form.ku_key_agreement.get(), "key_agreement"), + (form.ku_cert_sign.get(), "key_cert_sign"), + (form.ku_crl_sign.get(), "crl_sign"), + ] + .iter() + .filter(|(checked, _)| *checked) + .map(|(_, name)| json!(name)) + .collect::>(); + extensions["key_usage"] = json!({"critical": form.ku_critical.get(), "usages": usages}); + let eku = [ + (form.eku_server.get(), "serverAuth"), + (form.eku_client.get(), "clientAuth"), + (form.eku_code.get(), "codeSigning"), + (form.eku_email.get(), "emailProtection"), + (form.eku_time.get(), "timeStamping"), + (form.eku_ocsp.get(), "OCSPSigning"), + ] + .iter() + .filter(|(checked, _)| *checked) + .map(|(_, name)| json!(name)) + .collect::>(); + 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}); + } + if form.ski.get() { + extensions["subject_key_identifier"] = json!({"critical": false}); + } + if form.aki.get() { + extensions["authority_key_identifier"] = json!({"critical": false}); + } + if form.ocsp_staple.get() { + extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]}); + } + let extra = build_extra_extensions(&form.ext)?; + if let Some(object) = extra.as_object() { + for (key, value) in object { + extensions[key] = value.clone(); + } + } + Ok(extensions) +} + +fn validity_value(form: &CertFormSignals) -> Value { + if form.no_expiration.get() { + json!({"no_expiration": true}) + } else if form.validity_mode.get_clone() == "absolute" { + json!({}) + } else { + json!({ + "amount": form.validity_amount.get_clone().parse::().unwrap_or(365), + "unit": form.validity_unit.get_clone(), + "midnight": form.midnight.get(), + }) + } +} + +fn build_target(form: &CertFormSignals) -> Result { + let subject = build_subject(form)?; + let mut target = json!({ + "subject": subject, + "extensions": build_extensions(form)?, + "validity": validity_value(form), + }); + if form.validity_mode.get_clone() == "absolute" && !form.no_expiration.get() { + target["not_before"] = json!(form.not_before_text.get_clone()); + target["not_after"] = json!(form.not_after_text.get_clone()); + } + Ok(target) +} + +fn apply_validity_json(form: &CertFormSignals, value: &Value) { + if value.is_null() { + return; + } + let amount = value.get("amount").and_then(|v| v.as_i64()); + let unit = value.get("unit").and_then(|v| v.as_str()); + let midnight = value.get("midnight").and_then(|v| v.as_bool()); + let no_expiration = value + .get("no_expiration") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if let Some(amount) = amount { + form.validity_amount.set(amount.to_string()); + } + if let Some(unit) = unit { + form.validity_unit.set(unit.to_string()); + } + if let Some(midnight) = midnight { + form.midnight.set(midnight); + } + form.no_expiration.set(no_expiration); + if !no_expiration { + form.validity_mode.set("duration".to_string()); + } +} + +fn apply_extension_spec(form: &CertFormSignals, spec: &Value) { + load_extra_extensions(&form.ext, spec); + if let Some(bc) = spec.get("basic_constraints") { + if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) { + form.ca_mode.set("ca".to_string()); + form.path_length.set( + bc.get("path_length") + .and_then(|v| v.as_i64()) + .map(|v| v.to_string()) + .unwrap_or_default(), + ); + } else if bc.get("ca").is_some() { + form.ca_mode.set("end".to_string()); + form.path_length.set(String::new()); + } + form.bc_critical + .set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + } else { + form.ca_mode.set("none".to_string()); + } + if let Some(ku) = spec.get("key_usage") { + let usages = string_array(ku.get("usages")); + form.ku_digital + .set(usages.iter().any(|s| s == "digital_signature")); + form.ku_encipher + .set(usages.iter().any(|s| s == "key_encipherment")); + form.ku_key_agreement + .set(usages.iter().any(|s| s == "key_agreement")); + form.ku_cert_sign + .set(usages.iter().any(|s| s == "key_cert_sign")); + form.ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign")); + form.ku_critical + .set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + } + if let Some(eku) = spec.get("extended_key_usage") { + let usages = string_array(eku.get("usages")); + form.eku_server + .set(usages.iter().any(|s| s == "serverAuth")); + form.eku_client + .set(usages.iter().any(|s| s == "clientAuth")); + form.eku_code.set(usages.iter().any(|s| s == "codeSigning")); + form.eku_email + .set(usages.iter().any(|s| s == "emailProtection")); + form.eku_time + .set(usages.iter().any(|s| s == "timeStamping")); + form.eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning")); + form.eku_critical.set( + eku.get("critical") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ); + } + if let Some(san) = spec.get("subject_alt_name") { + let entries = san + .get("entries") + .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); + } else { + form.san_text.set(String::new()); + } + form.ski.set(spec.get("subject_key_identifier").is_some()); + form.aki.set(spec.get("authority_key_identifier").is_some()); + let features = spec + .get("tls_feature") + .and_then(|v| v.get("features")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + form.ocsp_staple.set( + features + .iter() + .any(|v| v.as_str() == Some("status_request")), + ); } fn check(label: &'static str, value: Signal) -> View { @@ -1076,14 +1275,6 @@ fn check(label: &'static str, value: Signal) -> View { } } -fn build_subject(fields: &[(&str, String)]) -> Vec { - fields - .iter() - .filter(|(_, value)| !value.trim().is_empty()) - .map(|(name, value)| json!({"name": name, "value": value.trim()})) - .collect() -} - fn parse_san(text: &str) -> Vec { text.split(',') .map(|chunk| chunk.trim()) diff --git a/web/src/pages/certificates.rs b/web/src/pages/certificates.rs index 40fbb6c..f94ded4 100644 --- a/web/src/pages/certificates.rs +++ b/web/src/pages/certificates.rs @@ -1,12 +1,16 @@ //! Certificate list, flat or as an issuer tree. use std::collections::HashMap; +use serde_json::json; + use sycamore::prelude::*; +use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use crate::api; use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::ui::{page_header, toast_error}; +use crate::dom; use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me}; pub fn CertificatesPage(vault_id: String) -> View { @@ -17,6 +21,11 @@ pub fn CertificatesPage(vault_id: String) -> View { let loading = create_signal(true); let filter = create_signal(String::new()); let plain = create_signal(false); + let selected = create_signal(Vec::::new()); + let bulk_reason = create_signal("unspecified".to_string()); + let bulk_invalidity = create_signal(String::new()); + let bulk_busy = create_signal(false); + let info = create_signal(None::); spawn_local(async move { match api::me().await { @@ -50,6 +59,97 @@ pub fn CertificatesPage(vault_id: String) -> View { } }); + let refresh = move || { + spawn_local(async move { + if let Ok(page) = api::certificates(id).await { + if certificates.is_alive() { + certificates.set(page.results); + } + } + }); + }; + + let toggle = move |event: web_sys::Event| { + if let Some(target) = event + .target() + .and_then(|t| t.dyn_into::().ok()) + { + if let Some(cert_id) = target.get_attribute("data-cert-id") { + let mut current = selected.get_clone(); + if target.checked() { + if !current.contains(&cert_id) { + current.push(cert_id); + } + } else { + current.retain(|value| value != &cert_id); + } + selected.set(current); + } + } + }; + let select_all = move |_| { + let all = certificates + .get_clone() + .into_iter() + .filter(|cert| cert.issuer_id.is_some()) + .map(|cert| cert.id) + .collect::>(); + selected.set(all); + }; + let clear_selection = move |_| selected.set(Vec::new()); + let bulk_revoke = move |_| { + let ids = selected.get_clone(); + if ids.is_empty() { + error.set(Some("select at least one certificate".to_string())); + return; + } + if !dom::confirm(&format!("Revoke {} selected certificate(s)?", ids.len())) { + return; + } + let payload = json!({ + "certificate_ids": ids, + "reason": bulk_reason.get_clone(), + "invalidity_date": if bulk_invalidity.get_clone().is_empty() { + serde_json::Value::Null + } else { + json!(bulk_invalidity.get_clone()) + }, + }); + bulk_busy.set(true); + spawn_local(async move { + match api::bulk_revoke_certificates(id, &payload).await { + Ok(value) => { + let revoked = value + .get("revoked") + .and_then(|v| v.as_array()) + .map(|v| v.len()) + .unwrap_or(0); + let skipped = value + .get("skipped") + .and_then(|v| v.as_array()) + .map(|v| v.len()) + .unwrap_or(0); + let errors = value + .get("errors") + .and_then(|v| v.as_array()) + .map(|v| v.len()) + .unwrap_or(0); + info.set(Some(format!( + "Revoked {revoked} certificate(s); {skipped} already revoked; {errors} error(s)." + ))); + selected.set(Vec::new()); + bulk_invalidity.set(String::new()); + refresh(); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + if bulk_busy.is_alive() { + bulk_busy.set(false); + } + }); + }; + let content = move || { if loading.get() { return spinner(); @@ -67,10 +167,11 @@ pub fn CertificatesPage(vault_id: String) -> View { || cert.fingerprint_sha256.to_lowercase().contains(&query) }) .collect(); + let chosen = selected.get_clone(); let html = if plain.get() { - flat_html(&visible, id) + flat_html(&visible, id, &chosen) } else { - tree_html(&visible, id) + tree_html(&visible, id, &chosen) }; view! { (page_header( @@ -90,7 +191,31 @@ pub fn CertificatesPage(vault_id: String) -> View { bind:value=filter) } } - div(class="card", dangerously_set_inner_html=html) {} + div(class="card mb-3") { + div(class="card-body") { + div(class="d-flex flex-wrap gap-2 align-items-end") { + div(class="me-auto") { + div(class="small text-secondary") { (format!("{} selected", selected.get_clone().len())) } + div(class="d-flex gap-2 mt-1") { + button(class="btn btn-sm btn-outline-light", on:click=select_all) { "Select all revocable" } + button(class="btn btn-sm btn-outline-light", on:click=clear_selection) { "Clear" } + } + } + div() { + label(class="form-label small mb-1") { "Reason" } + select(class="form-select form-select-sm", bind:value=bulk_reason) { (reason_options()) } + } + div() { + label(class="form-label small mb-1") { "Invalid since (optional)" } + input(class="form-control form-control-sm", r#type="datetime-local", bind:value=bulk_invalidity) + } + button(class="btn btn-warning", disabled=bulk_busy.get() || selected.get_clone().is_empty(), on:click=bulk_revoke) { + (if bulk_busy.get() { "Revoking…" } else { "Revoke selected" }) + } + } + } + } + div(class="card", on:change=toggle, dangerously_set_inner_html=html) {} } }; @@ -103,12 +228,13 @@ pub fn CertificatesPage(vault_id: String) -> View { (app_nav("/", Some(id), nav)) main(class="container page-shell py-4") { (toast_error(error)) + (crate::components::toast::toast(info, true)) (content) } } } -fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String { +fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { if rows.is_empty() { return "

No certificates match.

".to_string(); } @@ -116,7 +242,8 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String { for cert in rows { let (badge_class, badge_label) = status_badge(cert); body.push_str(&format!( - "{}
{}
{}{}{}{}{}{}{}", + "{}{}
{}
{}{}{}{}{}{}{}", + checkbox(cert, selected), escape(vault_id), escape(&cert.id), escape(&cert.name), @@ -132,11 +259,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String { )); } format!( - "
{body}
NameIssuerTypeSerialExpiresStatusSHA-256
" + "
{body}
NameIssuerTypeSerialExpiresStatusSHA-256
" ) } -fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String { +fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { if rows.is_empty() { return "

No certificates match.

".to_string(); } @@ -157,7 +284,7 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String { roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); let mut html = String::from("
    "); for root in &roots { - html.push_str(&node_html(root, &children, vault_id)); + html.push_str(&node_html(root, &children, vault_id, selected)); } html.push_str("
"); html @@ -167,6 +294,7 @@ fn node_html( cert: &CertificateSummary, children: &HashMap>, vault_id: &str, + selected: &[String], ) -> String { let (badge_class, badge_label) = status_badge(cert); let sub = if cert.subject == cert.issuer_name { @@ -175,7 +303,8 @@ fn node_html( format!("issuer: {}", cert.issuer_name) }; let mut row = format!( - "
{}{} {}{}expires {}{}
", + "
{}{}{} {}{}expires {}{}
", + checkbox(cert, selected), escape(vault_id), escape(&cert.id), escape(&cert.name), @@ -183,7 +312,7 @@ fn node_html( badge_class, badge_label, escape(&sub), - fmt_date(&cert.not_after), + if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) }, fmt_fp(&cert.fingerprint_sha256), ); let kids = children.get(&cert.id); @@ -191,7 +320,7 @@ fn node_html( if !kids.is_empty() { row = format!( "
  • {row}
      {}
  • ", - kids.iter().map(|child| node_html(child, children, vault_id)).collect::() + kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::() ); } else { row = format!("
  • {row}
  • "); @@ -202,6 +331,38 @@ fn node_html( row } +fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String { + if cert.issuer_id.is_none() { + return "".to_string(); + } + let checked = if selected.contains(&cert.id) { + " checked" + } else { + "" + }; + format!( + "", + escape(&cert.id), + checked + ) +} + +fn reason_options() -> Vec { + [ + "unspecified", + "keyCompromise", + "CACompromise", + "affiliationChanged", + "superseded", + "cessationOfOperation", + "certificateHold", + "privilegeWithdrawn", + ] + .iter() + .map(|reason| view! { option(value=*reason) { (*reason) } }) + .collect::>() +} + fn escape(value: &str) -> String { value .replace('&', "&")