372 lines
16 KiB
Markdown
372 lines
16 KiB
Markdown
# 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 <http://localhost:8080>, 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:<KCA CA UUID>` 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.
|