Close XCA parity gaps: advanced extensions, CA/revocation UI, imports, ZIP export, E2E
This commit is contained in:
@@ -8,3 +8,7 @@ web/.trunk
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
*.log
|
||||
|
||||
e2e/node_modules
|
||||
e2e/test-results
|
||||
e2e/playwright-report
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -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.
|
||||
Generated
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ExportPayload, String> {
|
||||
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<SummaryPage, String> {
|
||||
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<CertificateDet
|
||||
check(response).await
|
||||
}
|
||||
|
||||
pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
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<CertificateDetail, String> {
|
||||
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates"), Some(payload)).await?;
|
||||
check(response).await
|
||||
|
||||
@@ -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<String>,
|
||||
pub aia_ocsp: Signal<String>,
|
||||
pub aia_ca_issuers: Signal<String>,
|
||||
pub nc_permitted: Signal<String>,
|
||||
pub nc_excluded: Signal<String>,
|
||||
pub policy_oids: Signal<String>,
|
||||
pub policy_cps: Signal<String>,
|
||||
pub policy_require: Signal<String>,
|
||||
pub policy_inhibit: Signal<String>,
|
||||
pub inhibit_any: Signal<String>,
|
||||
pub advanced_json: Signal<String>,
|
||||
}
|
||||
|
||||
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<Value, String> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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<String> {
|
||||
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<Option<i64>, String> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
text.parse::<i64>()
|
||||
.map(Some)
|
||||
.map_err(|_| format!("{field} must be an integer"))
|
||||
}
|
||||
|
||||
fn parse_general_names(text: &str) -> Result<Vec<Value>, 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::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod extension_form;
|
||||
pub mod nav;
|
||||
pub mod toast;
|
||||
pub mod ui;
|
||||
|
||||
+4
-1
@@ -19,6 +19,8 @@ enum AppRoutes {
|
||||
Tokens,
|
||||
#[to("/vaults/<id>/certificates/new")]
|
||||
CertificateNew { id: String },
|
||||
#[to("/vaults/<id>/certificates/<cert_id>/similar")]
|
||||
CertificateSimilar { id: String, cert_id: String },
|
||||
#[to("/vaults/<id>/certificates/<cert_id>/renew")]
|
||||
CertificateRenew { id: String, cert_id: String },
|
||||
#[to("/vaults/<id>/certificates/<cert_id>")]
|
||||
@@ -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())) },
|
||||
|
||||
+3
-1
@@ -190,6 +190,8 @@ pub struct CertificateSummary {
|
||||
#[serde(default)]
|
||||
pub not_after: Option<String>,
|
||||
#[serde(default)]
|
||||
pub no_expiration: bool,
|
||||
#[serde(default)]
|
||||
pub is_ca: bool,
|
||||
#[serde(default)]
|
||||
pub path_length: Option<i64>,
|
||||
@@ -315,7 +317,7 @@ pub struct AuthorityInfo {
|
||||
#[serde(default)]
|
||||
pub crl_days: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub default_template_id: Option<String>,
|
||||
pub default_template: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_tls_auth_key: bool,
|
||||
}
|
||||
|
||||
@@ -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::<crate::model::ChainItem>::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::<TemplateSummary>::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::<i64>().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::<i64>().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::<String>())
|
||||
.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::<Vec<View>>())
|
||||
}
|
||||
}
|
||||
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<View> {
|
||||
["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"]
|
||||
.iter()
|
||||
.map(|reason| view! { option(value=*reason) { (*reason) } })
|
||||
.collect::<Vec<View>>()
|
||||
[
|
||||
"unspecified",
|
||||
"keyCompromise",
|
||||
"CACompromise",
|
||||
"affiliationChanged",
|
||||
"superseded",
|
||||
"cessationOfOperation",
|
||||
"certificateHold",
|
||||
"privilegeWithdrawn",
|
||||
]
|
||||
.iter()
|
||||
.map(|reason| view! { option(value=*reason) { (*reason) } })
|
||||
.collect::<Vec<View>>()
|
||||
}
|
||||
|
||||
@@ -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<String>) -> View {
|
||||
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||
let me = create_signal(None::<Me>);
|
||||
let keys = create_signal(Vec::<KeySummary>::new());
|
||||
@@ -59,11 +62,39 @@ 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 {
|
||||
@@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>().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::<Vec<_>>()
|
||||
.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::<i64>().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::<Vec<_>>();
|
||||
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::<i64>().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::<i64>().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::<i64>().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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<i64>().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::<Vec<View>>()
|
||||
signers
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.map(|cert| {
|
||||
view! {
|
||||
option(value=cert.id.clone()) { (cert.name.clone()) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<View>>()
|
||||
};
|
||||
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::<Vec<View>>()
|
||||
keys.get_clone()
|
||||
.into_iter()
|
||||
.filter(|key| key.has_private)
|
||||
.map(|key| {
|
||||
view! {
|
||||
option(value=key.id.clone()) { (key.name.clone()) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<View>>()
|
||||
};
|
||||
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<serde_json::Value> {
|
||||
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { " <span class=\"badge badge-soft\">key</span>" } 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('"', """)
|
||||
}
|
||||
|
||||
+104
-22
@@ -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::<ImportItem>::new());
|
||||
let selected = create_signal(Vec::<i64>::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::<Vec<_>>();
|
||||
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::<web_sys::HtmlInputElement>().ok())
|
||||
{
|
||||
if let Some(raw) = target.get_attribute("data-index") {
|
||||
if let Ok(index) = raw.parse::<i64>() {
|
||||
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::<Vec<_>>();
|
||||
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!(
|
||||
"<tr><td><input class=\"form-check-input\" type=\"checkbox\" data-index=\"{}\"{checked}></td><td>{}</td><td>{}{}</td><td class=\"item-sub\">{}</td><td>{}</td></tr>",
|
||||
item.index,
|
||||
item.kind,
|
||||
item.name,
|
||||
if item.is_private { " (private)" } else { "" },
|
||||
if item.duplicate { " [duplicate]" } else { "" },
|
||||
)
|
||||
}).collect::<Vec<_>>().join("\n")
|
||||
escape(&item.kind),
|
||||
escape(&item.name),
|
||||
if item.is_private { " <span class=\"badge badge-ca\">private</span>" } else { "" },
|
||||
escape(&item.summary),
|
||||
if item.duplicate { "<span class=\"badge badge-warn\">duplicate</span>" } else { "<span class=\"badge badge-soft\">new</span>" },
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"<table class=\"table table-sm mb-0\"><thead><tr><th></th><th>Kind</th><th>Name</th><th>Summary</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>"
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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<String>) -> 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::<Me>);
|
||||
let template = create_signal(None::<TemplateSummary>);
|
||||
let error = create_signal(None::<String>);
|
||||
@@ -45,6 +50,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<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) = 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<String>) -> 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::<Vec<_>>().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::<Vec<_>>()
|
||||
.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<String>) -> 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<String>) -> View
|
||||
if mode == "ca" {
|
||||
extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": true, "path_length": path_length.get_clone().parse::<i64>().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<String>) -> 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<String>) -> 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<String>) -> 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<serde_json::Value> {
|
||||
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||
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()
|
||||
}
|
||||
|
||||
+31
-1
@@ -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<String>, value: i64, href: String) -> View {
|
||||
}
|
||||
|
||||
fn escape(value: &str) -> String {
|
||||
value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user