# KCA — web PKI management KCA is a browser-based reimplementation of the software-key workflows of [XCA](https://github.com/chris2511/xca): private keys, PKCS#10 requests, X.509 certificates, certificate revocation lists and reusable templates, organised in private **vaults** (each vault is the web equivalent of one XCA database). - **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend: OIDC login, Redis sessions, personal API tokens, PostgreSQL or SQLite (Tortoise ORM + aerich migrations), AES-256-GCM private-key encryption, and a complete REST API. The pure PKI domain logic lives in `packages/kca-core`; the deployable app is `packages/kca-app`. - **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under `web/vendor/`) and a dark PKI-console theme. ## Run the whole stack ```sh docker compose up --build ``` Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`, `dave`; any username works), the database migrator and the app all start together. The app listens on `http://127.0.0.1:8080`. The browser and the app both use the OIDC issuer `http://mockoauth:8180/kca`, so add a host entry once: ```sh echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts ``` Then open , click **Sign in with default**, and type `alice` (no password). The SPA and API are served from the same origin; the session cookie authenticates both. For a real deployment set at least: ```sh # 32 random bytes, base64 or hex: KCA_MASTER_KEY="$(openssl rand -base64 32)" # Point these at your IdP and public callback URL: OIDC_0_ISSUER=https://idp.example.com OIDC_0_CLIENT_ID=kca OIDC_0_CLIENT_SECRET=... OIDC_0_REDIRECT_URI=https://kca.example.com/auth/default/callback ``` `server/.env.example` documents every setting. ## Database backends PostgreSQL is the default production backend. KCA also supports persistent, file-backed SQLite for single-writer deployments. Configure it from `server/` with either the component settings: ```sh export DATABASE_ENGINE=sqlite export DATABASE_NAME=/data/kca.db export DATABASE_OPTIONS=busy_timeout=5000 ``` or one URL: ```sh export DATABASE_URL='sqlite:///data/kca.db?busy_timeout=5000' ``` Run migrations before starting or upgrading the app: ```sh uv run aerich upgrade ``` Tortoise enables WAL journaling and foreign-key enforcement by default; the busy timeout allows concurrent readers and a writer to wait briefly for locks. Only in-memory SQLite test databases create their schemas implicitly. For containers, mount a persistent volume at `/data` for both the migration and application containers and use the same SQLite URL for each. The image's `/data` directory is writable by its non-root application user. When invoking `aerich upgrade` directly in the image, set its working directory to `/app` (for example, `docker run -w /app ...`). Keep SQLite to one application writer/replica, and use SQLite-aware backups rather than independently copying live database, WAL and shared-memory files. When changing models, migration SQL must support both PostgreSQL and SQLite. The PostgreSQL migration commands below remain the default development flow. ## What it does ### Vaults Every user owns a private set of vaults. A vault contains keys, certificates, requests, CRLs and templates; non-owners get `404`. Vaults can be renamed, deleted and configured from **Settings** (default hash, serial length, explicit/mandatory DN entries, default key type/size/curve, default validity). ### Keys - Generate RSA (1024–8192), DSA (1024–3072), Ed25519 and EC keys across the complete set of 79 named curves exposed by XCA (NIST/SECG, X9.62, binary, WTLS and Brainpool variants). Legacy curves use an in-memory OpenSSL 3 compatibility backend; existing native curves continue through `cryptography`. - Sign certificates, requests and CRLs with XCA's key-specific hash matrix: RSA supports MD5, RIPEMD-160, SHA-1, SHA-2 and SHA-3; EC supports SHA-1, SHA-2 and SHA-3; DSA supports SHA-1/SHA-224/SHA-256; Ed25519 uses its built-in signature algorithm. Insecure compatibility choices are marked in the UI and SHA-256 remains the default. - Import PEM/DER public or private keys, PKCS#8, OpenSSH, PKCS#12 and PVK. - Export PEM public/private, traditional encrypted PEM, PKCS#8 (plain or encrypted), DER public/private, SSH2 public/private, JWK public/private and PVK. - Private keys are stored encrypted with AES-256-GCM under `KCA_MASTER_KEY` (AAD bound to the key row id). They are never included in normal API responses; only an explicit export decrypts them. ### Requests and certificates - Create PKCS#10 requests with subject, key, extensions and attributes (challenge password, unstructured name); verify imported requests. - Issue self-signed certificates or certificates signed by a CA in the same vault, from scratch or from a request. Imported CSRs are marked **signed** once a certificate is issued from them. - The certificate wizard covers subject entries in the vault's explicit DN order, additional subject attributes, mandatory DN enforcement, validity, basic constraints, key usage, extended key usage, SAN/IAN, subject/authority key identifiers, CRL distribution points, authority information access, name constraints, certificate policies, policy constraints, inhibit-any-policy, OCSP no-check, TLS feature (OCSP stapling), Netscape legacy extensions and raw 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 are split into Overview, Extensions, Validation, Metadata and CA tabs, with hash deep links. Header actions provide export, renewal, revocation, deletion and certificate transformation. They show subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256 fingerprints, parsed extensions, OpenSSL validation errors, purposes and the issuer chain. - Renew with a new or kept serial, optionally revoke/replace the old certificate. Revoke with a reason; manage the CA's revocation list manually, including revocation dates and invalidity dates. CA properties (CRL days and default signing template, including built-ins) are editable from the CA detail page, and the wizard offers the CA default template explicitly without overwriting edits. - Absolute validity dates and XCA's "no well-defined expiration" mode are supported alongside duration-based validity. - Transform certificates to a public key, a CSR or a template. - 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, vCalendar, JWK/JWK chain and the CA's OpenVPN tls-auth key. ### CRLs Generate a CRL from a CA's revocation entries with configurable hash, dates, reason inclusion, AKI and issuer alternative name. Imported CRLs are linked to their CA and their serials are merged into its revocation list. Export PEM, DER or vCalendar. ### Templates Built-in **CA**, **TLS server** and **TLS client** templates mirror XCA's presets. User templates store subject defaults, extension defaults and a relative validity range. XCA's binary template format is supported for import and export, and templates are applied to a certificate/request spec server-side with **Apply all**, **Subject only**, and **Extensions + validity** modes. ### Import and migration - Paste PEM or upload files: PEM bundles, DER certificates/requests/CRLs/keys, PKCS#7, PKCS#12, SSH keys and XCA templates. - 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 imported when the supplied database password decrypts them. ### Search and API tokens Global search covers internal names, comments, subjects, issuers, serials, fingerprints and extension text. Personal API tokens (`Authorization: Bearer kca_…`) let scripts use the REST API; tokens act as their owner, are stored only as SHA-256 digests, and cannot mint/revoke tokens. ## OCSP responder KCA publishes full per-CA certificate-status snapshots to Redis and ships a standalone Rust/Axum responder on port `2560`. By default, snapshots use the same Redis instance and logical database as sessions; setting `KCA_OCSP_REDIS_URL` selects a dedicated OCSP instance instead. - KCA publishes `ocsp:` whenever certificates are issued, renewed, revoked, deleted or imported. Revocation reasons are included; this is a full CBOR-encoded status snapshot, not a revoked-only list. OCSP keys are separate from session keys through their prefix. - The responder has no Postgres or KCA master-key access. It reads only OCSP snapshots from Redis and uses a delegated OCSP signing certificate/key per CA. Where Redis ACLs are available, restrict the responder to `ocsp:*` so it cannot read session entries. - Each CA snapshot is cached for one second with a single-flight refresh, so concurrent requests share one atomic Redis `GET` + `PTTL` lookup per CA. - The publisher refreshes unchanged snapshots every 30 seconds by default and sets each key's expiry to 60 seconds. A missing or expired snapshot produces `unknown`; Redis failures produce `tryLater` when no unexpired cached snapshot remains. - OCSP `thisUpdate` is the response time and `nextUpdate` never extends beyond the snapshot's Redis expiry. - Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same `release/*` tags as the server image. The responder image is a statically linked musl executable running `FROM scratch` with only the CA bundle. Configure `REDIS_URL`, optional `KCA_OCSP_REDIS_URL`, and the other `KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a configuration derived from `ocsp-responder/responder.example.yaml`, setting each issuer's `ca_id` to its CA certificate UUID in KCA and using the OCSP Redis URL, or with Compose: ```sh docker compose --profile ocsp up --build ``` See `ocsp-responder/README.md` for the CBOR layout, signer requirements and `openssl ocsp` verification. ## REST API 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`, `POST /api/vaults/{id}/export` | | Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` | | Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` | | Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET /api/vaults/{id}/certificates/{ca_id}/ocsp-status`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `POST /api/vaults/{id}/certificates/bulk-revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms | | CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` | | Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` | | Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) | Errors are always `{"error": "..."}`. Mutating resources carry a `version` for optimistic concurrency (`409` on stale writes). Export endpoints return JSON with `content_base64` so the SPA can download or copy the bytes. ## Development Backend (from `server/`, using [uv](https://docs.astral.sh/uv/)): ```sh uv sync --package kca-app uv run python -m unittest discover -s tests -t . uv run python -m unittest discover -s packages/kca-core/tests 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`. OCSP responder (from `ocsp-responder/`): ```sh cargo test cargo run # uses OCSP_CONFIG or /etc/ocsp/responder.yaml ``` Frontend (from `web/`, with the backend running on `:8080`): ```sh trunk serve # SPA on http://localhost:8000, /api and /auth proxied cargo test ``` Migrations live in `server/migrations/`. After changing `models.py`: ```sh DATABASE_URL=postgres://kca:password@localhost:5432/kca uv run aerich migrate --name describe_change DATABASE_URL=postgres://kca:password@localhost:5432/kca uv run aerich upgrade ``` ## OpenTelemetry OpenTelemetry is optional and disabled by default. The Docker image includes the `otel` extra; for a non-image install run: ```sh uv sync --package kca-app --extra otel ``` Enable export with: ```sh OTEL_ENABLED=1 OTEL_SERVICE_NAME=kca OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 # Optional comma-separated "Name=Value" pairs: OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer token,X-Tenant=one" ``` The OTLP/HTTP endpoints are derived automatically (`/v1/traces`, `/v1/metrics`, `/v1/logs`). KCA exports: - HTTP server spans and metrics through `kaya-otel`, with W3C trace-context propagation and health-check exclusion; - asyncpg and Redis client spans; - the `kca.active_sessions` gauge plus process/GC system metrics; - application logs through the OTLP log exporter (OpenTelemetry's own SDK logs are filtered to avoid export loops); - `session.id` (truncated digest) and `user.id` span attributes for trace correlation. `OTEL_EXCLUDED_PATHS`, `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` and `OTEL_METRICS_POLL_INTERVAL_SECONDS` are documented in `server/.env.example`. ## Layout ``` server/ packages/kca-core/ # pure PKI domain (keys, names, extensions, certs, CSRs, # CRLs, templates, imports/exports, .xdb, AES-GCM) packages/kca-app/ # kaya app, config, telemetry, Tortoise models, routes, static migrations/ # aerich schema history tests/ # app integration suite dev/mockoauth/ # dev OIDC provider image + test users web/ src/pages/ # dashboard, vault, keys, requests, certificates, # wizard, CRLs, templates, import, search, settings, tokens src/components/ # nav, toast, shared UI src/{api,auth,model,dom}.rs pwa/ # manifest + app-shell assets e2e/ # Playwright browser suite (Node) ocsp-responder/ # standalone Rust/Axum OCSP responder ``` ## Limits - **Smart cards / PKCS#11 / HSMs are not supported.** A browser app cannot use a local card reader; server-side PKCS#11 would require a separate HSM deployment and is deliberately out of scope. - The desktop command-line interface is replaced by the REST API and personal access tokens. - PKCS#12 export uses PBES2/AES via `cryptography`; legacy RC2/3DES PKCS#12 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 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 192/512 icons remain available for installation. - Column layouts, window geometry, language packs and other desktop-only XCA preferences are not part of the web UI.