From 5eb7866f5bbbad3076c122d5506c5fb86048b418 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 23 Oct 2026 04:26:35 +0000 Subject: [PATCH] Close XCA parity gaps: advanced extensions, CA/revocation UI, imports, ZIP export, E2E --- .dockerignore | 4 + README.md | 38 +- e2e/.gitignore | 3 + e2e/README.md | 29 + e2e/package-lock.json | 60 +++ e2e/package.json | 12 + e2e/playwright.config.js | 22 + e2e/tests/advanced.spec.js | 62 +++ e2e/tests/core.spec.js | 96 ++++ ...0261023030253_certificate_no_expiration.py | 86 +++ ...2_20261023030349_authority_template_key.py | 86 +++ server/packages/kca-app/src/kca/models.py | 6 + .../kca-app/src/kca/routes/certificates.py | 44 +- .../packages/kca-app/src/kca/routes/vaults.py | 89 +++- server/packages/kca-app/src/kca/service.py | 110 +++- server/tests/test_routes_extra.py | 113 ++++ web/src/api.rs | 15 + web/src/components/extension_form.rs | 360 +++++++++++++ web/src/components/mod.rs | 1 + web/src/main.rs | 5 +- web/src/model.rs | 4 +- web/src/pages/certificate_detail.rs | 146 +++++- web/src/pages/certificate_new.rs | 494 ++++++++++++++++-- web/src/pages/certificates.rs | 13 +- web/src/pages/import.rs | 126 ++++- web/src/pages/template_editor.rs | 132 ++++- web/src/pages/vault.rs | 32 +- 27 files changed, 2054 insertions(+), 134 deletions(-) create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.js create mode 100644 e2e/tests/advanced.spec.js create mode 100644 e2e/tests/core.spec.js create mode 100644 server/migrations/models/1_20261023030253_certificate_no_expiration.py create mode 100644 server/migrations/models/2_20261023030349_authority_template_key.py create mode 100644 web/src/components/extension_form.rs diff --git a/.dockerignore b/.dockerignore index fad8231..b6264b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,7 @@ web/.trunk **/__pycache__ *.pyc *.log + +e2e/node_modules +e2e/test-results +e2e/playwright-report diff --git a/README.md b/README.md index 3d281a8..4252aa1 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,20 @@ explicit/mandatory DN entries, default key type/size, default validity). 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 - OID/DER extensions. OpenSSL config text is available on export. + OID/DER extensions. The SPA exposes typed controls for the common and + 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. - Renew with a new or kept serial, optionally revoke/replace the old - certificate. Revoke with a reason; manage the CA's revocation list manually. + 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. +- 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. - Export PEM, PEM chain, PEM+key, PEM+encrypted PKCS#8, PKCS#7, PKCS#7 chain, DER, PKCS#12, PKCS#12 chain, OpenVPN, OpenSSL config, certificate index, @@ -113,7 +121,11 @@ server-side. - Paste PEM or upload files: PEM bundles, DER certificates/requests/CRLs/keys, PKCS#7, PKCS#12, SSH keys and XCA templates. -- Preview imports before committing; duplicates are skipped. +- Preview imports with per-item selection before committing; duplicates are + skipped by default. +- Dump a whole vault as a ZIP (`POST /api/vaults/{id}/export`): manifest, + certificate/request/CRL PEM, public keys and optionally encrypted private + keys. - Import XCA SQLite `.xdb` databases (schema 7/8): software keys, certificates, requests, CRLs, revocations, authorities and templates. PKCS#11 token items are skipped, and private keys encrypted with individual passwords are only @@ -133,10 +145,10 @@ OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`. | Area | Endpoints | |---|---| | Identity | `GET /api/health`, `GET /api/me`, `GET /api/auth/providers`, `GET/POST/DELETE /api/tokens` | -| 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` | +| 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`, `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`, `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`) | @@ -159,6 +171,18 @@ uv run mypy -p kca.core -p kca Tests run in-process with in-memory sqlite, the in-memory session store and a fake OIDC user, exactly like the spendwise reference app. +Browser end-to-end tests (from the repository root, with the Compose stack up): + +```sh +cd e2e +npm install +npx playwright install chromium +npm test +``` + +They use Playwright Chromium. Set `KCA_BASE_URL` to target another port, e.g. +`KCA_BASE_URL=http://localhost:8083 npm test`. See `e2e/README.md`. + Frontend (from `web/`, with the backend running on `:8080`): ```sh @@ -189,6 +213,7 @@ web/ src/components/ # nav, toast, shared UI src/{api,auth,model,dom}.rs pwa/ # manifest + app-shell assets +e2e/ # Playwright browser suite (Node) ``` ## Limits @@ -202,7 +227,8 @@ web/ output is not available. Legacy inputs are imported when the library supports them. - XCA's OpenSSL-config "Advanced" text editor is replaced by typed extension - fields plus raw OID/DER rows. OpenSSL config remains an export format. + fields plus an advanced JSON editor carrying raw OID/DER and other less + common extensions. OpenSSL config remains an export format. - The PWA service worker is intentionally not registered: certificate and key data must never be served stale from a cache. The manifest and icons are still available for installation. diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..945fcd0 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..dd2c3f8 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,29 @@ +# KCA browser end-to-end tests + +Playwright tests that exercise the core PKI workflow through the real SPA and +API. + +Prerequisites: + +```sh +# from the repository root +docker compose up --build +# add the mock IdP host entry once: +echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts + +cd e2e +npm install +npx playwright install chromium +npm test +``` + +The default base URL is `http://localhost:8080`. Override it with +`KCA_BASE_URL`, e.g. when testing the Trunk dev server on `:8000` or a +non-default Compose port: + +```sh +KCA_BASE_URL=http://localhost:8083 npm test +``` + +The tests use unique usernames, so repeated runs do not collide. The mock OIDC +provider accepts any username. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..0405852 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "kca-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kca-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.64.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..f352571 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "kca-e2e", + "private": true, + "version": "0.1.0", + "description": "Browser end-to-end tests for KCA", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.64.0" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..b1c9f55 --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,22 @@ +// Playwright configuration for the KCA browser suite. +// +// The default target is the Docker Compose stack on :8080. Point +// KCA_BASE_URL at another port when testing a local Trunk/Granian setup. +const { defineConfig } = require("@playwright/test"); + +const baseURL = process.env.KCA_BASE_URL || "http://localhost:8080"; + +module.exports = defineConfig({ + testDir: "./tests", + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [["list"]], + use: { + baseURL, + headless: true, + ignoreHTTPSErrors: true, + trace: "retain-on-failure", + }, +}); diff --git a/e2e/tests/advanced.spec.js b/e2e/tests/advanced.spec.js new file mode 100644 index 0000000..8d40613 --- /dev/null +++ b/e2e/tests/advanced.spec.js @@ -0,0 +1,62 @@ +// Advanced extensions, preview/validation and vault ZIP export. +const { test, expect } = require("@playwright/test"); + +function unique(prefix) { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; +} + +async function field(page, label) { + return page.locator("label").filter({ hasText: label }).first().locator("..").locator("input, select, textarea").first(); +} + +async function login(page, username) { + await page.goto("/login"); + await page.getByRole("link", { name: "Sign in with default" }).click(); + await page.getByPlaceholder("Enter any user/subject").fill(username); + await page.getByRole("button", { name: "Sign-in" }).click(); + await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible(); +} + +test("advanced extensions, validation and vault export", async ({ page }) => { + const user = unique("advanced"); + const vaultName = unique("Advanced"); + const certName = unique("advanced.example"); + + await login(page, user); + await page.getByRole("button", { name: "New vault" }).click(); + await page.getByPlaceholder("Production PKI").fill(vaultName); + await page.getByRole("button", { name: "Create vault" }).click(); + await page.getByRole("link", { name: vaultName }).click(); + const vaultUrl = page.url(); + + await page.goto(`${vaultUrl}/certificates/new`); + await expect(page.getByRole("heading", { name: "New certificate" })).toBeVisible(); + await (await field(page, "Internal name")).fill(certName); + await (await field(page, "Common name (CN)")).fill(certName); + await (await field(page, "Organization (O)")).fill("KCA E2E"); + await (await field(page, "Country (C)")).fill("DE"); + await page.locator("#new-key").check(); + await page.locator("textarea[placeholder='http://example.com/ca.crl']").fill("http://example.com/ca.crl"); + await page.locator("textarea[placeholder='http://ocsp.example.com']").fill("http://ocsp.example.com"); + await page.locator("textarea[placeholder*='raw']").fill( + '{"raw":[{"oid":"1.2.3.4","critical":false,"der_hex":"0c03616263"}]}' + ); + + await page.getByRole("button", { name: "Validate extensions" }).click(); + await expect(page.getByText("Extension preview")).toBeVisible(); + await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible(); + await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible(); + + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByRole("heading", { name: certName })).toBeVisible(); + await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible(); + await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible(); + + // ZIP export prompts for the private-key password. + await page.goto(vaultUrl); + page.once("dialog", (dialog) => dialog.accept("secret")); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export ZIP" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.zip$/); +}); diff --git a/e2e/tests/core.spec.js b/e2e/tests/core.spec.js new file mode 100644 index 0000000..b214a85 --- /dev/null +++ b/e2e/tests/core.spec.js @@ -0,0 +1,96 @@ +// End-to-end smoke test for the KCA PKI workflow. +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).toHaveURL(/\/$/); + await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible(); +} + +test("core PKI workflow", async ({ page }) => { + const user = unique("e2e"); + const vaultName = unique("Vault"); + const caName = unique("E2E CA"); + const leafName = unique("www.e2e.example"); + + await login(page, user); + + // Vault + 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(); + await expect(page.getByRole("heading", { name: vaultName })).toBeVisible(); + const vaultUrl = page.url(); + + // Key + await page.getByRole("link", { name: "New key" }).click(); + await page.getByPlaceholder("webserver-key").fill(`${caName} key`); + await page.getByRole("button", { name: "Generate key" }).click(); + await expect(page.getByRole("heading", { name: `${caName} key` })).toBeVisible(); + + // CA certificate + await page.goto(`${vaultUrl}/certificates/new`); + await expect(page.getByRole("heading", { name: "New certificate" })).toBeVisible(); + await (await field(page, "Internal name")).fill(caName); + await (await field(page, "Common name (CN)")).fill(caName); + await (await field(page, "Organization (O)")).fill("KCA E2E"); + await (await field(page, "Country (C)")).fill("DE"); + await page.locator("#new-key").check(); + await (await field(page, "Basic constraints")).selectOption("ca"); + await page.locator("label").filter({ hasText: "Certificate sign" }).locator("..").locator("input[type=checkbox]").check(); + await page.locator("label").filter({ hasText: "CRL sign" }).locator("..").locator("input[type=checkbox]").check(); + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByText("CA certificate", { exact: false })).toBeVisible(); + const caUrl = page.url(); + + // Leaf signed by the CA + await page.goto(`${vaultUrl}/certificates/new`); + await (await field(page, "Internal name")).fill(leafName); + await (await field(page, "Issuer")).selectOption({ label: caName }); + await (await field(page, "Common name (CN)")).fill(leafName); + await (await field(page, "Organization (O)")).fill("KCA E2E"); + await (await field(page, "Country (C)")).fill("DE"); + await page.locator("#new-key").check(); + 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 expect(page.getByText("serverAuth", { exact: false })).toBeVisible(); + + // Search + await page.goto(`${vaultUrl}/search`); + await page.locator("input[placeholder='Search…']").fill(leafName); + await page.getByRole("button", { name: "Search" }).click(); + await expect(page.getByRole("link", { name: leafName, exact: true })).toBeVisible(); + + // Revoke + await page.getByRole("link", { name: leafName, 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.getByRole("button", { name: "Generate CRL" }).click(); + await expect(page).toHaveURL(/\/crls\//); + await expect(page.getByText(/CRL number \d+ · 1 revoked/)).toBeVisible(); + + + // API token + await page.goto("/tokens"); + await page.getByPlaceholder("CI uploader").fill(unique("token")); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByText("Token created", { exact: false })).toBeVisible(); +}); diff --git a/server/migrations/models/1_20261023030253_certificate_no_expiration.py b/server/migrations/models/1_20261023030253_certificate_no_expiration.py new file mode 100644 index 0000000..2e51ea6 --- /dev/null +++ b/server/migrations/models/1_20261023030253_certificate_no_expiration.py @@ -0,0 +1,86 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "certificate" ADD "no_expiration" BOOL NOT NULL DEFAULT False;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "certificate" DROP COLUMN "no_expiration";""" + + +MODELS_STATE = ( + "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" + "ndP8Y6VR4uZJNahv/wcN6vu0" +) diff --git a/server/migrations/models/2_20261023030349_authority_template_key.py b/server/migrations/models/2_20261023030349_authority_template_key.py new file mode 100644 index 0000000..253e122 --- /dev/null +++ b/server/migrations/models/2_20261023030349_authority_template_key.py @@ -0,0 +1,86 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "authority" ADD "template_key" VARCHAR(64);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "authority" DROP COLUMN "template_key";""" + + +MODELS_STATE = ( + "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" + "CyviyPmclMn+6ZzmHyuNEjdPqkF9+z+XiHCY" +) diff --git a/server/packages/kca-app/src/kca/models.py b/server/packages/kca-app/src/kca/models.py index 5aca958..bdb37f0 100644 --- a/server/packages/kca-app/src/kca/models.py +++ b/server/packages/kca-app/src/kca/models.py @@ -152,6 +152,9 @@ class Certificate(Model): issuer_hash = fields.CharField(max_length=64, db_index=True) not_before = fields.DatetimeField() not_after = fields.DatetimeField() + # True when the certificate intentionally has no well-defined expiry + # (the ASN.1 notAfter is a far-future sentinel, XCA's "no expiration"). + no_expiration = fields.BooleanField(default=False) is_ca = fields.BooleanField(default=False, db_index=True) path_length = fields.IntField(null=True) cert_der = fields.BinaryField() @@ -185,6 +188,9 @@ class Authority(Model): template: fields.ForeignKeyRelation["Template"] = fields.ForeignKeyField( "models.Template", related_name="authorities", on_delete=fields.SET_NULL, null=True ) + # A built-in template key (e.g. "builtin:ca"); mutually exclusive with + # the user-template FK above. + template_key = fields.CharField(max_length=64, null=True) crl_expire = fields.DatetimeField(null=True) crl_number = fields.IntField(default=0) crl_days = fields.IntField(default=30) diff --git a/server/packages/kca-app/src/kca/routes/certificates.py b/server/packages/kca-app/src/kca/routes/certificates.py index d4109f3..45f03a8 100644 --- a/server/packages/kca-app/src/kca/routes/certificates.py +++ b/server/packages/kca-app/src/kca/routes/certificates.py @@ -28,6 +28,7 @@ from ..service import ( generate_crl, issue_certificate, load_private_key, + preview_certificate, parse_datetime, parse_uuid, renew_certificate, @@ -96,6 +97,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/preview") + @operation(summary="Preview/validate a certificate payload", tags=["certificates"]) + @require_auth(kca.oidc) + async def preview(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 preview_certificate(vault, body) + except (ValidationError, ValueError) as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, result) + @app.GET("/api/vaults/${vault_id}/certificates/${cert_id}") @operation(summary="Certificate detail", tags=["certificates"]) @require_auth(kca.oidc) @@ -273,7 +293,7 @@ def register(app: KayaApp, kca: "Kca") -> None: "crl_days": authority.crl_days, "crl_number": authority.crl_number, "crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None, - "default_template_id": str(authority.template_id) if authority.template_id else None, + "default_template": authority.template_key or (str(authority.template_id) if authority.template_id else None), "has_tls_auth_key": bool(authority.tls_auth_key), }) @@ -309,20 +329,30 @@ def register(app: KayaApp, kca: "Kca") -> None: if "template_id" in body: template_id = body.get("template_id") if template_id: - template = await Template.get_or_none(id=parse_uuid(str(template_id)), vault_id=vault.id) - if template is None: - await send_error(ctx, 400, "template not found") - return - authority.template_id = template.id + text = str(template_id) + if text.startswith("builtin:"): + if text not in {item.id for item in template_core.builtin_templates()}: + await send_error(ctx, 400, "unknown built-in template") + return + authority.template_key = text + authority.template_id = None + else: + template = await Template.get_or_none(id=parse_uuid(text), vault_id=vault.id) + if template is None: + await send_error(ctx, 400, "template not found") + return + authority.template_id = template.id + authority.template_key = None else: authority.template_id = None + authority.template_key = None await authority.save() await send_json(ctx, 200, { "certificate_id": str(cert.id), "crl_days": authority.crl_days, "crl_number": authority.crl_number, "crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None, - "default_template_id": str(authority.template_id) if authority.template_id else None, + "default_template": authority.template_key or (str(authority.template_id) if authority.template_id else None), "has_tls_auth_key": bool(authority.tls_auth_key), }) diff --git a/server/packages/kca-app/src/kca/routes/vaults.py b/server/packages/kca-app/src/kca/routes/vaults.py index b8586c1..a60b247 100644 --- a/server/packages/kca-app/src/kca/routes/vaults.py +++ b/server/packages/kca-app/src/kca/routes/vaults.py @@ -1,6 +1,10 @@ """Vaults (XCA databases) and their settings.""" from __future__ import annotations +import base64 +import io +import json +import zipfile from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Dict, List, Optional @@ -15,6 +19,7 @@ from ..service import ( DEFAULT_VAULT_SETTINGS, ValidationError, iso, + load_private_key, merge_settings, parse_uuid, serialize_certificate_summary, @@ -27,11 +32,13 @@ from ..service import ( vault_for_owner, vault_settings, ) +from ..core import certificates as cert_core from ..core import crls as crl_core -from ..core import templates as template_core from ..core import extensions as ext_core from ..core import keys as key_core from ..core import names as name_core +from ..core import requests as req_core +from ..core import templates as template_core if TYPE_CHECKING: from ..mixin import Kca @@ -219,6 +226,86 @@ def register(app: KayaApp, kca: "Kca") -> None: "recent_keys": [await serialize_key_summary(k) for k in recent_keys], }) + @app.POST("/api/vaults/${vault_id}/export") + @operation(summary="Export the whole vault as a ZIP", tags=["vaults"]) + @require_auth(kca.oidc) + async def export_vault(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(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 + password = body.get("password") + if password is not None and not isinstance(password, str): + await send_error(ctx, 400, "password must be a string") + return + used: set[str] = set() + + def safe(name: str, suffix: str) -> str: + base = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in name).strip("._") or "item" + candidate = f"{base}{suffix}" + index = 2 + while candidate.lower() in used: + candidate = f"{base}_{index}{suffix}" + index += 1 + used.add(candidate.lower()) + return candidate + + buffer = io.BytesIO() + manifest = { + "format": "kca-vault", + "version": 1, + "exported_at": datetime.now(timezone.utc).isoformat(), + "vault": {"id": str(vault.id), "name": vault.name, "description": vault.description}, + "items": [], + "warnings": [], + "private_keys_encrypted": bool(password), + } + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for key in await Key.filter(vault_id=vault.id).order_by("name"): + public = key_core.public_key_from_der(bytes(key.public_der)) + public_name = safe(key.name, ".pub") + archive.writestr(f"keys/{public_name}", key_core.public_pem(public)) + if key.has_private and password: + try: + private = await load_private_key(key) + private_name = safe(key.name, ".key") + archive.writestr(f"keys/{private_name}", key_core.private_pem(private, password=password)) + except Exception: # noqa: BLE001 - keep the rest of the dump + manifest["warnings"].append(f"could not export private key {key.name!r}") + manifest["items"].append({"kind": "key", "id": str(key.id), "name": key.name, "has_private": key.has_private}) + for cert in await Certificate.filter(vault_id=vault.id).order_by("name"): + parsed = cert_core.parse_certificate(bytes(cert.cert_der)) + archive.writestr(f"certificates/{safe(cert.name, '.crt')}", cert_core.certificate_pem(parsed)) + manifest["items"].append({"kind": "certificate", "id": str(cert.id), "name": cert.name, "subject": parsed.subject.rfc4514_string()}) + for request in await Request.filter(vault_id=vault.id).order_by("name"): + parsed = req_core.parse_csr(bytes(request.req_der)) + archive.writestr(f"requests/{safe(request.name, '.csr')}", req_core.csr_pem(parsed)) + manifest["items"].append({"kind": "request", "id": str(request.id), "name": request.name}) + for crl in await Crl.filter(vault_id=vault.id).order_by("name"): + parsed = crl_core.parse_crl(bytes(crl.crl_der)) + archive.writestr(f"crls/{safe(crl.name, '.crl')}", crl_core.crl_pem(parsed)) + manifest["items"].append({"kind": "crl", "id": str(crl.id), "name": crl.name}) + for template in await Template.filter(vault_id=vault.id).order_by("name"): + data = template_core.template_to_xca_data({ + "name": template.name, + "comment": template.comment, + "subject": template.subject, + "settings": template.settings, + }) + archive.writestr(f"templates/{safe(template.name, '.xca')}", data) + manifest["items"].append({"kind": "template", "id": str(template.id), "name": template.name}) + archive.writestr("manifest.json", json.dumps(manifest, indent=2)) + body_bytes = buffer.getvalue() + await send_json(ctx, 200, { + "filename": f"{safe(vault.name, '.zip')}", + "content_type": "application/zip", + "content_base64": base64.b64encode(body_bytes).decode("ascii"), + }) + @app.GET("/api/vaults/${vault_id}/options") @operation(summary="Reference data for editors", tags=["vaults"]) @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 83d4eee..3208a42 100644 --- a/server/packages/kca-app/src/kca/service.py +++ b/server/packages/kca-app/src/kca/service.py @@ -193,6 +193,7 @@ async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]: "serial_hex": cert.serial_hex, "not_before": iso(cert.not_before), "not_after": iso(cert.not_after), + "no_expiration": cert.no_expiration, "is_ca": cert.is_ca, "path_length": cert.path_length, "has_private_key": bool(cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()), @@ -270,7 +271,10 @@ async def serialize_certificate_detail(cert: Certificate, vault: Vault) -> Dict[ "crl_expire": iso(authority.crl_expire) if authority else None, "crl_number": authority.crl_number if authority else None, "crl_days": authority.crl_days if authority else None, - "default_template_id": str(authority.template_id) if authority and authority.template_id else None, + "default_template": ( + authority.template_key + or (str(authority.template_id) if authority.template_id else None) + ), "has_tls_auth_key": bool(authority and authority.tls_auth_key), } if authority @@ -560,14 +564,16 @@ def parse_extensions(payload: Any) -> Dict[str, Any]: return payload -def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime]: +def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime, bool]: now = datetime.now(timezone.utc) not_before = parse_datetime(payload.get("not_before") or now.isoformat(), "not_before") - raw_after = payload.get("not_after") - if raw_after: - not_after = parse_datetime(raw_after, "not_after") + validity = payload.get("validity") or {} + no_expiration = bool(validity.get("no_expiration")) + if no_expiration: + not_after = cert_core.FAR_FUTURE + elif payload.get("not_after"): + not_after = parse_datetime(payload["not_after"], "not_after") else: - validity = payload.get("validity") or {} amount = int(validity.get("amount", 365)) unit = str(validity.get("unit", "days")) if unit == "days": @@ -578,12 +584,12 @@ def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime]: not_after = not_before + timedelta(days=amount * 365) else: raise ValidationError("validity unit must be days, months or years") - if validity.get("midnight"): - not_before = not_before.replace(hour=0, minute=0, second=0, microsecond=0) - not_after = not_after.replace(hour=23, minute=59, second=59, microsecond=0) + if validity.get("midnight") and not no_expiration: + not_before = not_before.replace(hour=0, minute=0, second=0, microsecond=0) + not_after = not_after.replace(hour=23, minute=59, second=59, microsecond=0) if not_after <= not_before: raise ValidationError("not_after must be later than not_before") - return not_before, not_after + return not_before, not_after, no_expiration async def unique_serial(vault: Vault, issuer: Optional[Certificate], bits: int) -> int: @@ -621,6 +627,20 @@ async def find_request(vault: Vault, request_id: Optional[str]) -> Optional[Requ return await Request.get_or_none(id=uuid, vault_id=vault.id) +def check_mandatory_dn(subject: x509.Name, settings_obj: Mapping[str, Any]) -> None: + mandatory = settings_obj.get("mandatory_dn") or [] + missing = [] + for name in mandatory: + try: + oid = name_core.resolve_oid(str(name)) + except ValueError: + continue + if not subject.get_attributes_for_oid(x509.oid.ObjectIdentifier(oid)): + missing.append(str(name)) + if missing: + raise ValidationError("missing mandatory subject entries: " + ", ".join(missing)) + + async def issue_certificate( vault: Vault, payload: Mapping[str, Any], @@ -692,8 +712,9 @@ async def issue_certificate( if request_obj is not None and payload.get("copy_request_extensions"): extensions = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions) - not_before, not_after = validate_validity(payload) + not_before, not_after, no_expiration = validate_validity(payload) settings_obj = vault_settings(vault) + check_mandatory_dn(subject, settings_obj) bits = int(settings_obj.get("serial_len_bits", 64)) raw_serial = payload.get("serial") or payload.get("serial_hex") or 0 try: @@ -748,6 +769,7 @@ async def issue_certificate( issuer_hash=info.issuer_hash, not_before=info.not_before, not_after=info.not_after, + no_expiration=no_expiration, is_ca=info.is_ca, path_length=info.path_length, cert_der=cert_core.certificate_der(cert), @@ -784,6 +806,67 @@ async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None: await cert.delete() +async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]: + """Build the extension list for a wizard payload without persisting it.""" + request_obj = await find_request(vault, payload.get("request_id")) + if payload.get("request_id") and request_obj is None: + raise ValidationError("signing request not found") + signer = await find_certificate(vault, payload.get("signer_id")) + if payload.get("signer_id") and signer is None: + raise ValidationError("signing certificate not found") + if signer is not None and not signer.is_ca: + raise ValidationError("the signing certificate is not a CA") + + if request_obj is not None and not payload.get("modify_subject"): + subject = req_core.parse_csr(bytes(request_obj.req_der)).subject + else: + subject = parse_subject(payload.get("subject") or []) + if not subject.rdns: + raise ValidationError("the subject must not be empty") + check_mandatory_dn(subject, vault_settings(vault)) + + public_key = None + key_model = await find_key(vault, payload.get("key_id")) + if key_model is not None: + public_key = key_core.public_key_from_der(bytes(key_model.public_der)) + elif request_obj is not None: + public_key = req_core.csr_public_key(req_core.parse_csr(bytes(request_obj.req_der))) + else: + new_key = payload.get("new_key") + if isinstance(new_key, Mapping): + generated = key_core.generate_key( + str(new_key.get("key_type") or "ec"), + int(new_key.get("size") or 2048), + new_key.get("curve") or "secp256r1", + ) + else: + generated = key_core.generate_key("ec", curve="secp256r1") + public_key = generated.public_key() + + extensions_spec = parse_extensions(payload.get("extensions")) + if request_obj is not None and payload.get("copy_request_extensions"): + extensions_spec = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions) + issuer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) if signer is not None else None + try: + extensions = ext_core.build_extensions( + extensions_spec, + public_key=public_key, + subject_name=subject, + issuer_cert=issuer_cert, + ) + except ValueError as exc: + raise ValidationError(str(exc)) from exc + not_before, not_after, no_expiration = validate_validity(payload) + return { + "subject": [entry.as_dict() for entry in name_core.entries_from_name(subject)], + "extensions": ext_core.extension_rows(extensions), + "openssl_config": ext_core.extension_config_lines(extensions), + "not_before": iso(not_before), + "not_after": iso(not_after), + "no_expiration": no_expiration, + } + + async def renew_certificate( vault: Vault, cert: Certificate, @@ -796,7 +879,8 @@ async def renew_certificate( parsed = cert_core.parse_certificate(bytes(cert.cert_der)) keep_serial = bool(payload.get("keep_serial")) serial = parsed.serial_number if keep_serial else await unique_serial(vault, signer, int(vault_settings(vault).get("serial_len_bits", 64))) - not_before, not_after = validate_validity(payload) + not_before, not_after, no_expiration = validate_validity(payload) + check_mandatory_dn(parsed.subject, vault_settings(vault)) signer_private = await load_private_key(signer_key) signer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) new_cert = cert_core.build_certificate( @@ -825,6 +909,7 @@ async def renew_certificate( issuer_hash=info.issuer_hash, not_before=info.not_before, not_after=info.not_after, + no_expiration=no_expiration, is_ca=info.is_ca, path_length=info.path_length, cert_der=cert_core.certificate_der(new_cert), @@ -1252,6 +1337,7 @@ __all__ = [ "load_private_key", "merge_settings", "parse_datetime", + "preview_certificate", "parse_key_generation", "parse_subject", "parse_uuid", diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index c43ece9..6928a0c 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -193,3 +193,116 @@ class RouteExtraTests(unittest.TestCase): assert response.status_code == 204 response = await client.get(f"/api/vaults/{vault_id}/certificates/{cert['id']}") assert response.json()["key_id"] is None + + +class ExtendedRouteTests(unittest.TestCase): + @async_test + async def test_preview_advanced_extensions(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + key = await _key(client, vault_id) + payload = { + "name": "preview", + "key_id": key["id"], + "subject": _subject("preview"), + "extensions": { + "basic_constraints": {"critical": True, "ca": False, "path_length": None}, + "crl_distribution_points": {"critical": False, "urls": ["http://example.com/ca.crl"]}, + "authority_information_access": { + "critical": False, + "descriptions": [{"method": "ocsp", "location": {"type": "uri", "value": "http://ocsp.example.com"}}], + }, + "raw": [{"oid": "1.2.3.4", "critical": False, "der_hex": "0c03616263"}], + }, + } + response = await client.post(f"/api/vaults/{vault_id}/certificates/preview", json=payload) + assert response.status_code == 200, response.text + names = [row["name"] for row in response.json()["extensions"]] + assert "CRL Distribution Points" in names + assert "Authority Information Access" in names + + @async_test + async def test_mandatory_dn_and_no_expiration(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + settings = (await client.get(f"/api/vaults/{vault_id}/settings")).json() + await client.patch( + f"/api/vaults/{vault_id}/settings", + json={"settings": {"mandatory_dn": ["CN"]}, "version": settings["version"]}, + ) + key = await _key(client, vault_id) + now = datetime.now(timezone.utc) + response = await client.post( + f"/api/vaults/{vault_id}/certificates", + json={ + "name": "missing cn", + "key_id": key["id"], + "subject": [{"name": "O", "value": "KCA"}], + "validity": {"amount": 1, "unit": "days"}, + }, + ) + assert response.status_code == 400 + assert "mandatory" in response.json()["error"] + response = await client.post( + f"/api/vaults/{vault_id}/certificates", + json={ + "name": "forever", + "key_id": key["id"], + "subject": _subject("forever"), + "validity": {"no_expiration": True}, + }, + ) + assert response.status_code == 201, response.text + assert response.json()["no_expiration"] is True + + @async_test + async def test_ca_template_and_revocation_edit(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "ca") + ca = await _cert(client, vault_id, ca_key["id"], "CA", ca=True) + response = await client.patch( + f"/api/vaults/{vault_id}/certificates/{ca['id']}/ca", + json={"crl_days": 45, "template_id": "builtin:tls-server"}, + ) + assert response.status_code == 200, response.text + assert response.json()["default_template"] == "builtin:tls-server" + assert response.json()["crl_days"] == 45 + response = await client.post( + f"/api/vaults/{vault_id}/certificates/{ca['id']}/revocations", + json={"serial_hex": "1234", "reason": "keyCompromise", "invalidity_date": datetime.now(timezone.utc).isoformat()}, + ) + assert response.status_code == 201, response.text + rev = response.json() + response = await client.patch( + f"/api/vaults/{vault_id}/certificates/{ca['id']}/revocations/{rev['id']}", + json={"reason": "superseded", "invalidity_date": None}, + ) + assert response.status_code == 200, response.text + assert response.json()["reason"] == "superseded" + + +class VaultExportTests(unittest.TestCase): + @async_test + async def test_vault_zip_export(self) -> None: + import io + import zipfile + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + key = await _key(client, vault_id, "zip key") + cert = await _cert(client, vault_id, key["id"], "Zip Cert", ca=True) + response = await client.post(f"/api/vaults/{vault_id}/export", json={"password": "secret"}) + assert response.status_code == 200, response.text + payload = response.json() + data = base64.b64decode(payload["content_base64"]) + with zipfile.ZipFile(io.BytesIO(data)) as archive: + 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) + manifest = archive.read("manifest.json") + assert b"Zip Cert" in manifest diff --git a/web/src/api.rs b/web/src/api.rs index 951df6e..e3d701d 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -175,6 +175,16 @@ pub async fn update_vault_settings(id: &str, settings: &serde_json::Value, versi check(response).await } +pub async fn export_vault(id: &str, password: Option<&str>) -> Result { + let response = send( + "POST", + &format!("/api/vaults/{id}/export"), + Some(&json!({"password": password})), + ) + .await?; + check(response).await +} + pub async fn vault_summary(id: &str) -> Result { let response = Request::get(&format!("/api/vaults/{id}/summary")).send().await.map_err(|e| e.to_string())?; check(response).await @@ -287,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/preview"), Some(payload)).await?; + check(response).await +} + pub async fn create_certificate(vault_id: &str, payload: &serde_json::Value) -> Result { let response = send("POST", &format!("/api/vaults/{vault_id}/certificates"), Some(payload)).await?; check(response).await diff --git a/web/src/components/extension_form.rs b/web/src/components/extension_form.rs new file mode 100644 index 0000000..911991e --- /dev/null +++ b/web/src/components/extension_form.rs @@ -0,0 +1,360 @@ +//! Shared editor for the less common X.509 extensions. +//! +//! The common controls (basic constraints, key usage, EKU, SAN, key IDs) stay +//! in the certificate/template pages; this module covers CRL distribution +//! points, AIA, name constraints, policies, policy constraints and an +//! "advanced JSON" escape hatch for anything else (raw OID/DER, Netscape, +//! certificate policies with full qualifiers, ...). +use serde_json::{json, Map, Value}; +use sycamore::prelude::*; + +#[derive(Clone, Copy)] +pub struct ExtensionSignals { + pub crl_urls: Signal, + pub aia_ocsp: Signal, + pub aia_ca_issuers: Signal, + pub nc_permitted: Signal, + pub nc_excluded: Signal, + pub policy_oids: Signal, + pub policy_cps: Signal, + pub policy_require: Signal, + pub policy_inhibit: Signal, + pub inhibit_any: Signal, + pub advanced_json: Signal, +} + +pub fn extension_fields(s: &ExtensionSignals) -> View { + let crl_urls = s.crl_urls; + let aia_ocsp = s.aia_ocsp; + let aia_ca_issuers = s.aia_ca_issuers; + let nc_permitted = s.nc_permitted; + let nc_excluded = s.nc_excluded; + let policy_oids = s.policy_oids; + let policy_cps = s.policy_cps; + let policy_require = s.policy_require; + let policy_inhibit = s.policy_inhibit; + let inhibit_any = s.inhibit_any; + let advanced_json = s.advanced_json; + view! { + div(class="form-section") { + div(class="form-section-title") { "Advanced extensions" } + div(class="row g-3") { + div(class="col-md-6") { + label(class="form-label") { "CRL distribution point URLs" } + textarea(class="form-control", rows="2", placeholder="http://example.com/ca.crl", bind:value=crl_urls) {} + } + div(class="col-md-3") { + label(class="form-label") { "AIA · OCSP" } + textarea(class="form-control", rows="2", placeholder="http://ocsp.example.com", bind:value=aia_ocsp) {} + } + div(class="col-md-3") { + label(class="form-label") { "AIA · caIssuers" } + textarea(class="form-control", rows="2", placeholder="http://example.com/ca.crt", bind:value=aia_ca_issuers) {} + } + div(class="col-md-6") { + label(class="form-label") { "Name constraints · permitted" } + textarea(class="form-control", rows="2", placeholder="DNS:.example.com, IP:10.0.0.0/8", bind:value=nc_permitted) {} + } + div(class="col-md-6") { + label(class="form-label") { "Name constraints · excluded" } + textarea(class="form-control", rows="2", placeholder="DNS:.bad.example", bind:value=nc_excluded) {} + } + div(class="col-md-4") { + label(class="form-label") { "Certificate policy OIDs" } + input(class="form-control", placeholder="1.3.6.1.4.1.1", bind:value=policy_oids) + } + div(class="col-md-4") { + label(class="form-label") { "Policy CPS URIs" } + input(class="form-control", placeholder="https://example.com/cps", bind:value=policy_cps) + } + div(class="col-md-2") { + label(class="form-label") { "Require explicit policy" } + input(class="form-control", placeholder="0", bind:value=policy_require) + } + div(class="col-md-2") { + label(class="form-label") { "Inhibit policy mapping" } + input(class="form-control", placeholder="0", bind:value=policy_inhibit) + } + div(class="col-md-3") { + label(class="form-label") { "Inhibit anyPolicy" } + input(class="form-control", placeholder="0", bind:value=inhibit_any) + } + div(class="col-12") { + label(class="form-label") { "Advanced extension JSON" } + textarea(class="form-control mono", rows="5", + placeholder=r#"{"raw":[{"oid":"1.2.3.4","critical":false,"der_hex":"0c03616263"}]}"#, + bind:value=advanced_json) {} + div(class="form-text") { + "Merged into the extension spec; this is the escape hatch for raw DER, Netscape and other less common extensions." + } + } + } + } + } +} + +pub fn build_extra_extensions(s: &ExtensionSignals) -> Result { + let mut extensions = Map::new(); + + let crl_urls = split_list(&s.crl_urls.get_clone()); + if !crl_urls.is_empty() { + extensions.insert( + "crl_distribution_points".to_string(), + json!({"critical": false, "urls": crl_urls}), + ); + } + + let mut aia = Vec::new(); + for url in split_list(&s.aia_ocsp.get_clone()) { + aia.push(json!({"method": "ocsp", "location": {"type": "uri", "value": url}})); + } + for url in split_list(&s.aia_ca_issuers.get_clone()) { + aia.push(json!({"method": "ca_issuers", "location": {"type": "uri", "value": url}})); + } + if !aia.is_empty() { + extensions.insert( + "authority_information_access".to_string(), + json!({"critical": false, "descriptions": aia}), + ); + } + + let permitted = parse_general_names(&s.nc_permitted.get_clone())?; + let excluded = parse_general_names(&s.nc_excluded.get_clone())?; + if !permitted.is_empty() || !excluded.is_empty() { + extensions.insert( + "name_constraints".to_string(), + json!({"critical": true, "permitted": permitted, "excluded": excluded}), + ); + } + + let policy_oids = split_list(&s.policy_oids.get_clone()); + if !policy_oids.is_empty() { + let cps = split_list(&s.policy_cps.get_clone()); + let policies = policy_oids + .iter() + .map(|oid| json!({"oid": oid, "cps": cps})) + .collect::>(); + extensions.insert( + "certificate_policies".to_string(), + json!({"critical": false, "policies": policies}), + ); + } + + let require = optional_int(&s.policy_require.get_clone(), "require explicit policy")?; + let inhibit = optional_int(&s.policy_inhibit.get_clone(), "inhibit policy mapping")?; + if require.is_some() || inhibit.is_some() { + extensions.insert( + "policy_constraints".to_string(), + json!({"critical": true, "require_explicit_policy": require, "inhibit_policy_mapping": inhibit}), + ); + } + + let inhibit_any = optional_int(&s.inhibit_any.get_clone(), "inhibit anyPolicy")?; + if let Some(skip) = inhibit_any { + extensions.insert( + "inhibit_any_policy".to_string(), + json!({"critical": true, "skip_certs": skip}), + ); + } + + let advanced = s.advanced_json.get_clone(); + if !advanced.trim().is_empty() { + let parsed: Value = serde_json::from_str(&advanced) + .map_err(|_| "advanced extension JSON is not valid JSON".to_string())?; + let object = parsed + .as_object() + .ok_or_else(|| "advanced extension JSON must be an object".to_string())?; + for (key, value) in object { + extensions.insert(key.clone(), value.clone()); + } + } + Ok(Value::Object(extensions)) +} + +pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) { + s.crl_urls.set( + spec.get("crl_distribution_points") + .and_then(|v| v.get("urls")) + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default(), + ); + let descriptions = spec + .get("authority_information_access") + .and_then(|v| v.get("descriptions")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let ocsp = descriptions + .iter() + .filter(|item| item.get("method").and_then(|v| v.as_str()) == Some("ocsp")) + .filter_map(|item| { + item.get("location") + .and_then(|v| v.get("value")) + .and_then(|v| v.as_str()) + }) + .collect::>() + .join("\n"); + let ca_issuers = descriptions + .iter() + .filter(|item| item.get("method").and_then(|v| v.as_str()) == Some("ca_issuers")) + .filter_map(|item| { + item.get("location") + .and_then(|v| v.get("value")) + .and_then(|v| v.as_str()) + }) + .collect::>() + .join("\n"); + s.aia_ocsp.set(ocsp); + s.aia_ca_issuers.set(ca_issuers); + + let nc = spec.get("name_constraints"); + s.nc_permitted + .set(names_to_text(nc.and_then(|v| v.get("permitted")))); + s.nc_excluded + .set(names_to_text(nc.and_then(|v| v.get("excluded")))); + + let policies = spec + .get("certificate_policies") + .and_then(|v| v.get("policies")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + s.policy_oids.set( + policies + .iter() + .filter_map(|item| item.get("oid").and_then(|v| v.as_str())) + .collect::>() + .join(", "), + ); + let cps = policies + .iter() + .flat_map(|item| { + item.get("cps") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + }) + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect::>(); + s.policy_cps.set(cps.join(", ")); + + let pc = spec.get("policy_constraints"); + s.policy_require.set( + pc.and_then(|v| v.get("require_explicit_policy")) + .and_then(|v| v.as_i64()) + .map(|v| v.to_string()) + .unwrap_or_default(), + ); + s.policy_inhibit.set( + pc.and_then(|v| v.get("inhibit_policy_mapping")) + .and_then(|v| v.as_i64()) + .map(|v| v.to_string()) + .unwrap_or_default(), + ); + s.inhibit_any.set( + spec.get("inhibit_any_policy") + .and_then(|v| v.get("skip_certs")) + .and_then(|v| v.as_i64()) + .map(|v| v.to_string()) + .unwrap_or_default(), + ); + + let mut remaining = spec.clone(); + if let Some(object) = remaining.as_object_mut() { + for key in [ + "basic_constraints", + "key_usage", + "extended_key_usage", + "subject_alt_name", + "subject_key_identifier", + "authority_key_identifier", + "crl_distribution_points", + "authority_information_access", + "name_constraints", + "certificate_policies", + "policy_constraints", + "inhibit_any_policy", + ] { + object.remove(key); + } + if object.is_empty() { + s.advanced_json.set(String::new()); + } else { + s.advanced_json + .set(serde_json::to_string_pretty(&remaining).unwrap_or_default()); + } + } +} + +fn split_list(text: &str) -> Vec { + text.split(|c| c == ',' || c == '\n') + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect() +} + +fn optional_int(text: &str, field: &str) -> Result, String> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + text.parse::() + .map(Some) + .map_err(|_| format!("{field} must be an integer")) +} + +fn parse_general_names(text: &str) -> Result, String> { + split_list(text) + .into_iter() + .map(|item| { + if let Some((prefix, value)) = item.split_once(':') { + let prefix = prefix.trim().to_lowercase(); + let kind = match prefix.as_str() { + "dns" => "dns", + "ip" => "ip", + "uri" => "uri", + "email" => "email", + "rid" => "rid", + "upn" => "upn", + "othername" | "other_name" => "other_name", + _ => return Err(format!("unknown general-name type {prefix:?}")), + }; + if kind == "upn" { + Ok(json!({"type": "upn", "value": value.trim()})) + } else { + Ok(json!({"type": kind, "value": value.trim()})) + } + } else { + Ok(json!({"type": "dns", "value": item})) + } + }) + .collect() +} + +fn names_to_text(value: Option<&Value>) -> String { + value + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| { + let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("dns"); + let value = item.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if value.is_empty() { + None + } else { + Some(format!("{}:{}", kind.to_uppercase(), value)) + } + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default() +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index b6ff70d..bc9f0e2 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,3 +1,4 @@ +pub mod extension_form; pub mod nav; pub mod toast; pub mod ui; diff --git a/web/src/main.rs b/web/src/main.rs index 8daaeb7..aa959d2 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -19,6 +19,8 @@ enum AppRoutes { Tokens, #[to("/vaults//certificates/new")] CertificateNew { id: String }, + #[to("/vaults//certificates//similar")] + CertificateSimilar { id: String, cert_id: String }, #[to("/vaults//certificates//renew")] CertificateRenew { id: String, cert_id: String }, #[to("/vaults//certificates/")] @@ -72,7 +74,8 @@ fn main() { AppRoutes::Dashboard => view! { pages::dashboard::DashboardPage() }, AppRoutes::Login => view! { pages::login::LoginPage() }, AppRoutes::Tokens => view! { pages::tokens::TokensPage() }, - AppRoutes::CertificateNew { id } => view! { (pages::certificate_new::CertificateNewPage(id.clone())) }, + AppRoutes::CertificateNew { id } => view! { (pages::certificate_new::CertificateNewPage(id.clone(), None)) }, + AppRoutes::CertificateSimilar { id, cert_id } => view! { (pages::certificate_new::CertificateNewPage(id.clone(), Some(cert_id.clone()))) }, AppRoutes::CertificateRenew { id, cert_id } => view! { (pages::certificate_new::CertificateRenewPage(id.clone(), cert_id.clone())) }, AppRoutes::CertificateDetail { id, cert_id } => view! { (pages::certificate_detail::CertificateDetailPage(id.clone(), cert_id.clone())) }, AppRoutes::Certificates { id } => view! { (pages::certificates::CertificatesPage(id.clone())) }, diff --git a/web/src/model.rs b/web/src/model.rs index 3903eca..5985b97 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -190,6 +190,8 @@ pub struct CertificateSummary { #[serde(default)] pub not_after: Option, #[serde(default)] + pub no_expiration: bool, + #[serde(default)] pub is_ca: bool, #[serde(default)] pub path_length: Option, @@ -315,7 +317,7 @@ pub struct AuthorityInfo { #[serde(default)] pub crl_days: Option, #[serde(default)] - pub default_template_id: Option, + pub default_template: Option, #[serde(default)] pub has_tls_auth_key: bool, } diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs index 1e69ab0..6a468b8 100644 --- a/web/src/pages/certificate_detail.rs +++ b/web/src/pages/certificate_detail.rs @@ -7,7 +7,7 @@ use crate::api; use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::ui::{copy_button, empty_state, page_header, toast_error}; use crate::dom; -use crate::model::{fmt_date, CertificateDetail, Me, Revocation}; +use crate::model::{fmt_date, CertificateDetail, Me, Revocation, TemplateSummary}; pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); @@ -27,6 +27,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let chain = create_signal(Vec::::new()); let rev_reason = create_signal("unspecified".to_string()); let rev_serial = create_signal(String::new()); + let rev_invalidity = create_signal(String::new()); + let editing_rev = create_signal(String::new()); + let ca_template = create_signal(String::new()); + let templates = create_signal(Vec::::new()); let ca_days = create_signal("30".to_string()); let crl_days = create_signal("30".to_string()); let crl_hash = create_signal("sha256".to_string()); @@ -44,6 +48,14 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { ca_days.set(days.to_string()); crl_days.set(days.to_string()); } + if let Some(template) = value + .authority + .as_ref() + .and_then(|a| a.default_template.clone()) + { + ca_template.set(template); + } + let is_ca = value.summary.is_ca; subject_entries.set(value.subject_entries.clone()); issuer_entries.set(value.issuer_entries.clone()); extensions.set(value.extensions.clone()); @@ -51,6 +63,11 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { purposes.set(value.purposes.clone()); chain.set(value.chain.clone()); cert.set(Some(value)); + if is_ca { + if let Ok(page) = api::templates(id).await { + templates.set(page.results); + } + } if let Ok(page) = api::revocations(id, cert_id_value).await { revocations.set(page.results); } @@ -76,7 +93,11 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { spawn_local(async move { match api::export_certificate(id, cert_id_value, &payload).await { Ok(payload) => { - if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) { + if let Err(message) = dom::download_base64( + &payload.filename, + &payload.content_type, + &payload.content_base64, + ) { error.set(Some(message)); } } @@ -143,6 +164,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }); }; + let save_ca = move |_| { + let payload = json!({ + "crl_days": ca_days.get_clone().parse::().unwrap_or(30), + "template_id": if ca_template.get_clone().is_empty() { serde_json::Value::Null } else { json!(ca_template.get_clone()) }, + }); + spawn_local(async move { + match api::update_ca(id, cert_id_value, &payload).await { + Ok(_) => {} + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + let generate_crl = move |_| { let payload = json!({ "days": crl_days.get_clone().parse::().unwrap_or(30), @@ -152,22 +187,36 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }); spawn_local(async move { match api::generate_crl(id, cert_id_value, &payload).await { - Ok(crl) => sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)), + Ok(crl) => { + sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)) + } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; - let add_revocation = move |_| { - let payload = json!({"reason": rev_reason.get_clone(), "serial_hex": rev_serial.get_clone()}); + let save_revocation = move |_| { + let payload = json!({ + "reason": rev_reason.get_clone(), + "serial_hex": rev_serial.get_clone(), + "invalidity_date": if rev_invalidity.get_clone().is_empty() { serde_json::Value::Null } else { json!(rev_invalidity.get_clone()) }, + }); + let editing = editing_rev.get_clone(); spawn_local(async move { - match api::add_revocation(id, cert_id_value, &payload).await { + let result = if editing.is_empty() { + api::add_revocation(id, cert_id_value, &payload).await + } else { + api::update_revocation(id, cert_id_value, &editing, &payload).await + }; + match result { Ok(_) => { if let Ok(page) = api::revocations(id, cert_id_value).await { revocations.set(page.results); } rev_serial.set(String::new()); + rev_invalidity.set(String::new()); + editing_rev.set(String::new()); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), @@ -175,6 +224,18 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }); }; + let edit_revocation = move |rev: Revocation| { + editing_rev.set(rev.id.clone()); + rev_serial.set(rev.serial_hex.clone()); + rev_reason.set(rev.reason.clone()); + rev_invalidity.set( + rev.invalidity_date + .clone() + .map(|value| value.chars().take(16).collect::()) + .unwrap_or_default(), + ); + }; + let delete_revocation = move |rev_id: String| { spawn_local(async move { match api::delete_revocation(id, cert_id_value, &rev_id).await { @@ -340,6 +401,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { 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" } } } } @@ -349,6 +411,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> 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" } @@ -376,19 +456,44 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }) h3(class="h6 mt-3") { "Revocations" } div(class="row g-2 align-items-end mb-2") { - div(class="col-md-4") { + div(class="col-md-3") { input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial) } - div(class="col-md-4") { + div(class="col-md-3") { select(class="form-select", bind:value=rev_reason) { (reason_options()) } } - div(class="col-md-4") { - button(class="btn btn-outline-light", on:click=add_revocation) { "Add revocation" } + 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() {} } } - tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } td() { (fmt_date(&rev.revoked_at)) } td() { (rev.reason) } td() { button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } } } } }) } + 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" } + } + } + } }) } } } } @@ -414,8 +519,17 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } fn reason_options() -> Vec { - ["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"] - .iter() - .map(|reason| view! { option(value=*reason) { (*reason) } }) - .collect::>() + [ + "unspecified", + "keyCompromise", + "CACompromise", + "affiliationChanged", + "superseded", + "cessationOfOperation", + "certificateHold", + "privilegeWithdrawn", + ] + .iter() + .map(|reason| view! { option(value=*reason) { (*reason) } }) + .collect::>() } diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index 5a52f63..22df31e 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -4,11 +4,14 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api; +use crate::components::extension_form::{ + build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals, +}; use crate::components::nav::{app_nav, whoami}; use crate::components::ui::{page_header, toast_error}; use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary}; -pub fn CertificateNewPage(vault_id: String) -> View { +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::); let keys = create_signal(Vec::::new()); @@ -59,12 +62,40 @@ pub fn CertificateNewPage(vault_id: String) -> View { let ski = create_signal(true); let aki = create_signal(true); let ocsp_staple = create_signal(false); + let crl_urls = create_signal(String::new()); + let aia_ocsp = create_signal(String::new()); + let aia_ca_issuers = create_signal(String::new()); + let nc_permitted = create_signal(String::new()); + let nc_excluded = create_signal(String::new()); + let policy_oids = create_signal(String::new()); + let policy_cps = create_signal(String::new()); + let policy_require = create_signal(String::new()); + let policy_inhibit = create_signal(String::new()); + let inhibit_any = create_signal(String::new()); + let advanced_json = create_signal(String::new()); + let ext = ExtensionSignals { + crl_urls, + aia_ocsp, + aia_ca_issuers, + nc_permitted, + nc_excluded, + policy_oids, + policy_cps, + policy_require, + policy_inhibit, + inhibit_any, + advanced_json, + }; + let preview_text = create_signal(String::new()); + let validity_mode = create_signal("duration".to_string()); let validity_amount = create_signal("365".to_string()); let validity_unit = create_signal("days".to_string()); let midnight = create_signal(false); + let not_before_text = create_signal(String::new()); + let not_after_text = create_signal(String::new()); + let no_expiration = create_signal(false); - spawn_local(async move { if let Ok(user) = api::me().await { if me.is_alive() { @@ -75,11 +106,134 @@ pub fn CertificateNewPage(vault_id: String) -> View { keys.set(page.results); } if let Ok(page) = api::certificates(&id).await { - signers.set(page.results.into_iter().filter(|cert| cert.is_ca && cert.has_private_key).collect()); + signers.set( + page.results + .into_iter() + .filter(|cert| cert.is_ca && cert.has_private_key) + .collect(), + ); } if let Ok(page) = api::templates(&id).await { templates.set(page.results); } + if let Ok(value) = api::vault_settings(&id).await { + if let Some(settings) = value.get("settings") { + if let Some(hash) = settings.get("default_hash").and_then(|v| v.as_str()) { + signature_hash.set(hash.to_string()); + } + if let Some(key) = settings.get("default_key") { + if let Some(kind) = key.get("type").and_then(|v| v.as_str()) { + key_type.set(kind.to_string()); + } + if let Some(size) = key.get("size").and_then(|v| v.as_i64()) { + key_size.set(size.to_string()); + } + } + 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()); + } + if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) { + validity_unit.set(unit.to_string()); + } + } + } + } + if !request_mode { + if let Some(cert_id) = from_cert.clone() { + if let Ok(value) = api::certificate(&id, &cert_id).await { + name.set(format!("{} copy", value.summary.name)); + comment.set(value.summary.comment.clone()); + if let Some(key) = value.summary.key_id.clone() { + key_id.set(key); + } + if let Some(spec) = value + .cert_spec + .get("signature_hash") + .and_then(|v| v.as_str()) + { + 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()); + no_expiration.set(value.summary.no_expiration); + } + } + } if request_mode { if let Ok(value) = api::request(&id, &request_id_for_load).await { name.set(format!("{} certificate", value.summary.name)); @@ -101,7 +255,11 @@ pub fn CertificateNewPage(vault_id: String) -> View { 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") { + if let Some(first) = value + .subject_entries + .iter() + .find(|e| e.name == "emailAddress") + { email.set(first.value.clone()); } } @@ -120,16 +278,21 @@ pub fn CertificateNewPage(vault_id: String) -> View { 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()) { + 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)); + 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")); @@ -138,7 +301,9 @@ pub fn CertificateNewPage(vault_id: String) -> View { 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)); + 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")); @@ -148,15 +313,37 @@ pub fn CertificateNewPage(vault_id: String) -> View { 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)); + 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(", "); + 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()); @@ -165,7 +352,11 @@ pub fn CertificateNewPage(vault_id: String) -> View { 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(); + 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), @@ -185,7 +376,12 @@ pub fn CertificateNewPage(vault_id: String) -> View { 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)); + midnight.set( + validity + .get("midnight") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ); } } Err(message) if message == api::LOGIN_REQUIRED => {} @@ -227,7 +423,8 @@ pub fn CertificateNewPage(vault_id: String) -> View { "path_length": path_length.get_clone().parse::().ok(), }); } else if mode == "end" { - extensions["basic_constraints"] = json!({"critical": crit, "ca": false, "path_length": null}); + extensions["basic_constraints"] = + json!({"critical": crit, "ca": false, "path_length": null}); } let usages = [ (ku_digital.get(), "digital_signature"), @@ -253,7 +450,8 @@ pub fn CertificateNewPage(vault_id: String) -> View { .filter(|(checked, _)| *checked) .map(|(_, name)| json!(name)) .collect::>(); - extensions["extended_key_usage"] = json!({"critical": eku_critical.get(), "usages": eku}); + 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}); @@ -265,7 +463,21 @@ pub fn CertificateNewPage(vault_id: String) -> View { extensions["authority_key_identifier"] = json!({"critical": false}); } if ocsp_staple.get() { - extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]}); + 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(); + } + } + } + Err(message) => { + error.set(Some(message)); + return; + } } let signer = signer_id.get_clone(); let mut payload = json!({ @@ -275,12 +487,22 @@ pub fn CertificateNewPage(vault_id: String) -> View { "extensions": extensions, "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, - "validity": { - "amount": validity_amount.get_clone().parse::().unwrap_or(365), - "unit": validity_unit.get_clone(), - "midnight": midnight.get(), + "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(), + }) }, }); + if validity_mode.get_clone() == "absolute" && !no_expiration.get() { + payload["not_before"] = json!(not_before_text.get_clone()); + payload["not_after"] = json!(not_after_text.get_clone()); + } if !request_id.is_empty() { payload["request_id"] = json!(request_id); payload["modify_subject"] = json!(true); @@ -303,26 +525,158 @@ pub fn CertificateNewPage(vault_id: String) -> View { match api::create_certificate(&id, &payload).await { Ok(cert) => { saving.set(false); - sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, cert.summary.id)); + sycamore_router::navigate(&format!( + "/vaults/{}/certificates/{}", + id, cert.summary.id + )); return; } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } - if saving.is_alive() { saving.set(false); } + if saving.is_alive() { + saving.set(false); + } + }); + } + }; + + 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(); + } + } + } + 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() }, + "subject": subject, + "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}) + }, + }); + let mut payload = payload; + if validity_mode.get_clone() == "absolute" && !no_expiration.get() { + payload["not_before"] = json!(not_before_text.get_clone()); + payload["not_after"] = json!(not_after_text.get_clone()); + } + let id = id.clone(); + spawn_local(async move { + match api::preview_certificate(&id, &payload).await { + Ok(value) => { + let mut text = String::new(); + if let Some(rows) = value.get("extensions").and_then(|v| v.as_array()) { + for row in rows { + let name = row.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = row.get("value").and_then(|v| v.as_str()).unwrap_or(""); + text.push_str(&format!("{name}: {value}\n")); + } + } + preview_text.set(text); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } }); } }; let signer_options = move || { - signers.get_clone().into_iter().map(|cert| view! { - option(value=cert.id.clone()) { (cert.name.clone()) } - }).collect::>() + signers + .get_clone() + .into_iter() + .map(|cert| { + view! { + option(value=cert.id.clone()) { (cert.name.clone()) } + } + }) + .collect::>() }; let key_options = move || { - keys.get_clone().into_iter().filter(|key| key.has_private).map(|key| view! { - option(value=key.id.clone()) { (key.name.clone()) } - }).collect::>() + keys.get_clone() + .into_iter() + .filter(|key| key.has_private) + .map(|key| { + view! { + option(value=key.id.clone()) { (key.name.clone()) } + } + }) + .collect::>() }; let template_options = move || { templates.get_clone().into_iter().map(|template| view! { @@ -500,29 +854,69 @@ pub fn CertificateNewPage(vault_id: String) -> View { } } } + (extension_fields(&ext)) div(class="form-section") { div(class="form-section-title") { "Validity" } div(class="row g-3") { div(class="col-md-3") { - label(class="form-label") { "Duration" } - input(class="form-control", bind:value=validity_amount) - } - div(class="col-md-3") { - label(class="form-label") { "Unit" } - select(class="form-select", bind:value=validity_unit) { - option(value="days") { "Days" } - option(value="months") { "Months" } - option(value="years") { "Years" } + label(class="form-label") { "Mode" } + select(class="form-select", bind:value=validity_mode) { + option(value="duration") { "Duration" } + option(value="absolute") { "Absolute dates" } } } + (if validity_mode.get_clone() == "duration" { + view! { + div(class="col-md-3") { + label(class="form-label") { "Duration" } + input(class="form-control", disabled=no_expiration.get(), bind:value=validity_amount) + } + div(class="col-md-3") { + label(class="form-label") { "Unit" } + select(class="form-select", disabled=no_expiration.get(), bind:value=validity_unit) { + option(value="days") { "Days" } + option(value="months") { "Months" } + option(value="years") { "Years" } + } + } + div(class="col-md-3 d-flex align-items-end") { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", disabled=no_expiration.get(), bind:checked=midnight) + label(class="form-check-label") { "Round to midnight" } + } + } + } + } else { + view! { + div(class="col-md-4") { + label(class="form-label") { "Not before" } + input(class="form-control", r#type="datetime-local", disabled=no_expiration.get(), bind:value=not_before_text) + } + div(class="col-md-4") { + label(class="form-label") { "Not after" } + input(class="form-control", r#type="datetime-local", disabled=no_expiration.get(), bind:value=not_after_text) + } + } + }) div(class="col-md-3 d-flex align-items-end") { div(class="form-check") { - input(class="form-check-input", r#type="checkbox", bind:checked=midnight) - label(class="form-check-label") { "Round to midnight" } + input(class="form-check-input", r#type="checkbox", bind:checked=no_expiration) + label(class="form-check-label") { "No well-defined expiration" } } } } } + (if preview_text.get_clone().is_empty() { + view! {} + } else { + view! { + div(class="card mb-3") { + div(class="card-header") { "Extension preview" } + div(class="card-body") { pre(class="pem mb-0") { (preview_text.get_clone()) } } + } + } + }) + button(class="btn btn-outline-light btn-lg me-2", on:click=validate) { "Validate extensions" } button(class="btn btn-primary btn-lg", disabled=saving.get(), on:click=submit) { (if saving.get() { "Issuing…" } else if request_mode { "Issue certificate" } else { "Create certificate" }) } @@ -583,13 +977,18 @@ pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View { match api::renew_certificate(&id, &cert_id_value, &payload).await { Ok(value) => { saving.set(false); - sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, value.summary.id)); + sycamore_router::navigate(&format!( + "/vaults/{}/certificates/{}", + id, value.summary.id + )); return; } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } - if saving.is_alive() { saving.set(false); } + if saving.is_alive() { + saving.set(false); + } }); } }; @@ -710,7 +1109,12 @@ fn parse_san(text: &str) -> Vec { fn string_array(value: Option<&serde_json::Value>) -> Vec { value .and_then(|v| v.as_array()) - .map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) .unwrap_or_default() } diff --git a/web/src/pages/certificates.rs b/web/src/pages/certificates.rs index 07dda64..40fbb6c 100644 --- a/web/src/pages/certificates.rs +++ b/web/src/pages/certificates.rs @@ -124,7 +124,7 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String { escape(&cert.issuer_name), if cert.is_ca { "CA" } else { "end entity" }, escape(&cert.serial_hex), - fmt_date(&cert.not_after), + if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) }, badge_class, badge_label, if cert.has_private_key { " key" } else { "" }, @@ -146,7 +146,10 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String { for cert in rows { match &cert.issuer_id { Some(issuer) if ids.contains(issuer) && issuer != &cert.id => { - children.entry(issuer.clone()).or_default().push(cert.clone()); + children + .entry(issuer.clone()) + .or_default() + .push(cert.clone()); } _ => roots.push(cert.clone()), } @@ -200,5 +203,9 @@ fn node_html( } fn escape(value: &str) -> String { - value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } diff --git a/web/src/pages/import.rs b/web/src/pages/import.rs index 4c87068..93bee38 100644 --- a/web/src/pages/import.rs +++ b/web/src/pages/import.rs @@ -1,6 +1,7 @@ //! Import files, PEM bundles and XCA databases. use serde_json::json; use sycamore::prelude::*; +use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use crate::api; @@ -21,7 +22,8 @@ pub fn ImportPage(vault_id: String) -> View { let pem_text = create_signal(String::new()); let password = create_signal(String::new()); let xca_db = create_signal(false); - let preview_text = create_signal(String::new()); + let preview_items = create_signal(Vec::::new()); + let selected = create_signal(Vec::::new()); let result_text = create_signal(String::new()); spawn_local(async move { @@ -32,11 +34,14 @@ pub fn ImportPage(vault_id: String) -> View { }); let choose_file = move |event: web_sys::Event| { - let Some(file) = dom::input_file(&event) else { return }; + let Some(file) = dom::input_file(&event) else { + return; + }; let name = file.name(); xca_db.set(name.to_lowercase().ends_with(".xdb")); filename.set(name); - preview_text.set(String::new()); + preview_items.set(Vec::new()); + selected.set(Vec::new()); result_text.set(String::new()); content_base64.set(String::new()); spawn_local(async move { @@ -69,11 +74,21 @@ pub fn ImportPage(vault_id: String) -> View { return; } busy.set(true); - preview_text.set(String::new()); + preview_items.set(Vec::new()); + selected.set(Vec::new()); result_text.set(String::new()); spawn_local(async move { match api::import_items(id, &payload).await { - Ok(value) => preview_text.set(preview_lines(&value.items)), + Ok(value) => { + let chosen = value + .items + .iter() + .filter(|item| !item.duplicate) + .map(|item| item.index) + .collect::>(); + selected.set(chosen); + preview_items.set(value.items); + } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } @@ -82,7 +97,10 @@ pub fn ImportPage(vault_id: String) -> View { }; let commit = move |_| { - let payload = build_payload(false); + let mut payload = build_payload(false); + if !preview_items.get_clone().is_empty() { + payload["selected"] = json!(selected.get_clone()); + } if payload.get("pem").is_none() && payload.get("content_base64").is_none() { error.set(Some("choose a file or paste PEM text".to_string())); return; @@ -92,9 +110,14 @@ pub fn ImportPage(vault_id: String) -> View { spawn_local(async move { match api::import_commit(id, &payload).await { Ok(value) => { - info.set(Some(format!("Imported {} item(s), skipped {}.", value.imported.len(), value.skipped.len()))); + info.set(Some(format!( + "Imported {} item(s), skipped {}.", + value.imported.len(), + value.skipped.len() + ))); result_text.set(import_result_lines(&value.imported, &value.skipped)); - preview_text.set(String::new()); + preview_items.set(Vec::new()); + selected.set(Vec::new()); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), @@ -103,10 +126,41 @@ pub fn ImportPage(vault_id: String) -> View { }); }; + let toggle = move |event: web_sys::Event| { + if let Some(target) = event + .target() + .and_then(|t| t.dyn_into::().ok()) + { + if let Some(raw) = target.get_attribute("data-index") { + if let Ok(index) = raw.parse::() { + let mut current = selected.get_clone(); + if target.checked() { + if !current.contains(&index) { + current.push(index); + } + } else { + current.retain(|value| *value != index); + } + selected.set(current); + } + } + } + }; + let select_all = move |_| { + let all = preview_items + .get_clone() + .iter() + .map(|item| item.index) + .collect::>(); + selected.set(all); + }; + let select_none = move |_| selected.set(Vec::new()); + let content = move || { if loading.get() { return spinner(); } + let preview_html = preview_table(&preview_items.get_clone(), &selected.get_clone()); view! { (page_header( "Import", @@ -152,10 +206,14 @@ pub fn ImportPage(vault_id: String) -> View { } div(class="col-12 col-lg-7") { div(class="card mb-3") { - div(class="card-header") { "Preview" } - div(class="card-body") { - pre(class="pem mb-0") { (preview_text.get_clone()) } + div(class="card-header d-flex justify-content-between align-items-center") { + span() { "Preview" } + div(class="d-flex gap-2") { + button(class="btn btn-sm btn-outline-light", on:click=select_all) { "All" } + button(class="btn btn-sm btn-outline-light", on:click=select_none) { "None" } + } } + div(class="table-responsive", on:change=toggle, dangerously_set_inner_html=preview_html) {} } div(class="card") { div(class="card-header") { "Result" } @@ -182,17 +240,38 @@ pub fn ImportPage(vault_id: String) -> View { } } -fn preview_lines(items: &[ImportItem]) -> String { - items.iter().map(|item| { - format!( - "[{}] {} {}{}{}", +fn preview_table(items: &[ImportItem], selected: &[i64]) -> String { + if items.is_empty() { + return String::new(); + } + let mut rows = String::new(); + for item in items { + let checked = if selected.contains(&item.index) { + " checked" + } else { + "" + }; + rows.push_str(&format!( + "{}{}{}{}{}", item.index, - item.kind, - item.name, - if item.is_private { " (private)" } else { "" }, - if item.duplicate { " [duplicate]" } else { "" }, - ) - }).collect::>().join("\n") + escape(&item.kind), + escape(&item.name), + if item.is_private { " private" } else { "" }, + escape(&item.summary), + if item.duplicate { "duplicate" } else { "new" }, + )); + } + format!( + "{rows}
KindNameSummaryStatus
" + ) +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> String { @@ -201,7 +280,10 @@ fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> Strin lines.push(format!("imported {} {}", item.kind, item.name)); } for item in skipped { - lines.push(format!("skipped {} {} ({})", item.kind, item.name, item.summary)); + lines.push(format!( + "skipped {} {} ({})", + item.kind, item.name, item.summary + )); } lines.join("\n") } diff --git a/web/src/pages/template_editor.rs b/web/src/pages/template_editor.rs index 845cc69..6c2216e 100644 --- a/web/src/pages/template_editor.rs +++ b/web/src/pages/template_editor.rs @@ -4,6 +4,9 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api; +use crate::components::extension_form::{ + build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals, +}; use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::ui::{page_header, toast_error}; use crate::dom; @@ -11,7 +14,9 @@ use crate::model::{Me, TemplateSummary}; pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); - let template_id_value: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str); + let template_id_value: Option<&'static str> = template_id + .as_ref() + .map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str); let me = create_signal(None::); let template = create_signal(None::); let error = create_signal(None::); @@ -45,6 +50,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View let san_text = create_signal(String::new()); let ski = create_signal(true); let aki = create_signal(true); + let crl_urls = create_signal(String::new()); + let aia_ocsp = create_signal(String::new()); + let aia_ca_issuers = create_signal(String::new()); + let nc_permitted = create_signal(String::new()); + let nc_excluded = create_signal(String::new()); + let policy_oids = create_signal(String::new()); + let policy_cps = create_signal(String::new()); + let policy_require = create_signal(String::new()); + let policy_inhibit = create_signal(String::new()); + let inhibit_any = create_signal(String::new()); + let advanced_json = create_signal(String::new()); + let ext = ExtensionSignals { + crl_urls, + aia_ocsp, + aia_ca_issuers, + nc_permitted, + nc_excluded, + policy_oids, + policy_cps, + policy_require, + policy_inhibit, + inhibit_any, + advanced_json, + }; if let Some(template_id) = template_id.clone() { spawn_local(async move { @@ -59,7 +88,11 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View if let Some(subject) = value.subject.as_array() { for entry in subject { let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let val = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let val = entry + .get("value") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); match key { "CN" => cn.set(val), "O" => organization.set(val), @@ -76,7 +109,12 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) { unit.set(v.to_string()); } - midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false)); + midnight.set( + validity + .get("midnight") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ); } if let Some(extensions) = value.settings.get("extensions") { if let Some(bc) = extensions.get("basic_constraints") { @@ -87,7 +125,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View } else { ca_mode.set("none".to_string()); } - bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + bc_critical + .set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) { path_length.set(len.to_string()); } @@ -99,7 +138,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View 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)); + ku_critical + .set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); } if let Some(eku) = extensions.get("extended_key_usage") { let usages = string_array(eku.get("usages")); @@ -107,19 +147,38 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View 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_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false)); + eku_critical.set( + eku.get("critical") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ); } if let Some(san) = extensions.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(", "); + 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(extensions.get("subject_key_identifier").is_some()); aki.set(extensions.get("authority_key_identifier").is_some()); + load_extra_extensions(&ext, extensions); } template.set(Some(value)); } @@ -138,7 +197,9 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View let submit = { let id = id.clone(); - let template_id: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str); + let template_id: Option<&'static str> = template_id + .as_ref() + .map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str); move |_| { let id = id.clone(); let template_id = template_id.clone(); @@ -180,12 +241,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View if mode == "ca" { extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": true, "path_length": path_length.get_clone().parse::().ok()}); } else if mode == "end" { - extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": false, "path_length": null}); + extensions["basic_constraints"] = + json!({"critical": bc_critical.get(), "ca": false, "path_length": null}); } else { extensions["basic_constraints"] = serde_json::Value::Null; } let san = parse_san(&san_text.get_clone()); - extensions["subject_alt_name"] = if san.is_empty() { serde_json::Value::Null } else { json!({"critical": false, "entries": san}) }; + extensions["subject_alt_name"] = if san.is_empty() { + serde_json::Value::Null + } else { + json!({"critical": false, "entries": san}) + }; + match build_extra_extensions(&ext) { + Ok(extra) => { + if let Some(object) = extra.as_object() { + for (key, value) in object { + extensions[key] = value.clone(); + } + } + } + Err(message) => { + error.set(Some(message)); + return; + } + } let payload = json!({ "name": name.get_clone(), "comment": comment.get_clone(), @@ -204,13 +283,18 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View match result { Ok(value) => { saving.set(false); - sycamore_router::navigate(&format!("/vaults/{}/templates/{}", id, value.id)); + sycamore_router::navigate(&format!( + "/vaults/{}/templates/{}", + id, value.id + )); return; } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } - if saving.is_alive() { saving.set(false); } + if saving.is_alive() { + saving.set(false); + } }); } }; @@ -220,7 +304,11 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View spawn_local(async move { match api::export_template(id, template_id, &json!({"format": "xca"})).await { Ok(payload) => { - if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) { + if let Err(message) = dom::download_base64( + &payload.filename, + &payload.content_type, + &payload.content_base64, + ) { error.set(Some(message)); } } @@ -326,6 +414,7 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View } } } + (extension_fields(&ext)) div(class="form-section") { div(class="form-section-title") { "Default validity" } div(class="row g-3") { @@ -417,6 +506,11 @@ fn parse_san(text: &str) -> Vec { fn string_array(value: Option<&serde_json::Value>) -> Vec { value .and_then(|v| v.as_array()) - .map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) .unwrap_or_default() } diff --git a/web/src/pages/vault.rs b/web/src/pages/vault.rs index 5029799..4554a72 100644 --- a/web/src/pages/vault.rs +++ b/web/src/pages/vault.rs @@ -5,6 +5,7 @@ use wasm_bindgen_futures::spawn_local; use crate::api; use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::dom; use crate::model::{fmt_date, item_total, Me, SummaryPage, VaultDetail}; pub fn VaultPage(vault_id: String) -> View { @@ -59,6 +60,30 @@ pub fn VaultPage(vault_id: String) -> View { } }); + let export = move |_| { + let password = web_sys::window().and_then(|window| { + window + .prompt_with_message("Password for private keys (empty: public material only)") + .ok() + .flatten() + }); + spawn_local(async move { + match api::export_vault(id, password.as_deref()).await { + Ok(payload) => { + if let Err(message) = dom::download_base64( + &payload.filename, + &payload.content_type, + &payload.content_base64, + ) { + error.set(Some(message)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + let content = move || { if loading.get() { return spinner(); @@ -98,6 +123,7 @@ pub fn VaultPage(vault_id: String) -> View { a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys/new")) { "New key" } a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" } a(class="btn btn-outline-light", href=format!("/vaults/{id}/settings")) { "Settings" } + button(class="btn btn-outline-light", on:click=export) { "Export ZIP" } }, )) div(class="row g-3 mb-4") { @@ -169,5 +195,9 @@ fn stat_card(label: impl Into, value: i64, href: String) -> View { } fn escape(value: &str) -> String { - value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) }