Implement KCA: web reimplementation of XCA PKI workflows

- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models
- AES-256-GCM private key storage and full certificate/CSR/CRL domain logic
- Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates
- imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
2026-10-23 02:06:55 +00:00
commit 4f3a632f4b
109 changed files with 21048 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
server/.venv
server/.mypy_cache
server/tests
web/target
web/dist
web/.trunk
**/__pycache__
*.pyc
*.log
+43
View File
@@ -0,0 +1,43 @@
name: CI
on:
push:
tags:
- 'release/*'
jobs:
build_and_push_docker_image:
name: "Build and push docker image"
runs-on: hostinger
steps:
- name: Checkout sources
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea container registry
uses: docker/login-action@v3
with:
registry: gitea.woggioni.net
username: woggioni-opencode-agent
password: ${{ secrets.PUBLISHER_TOKEN }}
- name: Extract metadata for image tags
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.woggioni.net/woggioni/kca
flavor: latest=false
tags: |
type=match,pattern=release/(.*),group=1
type=raw,value=latest
- name: Build and push docker image
uses: docker/build-push-action@v6
with:
context: .
builder: multiplatform-builder
file: server/Dockerfile
platforms: linux/amd64
push: true
pull: true
tags: ${{ steps.meta.outputs.tags }}
+23
View File
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
.venv/
.mypy_cache/
.pytest_cache/
*.egg-info/
# Rust / Trunk
web/target/
web/dist/
web/.trunk/
# Environment and local data
.env
*.log
*.xdb
# Editor
.idea/
.vscode/
.DS_Store
.playwright-mcp/
+210
View File
@@ -0,0 +1,210 @@
# 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, Postgres (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.
## 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, default validity).
### Keys
- Generate RSA (1024–8192), DSA (1024–3072), EC (P-192/P-224/P-256/P-384/P-521,
secp256k1, brainpool) and Ed25519 keys.
- 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, 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. 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.
- 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 can be applied to a certificate/request spec
server-side.
### 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 before committing; duplicates are skipped.
- 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.
## 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` |
| 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 |
| 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.
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
```
## 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, 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
```
## 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 raw OID/DER rows. 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.
- Column layouts, window geometry, language packs and other desktop-only XCA
preferences are not part of the web UI.
+132
View File
@@ -0,0 +1,132 @@
# KCA — Kubernetes deployment sketch (namespace: kca).
#
# Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis
# is included because sessions are disposable. KCA_MASTER_KEY is the AES key
# protecting private keys at rest: generate it once and never lose it.
#
# kubectl apply -f deploy/k8s/kca.yaml
# kubectl port-forward -n kca svc/kca 8080:80
---
apiVersion: v1
kind: Namespace
metadata:
name: kca
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kca-config
namespace: kca
data:
REDIS_URL: redis://redis.kca.svc.cluster.local:6379/0
DATABASE_ENGINE: postgres
DATABASE_HOST: REPLACE_ME
DATABASE_PORT: "5432"
DATABASE_NAME: kca
DATABASE_USER: kca
STATIC_DIR: /app/web/dist
OIDC_0_PROVIDER: default
OIDC_0_CLIENT_ID: kca
OIDC_0_ISSUER: REPLACE_ME
OIDC_0_REDIRECT_URI: REPLACE_ME
OIDC_0_POST_LOGIN_REDIRECT: /
OIDC_0_POST_LOGOUT_REDIRECT: /
---
apiVersion: v1
kind: Secret
metadata:
name: kca-secrets
namespace: kca
type: Opaque
stringData:
DATABASE_PASSWORD: REPLACE_ME
OIDC_0_CLIENT_SECRET: REPLACE_ME
# openssl rand -base64 32
KCA_MASTER_KEY: REPLACE_ME
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: kca
spec:
replicas: 1
selector:
matchLabels:
app: kca-redis
template:
metadata:
labels:
app: kca-redis
spec:
containers:
- name: redis
image: redis:8-alpine
ports:
- containerPort: 6379
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {memory: 128Mi}
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: kca
spec:
selector:
app: kca-redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kca
namespace: kca
spec:
replicas: 1
selector:
matchLabels:
app: kca
template:
metadata:
labels:
app: kca
spec:
securityContext:
runAsNonRoot: true
containers:
- name: kca
image: gitea.woggioni.net/woggioni/kca:latest
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: kca-config
- secretRef:
name: kca-secrets
readinessProbe:
httpGet:
path: /api/health
port: 8080
livenessProbe:
httpGet:
path: /api/health
port: 8080
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {memory: 512Mi}
---
apiVersion: v1
kind: Service
metadata:
name: kca
namespace: kca
spec:
selector:
app: kca
ports:
- port: 80
targetPort: 8080
+104
View File
@@ -0,0 +1,104 @@
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: kca
POSTGRES_USER: kca
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kca"]
interval: 5s
timeout: 3s
retries: 10
# Mock OIDC provider (navikt/mock-oauth2-server). Interactive login accepts
# any username; alice, bob, carol and dave carry profile claims.
# The issuer is http://mockoauth:8180/kca, so both the browser and the app
# need the /etc/hosts entry documented in README.md.
mockoauth:
build: ./server/dev/mockoauth
environment:
SERVER_PORT: "8180"
LOG_LEVEL: INFO
ports:
- "8180:8180"
mockoauth-init:
image: curlimages/curl
depends_on:
- mockoauth
entrypoint: >
/bin/sh -c "
until curl -sf http://mockoauth:8180/kca/.well-known/openid-configuration > /dev/null; do
echo 'waiting for mockoauth...'; sleep 2;
done
"
redis:
image: redis:8-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
db-migrate:
build:
context: .
dockerfile: server/Dockerfile
working_dir: /app
command: ["aerich", "upgrade"]
environment:
DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: kca
DATABASE_USER: kca
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
depends_on:
postgres:
condition: service_healthy
kca:
build:
context: .
dockerfile: server/Dockerfile
depends_on:
postgres:
condition: service_healthy
db-migrate:
condition: service_completed_successfully
mockoauth-init:
condition: service_completed_successfully
redis:
condition: service_healthy
environment:
DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: kca
DATABASE_USER: kca
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/kca}
OIDC_0_CLIENT_ID: ${OIDC_0_CLIENT_ID:-kca}
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback}
OIDC_0_POST_LOGIN_REDIRECT: ${OIDC_0_POST_LOGIN_REDIRECT:-http://localhost:8080/}
OIDC_0_POST_LOGOUT_REDIRECT: ${OIDC_0_POST_LOGOUT_REDIRECT:-http://localhost:8080/}
REDIS_URL: redis://redis:6379/0
# Development master key. Override for any real deployment:
# openssl rand -base64 32
KCA_MASTER_KEY: ${KCA_MASTER_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-20971520}
ports:
- "127.0.0.1:${APP_PORT:-8080}:8080"
volumes:
pgdata:
+48
View File
@@ -0,0 +1,48 @@
# Environment configuration for KCA. Copy to .env or export the variables.
# --- Database --------------------------------------------------------------
DATABASE_ENGINE=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=kca
DATABASE_USER=kca
DATABASE_PASSWORD=password
#DATABASE_OPTIONS=
#DATABASE_URL=
# --- OIDC ------------------------------------------------------------------
# Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0.
OIDC_0_PROVIDER=default
OIDC_0_ISSUER=http://localhost:8180/kca
OIDC_0_CLIENT_ID=kca
OIDC_0_CLIENT_SECRET=dev-secret
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8080/
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8080/
# --- Sessions --------------------------------------------------------------
REDIS_URL=redis://localhost:6379/0
# --- Private-key encryption ------------------------------------------------
# REQUIRED in production: a random 32-byte AES-256 key, base64 or hex.
# openssl rand -base64 32
# When unset, a built-in development key is used (the UI warns about it).
#KCA_MASTER_KEY=
# --- Application -----------------------------------------------------------
APP_HOST=0.0.0.0
APP_PORT=8080
STATIC_DIR=web/dist
MAX_UPLOAD_BYTES=20971520
KEYGEN_CONCURRENCY=2
SESSION_COOKIE_SECURE=false
#LOGGING_CONFIG=
# --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS=
#CORS_ALLOW_ORIGIN_REGEX=
#CORS_ALLOW_METHODS=GET
#CORS_ALLOW_HEADERS=
#CORS_ALLOW_CREDENTIALS=false
#CORS_EXPOSE_HEADERS=
#CORS_MAX_AGE=600
+87
View File
@@ -0,0 +1,87 @@
# syntax=docker/dockerfile:1
# Multi-stage production image for KCA (kaya backend + Sycamore/WASM frontend).
# The build context is the repository root (see ../docker-compose.yml).
# --- Frontend --------------------------------------------------------------
FROM rust:1-slim AS web-builder
ARG TRUNK_VERSION=0.21.14
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
/etc/apt/sources.list.d/debian.sources 2>/dev/null || true \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sSfL \
"https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C /usr/local/bin \
&& rustup target add wasm32-unknown-unknown
WORKDIR /web
COPY web/Cargo.toml web/Cargo.lock ./
COPY web/index.html web/style.css web/theme.css web/Trunk.toml ./
COPY web/vendor ./vendor
COPY web/pwa ./pwa
COPY web/src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/web/target \
trunk build --release --public-url /static/
# --- Backend ---------------------------------------------------------------
FROM alpine:3.24 AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 build-base python3-dev git
RUN git config --global --add safe.directory /repo
ENV UV_PYTHON_DOWNLOADS=0 \
UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_LINK_MODE=copy
WORKDIR /repo/server
COPY server/uv.lock server/pyproject.toml ./
COPY server/packages/kca-core/pyproject.toml packages/kca-core/
COPY server/packages/kca-app/pyproject.toml packages/kca-app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-workspace --package kca-app --no-dev
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --package kca-app --no-dev \
--reinstall-package kca-core --reinstall-package kca-app
COPY server/migrations ./migrations
# --- Runtime ---------------------------------------------------------------
FROM alpine:3.24
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 ca-certificates tzdata \
&& addgroup -S app && adduser -S -G app app
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /repo/server/migrations /app/migrations
COPY --from=builder /repo/server/pyproject.toml /app/pyproject.toml
COPY --from=web-builder /web/dist /app/web/dist
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi \
GRANIAN_STATIC_PATH_ROUTE="/static" \
GRANIAN_STATIC_PATH_MOUNT="/app/web/dist" \
GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \
STATIC_DIR=/app/web/dist
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
CMD ["granian", "kca.app:app"]
+12
View File
@@ -0,0 +1,12 @@
"""pytest collection hook: mirror tests/__init__.py environment setup."""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_0_PROVIDER", "default")
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/kca")
os.environ.setdefault("OIDC_0_CLIENT_ID", "kca")
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
os.environ.setdefault("KCA_MASTER_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
os.environ.pop("REDIS_URL", None)
+6
View File
@@ -0,0 +1,6 @@
FROM ghcr.io/navikt/mock-oauth2-server:5.0.2
# The config is COPYed into the image rather than bind-mounted so the
# stack also works against containerized/rootless Docker daemons, where
# bind mounts of the build context are not always visible.
COPY config.json /config.json
ENV JSON_CONFIG_PATH=/config.json
+74
View File
@@ -0,0 +1,74 @@
{
"interactiveLogin": true,
"tokenCallbacks": [
{
"issuerId": "kca",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "subject",
"match": "alice",
"claims": {
"sub": "alice",
"name": "Alice Anderson",
"given_name": "Alice",
"family_name": "Anderson",
"middle_name": "B.",
"nickname": "ali",
"preferred_username": "alice",
"profile": "https://example.com/alice",
"picture": "https://i.pravatar.cc/128?u=alice",
"website": "https://alice.example.com",
"gender": "female",
"birthdate": "1990-04-12",
"zoneinfo": "Europe/Oslo",
"locale": "en-US",
"updated_at": 1735689600
}
},
{
"requestParam": "subject",
"match": "bob",
"claims": {
"sub": "bob",
"name": "Bob Brown",
"given_name": "Bob",
"family_name": "Brown",
"preferred_username": "bob",
"picture": "https://i.pravatar.cc/128?u=bob",
"zoneinfo": "Europe/Oslo",
"locale": "nb-NO",
"updated_at": 1735689600
}
},
{
"requestParam": "subject",
"match": "carol",
"claims": {
"sub": "carol",
"name": "Carol",
"preferred_username": "carol",
"locale": "en-GB"
}
},
{
"requestParam": "subject",
"match": "dave",
"claims": {
"sub": "dave",
"name": "Dave",
"preferred_username": "dave"
}
},
{
"requestParam": "subject",
"match": ".*",
"claims": {
"sub": "${subject}",
"preferred_username": "${subject}"
}
}
]
}
]
}
@@ -0,0 +1,287 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "api_token" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"token_prefix" VARCHAR(16) NOT NULL,
"token_hash" VARCHAR(64) NOT NULL UNIQUE,
"created_at" TIMESTAMPTZ NOT NULL,
"last_used_at" TIMESTAMPTZ,
"expires_at" DATE
);
CREATE INDEX IF NOT EXISTS "idx_api_token_user_su_7c5de7" ON "api_token" ("user_sub");
COMMENT ON TABLE "api_token" IS 'Personal access token for programmatic API access.';
CREATE TABLE IF NOT EXISTS "app_user" (
"id" UUID NOT NULL PRIMARY KEY,
"sub" VARCHAR(255) NOT NULL UNIQUE,
"display_name" VARCHAR(200) NOT NULL,
"name" VARCHAR(255),
"family_name" VARCHAR(255),
"given_name" VARCHAR(255),
"middle_name" VARCHAR(255),
"nickname" VARCHAR(255),
"preferred_username" VARCHAR(255),
"profile" VARCHAR(2048),
"picture" VARCHAR(2048),
"website" VARCHAR(2048),
"gender" VARCHAR(100),
"birthdate" VARCHAR(32),
"zoneinfo" VARCHAR(100),
"locale" VARCHAR(100),
"updated_at" BIGINT,
"created_at" TIMESTAMPTZ NOT NULL,
"last_seen_at" TIMESTAMPTZ NOT NULL
);
COMMENT ON TABLE "app_user" IS 'One OIDC identity that has logged in at least once.';
CREATE TABLE IF NOT EXISTS "vault" (
"id" UUID NOT NULL PRIMARY KEY,
"owner_sub" VARCHAR(255) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"description" TEXT NOT NULL,
"settings" JSONB NOT NULL,
"version" INT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_vault_owner_s_cdc1f8" ON "vault" ("owner_sub");
COMMENT ON TABLE "vault" IS 'One private XCA database owned by an OIDC user.';
CREATE TABLE IF NOT EXISTS "key" (
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"key_type" VARCHAR(20) NOT NULL,
"key_size" INT,
"curve" VARCHAR(100),
"public_der" BYTEA NOT NULL,
"public_key_sha256" VARCHAR(64) NOT NULL,
"private_der" BYTEA,
"private_nonce" BYTEA,
"has_private" BOOL NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_key_vault_i_e0ffb5" UNIQUE ("vault_id", "public_key_sha256")
);
CREATE INDEX IF NOT EXISTS "idx_key_public__eba47e" ON "key" ("public_key_sha256");
CREATE INDEX IF NOT EXISTS "idx_key_vault_i_a6f27f" ON "key" ("vault_id", "name");
CREATE INDEX IF NOT EXISTS "idx_key_vault_i_c2e997" ON "key" ("vault_id", "key_type");
COMMENT ON TABLE "key" IS 'A public or private key item.';
CREATE TABLE IF NOT EXISTS "request" (
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"subject_der" BYTEA NOT NULL,
"req_der" BYTEA NOT NULL,
"public_key_der" BYTEA NOT NULL,
"request_sha256" VARCHAR(64) NOT NULL,
"signed" BOOL NOT NULL,
"signed_at" TIMESTAMPTZ,
"attributes" JSONB NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"key_id" UUID REFERENCES "key" ("id") ON DELETE SET NULL,
"vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_request_vault_i_b661f4" UNIQUE ("vault_id", "request_sha256")
);
CREATE INDEX IF NOT EXISTS "idx_request_request_fad061" ON "request" ("request_sha256");
CREATE INDEX IF NOT EXISTS "idx_request_vault_i_346e22" ON "request" ("vault_id", "name");
CREATE INDEX IF NOT EXISTS "idx_request_vault_i_2cd9ef" ON "request" ("vault_id", "signed");
COMMENT ON TABLE "request" IS 'A PKCS#10 certificate signing request item.';
CREATE TABLE IF NOT EXISTS "certificate" (
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"serial_hex" VARCHAR(128) NOT NULL,
"subject_der" BYTEA NOT NULL,
"issuer_der" BYTEA NOT NULL,
"subject_hash" VARCHAR(64) NOT NULL,
"issuer_hash" VARCHAR(64) NOT NULL,
"not_before" TIMESTAMPTZ NOT NULL,
"not_after" TIMESTAMPTZ NOT NULL,
"is_ca" BOOL NOT NULL,
"path_length" INT,
"cert_der" BYTEA NOT NULL,
"public_key_der" BYTEA NOT NULL,
"signature_algorithm" VARCHAR(100) NOT NULL,
"fingerprint_md5" VARCHAR(32) NOT NULL,
"fingerprint_sha1" VARCHAR(40) NOT NULL,
"fingerprint_sha256" VARCHAR(64) NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"issuer_id" UUID REFERENCES "certificate" ("id") ON DELETE SET NULL,
"key_id" UUID REFERENCES "key" ("id") ON DELETE SET NULL,
"request_id" UUID REFERENCES "request" ("id") ON DELETE SET NULL,
"vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_certificate_vault_i_842384" UNIQUE ("vault_id", "fingerprint_sha256")
);
CREATE INDEX IF NOT EXISTS "idx_certificate_serial__787fd1" ON "certificate" ("serial_hex");
CREATE INDEX IF NOT EXISTS "idx_certificate_subject_ed411b" ON "certificate" ("subject_hash");
CREATE INDEX IF NOT EXISTS "idx_certificate_issuer__b8daa0" ON "certificate" ("issuer_hash");
CREATE INDEX IF NOT EXISTS "idx_certificate_is_ca_906cf3" ON "certificate" ("is_ca");
CREATE INDEX IF NOT EXISTS "idx_certificate_fingerp_12be2c" ON "certificate" ("fingerprint_sha256");
CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_18b9a0" ON "certificate" ("vault_id", "name");
CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_dfb18f" ON "certificate" ("vault_id", "issuer_id");
CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_eded8f" ON "certificate" ("vault_id", "not_after");
COMMENT ON TABLE "certificate" IS 'An X.509 certificate item.';
CREATE TABLE IF NOT EXISTS "certificate_revocation" (
"id" UUID NOT NULL PRIMARY KEY,
"serial_hex" VARCHAR(128) NOT NULL,
"revoked_at" TIMESTAMPTZ NOT NULL,
"invalidity_date" TIMESTAMPTZ,
"reason" VARCHAR(50) NOT NULL,
"crl_number" INT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"ca_id" UUID NOT NULL REFERENCES "certificate" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_certificate_ca_id_1c9aa5" UNIQUE ("ca_id", "serial_hex")
);
CREATE INDEX IF NOT EXISTS "idx_certificate_serial__8fa20f" ON "certificate_revocation" ("serial_hex");
COMMENT ON TABLE "certificate_revocation" IS 'A serial revoked by a CA (XCA stores revocations on the CA).';
CREATE TABLE IF NOT EXISTS "crl" (
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"crl_der" BYTEA NOT NULL,
"crl_hash" VARCHAR(64) NOT NULL,
"crl_number" INT,
"last_update" TIMESTAMPTZ NOT NULL,
"next_update" TIMESTAMPTZ,
"revoked_count" INT NOT NULL,
"signature_algorithm" VARCHAR(100) NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"issuer_id" UUID REFERENCES "certificate" ("id") ON DELETE SET NULL,
"vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_crl_vault_i_e5461a" UNIQUE ("vault_id", "crl_hash")
);
CREATE INDEX IF NOT EXISTS "idx_crl_crl_has_c44d61" ON "crl" ("crl_hash");
CREATE INDEX IF NOT EXISTS "idx_crl_vault_i_6af15c" ON "crl" ("vault_id", "name");
CREATE INDEX IF NOT EXISTS "idx_crl_vault_i_576344" ON "crl" ("vault_id", "issuer_id");
COMMENT ON TABLE "crl" IS 'A certificate revocation list item.';
CREATE TABLE IF NOT EXISTS "template" (
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"subject" JSONB NOT NULL,
"settings" JSONB NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_template_vault_i_18d8f5" ON "template" ("vault_id", "name");
COMMENT ON TABLE "template" IS 'An XCA-style template: subject + extension/validity defaults.';
CREATE TABLE IF NOT EXISTS "authority" (
"id" UUID NOT NULL PRIMARY KEY,
"crl_expire" TIMESTAMPTZ,
"crl_number" INT NOT NULL,
"crl_days" INT NOT NULL,
"tls_auth_key" BYTEA,
"tls_auth_key_nonce" BYTEA,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"template_id" UUID REFERENCES "template" ("id") ON DELETE SET NULL,
"certificate_id" UUID NOT NULL UNIQUE REFERENCES "certificate" ("id") ON DELETE CASCADE
);
COMMENT ON TABLE "authority" IS 'CA-specific properties, one row per CA certificate.';
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
MODELS_STATE = (
"eJztXW1z2zYS/isc3ZdkLvHZsuS4nZubkRW39SWxM46Sc9vpcCASknCmSJUEbau5/PcDKF"
"J8A19AixLJ7BcnBrEQ/SwEYp9nsfzaW1o6Npyj0YpMrHts9n5UvvZMtMTsP6lrr5QeWq3C"
"K7yBoqnhdUYrotJtt6lDbaRRdmGGDAezJh07mk1WlFj8Q3ofse1YJjIUpGnYcRTPVJlZtr"
"KyrbmNlktEiaaMPl75PY74uLqlsYGJOa8+hGuSP13MbnWO6QLbbKDf/2DNxNTxE3aCX1f3"
"6oxgQ4/hQXQ+gNeu0vXKa/v8+ertT15PfntTVbMMd2mGvVdrurDMbXfXJfoRt+HX5tjENq"
"JYj8BkuobhQxo0be6YNVDbxdtb1cMGHc+Qa3Cwe/+cuabGMVa8T+I/Bv/yby3STVWvbybq"
"p8uJqvZSvuG3kMDab9Isk/uVmJQD9fXbZtwQEK+1xz9g/Mvo9sXp2UsPAsuhc9u76MHV++"
"YZIoo2ph7oIcqug23VcadprMcLZIuxjtokEGc3XAXroCEH7ACnGpDtLdGTamBzThfs1/5w"
"mAP1l9Gthzbr5cFtsS/e5it57V/qb65x2EOYvX8lIA761wVvuE7sHd+T4+MS+LJemfh61+"
"L4equRurLxjDzJ4Jy06yLeZ2XgPstG+0wM9gI5C3moA6vdAF28Ru8N5rNBCZjPBpkw80tx"
"mDUbcyhURNMwv2VXKFliMdRxywTUum96FPynjTOc/YH6jWmsfZfnQD+5+nD5aTL68JF/3N"
"Jx/jQ8/EaTS36l77WuE60vkt+G7SDKf64mvyj8V+W3m+vL5AN322/yW4/fE3KppZrWo4r0"
"yOwMWgPUYl43kENV9oSt4vek7Q48799yUxzfEkcHmOR6Gj+tCBsu089iH8et8jzcKe9yj7"
"H1kUcLs/vITpY3TJF2/4hsXU1dsfpWVt/0pWV/mWxBJpp7MPD75XcXBGkug90mdN0TRXDb"
"i69yQ7hYt8IQbjx67aywRmYsyGIR1wrblGDnlWKZWLGtR4U1KOORovF21of5PB3DVRwDgr"
"iGB3GabaibdUF+pxC1hOdFk58X3Femu5xiO+3lK5NmOzg0SjiYoVTX5u+4rp3fnN/B6/7J"
"4M3g/PRscM66eHe5bXmT4/Or60lqk22oOlo7kpAGJvsD9LQtiFKDbU7Ys029x+s0qhfERP"
"Y6I0JMWCbAna4pdjq1BF28v7mIrT4sGry4uh7d/vriw+juZWwJen9z/XPQP7qyXPw6uRzl"
"uIAtJ6YmeC6UdURoD+6o6A4I5LMXn+4G8u5Kr+j1uCV4vSleF+zS/LuPLL14uTKYJ1S5yC"
"dh9owQqD3LbWHAE1lBw4hUEti05X7Dy0ZCm2JR0vM3jfFPlo3J3HyHN5uGK3YvSLwz8HmQ"
"SWSoVk7asDW8RRs9bmmO5LeWQcD+cEw9ENg9KNef37/vfZOhqIRTPu2LGxNPLPajpCfG8b"
"HaOMsLfZH+osfcMR59Go/eXia88SwSMIqqgAZMgJ5NBCY8XUwFjkzl7mh4/EOUqFMIm4xp"
"xi+/q4DY+7334PnPm3LmHNsrNhRVnQXqD896fyR4v0hv769nlyNNxHFcNmq80bSoimaUtw"
"NreFDWEHISwpyPUjkJ/ZychH46J0GzlktsCnb/E/yURS2FJvtDuXeAaTu5vJvEdvQBkuJI"
"O+geQX7Mg+844I7l2iKqI3tKhxZ7hDscdE9zu9TUzpnZyYntYJsgQ11gqVSbuFX38sZO+u"
"dlEm3659mZNvxaAmp3+l+sUXbvAtI/j8JLGFbk7pq2ah+AvNtsYuQdELcD/KviH0xk2XSz"
"pF33FpzdZ5z5c1YW6oQZIF2MNI+CpnhmySv2ccsOksPtJYPTGkAY7Fbw8tYQnNxoJxNH1Z"
"Bgc2BZBkZm1pLp2yQ3BsyoptVy69897wtubt7HPHlxlQzlPn+4uGQ7Y8+FrBPZMHbpzIIV"
"ootgUU7BnZmukbCqlLHRMH5650kw2K4QYkStYH9bdX+7cqcG0bxMC2kPpG3BD5XjDDI3EX"
"VtrCJjzvNyF0upcENs3gEWbw+nt6JKw1IfyuAuMO0c5qf9EpCf9jMR55eyAXcW6KQq4oFt"
"5yAflJnlg+xJPsid476aVh103xoi7eJI+wHbDr+l8hvGiMX+0ntPWrJVdDCytYVK8ZOUsJ"
"Uw68B6sW9xC5JIs+d4O9MJIYn0e/R6mSRSn1CXTIOJGkECaXz15CGyHJyhBWAZx9LG7I9x"
"qCSecSvANLFL5TcliWjUZpfZb60FNCetOZLU+Kyc5i/BOO0EtTCLNjqnxPmziVV1B5i+24"
"zSyq99IaDhYyQjOzz93N8BpHIJ3y2FNrbhKYGu/wTaAby34UjdhDb+sJY+1xCJWhfE0G0s"
"oF8ufMuf3t1iA1Ex4dLl6ZyYmw+W5qEgOIleEarb7ZgdemAlD+8/Fy67jNzelhklVfImhD"
"FWiUaMZXDgqBjRWPWbDuFa7WBQ5DuYf0Qo/mUtdVhIteM2xeeGlE0OtsIN77GuTNcK4gV/"
"XtyxHw5lj0FHiaxEimUqdIFZj5eCk0XPHEx49miTExPJFE8eOIIjQwc9MgSZ/3vL/Pe/Vh"
"WY37hlB5nf9jK9guRB8wEZRGfPSzWo2Sfja4E5FPFqsr9tjByRGp29eoYWe1RNXdMvFLiv"
"U2rDMgkXw+yEi2H6+CWUS6ujXBqo0N3SI8uo0BqSrbqCQJcopUuIDg7UzPY2FNfiAh+ofF"
"2PvRUC5vyRKLDf0Eo5YbzfoUTMHq3cEcbTikEcmlX0o5xNfvUP/iT0TvRVq/kBgTrU9oDa"
"HlDbI4ok1PZofm0Pr7ix9Emw0AgOICXnd/kqsYb0yfuoTfd41TpeqXMYTqBhhNquWYHNe2"
"dWVQjMhGkHeYGW8ACl2EuT7W4qOjphCix1k/0cSEia5Yo2uZlLZcoOGNTUASo4Z1xKU63j"
"nDEcXoPDa03bHnRYNoDDa13zumDzAIfX4HBQGwCFw0FFoO74cBAcZpHBtsxhlsNInPx4lk"
"Di9E9tZUuc/uGwMhLnpo6VYtnKyiYPXLRkxpniZkHvfFkzUjKr0jsNuKE3P0HhBIUTFE5Q"
"OEHhbJnCuV3BJZCO2nRyAakDZIf8JQA5kzuOmoDIJki9de0HqUm7Nag0Y5uGZv3ssL81rF"
"qFFRIgdlcJV75AotAYUiKKUyL8GKrCtI8bwtuhq057H8cK7+lOmYITKjphgRzVR1PggrxK"
"/gnL2ur5pz2wbWltQX+ozbrTHSLI2yBvN20f2VKhE+Tt79HrZeRtEGNBjG2CYFgsxpaqzx"
"Zqq7srPNbaiKOoRptXFO+ZOHWudqBc1TFJ8TlASyBAR4DMFqEjNR/LCNEf340//e3kOHZ+"
"lmfcMlwUf6hMWVrKNl+kDsovVlKo+WcyKEGfBn26IWw96NNdCHJBn96PdAqvfD80Gcwev/"
"LoR4wA+arIw8swm+GHxAZUYs1PW4IIWyzC+nv29HzPU55CIxCdJESnDWwVWNuYIZxfbg5H"
"m6bmEWV/69QVklr//nRzLXZw3CrpYaJR5X9eCa3a9q5h2Dt1iUGJ6RzxzztE5MtRyg8hkt"
"FCwo18gGQIAXpvD/TeDoTCoPdmz/F2Kn+g936PXi+j98KbIwtWWDjIDNp5s0CFtxw27i2H"
"kIvQbJ19gpcrA3leSwnt22uv8pR2Gu1VLLWbyt149NqhawMrgemPii9hKH9XWHCCTR76/S"
"N4wYniI+0IxPfnjiaQ44s0d5DXQV4HeR3k9SiSIK+3Rl6X4YUjJkAK75wUdjBlf9RciqmP"
"2jTDJfxjO+MS4Ol7wNN34JkKPH32HG8nYws8/ffo9TI8PXDLwC03gQrdzbksNtMXvLo7eS"
"4VOvIH6hD5XCsR+tnx6L8UCeq1v8ojQNkV1Q16FRKgNyZWbq7ejhWiY5NyQpIuEFUWyFEM"
"a85uTSGmwhoMjByq8Poeadqz4hjFZCdwmwflNh13KsUDbbrvJj4oRnh/vOZwWIb8GQ6z2R"
"9+LR4S6MRZGWityvLHSTvgkcvxyHvl6Rv2ANvDdJ6hJTHkZ3PCDMAuBfacPGBTGuu4FUBd"
"Cuol0XUDS2OdMAOwS4FtEu1eep2O2ADMpWBe2XiGbRvrXpggC7jYGqAvCb01I4Yk3luTDo"
"J8PDgvtdcbnOds9vjFBM5E4++mk8I5NAGcy+L8iKcOEZWqzMY5YgI4l8WZYSI8h5yz29ta"
"dA/lWqpuT4lNF+IXymajHDPqHtCn/RI4n/YzYeaX4ij/ZZmYmDNLBuSoTfcwrmUyG5aG5L"
"YZoQVAXAriPHX3gswzM1Rytd02v2Dih37/9PRN//j07Hw4ePNmeH68zVdJX8pLXLm4+pnn"
"rsRcIXgNBeRUdExdL5NTYSBe0wNjs4Lfk7bg+aZ4PiuvIpUGkC1Z16nFfvGdlBJjtzkE2W"
"rsNl2hnBQbvEnwbjxSuC43RQ5WrEcT68p0rSBzo7Ny5kUsw0ragwS7s69ePRIsd52tSgqx"
"MaPulT2qh3uGczy16q/RO0vBnJ17nDCD3GP58zxwrAGONXT6WANEgtlQtzMegOz679HrO4"
"gCD1/EoWlzITN5Ob6CGs8Fya6t0uehwbnH62eCU67kSivB2fe7SFoJUlAJ5JkoReufdAWm"
"Omm7EbaJtugJeDv/yqvcYxRhnyLmLhuGHfNrmfGBkF4ThAb+xqq5ifc7CQ2y6bTMyCub6M"
"kOvTrB9dTBpfEvlQTCfvcOoluL8Mw+kQor4mTzORGT/dM59T9WaiVuDqpBffs/ebmCjQ=="
)
+5
View File
@@ -0,0 +1,5 @@
# kca-app
The deployable KCA application: environment-driven settings, kaya app assembly
(sessions, OIDC, Tortoise, OpenAPI, CORS, optional OpenTelemetry), the HTTP
API, Tortoise models, aerich migrations, and the SPA/PWA shell hosting.
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "kca-app"
version = "0.1.0"
description = "KCA: kaya application wiring, Tortoise models, API and SPA hosting"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kca-core",
"kaya-core>=0.0.7",
"kaya-cors",
"kaya-session",
"kaya-session-redis",
"kaya-oidc>=0.0.7",
"kaya-openapi",
"kaya-rsgi",
"granian>=2.0",
"tortoise-orm>=0.25",
"asyncpg",
"aerich",
"httpx",
"PyJWT[crypto]",
"PyYAML",
"redis",
"cryptography>=44",
"pyOpenSSL>=25",
"paramiko>=3.5",
]
[project.optional-dependencies]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0",
]
[dependency-groups]
dev = [
"mypy",
"types-PyYAML",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
[[tool.mypy.overrides]]
module = "kca.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "kca.service"
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
[[tool.mypy.overrides]]
module = "kca.routes.*"
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
@@ -0,0 +1,14 @@
"""Tortoise ORM configuration consumed by the aerich CLI."""
from __future__ import annotations
from .config import settings
TORTOISE_ORM = {
"connections": {"default": settings.database_url},
"apps": {
"models": {
"models": ["kca.models", "aerich.models"],
"default_connection": "default",
}
},
}
+110
View File
@@ -0,0 +1,110 @@
"""Application entry point.
Assembles the kaya app with sessions, OIDC, Tortoise, OpenAPI, the KCA API
and the SPA shell. Optional CORS mirrors spendwise's environment-driven
configuration.
"""
from __future__ import annotations
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.session import SessionMixin
from redis.asyncio import Redis
from .config import Settings, settings
from .logging_config import configure_logging
from .mixin import Kca, KcaMixin
from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI
from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config)
log = getLogger(__name__)
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
return None
return CorsMixin(
allow_origins=settings.cors_allow_origins or (),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_methods=settings.cors_allow_methods or ("GET",),
allow_headers=settings.cors_allow_headers or (),
allow_credentials=settings.cors_allow_credentials,
expose_headers=settings.cors_expose_headers or (),
max_age=settings.cors_max_age,
)
if settings.master_key_is_dev:
log.warning(
"KCA_MASTER_KEY is unset: using the built-in development key. "
"Set a random 32-byte base64 key in production."
)
if settings.redis_url is not None:
from kaya.session.redis import RedisSessionStore
session_store: Any = RedisSessionStore(Redis.from_url(settings.redis_url))
log.info("using the Redis session store")
else:
from kaya.session import InMemorySessionStore
session_store = InMemorySessionStore()
log.info("REDIS_URL unset: using the in-memory session store")
session_mixin = SessionMixin(session_store, secure=settings.session_cookie_secure)
oidc = OIDCProviders(
[
OIDCConfig(
provider=provider.provider,
issuer=provider.issuer,
client_id=provider.client_id,
client_secret=provider.client_secret,
redirect_uri=provider.redirect_uri,
post_login_redirect=provider.post_login_redirect,
post_logout_redirect=provider.post_logout_redirect,
scopes=("openid", "profile"),
fetch_userinfo=True,
)
for provider in settings.oidc_providers
],
session=session_mixin,
)
log.info("OIDC providers: %s", ", ".join(p.provider for p in settings.oidc_providers))
openapi_mixin = KcaOpenAPI(
title="KCA",
version="0.1.0",
description="Key and certificate authority API (XCA web reimplementation)",
spec_path="/api/openapi.json",
docs_path="/api/docs",
components={"schemas": SCHEMAS, "securitySchemes": SECURITY_SCHEMES},
)
tortoise_mixin = TortoiseMixin(
database_url=settings.database_url,
models_modules=["kca.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
kca = Kca(oidc=oidc)
mixins: list[KayaMixin] = [
session_mixin,
oidc,
tortoise_mixin,
openapi_mixin,
KcaMixin(kca),
]
cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None:
mixins.insert(0, cors_mixin)
log.info("CORS enabled: origins=%s", settings.cors_allow_origins)
app = KayaApp(mixins=mixins)
# Register the SPA shell and PWA entry points.
from . import static # noqa: E402,F401
+192
View File
@@ -0,0 +1,192 @@
"""Authentication helpers on top of the kaya-oidc mixin."""
from __future__ import annotations
from logging import getLogger
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
from kaya.core import HttpContext
from kaya.oidc import OIDCProviders, OIDCUser
from kaya.session import Session
from .models import User
from .tokens import bearer_token, resolve_token
log = getLogger(__name__)
PROFILE_CLAIMS = (
"name",
"family_name",
"given_name",
"middle_name",
"nickname",
"preferred_username",
"profile",
"picture",
"website",
"gender",
"birthdate",
"zoneinfo",
"locale",
"updated_at",
)
_URL_CLAIMS = frozenset({"profile", "picture", "website"})
_CLAIM_MAX_LENGTH: Dict[str, int] = {
"name": 255,
"family_name": 255,
"given_name": 255,
"middle_name": 255,
"nickname": 255,
"preferred_username": 255,
"profile": 2048,
"picture": 2048,
"website": 2048,
"gender": 100,
"birthdate": 32,
"zoneinfo": 100,
"locale": 100,
}
_OIDC_PREFIX = "oidc:"
_USER_SUFFIX = ":user"
_CACHED_USER_ATTR = "_kca_user"
_UNAUTHENTICATED_BODY = b'{"error":"unauthenticated"}'
_UNAUTHENTICATED_HEADERS = {"content-type": ("application/json",)}
def session_user(session: Session) -> Optional[OIDCUser]:
for key in session:
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
claims = session.get(key)
if isinstance(claims, Mapping):
return OIDCUser(claims)
return None
def display_name(user: OIDCUser) -> str:
for key in ("preferred_username", "name"):
value = user.get(key)
if isinstance(value, str) and value:
return value
return user.sub
def _text_claim(value: Any, max_length: int) -> Optional[str]:
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
return value[:max_length]
def _url_claim(value: Any, max_length: int) -> Optional[str]:
value = _text_claim(value, max_length)
if value is not None and not value.startswith(("http://", "https://")):
return None
return value
def profile_claims(user: OIDCUser) -> Dict[str, Any]:
values: Dict[str, Any] = {}
for name in PROFILE_CLAIMS:
raw = user.get(name)
if name == "updated_at":
values[name] = raw if isinstance(raw, int) and not isinstance(raw, bool) else None
elif name in _URL_CLAIMS:
values[name] = _url_claim(raw, _CLAIM_MAX_LENGTH[name])
else:
values[name] = _text_claim(raw, _CLAIM_MAX_LENGTH[name])
return values
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
return next(iter(oidc.get_users(ctx).values()), None)
async def ensure_user(user: OIDCUser) -> User:
updates: Dict[str, Any] = profile_claims(user)
updates["display_name"] = display_name(user)
row = await User.get_or_none(sub=user.sub)
if row is None:
row = await User.create(sub=user.sub, **updates)
log.info("registered user sub=%s name=%r", user.sub, updates["display_name"])
return row
changed = [field for field, value in updates.items() if getattr(row, field) != value]
if changed:
for field in changed:
setattr(row, field, updates[field])
await row.save(update_fields=changed)
return row
async def _resolve_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]:
secret = bearer_token(ctx)
if secret is not None:
return await resolve_token(secret)
oidc_user = current_user(oidc, ctx)
if oidc_user is None:
return None
return await ensure_user(oidc_user)
async def _send_unauthenticated(ctx: HttpContext) -> None:
await ctx.send_bytes(401, _UNAUTHENTICATED_BODY, _UNAUTHENTICATED_HEADERS)
async def authenticated_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]:
cached = getattr(ctx, _CACHED_USER_ATTR, None)
if cached is not None:
return cached
return await _resolve_user(oidc, ctx)
def cached_user(ctx: HttpContext) -> Optional[User]:
user = getattr(ctx, _CACHED_USER_ATTR, None)
return user if isinstance(user, User) else None
def _require_user(
resolve: Callable[[HttpContext], Awaitable[Optional[User]]],
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
user = await resolve(ctx)
if user is None:
await _send_unauthenticated(ctx)
return
setattr(ctx, _CACHED_USER_ATTR, user)
await handler(ctx, *args, **kwargs)
return guarded
return decorator
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
return _require_user(lambda ctx: _resolve_user(oidc, ctx))
def require_session_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
async def resolve_session(ctx: HttpContext) -> Optional[User]:
oidc_user = current_user(oidc, ctx)
if oidc_user is None:
return None
return await ensure_user(oidc_user)
return _require_user(resolve_session)
__all__ = [
"PROFILE_CLAIMS",
"authenticated_user",
"cached_user",
"current_user",
"display_name",
"ensure_user",
"profile_claims",
"require_auth",
"require_session_auth",
"session_user",
]
+237
View File
@@ -0,0 +1,237 @@
"""Environment-driven configuration for KCA.
Mirrors kaya's own pattern: read ``os.environ`` directly into a frozen
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import quote
from kca.core.crypto import decode_master_key
# Per-provider OIDC settings are indexed with a 0-based suffix.
_OIDC_VARIABLES = (
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
)
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/kca"
_DEFAULT_OIDC_CLIENT_ID = "kca"
#: Development-only master key. Production deployments must set
#: KCA_MASTER_KEY to a random 32-byte base64 value.
_DEV_MASTER_KEY = hashlib.sha256(b"kca-development-master-key").digest()
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
return value
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
value = os.environ.get(name)
if value is None or value.strip() == "":
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True)
class OidcProviderSettings:
provider: str
issuer: str
client_id: str
client_secret: Optional[str]
redirect_uri: str
post_login_redirect: str
post_logout_redirect: str
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
indices: set[int] = set()
for name in os.environ:
match = _OIDC_ENV.match(name)
if match is None:
continue
index, variable = match.groups()
if variable not in _OIDC_VARIABLES:
raise RuntimeError(
f"unknown OIDC environment variable {name!r}; expected one of "
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
)
indices.add(int(index))
if not indices:
return (
OidcProviderSettings(
provider="default",
issuer=_DEFAULT_OIDC_ISSUER,
client_id=_DEFAULT_OIDC_CLIENT_ID,
client_secret=None,
redirect_uri="http://localhost:8080/auth/default/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
)
missing = sorted(set(range(max(indices) + 1)) - indices)
if missing:
raise RuntimeError(
"OIDC provider indices must be contiguous from 0; missing "
+ ", ".join(f"OIDC_{index}_*" for index in missing)
)
providers = []
seen = set()
for index in sorted(indices):
prefix = f"OIDC_{index}_"
provider = _env(prefix + "PROVIDER")
if _PROVIDER_NAME.match(provider) is None:
raise RuntimeError(
f"{prefix}PROVIDER must be a non-empty string of letters, "
f"digits, underscores or hyphens (got {provider!r})"
)
if provider in seen:
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
seen.add(provider)
providers.append(
OidcProviderSettings(
provider=provider,
issuer=_env(prefix + "ISSUER"),
client_id=_env(prefix + "CLIENT_ID"),
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
redirect_uri=_env(
prefix + "REDIRECT_URI",
f"http://localhost:8080/auth/{provider}/callback",
),
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
)
)
return tuple(providers)
def _database_url_from_parts(
engine: str,
user: str,
password: Optional[str],
host: str,
port: str,
name: str,
options: str,
) -> str:
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_providers: Tuple[OidcProviderSettings, ...]
app_host: str
app_port: int
redis_url: Optional[str]
static_dir: str
logging_config: Optional[str]
#: AES-256-GCM master key protecting private keys at rest.
master_key: bytes
#: True when the master key came from the built-in development default.
master_key_is_dev: bool
max_upload_bytes: int
keygen_concurrency: int
session_cookie_secure: bool
cors_allow_origins: Optional[Tuple[str, ...]]
cors_allow_origin_regex: Optional[str]
cors_allow_methods: Optional[Tuple[str, ...]]
cors_allow_headers: Optional[Tuple[str, ...]]
cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int
otel_enabled: bool
otel_service_name: str
otel_exporter_endpoint: Optional[str]
otel_exporter_headers: Optional[Tuple[str, ...]]
otel_excluded_paths: Tuple[str, ...]
otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int
@staticmethod
def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY")
master_key_is_dev = not raw_master
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY
return Settings(
database_url=os.environ.get("DATABASE_URL")
or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "kca"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "kca"),
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_providers=_oidc_providers_from_env(),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
redis_url=os.environ.get("REDIS_URL") or None,
static_dir=_env("STATIC_DIR", "web/dist"),
logging_config=os.environ.get("LOGGING_CONFIG") or None,
master_key=master_key,
master_key_is_dev=master_key_is_dev,
max_upload_bytes=int(_env("MAX_UPLOAD_BYTES", str(20 * 1024 * 1024))),
keygen_concurrency=max(1, int(_env("KEYGEN_CONCURRENCY", "2"))),
session_cookie_secure=_env_bool("SESSION_COOKIE_SECURE"),
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
otel_enabled=_env_bool("OTEL_ENABLED"),
otel_service_name=_env("OTEL_SERVICE_NAME", "kca"),
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
otel_metric_export_interval_millis=int(
_env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000")
),
otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
),
)
settings: Settings = Settings.from_env()
+73
View File
@@ -0,0 +1,73 @@
"""JSON helpers for kaya HTTP handlers.
Kaya has no built-in request/response JSON helpers: the request body is an
async byte stream on ``ctx.request_body`` and responses are sent with
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
parsing JSON, and sending JSON responses.
"""
from __future__ import annotations
import json
from typing import Any, List, Mapping
from kaya.core import HttpContext
JSON_HEADERS = {"content-type": ("application/json",)}
class JsonRequestError(ValueError):
"""Raised by :func:`read_json` when the request body is not valid JSON
or is not a JSON object."""
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
"""Parse a raw query string into a mapping of param name to list of
values.
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
incantation; always returns a mapping (never None).
"""
from urllib.parse import parse_qs
return parse_qs(query_string, keep_blank_values=True)
async def read_json(ctx: HttpContext) -> dict:
"""Drain and parse the request body as JSON.
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
with a short human-readable reason on failure.
"""
body = b""
async for chunk in ctx.request_body:
body += chunk
if not body:
raise JsonRequestError("empty body")
try:
parsed = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise JsonRequestError("invalid JSON") from exc
if not isinstance(parsed, dict):
raise JsonRequestError("JSON body must be an object")
return parsed
async def read_json_optional(ctx: HttpContext) -> dict:
"""Like :func:`read_json` but treats an empty body as ``{}``."""
try:
return await read_json(ctx)
except JsonRequestError as exc:
if str(exc) == "empty body":
return {}
raise
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
"""Send ``payload`` as a JSON response."""
body = json.dumps(payload).encode("utf-8")
await ctx.send_bytes(status, body, JSON_HEADERS)
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
"""Send a JSON error envelope."""
await send_json(ctx, status, {"error": message})
@@ -0,0 +1,84 @@
"""Logging setup for the kca application.
Configured once at app import time (see :mod:`kca.app`). By default a
single console handler at DEBUG level is installed, formatting records as
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``;
``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG
chatter does not drown the log.
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
over the configuration entirely; the file follows the
:data:`logging.config.dictConfig` schema, e.g.::
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
kca:
level: INFO
``disable_existing_loggers`` should stay ``false``: Granian configures its
own loggers before importing the application, and disabling them would
silence the server and Tortoise.
"""
from __future__ import annotations
from logging.config import dictConfig
from typing import Optional
import yaml
DEFAULT_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
},
"loggers": {
# Every pooled HTTP request is a DEBUG record; keep the logger's
# INFO+ output, drop the per-connection noise.
"urllib3.connectionpool": {
"level": "INFO",
},
},
}
def configure_logging(config_path: Optional[str]) -> None:
"""Apply the YAML logging configuration at ``config_path``, or the
built-in default when unset."""
if config_path is None:
dictConfig(DEFAULT_CONFIG)
return
try:
with open(config_path, "rb") as handle:
config = yaml.safe_load(handle)
except OSError as exc:
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
if not isinstance(config, dict):
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
dictConfig(config)
+49
View File
@@ -0,0 +1,49 @@
"""The KCA mixin: mounts the whole API on a kaya app."""
from __future__ import annotations
from dataclasses import dataclass
from kaya.core import KayaApp, KayaMixin
from kaya.oidc import OIDCProviders
@dataclass
class Kca:
oidc: OIDCProviders
class KcaMixin(KayaMixin):
def __init__(self, kca: Kca) -> None:
self.kca = kca
def apply(self, app: KayaApp) -> None:
from .routes import (
auth,
certificates,
crls,
health,
import_,
keys,
me,
requests,
search,
templates,
tokens,
vaults,
)
for module in (
health,
auth,
me,
tokens,
vaults,
search,
keys,
requests,
certificates,
crls,
templates,
import_,
):
module.register(app, self.kca)
+332
View File
@@ -0,0 +1,332 @@
"""Tortoise ORM models for KCA.
The schema mirrors XCA's entities rather than spendwise's expense-tracking
model: a *vault* is one XCA database, and keys, certificates, requests, CRLs,
templates and revocations are its items. Private key material is stored as
AES-256-GCM ciphertext (``private_der`` + ``private_nonce``); the plaintext
exists only in memory during a request.
"""
from __future__ import annotations
from typing import List
from tortoise import fields
from tortoise.models import Model
class User(Model):
"""One OIDC identity that has logged in at least once."""
id = fields.UUIDField(pk=True)
sub = fields.CharField(max_length=255, unique=True)
display_name = fields.CharField(max_length=200)
name = fields.CharField(max_length=255, null=True)
family_name = fields.CharField(max_length=255, null=True)
given_name = fields.CharField(max_length=255, null=True)
middle_name = fields.CharField(max_length=255, null=True)
nickname = fields.CharField(max_length=255, null=True)
preferred_username = fields.CharField(max_length=255, null=True)
profile = fields.CharField(max_length=2048, null=True)
picture = fields.CharField(max_length=2048, null=True)
website = fields.CharField(max_length=2048, null=True)
gender = fields.CharField(max_length=100, null=True)
birthdate = fields.CharField(max_length=32, null=True)
zoneinfo = fields.CharField(max_length=100, null=True)
locale = fields.CharField(max_length=100, null=True)
updated_at = fields.BigIntField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
last_seen_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "app_user"
ordering = ["display_name", "id"]
def __str__(self) -> str:
return f"User(sub={self.sub!r}, display_name={self.display_name!r})"
class ApiToken(Model):
"""Personal access token for programmatic API access."""
id = fields.UUIDField(pk=True)
user_sub = fields.CharField(max_length=255, db_index=True)
name = fields.CharField(max_length=100)
token_prefix = fields.CharField(max_length=16)
token_hash = fields.CharField(max_length=64, unique=True)
created_at = fields.DatetimeField(auto_now_add=True)
last_used_at = fields.DatetimeField(null=True)
expires_at = fields.DateField(null=True)
class Meta:
table = "api_token"
ordering = ["-created_at", "id"]
def __str__(self) -> str:
return f"ApiToken(name={self.name!r}, prefix={self.token_prefix!r})"
class Vault(Model):
"""One private XCA database owned by an OIDC user."""
id = fields.UUIDField(pk=True)
owner_sub = fields.CharField(max_length=255, db_index=True)
name = fields.CharField(max_length=200)
description = fields.TextField(default="")
settings = fields.JSONField(default=dict)
version = fields.IntField(default=1)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
keys: fields.ReverseRelation["Key"]
certificates: fields.ReverseRelation["Certificate"]
requests: fields.ReverseRelation["Request"]
crls: fields.ReverseRelation["Crl"]
templates: fields.ReverseRelation["Template"]
class Meta:
table = "vault"
ordering = ["name", "id"]
def __str__(self) -> str:
return f"Vault(name={self.name!r})"
class Key(Model):
"""A public or private key item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="keys", on_delete=fields.CASCADE
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
key_type = fields.CharField(max_length=20)
key_size = fields.IntField(null=True)
curve = fields.CharField(max_length=100, null=True)
public_der = fields.BinaryField()
public_key_sha256 = fields.CharField(max_length=64, db_index=True)
private_der = fields.BinaryField(null=True)
private_nonce = fields.BinaryField(null=True)
has_private = fields.BooleanField(default=False)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "key"
ordering = ["name", "id"]
unique_together = (("vault", "public_key_sha256"),)
indexes = (("vault", "name"), ("vault", "key_type"))
def __str__(self) -> str:
return f"Key(name={self.name!r}, type={self.key_type!r})"
class Certificate(Model):
"""An X.509 certificate item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="certificates", on_delete=fields.CASCADE
)
key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField(
"models.Key", related_name="certificates", on_delete=fields.SET_NULL, null=True
)
issuer: fields.ForeignKeyRelation["Certificate"] = fields.ForeignKeyField(
"models.Certificate", related_name="children", on_delete=fields.SET_NULL, null=True
)
request: fields.ForeignKeyRelation["Request"] = fields.ForeignKeyField(
"models.Request", related_name="certificates", on_delete=fields.SET_NULL, null=True
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
serial_hex = fields.CharField(max_length=128, db_index=True)
subject_der = fields.BinaryField()
issuer_der = fields.BinaryField()
subject_hash = fields.CharField(max_length=64, db_index=True)
issuer_hash = fields.CharField(max_length=64, db_index=True)
not_before = fields.DatetimeField()
not_after = fields.DatetimeField()
is_ca = fields.BooleanField(default=False, db_index=True)
path_length = fields.IntField(null=True)
cert_der = fields.BinaryField()
public_key_der = fields.BinaryField()
signature_algorithm = fields.CharField(max_length=100, default="")
fingerprint_md5 = fields.CharField(max_length=32, default="")
fingerprint_sha1 = fields.CharField(max_length=40, default="")
fingerprint_sha256 = fields.CharField(max_length=64, db_index=True)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "certificate"
ordering = ["name", "id"]
unique_together = (("vault", "fingerprint_sha256"),)
indexes = (("vault", "name"), ("vault", "issuer"), ("vault", "not_after"))
def __str__(self) -> str:
return f"Certificate(name={self.name!r}, serial={self.serial_hex!r})"
class Authority(Model):
"""CA-specific properties, one row per CA certificate."""
id = fields.UUIDField(pk=True)
certificate: fields.OneToOneRelation[Certificate] = fields.OneToOneField(
"models.Certificate", related_name="authority", on_delete=fields.CASCADE
)
template: fields.ForeignKeyRelation["Template"] = fields.ForeignKeyField(
"models.Template", related_name="authorities", on_delete=fields.SET_NULL, null=True
)
crl_expire = fields.DatetimeField(null=True)
crl_number = fields.IntField(default=0)
crl_days = fields.IntField(default=30)
tls_auth_key = fields.BinaryField(null=True)
tls_auth_key_nonce = fields.BinaryField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "authority"
def __str__(self) -> str:
return f"Authority(certificate={self.certificate_id})"
class CertificateRevocation(Model):
"""A serial revoked by a CA (XCA stores revocations on the CA)."""
id = fields.UUIDField(pk=True)
ca: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField(
"models.Certificate", related_name="revocations", on_delete=fields.CASCADE
)
serial_hex = fields.CharField(max_length=128, db_index=True)
revoked_at = fields.DatetimeField()
invalidity_date = fields.DatetimeField(null=True)
reason = fields.CharField(max_length=50, default="unspecified")
crl_number = fields.IntField(default=0)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "certificate_revocation"
ordering = ["-revoked_at", "id"]
unique_together = (("ca", "serial_hex"),)
def __str__(self) -> str:
return f"Revocation(ca={self.ca_id}, serial={self.serial_hex!r})"
class Request(Model):
"""A PKCS#10 certificate signing request item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="requests", on_delete=fields.CASCADE
)
key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField(
"models.Key", related_name="requests", on_delete=fields.SET_NULL, null=True
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
subject_der = fields.BinaryField()
req_der = fields.BinaryField()
public_key_der = fields.BinaryField()
request_sha256 = fields.CharField(max_length=64, db_index=True)
signed = fields.BooleanField(default=False)
signed_at = fields.DatetimeField(null=True)
attributes = fields.JSONField(default=list)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "request"
ordering = ["name", "id"]
unique_together = (("vault", "request_sha256"),)
indexes = (("vault", "name"), ("vault", "signed"))
def __str__(self) -> str:
return f"Request(name={self.name!r}, signed={self.signed})"
class Crl(Model):
"""A certificate revocation list item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="crls", on_delete=fields.CASCADE
)
issuer: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField(
"models.Certificate", related_name="crls", on_delete=fields.SET_NULL, null=True
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
crl_der = fields.BinaryField()
crl_hash = fields.CharField(max_length=64, db_index=True)
crl_number = fields.IntField(null=True)
last_update = fields.DatetimeField()
next_update = fields.DatetimeField(null=True)
revoked_count = fields.IntField(default=0)
signature_algorithm = fields.CharField(max_length=100, default="")
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "crl"
ordering = ["-last_update", "id"]
unique_together = (("vault", "crl_hash"),)
indexes = (("vault", "name"), ("vault", "issuer"))
def __str__(self) -> str:
return f"Crl(name={self.name!r}, number={self.crl_number})"
class Template(Model):
"""An XCA-style template: subject + extension/validity defaults."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="templates", on_delete=fields.CASCADE
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
subject = fields.JSONField(default=list)
settings = fields.JSONField(default=dict)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "template"
ordering = ["name", "id"]
indexes = (("vault", "name"),)
def __str__(self) -> str:
return f"Template(name={self.name!r})"
__all__: List[str] = [
"ApiToken",
"Authority",
"Certificate",
"CertificateRevocation",
"Crl",
"Key",
"Request",
"Template",
"User",
"Vault",
]
+192
View File
@@ -0,0 +1,192 @@
"""Shared OpenAPI fragments and the authentication post-processor."""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping
from kaya.core import KayaApp
from kaya.openapi import OpenAPIMixin, schema_ref
from .auth import PROFILE_CLAIMS
__all__ = [
"API_SECURITY",
"PUBLIC_PATHS",
"SCHEMAS",
"SECURITY_SCHEMES",
"SESSION_ONLY_PATHS",
"KcaOpenAPI",
"error_response",
"json_response",
"schema_ref",
]
SESSION_COOKIE_NAME = "session_id"
SECURITY_SCHEMES: Dict[str, Any] = {
"sessionCookie": {
"type": "apiKey",
"in": "cookie",
"name": SESSION_COOKIE_NAME,
"description": "The OIDC login session (interactive use).",
},
"bearerToken": {
"type": "http",
"scheme": "bearer",
"description": "A personal access token, ``Authorization: Bearer kca_...``.",
},
}
PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/providers"})
SESSION_ONLY_PATHS = frozenset({"/api/tokens", "/api/tokens/{token_id}"})
API_SECURITY: List[Dict[str, List[str]]] = [
{"sessionCookie": []},
{"bearerToken": []},
]
SESSION_SECURITY: List[Dict[str, List[str]]] = [{"sessionCookie": []}]
def _object(properties: Mapping[str, Any], required: Iterable[str] = ()) -> Dict[str, Any]:
schema: Dict[str, Any] = {"type": "object", "properties": dict(properties)}
if required:
schema["required"] = list(required)
return schema
def _string(*, nullable: bool = False) -> Dict[str, Any]:
return {"type": ["string", "null"] if nullable else "string"}
def _integer(*, nullable: bool = False) -> Dict[str, Any]:
return {"type": ["integer", "null"] if nullable else "integer"}
def _boolean() -> Dict[str, Any]:
return {"type": "boolean"}
def _date_time() -> Dict[str, Any]:
return {"type": "string", "format": "date-time"}
def _array(items: Mapping[str, Any]) -> Dict[str, Any]:
return {"type": "array", "items": items}
def _loose(name: str) -> Dict[str, Any]:
"""A named, intentionally loose object schema.
The API is documented at the field level in the README and the route
descriptions; keeping the OpenAPI component loose avoids duplicating the
large nested extension/template structures here.
"""
return {"type": "object", "description": name, "additionalProperties": True}
def json_response(description: str, schema: Mapping[str, Any]) -> Dict[str, Any]:
return {"description": description, "content": {"application/json": {"schema": dict(schema)}}}
def error_response(description: str) -> Dict[str, Any]:
return json_response(description, schema_ref("Error"))
ERROR_SCHEMA = _object({"error": _string()}, required=("error",))
HEALTH_SCHEMA = _object({"status": _string()}, required=("status",))
ME_SCHEMA = _object(
{
"sub": _string(),
"display_name": _string(),
**{
claim: _integer(nullable=True) if claim == "updated_at" else _string(nullable=True)
for claim in PROFILE_CLAIMS
},
"master_key_is_dev": _boolean(),
},
required=("sub", "display_name", *PROFILE_CLAIMS, "master_key_is_dev"),
)
AUTH_PROVIDER_SCHEMA = _object({"name": _string(), "login_url": _string()}, required=("name", "login_url"))
AUTH_PROVIDERS_PAGE_SCHEMA = _object({"providers": _array(schema_ref("AuthProvider"))}, required=("providers",))
API_TOKEN_SCHEMA = _object(
{
"id": _string(),
"name": _string(),
"prefix": _string(),
"created_at": _date_time(),
"last_used_at": _string(nullable=True),
"expires_at": _string(nullable=True),
}
)
CREATED_API_TOKEN_SCHEMA = _object(
{
"id": _string(),
"name": _string(),
"prefix": _string(),
"created_at": _date_time(),
"last_used_at": _string(nullable=True),
"expires_at": _string(nullable=True),
"token": _string(),
}
)
API_TOKENS_PAGE_SCHEMA = _object({"results": _array(schema_ref("ApiToken"))}, required=("results",))
SCHEMAS: Dict[str, Any] = {
"Error": ERROR_SCHEMA,
"Health": HEALTH_SCHEMA,
"Me": ME_SCHEMA,
"AuthProvider": AUTH_PROVIDER_SCHEMA,
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
"ApiToken": API_TOKEN_SCHEMA,
"CreatedApiToken": CREATED_API_TOKEN_SCHEMA,
"ApiTokensPage": API_TOKENS_PAGE_SCHEMA,
}
for name in (
"VaultSummary",
"VaultDetail",
"VaultsPage",
"KeySummary",
"KeyDetail",
"KeysPage",
"CertificateSummary",
"CertificateDetail",
"CertificatesPage",
"RequestSummary",
"RequestDetail",
"RequestsPage",
"CrlSummary",
"CrlDetail",
"CrlsPage",
"TemplateSummary",
"TemplateDetail",
"TemplatesPage",
"Revocation",
"RevocationsPage",
"SearchResult",
"SearchPage",
"VaultSummaryPage",
"ImportPreview",
"ImportResult",
"ExportPayload",
):
SCHEMAS[name] = _loose(name)
class KcaOpenAPI(OpenAPIMixin):
"""Attach the authentication schemes to the generated document."""
def generate_spec(self, app: KayaApp) -> Mapping[str, Any]:
spec: Dict[str, Any] = dict(super().generate_spec(app))
paths = spec.get("paths")
if isinstance(paths, Mapping):
for path, item in paths.items():
if not isinstance(item, Mapping):
continue
if path in PUBLIC_PATHS or not path.startswith("/api/"):
security: List[Dict[str, List[str]]] = []
elif path in SESSION_ONLY_PATHS:
security = SESSION_SECURITY
else:
security = API_SECURITY
for operation in item.values():
if isinstance(operation, dict):
operation["security"] = security
return spec
@@ -0,0 +1,158 @@
"""Cursor-based pagination for listing endpoints.
Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
cursor encoding the sort key tuple of the last item on that page; the next
request passes that cursor and the query continues from the point it left
off. This is stable under concurrent inserts and cheaper than OFFSET for
large result sets.
Cursor is a base64-encoded JSON object mapping the sort-field names to the
values of the last item on the previous page. It's opaque to callers and
must be treated as a black box.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from tortoise.queryset import QuerySet
from .http import extract_query_params
if TYPE_CHECKING:
from tortoise.models import Model
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MIN_LIMIT = 1
CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit"
class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded."""
def encode_cursor(values: Dict[str, Any]) -> str:
import base64
import json
# ``default=str`` handles datetimes/dates (ISO) so keyset cursors can
# carry date-typed sort fields.
raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]:
import base64
import json
if s is None or s == "":
return None
try:
# Tolerate missing padding.
padded = s + "=" * (-len(s) % 4)
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
obj = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise CursorDecodeError("invalid cursor") from exc
if not isinstance(obj, dict):
raise CursorDecodeError("invalid cursor")
return obj
@dataclass(frozen=True)
class Cursor:
limit: int
after: Optional[Dict[str, Any]]
def parse_cursor_params(query_string: str) -> Cursor:
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise CursorDecodeError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
after = decode_cursor(params.get(CURSOR_PARAM, [None])[0])
return Cursor(limit=limit, after=after)
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]]
def _coerce_sort_values(
model: "type[Model]", sort: Sort, after: Dict[str, Any]
) -> Dict[str, Any]:
"""Restore the Python type of every decoded cursor value.
The cursor is JSON, so field values travel as strings (datetimes via
``default=str``). asyncpg rejects strings where a ``timestamp``
parameter is expected, and sqlite only tolerates them by accident, so
each value is run through its model field's ``to_python_value`` before
it reaches the keyset filter.
"""
fields = model._meta.fields_map
coerced: Dict[str, Any] = {}
for field, _ in sort:
value = after.get(field)
field_obj = fields.get(field)
if value is not None and field_obj is not None:
value = field_obj.to_python_value(value)
coerced[field] = value
return coerced
def _keyset_where(sort: Sort, after: Dict[str, Any]):
"""Build a Tortoise ``Q`` filter from a cursor."""
from tortoise.queryset import Q # local to keep import edge narrow
clauses = []
for i, (field, direction) in enumerate(sort):
key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}"
value = after.get(field)
if value is None:
return Q()
clause = Q(**{key: value})
for j in range(i):
prev_field, _ = sort[j]
prev_value = after.get(prev_field)
if prev_value is None:
return Q()
clause = clause & Q(**{prev_field: prev_value})
clauses.append(clause)
result = clauses[0]
for clause in clauses[1:]:
result = result | clause
return result
async def paginate(
queryset: QuerySet,
sort: Sort,
cursor: Cursor,
) -> Tuple[List[Any], Optional[str]]:
"""Return one page of ``queryset`` plus the opaque cursor to continue."""
order_by: List[str] = []
for field, direction in sort:
order_by.append(field if direction == "ASC" else f"-{field}")
qs = queryset.order_by(*order_by)
if cursor.after:
after = _coerce_sort_values(queryset.model, sort, cursor.after)
qs = qs.filter(_keyset_where(sort, after))
rows = await qs.limit(cursor.limit + 1)
if len(rows) <= cursor.limit:
return rows, None
page = rows[: cursor.limit]
last = page[-1]
key: Dict[str, Any] = {}
for field, _ in sort:
key[field] = getattr(last, field)
return page, encode_cursor(key)
@@ -0,0 +1 @@
"""HTTP route modules."""
@@ -0,0 +1,39 @@
"""OIDC provider list and provider-agnostic login/logout routes."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..http import send_json
from ..openapi import json_response, schema_ref
if TYPE_CHECKING:
from ..mixin import Kca
def _login_url(kca: "Kca", provider: str) -> str:
return f"{kca.oidc.base_path}/{provider}/login"
def register(app: KayaApp, kca: "Kca") -> None:
providers = list(kca.oidc.providers)
@app.GET("/api/auth/providers")
@operation(summary="Configured login providers", tags=["auth"],
responses={200: json_response("The configured providers", schema_ref("AuthProvidersPage"))})
async def auth_providers(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"providers": [{"name": name, "login_url": _login_url(kca, name)} for name in providers]
})
@app.GET("/auth/login")
async def login(ctx: HttpContext) -> None:
await ctx.send_empty(302, {"Location": _login_url(kca, providers[0])})
@app.GET("/auth/logout")
async def logout(ctx: HttpContext) -> None:
logged_in = kca.oidc.logged_in_providers(ctx)
location = f"{kca.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/"
await ctx.send_empty(302, {"Location": location})
@@ -0,0 +1,612 @@
"""Certificates: issue, renew, revoke, CA properties and revocation manager."""
from __future__ import annotations
import base64
import os
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import certificates as cert_core
from kca.core import crls as crl_core
from kca.core import exports as export_core
from kca.core import requests as req_core
from kca.core import templates as template_core
from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob
from ..auth import authenticated_user, require_auth
from ..config import settings
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, Request, Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
chain_for_certificate,
delete_certificate_cascade,
generate_crl,
issue_certificate,
load_private_key,
parse_datetime,
parse_uuid,
renew_certificate,
revoke_certificate,
save_key,
search_text,
serialize_certificate_detail,
serialize_certificate_summary,
serialize_revocation,
serialize_template_summary,
serialize_crl_detail,
vault_for_owner,
vault_settings,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _cert(vault, cert_id: str) -> Optional[Certificate]:
uuid = parse_uuid(cert_id)
return await Certificate.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/certificates")
@operation(summary="List certificates", tags=["certificates"])
@require_auth(kca.oidc)
async def list_certificates(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_certificate_summary(c) for c in rows]})
@app.POST("/api/vaults/${vault_id}/certificates")
@operation(summary="Issue a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def create_certificate(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
cert = await issue_certificate(vault, body, user=user)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Certificate detail", tags=["certificates"])
@require_auth(kca.oidc)
async def get_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
await send_json(ctx, 200, await serialize_certificate_detail(cert, vault))
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Update certificate metadata", tags=["certificates"])
@require_auth(kca.oidc)
async def update_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != cert.version:
await send_error(ctx, 409, "the certificate changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
cert.name = body["name"].strip()[:200]
if "comment" in body:
cert.comment = str(body["comment"])[:8000]
cert.version += 1
await cert.save()
await send_json(ctx, 200, await serialize_certificate_detail(cert, vault))
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Delete a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def delete_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
await delete_certificate_cascade(vault, cert)
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/export")
@operation(summary="Export a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def export_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
chain = [cert_core.parse_certificate(bytes(item.cert_der)) for item in await chain_for_certificate(cert, vault)]
private_key = None
if cert.key_id:
key = await Key.get_or_none(id=cert.key_id)
if key is not None and key.has_private:
private_key = await load_private_key(key)
authority = await Authority.get_or_none(certificate_id=cert.id)
tls_auth = None
if authority is not None and authority.tls_auth_key and authority.tls_auth_key_nonce:
raw = decrypt_blob(
EncryptedBlob(bytes(authority.tls_auth_key), bytes(authority.tls_auth_key_nonce)),
master_key=settings.master_key,
aad=str(cert.id).encode(),
)
tls_auth = _format_tls_auth(raw)
try:
result = export_core.export_certificate(
parsed,
str(body.get("format") or "pem"),
name=cert.name,
chain=chain,
private_key=private_key,
password=body.get("password"),
tls_auth_key=tls_auth,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/renew")
@operation(summary="Renew a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def renew(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
new_cert = await renew_certificate(vault, cert, body)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 201, await serialize_certificate_detail(new_cert, vault))
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revoke")
@operation(summary="Revoke a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def revoke(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
row = await revoke_certificate(vault, cert, body)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, await serialize_revocation(row))
# ------------------------------------------------------------------
# CA properties / revocation manager / CRL generation
# ------------------------------------------------------------------
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ca")
@operation(summary="CA properties", tags=["ca"])
@require_auth(kca.oidc)
async def get_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
authority = await Authority.get_or_none(certificate_id=cert.id)
if authority is None:
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
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,
"has_tls_auth_key": bool(authority.tls_auth_key),
})
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/ca")
@operation(summary="Update CA properties", tags=["ca"])
@require_auth(kca.oidc)
async def update_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
authority = await Authority.get_or_none(certificate_id=cert.id)
if authority is None:
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
if "crl_days" in body:
try:
days = int(body["crl_days"])
except (TypeError, ValueError):
await send_error(ctx, 400, "crl_days must be an integer")
return
if days <= 0:
await send_error(ctx, 400, "crl_days must be positive")
return
authority.crl_days = days
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
else:
authority.template_id = 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,
"has_tls_auth_key": bool(authority.tls_auth_key),
})
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/revocations")
@operation(summary="List CA revocations", tags=["ca"])
@require_auth(kca.oidc)
async def list_revocations(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
rows = await CertificateRevocation.filter(ca_id=cert.id).order_by("-revoked_at")
await send_json(ctx, 200, {"results": [await serialize_revocation(row) for row in rows]})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revocations")
@operation(summary="Add a revocation manually", tags=["ca"])
@require_auth(kca.oidc)
async def add_revocation(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
serial = str(body.get("serial_hex") or "").strip().lower().replace(":", "")
if not serial:
await send_error(ctx, 400, "serial_hex is required")
return
try:
revoked_at = parse_datetime(body.get("revoked_at") or datetime.now(timezone.utc).isoformat())
invalidity = parse_datetime(body["invalidity_date"]) if body.get("invalidity_date") else None
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
reason = str(body.get("reason") or "unspecified")
if reason not in {item["name"] for item in crl_core.CRL_REASONS}:
await send_error(ctx, 400, "unknown revocation reason")
return
row, created = await CertificateRevocation.get_or_create(
ca_id=cert.id,
serial_hex=serial,
defaults={"revoked_at": revoked_at, "invalidity_date": invalidity, "reason": reason},
)
if not created:
await send_error(ctx, 409, "this serial is already revoked")
return
await send_json(ctx, 201, await serialize_revocation(row))
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
@operation(summary="Edit a revocation", tags=["ca"])
@require_auth(kca.oidc)
async def update_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "CA certificate not found")
return
row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id)
if row is None:
await send_error(ctx, 404, "revocation not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
if "revoked_at" in body:
row.revoked_at = parse_datetime(body["revoked_at"], "revoked_at")
if "invalidity_date" in body:
row.invalidity_date = (
parse_datetime(body["invalidity_date"], "invalidity_date")
if body["invalidity_date"]
else None
)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
if "reason" in body:
if str(body["reason"]) not in {item["name"] for item in crl_core.CRL_REASONS}:
await send_error(ctx, 400, "unknown revocation reason")
return
row.reason = str(body["reason"])
await row.save()
await send_json(ctx, 200, await serialize_revocation(row))
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
@operation(summary="Delete a revocation", tags=["ca"])
@require_auth(kca.oidc)
async def delete_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "CA certificate not found")
return
row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id)
if row is None:
await send_error(ctx, 404, "revocation not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/crl")
@operation(summary="Generate a CRL", tags=["ca"])
@require_auth(kca.oidc)
async def create_crl(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
row = await generate_crl(vault, cert, body)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 201, await serialize_crl_detail(row))
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/tls-auth")
@operation(summary="Generate or import the CA's OpenVPN tls-auth key", tags=["ca"])
@require_auth(kca.oidc)
async def tls_auth(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
authority = await Authority.get_or_none(certificate_id=cert.id)
if authority is None:
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
raw = None
if body.get("key"):
text = str(body["key"])
raw = _parse_tls_auth(text)
if raw is None:
await send_error(ctx, 400, "invalid OpenVPN tls-auth key")
return
else:
raw = os.urandom(32)
blob = encrypt_blob(raw, master_key=settings.master_key, aad=str(cert.id).encode())
authority.tls_auth_key = blob.ciphertext
authority.tls_auth_key_nonce = blob.nonce
await authority.save(update_fields=["tls_auth_key", "tls_auth_key_nonce"])
await send_json(ctx, 200, {"key": _format_tls_auth(raw)})
# ------------------------------------------------------------------
# Transforms
# ------------------------------------------------------------------
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/open-key")
@operation(summary="Extract the public key from a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def extract_key(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
key = await save_key(
vault,
parsed.public_key(),
name=f"{cert.name} public key",
comment=f"Extracted from certificate {cert.name}",
source="transformed",
)
await send_json(ctx, 201, {"id": str(key.id), "name": key.name})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/request")
@operation(summary="Create a request from a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def to_request(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
key = await Key.get_or_none(id=cert.key_id) if cert.key_id else None
if key is None or not key.has_private:
await send_error(ctx, 400, "the certificate has no private key")
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
try:
csr = req_core.build_csr(
subject=parsed.subject,
private_key=await load_private_key(key),
extension_spec=cert_core.certificate_extension_specs(parsed),
signature_hash=cert_core.signature_hash_name(parsed),
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
info = req_core.request_info(csr)
row = await Request.create(
vault_id=vault.id,
key_id=key.id,
name=f"{cert.name} request",
comment=f"Transformed from certificate {cert.name}",
source="transformed",
subject_der=info.subject.public_bytes(),
req_der=req_core.csr_der(csr),
public_key_der=info.public_key_der,
request_sha256=info.fingerprint_sha256,
attributes=info.attributes,
search_text=search_text(cert.name, "request", info.subject.rfc4514_string()),
)
await send_json(ctx, 201, {"id": str(row.id), "name": row.name})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/template")
@operation(summary="Create a template from a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def to_template(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
spec = template_core.template_from_certificate(
cert_core.parse_certificate(bytes(cert.cert_der)),
cert.name,
f"Transformed from certificate {cert.name}",
)
template = await Template.create(
vault_id=vault.id,
name=spec["name"],
comment=spec["comment"],
source="transformed",
subject=spec["subject"],
settings=spec["settings"],
search_text=search_text(spec["name"], spec["comment"]),
)
await send_json(ctx, 201, await serialize_template_summary(template))
def _parse_tls_auth(text: str) -> Optional[bytes]:
begin = "-----BEGIN OpenVPN Static key V1-----"
end = "-----END OpenVPN Static key V1-----"
if begin not in text or end not in text:
return None
body = text.split(begin, 1)[1].split(end, 1)[0]
try:
data = bytes.fromhex("".join(body.split()))
except ValueError:
return None
return data if len(data) == 32 else None
def _format_tls_auth(raw: bytes) -> str:
text = raw.hex()
return (
"-----BEGIN OpenVPN Static key V1-----\n"
+ "\n".join(text[i : i + 32] for i in range(0, len(text), 32))
+ "\n-----END OpenVPN Static key V1-----\n"
)
@@ -0,0 +1,109 @@
"""Revocation list list/detail/delete/export."""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import crls as crl_core
from kca.core import exports as export_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Crl
from ..openapi import error_response, json_response, schema_ref
from ..service import (
parse_uuid,
serialize_crl_detail,
serialize_crl_summary,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _crl(vault, crl_id: str) -> Optional[Crl]:
uuid = parse_uuid(crl_id)
return await Crl.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/crls")
@operation(summary="List revocation lists", tags=["crls"])
@require_auth(kca.oidc)
async def list_crls(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Crl.filter(vault_id=vault.id).order_by("-last_update")
await send_json(ctx, 200, {"results": [await serialize_crl_summary(c) for c in rows]})
@app.GET("/api/vaults/${vault_id}/crls/${crl_id}")
@operation(summary="Revocation list detail", tags=["crls"])
@require_auth(kca.oidc)
async def get_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _crl(vault, crl_id)
if row is None:
await send_error(ctx, 404, "CRL not found")
return
await send_json(ctx, 200, await serialize_crl_detail(row))
@app.DELETE("/api/vaults/${vault_id}/crls/${crl_id}")
@operation(summary="Delete a revocation list", tags=["crls"])
@require_auth(kca.oidc)
async def delete_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _crl(vault, crl_id)
if row is None:
await send_error(ctx, 404, "CRL not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/crls/${crl_id}/export")
@operation(summary="Export a revocation list", tags=["crls"])
@require_auth(kca.oidc)
async def export_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _crl(vault, crl_id)
if row is None:
await send_error(ctx, 404, "CRL not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = export_core.export_crl(
crl_core.parse_crl(bytes(row.crl_der)),
str(body.get("format") or "pem"),
name=row.name,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@@ -0,0 +1,20 @@
"""Liveness probe."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..openapi import json_response, schema_ref
if TYPE_CHECKING:
from ..mixin import Kca
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/health")
@operation(summary="Health check", tags=["health"],
responses={200: json_response("The service is up", schema_ref("Health"))})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(200, b'{"status":"ok"}', {"content-type": ("application/json",)})
@@ -0,0 +1,127 @@
"""Import files, PEM bundles and XCA databases."""
from __future__ import annotations
import base64
import binascii
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import certificates as cert_core
from kca.core import crls as crl_core
from kca.core import imports as import_core
from kca.core import keys as key_core
from kca.core import requests as req_core
from ..auth import authenticated_user, require_auth
from ..config import settings
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Certificate, Crl, Key, Request
from ..openapi import error_response, json_response, schema_ref
from ..service import (
import_parsed_items,
parse_uuid,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _duplicate(vault, item: import_core.ParsedItem) -> bool:
if item.kind == "key":
info = key_core.key_info(item.obj)
return await Key.filter(vault_id=vault.id, public_key_sha256=info.public_sha256).exists()
if item.kind == "certificate":
info = cert_core.certificate_info(item.obj)
return await Certificate.filter(vault_id=vault.id, fingerprint_sha256=info.fingerprint_sha256).exists()
if item.kind == "request":
info = req_core.request_info(item.obj)
return await Request.filter(vault_id=vault.id, request_sha256=info.fingerprint_sha256).exists()
if item.kind == "crl":
info = crl_core.crl_info(item.obj)
return await Crl.filter(vault_id=vault.id, crl_hash=info.fingerprint_sha256).exists()
return False
def register(app: KayaApp, kca: "Kca") -> None:
@app.POST("/api/vaults/${vault_id}/import")
@operation(summary="Import items into a vault", tags=["import"],
responses={200: json_response("Import result", schema_ref("ImportResult")),
400: error_response("Invalid file, password or selection"),
401: error_response("Authentication required"),
404: error_response("Vault not found"),
413: error_response("File too large")})
@require_auth(kca.oidc)
async def import_items(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
filename = str(body.get("filename") or "import")
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
raw: Optional[bytes] = None
if body.get("content_base64"):
try:
raw = base64.b64decode(str(body["content_base64"]), validate=True)
except (binascii.Error, ValueError):
await send_error(ctx, 400, "content_base64 is not valid base64")
return
elif body.get("pem"):
raw = str(body["pem"]).encode("utf-8")
if raw is None:
await send_error(ctx, 400, "content_base64 or pem is required")
return
if len(raw) > settings.max_upload_bytes:
await send_error(ctx, 413, f"the file is larger than {settings.max_upload_bytes} bytes")
return
dry_run = bool(body.get("dry_run"))
use_xca_db = bool(body.get("xca_db")) or filename.lower().endswith(".xdb")
try:
if use_xca_db:
items = import_core.parse_xca_database(raw, filename, password)
else:
items = import_core.parse_file(raw, filename, password)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
if dry_run:
preview = []
for index, item in enumerate(items):
preview.append(
{
"index": index,
"kind": item.kind,
"name": item.name,
"summary": item.summary,
"is_private": item.is_private,
"duplicate": await _duplicate(vault, item),
"warnings": item.warnings,
}
)
await send_json(ctx, 200, {"dry_run": True, "items": preview, "count": len(preview)})
return
selected = body.get("selected")
if selected is not None:
if not isinstance(selected, list) or not all(isinstance(i, int) for i in selected):
await send_error(ctx, 400, "selected must be a list of item indexes")
return
result = await import_parsed_items(vault, items, selected=selected)
await send_json(ctx, 200, result)
@@ -0,0 +1,225 @@
"""Key generation, detail, rename/delete and export."""
from __future__ import annotations
import asyncio
import base64
from typing import TYPE_CHECKING, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from tortoise.exceptions import IntegrityError
from kca.core import exports as export_core
from kca.core import keys as key_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Certificate, Key, Request
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
parse_key_generation,
parse_uuid,
save_key,
serialize_key_detail,
serialize_key_summary,
load_private_key,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
# Key generation is CPU-bound; this bounds concurrent RSA/DSA jobs.
_keygen_semaphore = asyncio.Semaphore(2)
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _key(vault, key_id: str) -> Optional[Key]:
uuid = parse_uuid(key_id)
return await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/keys")
@operation(summary="List keys", tags=["keys"],
responses={200: json_response("Keys", schema_ref("KeysPage")),
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def list_keys(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Key.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_key_summary(k) for k in rows]})
@app.POST("/api/vaults/${vault_id}/keys")
@operation(summary="Generate a key", tags=["keys"],
responses={201: json_response("Key created", schema_ref("KeyDetail")),
400: error_response("Invalid key parameters"),
401: error_response("Authentication required"),
404: error_response("Vault not found"),
409: error_response("Key already exists")})
@require_auth(kca.oidc)
async def generate_key(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:
params = parse_key_generation(body)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
try:
async with _keygen_semaphore:
generated = await asyncio.to_thread(
key_core.generate_key,
params["key_type"],
params["size"],
params["curve"],
)
key = await save_key(
vault,
generated,
name=params["name"],
comment=params["comment"],
source="generated",
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
except IntegrityError:
await send_error(ctx, 409, "this key is already in the vault")
return
await send_json(ctx, 201, await serialize_key_detail(key))
@app.GET("/api/vaults/${vault_id}/keys/${key_id}")
@operation(summary="Key detail", tags=["keys"],
responses={200: json_response("Key", schema_ref("KeyDetail")),
401: error_response("Authentication required"),
404: error_response("Key not found")})
@require_auth(kca.oidc)
async def get_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
await send_json(ctx, 200, await serialize_key_detail(key))
@app.PATCH("/api/vaults/${vault_id}/keys/${key_id}")
@operation(summary="Rename a key", tags=["keys"],
responses={200: json_response("Key updated", schema_ref("KeyDetail")),
400: error_response("Invalid body"),
401: error_response("Authentication required"),
404: error_response("Key not found"),
409: error_response("Stale version")})
@require_auth(kca.oidc)
async def update_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != key.version:
await send_error(ctx, 409, "the key changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
key.name = body["name"].strip()[:200]
if "comment" in body:
if not isinstance(body["comment"], str):
await send_error(ctx, 400, "comment must be a string")
return
key.comment = body["comment"][:8000]
key.version += 1
await key.save()
await send_json(ctx, 200, await serialize_key_detail(key))
@app.DELETE("/api/vaults/${vault_id}/keys/${key_id}")
@operation(summary="Delete a key", tags=["keys"],
responses={204: {"description": "Deleted"},
401: error_response("Authentication required"),
404: error_response("Key not found")})
@require_auth(kca.oidc)
async def delete_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
await Certificate.filter(key_id=key.id).update(key_id=None)
await Request.filter(key_id=key.id).update(key_id=None)
await key.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/keys/${key_id}/export")
@operation(summary="Export a key", tags=["keys"])
@require_auth(kca.oidc)
async def export_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
fmt = str(body.get("format") or "pem-public")
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
try:
if fmt in ("pem-private", "pem", "pem-encrypted", "der-private", "pkcs8", "pkcs8-private", "pkcs8-encrypted", "ssh-private", "jwk-private", "pvk"):
key_obj = await load_private_key(key)
is_private = True
else:
key_obj = key_core.public_key_from_der(bytes(key.public_der))
is_private = False
result = export_core.export_key(
key_obj,
fmt,
name=key.name,
password=password,
is_private=is_private,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@@ -0,0 +1,36 @@
"""Whoami endpoint."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import PROFILE_CLAIMS, authenticated_user, require_auth
from ..http import send_json
from ..openapi import error_response, json_response, schema_ref
from ..config import settings
if TYPE_CHECKING:
from ..mixin import Kca
def _serialize(user: Any) -> Dict[str, Any]:
payload: Dict[str, Any] = {"sub": user.sub, "display_name": user.display_name}
for claim in PROFILE_CLAIMS:
payload[claim] = getattr(user, claim)
return payload
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/me")
@operation(summary="Current user", tags=["auth"],
responses={200: json_response("The current user", schema_ref("Me")),
401: error_response("Not logged in")})
@require_auth(kca.oidc)
async def me(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
payload = _serialize(user)
payload["master_key_is_dev"] = settings.master_key_is_dev
await send_json(ctx, 200, payload)
@@ -0,0 +1,280 @@
"""Certificate signing requests: list, create, detail, export and transforms."""
from __future__ import annotations
import asyncio
import base64
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from tortoise.exceptions import IntegrityError
from kca.core import exports as export_core
from kca.core import keys as key_core
from kca.core import requests as req_core
from kca.core import templates as template_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Key, Request, Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
parse_extensions,
parse_subject,
parse_uuid,
save_key,
search_text,
serialize_request_detail,
serialize_request_summary,
serialize_template_summary,
load_private_key,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
_keygen_semaphore = asyncio.Semaphore(2)
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _request(vault, request_id: str) -> Optional[Request]:
uuid = parse_uuid(request_id)
return await Request.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/requests")
@operation(summary="List certificate signing requests", tags=["requests"])
@require_auth(kca.oidc)
async def list_requests(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Request.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_request_summary(r) for r in rows]})
@app.POST("/api/vaults/${vault_id}/requests")
@operation(summary="Create a certificate signing request", tags=["requests"])
@require_auth(kca.oidc)
async def create_request(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
name = str(body.get("name") or "").strip()
if not name:
await send_error(ctx, 400, "name is required")
return
comment = str(body.get("comment") or "")
try:
subject = parse_subject(body.get("subject") or [])
extensions = parse_extensions(body.get("extensions"))
if not subject.rdns:
raise ValidationError("the subject must not be empty")
key_model = None
key_obj = None
if body.get("key_id"):
uuid = parse_uuid(str(body["key_id"]))
key_model = await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
if key_model is None:
raise ValidationError("key not found")
if not key_model.has_private:
raise ValidationError("the selected key has no private part")
key_obj = await load_private_key(key_model)
elif isinstance(body.get("new_key"), dict):
new_key = body["new_key"]
async with _keygen_semaphore:
generated = await asyncio.to_thread(
key_core.generate_key,
str(new_key.get("key_type") or "rsa"),
int(new_key.get("size") or key_core.DEFAULT_KEY_SIZE),
new_key.get("curve"),
)
key_model = await save_key(
vault,
generated,
name=str(new_key.get("name") or name),
comment="Generated while creating a request",
source="generated",
)
key_obj = generated
else:
raise ValidationError("select an existing key or generate a new one")
csr = req_core.build_csr(
subject=subject,
private_key=key_obj,
extension_spec=extensions,
signature_hash=body.get("signature_hash"),
attributes=body.get("attributes") or {},
)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
info = req_core.request_info(csr)
try:
row = await Request.create(
vault_id=vault.id,
key_id=key_model.id if key_model else None,
name=name,
comment=comment,
source="generated",
subject_der=info.subject.public_bytes(),
req_der=req_core.csr_der(csr),
public_key_der=info.public_key_der,
request_sha256=info.fingerprint_sha256,
attributes=info.attributes,
search_text=search_text(name, comment, info.subject.rfc4514_string(), info.fingerprint_sha256),
)
except IntegrityError:
await send_error(ctx, 409, "this request is already in the vault")
return
await send_json(ctx, 201, await serialize_request_detail(row))
@app.GET("/api/vaults/${vault_id}/requests/${request_id}")
@operation(summary="Request detail", tags=["requests"])
@require_auth(kca.oidc)
async def get_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
await send_json(ctx, 200, await serialize_request_detail(row))
@app.PATCH("/api/vaults/${vault_id}/requests/${request_id}")
@operation(summary="Update a request", tags=["requests"])
@require_auth(kca.oidc)
async def update_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != row.version:
await send_error(ctx, 409, "the request changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
row.name = body["name"].strip()[:200]
if "comment" in body:
row.comment = str(body["comment"])[:8000]
row.version += 1
await row.save()
await send_json(ctx, 200, await serialize_request_detail(row))
@app.DELETE("/api/vaults/${vault_id}/requests/${request_id}")
@operation(summary="Delete a request", tags=["requests"])
@require_auth(kca.oidc)
async def delete_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/export")
@operation(summary="Export a request", tags=["requests"])
@require_auth(kca.oidc)
async def export_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = export_core.export_request(
req_core.parse_csr(bytes(row.req_der)),
str(body.get("format") or "pem"),
name=row.name,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/open-key")
@operation(summary="Extract the public key from a request", tags=["requests"])
@require_auth(kca.oidc)
async def extract_key(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
public_key = req_core.csr_public_key(req_core.parse_csr(bytes(row.req_der)))
key = await save_key(
vault,
public_key,
name=f"{row.name} public key",
comment=f"Extracted from request {row.name}",
source="transformed",
)
await send_json(ctx, 201, {"id": str(key.id), "name": key.name})
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/template")
@operation(summary="Create a template from a request", tags=["requests"])
@require_auth(kca.oidc)
async def to_template(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
spec = template_core.template_from_request(
req_core.parse_csr(bytes(row.req_der)), row.name, f"Transformed from request {row.name}"
)
template = await Template.create(
vault_id=vault.id,
name=spec["name"],
comment=spec["comment"],
source="transformed",
subject=spec["subject"],
settings=spec["settings"],
search_text=search_text(spec["name"], spec["comment"]),
)
await send_json(ctx, 201, await serialize_template_summary(template))
@@ -0,0 +1,75 @@
"""Global search across a vault's items."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import authenticated_user, require_auth
from ..http import extract_query_params, send_error, send_json
from ..models import Certificate, Crl, Key, Request, Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
parse_uuid,
serialize_certificate_summary,
serialize_crl_summary,
serialize_key_summary,
serialize_request_summary,
serialize_template_summary,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/search")
@operation(summary="Search a vault", tags=["search"],
responses={200: json_response("Search results", schema_ref("SearchPage")),
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def search(ctx: HttpContext, vault_id: str) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return
params = extract_query_params(ctx.query_string)
query = (params.get("q", [""])[0] or "").strip().lower()
kind = (params.get("type", [""])[0] or "").strip().lower()
if not query:
await send_json(ctx, 200, {"results": [], "counts": {}})
return
results: List[Dict[str, Any]] = []
counts: Dict[str, int] = {}
if kind in ("", "all", "certificate", "certificates"):
rows = await Certificate.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["certificates"] = len(rows)
for row in rows:
results.append({"type": "certificate", "item": await serialize_certificate_summary(row)})
if kind in ("", "all", "key", "keys"):
rows = await Key.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["keys"] = len(rows)
for row in rows:
results.append({"type": "key", "item": await serialize_key_summary(row)})
if kind in ("", "all", "request", "requests"):
rows = await Request.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["requests"] = len(rows)
for row in rows:
results.append({"type": "request", "item": await serialize_request_summary(row)})
if kind in ("", "all", "crl", "crls"):
rows = await Crl.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["crls"] = len(rows)
for row in rows:
results.append({"type": "crl", "item": await serialize_crl_summary(row)})
if kind in ("", "all", "template", "templates"):
rows = await Template.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["templates"] = len(rows)
for row in rows:
results.append({"type": "template", "item": await serialize_template_summary(row)})
await send_json(ctx, 200, {"results": results, "counts": counts})
@@ -0,0 +1,229 @@
"""Certificate/request templates, including the built-in XCA presets."""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import exports as export_core
from kca.core import templates as template_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
parse_uuid,
search_text,
serialize_template_detail,
serialize_template_summary,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _template(vault, template_id: str):
for builtin in template_core.builtin_templates():
if builtin.id == template_id:
return builtin.as_dict()
uuid = parse_uuid(template_id)
return await Template.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def _validate_template_body(body: Mapping[str, Any]) -> Dict[str, Any]:
name = body.get("name")
if not isinstance(name, str) or not name.strip():
raise ValidationError("name is required")
subject = body.get("subject") or []
if not isinstance(subject, list):
raise ValidationError("subject must be a list")
settings = body.get("settings") or {}
if not isinstance(settings, dict):
raise ValidationError("settings must be an object")
return {
"name": name.strip()[:200],
"comment": str(body.get("comment") or "")[:8000],
"subject": subject,
"settings": settings,
}
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/templates")
@operation(summary="List templates", tags=["templates"])
@require_auth(kca.oidc)
async def list_templates(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Template.filter(vault_id=vault.id).order_by("name", "id")
results = [await serialize_template_summary(t) for t in template_core.builtin_templates()]
results.extend([await serialize_template_summary(t) for t in rows])
await send_json(ctx, 200, {"results": results})
@app.POST("/api/vaults/${vault_id}/templates")
@operation(summary="Create a template", tags=["templates"])
@require_auth(kca.oidc)
async def create_template(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:
values = _validate_template_body(body)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
row = await Template.create(
vault_id=vault.id,
name=values["name"],
comment=values["comment"],
source="generated",
subject=values["subject"],
settings=values["settings"],
search_text=search_text(values["name"], values["comment"]),
)
await send_json(ctx, 201, await serialize_template_detail(row))
@app.GET("/api/vaults/${vault_id}/templates/${template_id}")
@operation(summary="Template detail", tags=["templates"])
@require_auth(kca.oidc)
async def get_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _template(vault, template_id)
if row is None:
await send_error(ctx, 404, "template not found")
return
await send_json(ctx, 200, await serialize_template_detail(row))
@app.PATCH("/api/vaults/${vault_id}/templates/${template_id}")
@operation(summary="Update a template", tags=["templates"])
@require_auth(kca.oidc)
async def update_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id)
if row is None:
await send_error(ctx, 404, "template not found (built-in templates cannot be edited)")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != row.version:
await send_error(ctx, 409, "the template changed since it was loaded")
return
try:
values = _validate_template_body({**{
"name": row.name,
"comment": row.comment,
"subject": row.subject,
"settings": row.settings,
}, **body})
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
row.name = values["name"]
row.comment = values["comment"]
row.subject = values["subject"]
row.settings = values["settings"]
row.version += 1
await row.save()
await send_json(ctx, 200, await serialize_template_detail(row))
@app.DELETE("/api/vaults/${vault_id}/templates/${template_id}")
@operation(summary="Delete a template", tags=["templates"])
@require_auth(kca.oidc)
async def delete_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
if template_id.startswith("builtin:"):
await send_error(ctx, 400, "built-in templates cannot be deleted")
return
row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id)
if row is None:
await send_error(ctx, 404, "template not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/templates/${template_id}/apply")
@operation(summary="Apply a template to a certificate/request spec", tags=["templates"])
@require_auth(kca.oidc)
async def apply_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _template(vault, template_id)
if row is None:
await send_error(ctx, 404, "template not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
template = await serialize_template_detail(row)
target = body.get("target") or {}
merged = template_core.apply_template(
template,
target,
apply_subject=bool(body.get("apply_subject", True)),
apply_extensions=bool(body.get("apply_extensions", True)),
)
await send_json(ctx, 200, merged)
@app.POST("/api/vaults/${vault_id}/templates/${template_id}/export")
@operation(summary="Export a template", tags=["templates"])
@require_auth(kca.oidc)
async def export_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _template(vault, template_id)
if row is None:
await send_error(ctx, 404, "template not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
template = await serialize_template_detail(row)
try:
result = export_core.export_template(
template,
str(body.get("format") or "xca"),
name=str(template.get("name") or "template"),
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@@ -0,0 +1,98 @@
"""Personal access tokens for programmatic API access."""
from __future__ import annotations
from logging import getLogger
from typing import TYPE_CHECKING, Optional
from uuid import UUID
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import authenticated_user, require_session_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import ApiToken
from ..openapi import error_response, json_response, schema_ref
from ..tokens import (
MAX_TOKENS_PER_USER,
TokenValidationError,
generate_token,
parse_token_payload,
serialize_token,
)
if TYPE_CHECKING:
from ..mixin import Kca
log = getLogger(__name__)
def _parse_uuid(value: str) -> Optional[UUID]:
try:
return UUID(value)
except (ValueError, AttributeError, TypeError):
return None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/tokens")
@operation(summary="List my API tokens", tags=["auth"],
responses={200: json_response("The caller's tokens", schema_ref("ApiTokensPage")),
401: error_response("Authentication required")})
@require_session_auth(kca.oidc)
async def list_tokens(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
rows = await ApiToken.filter(user_sub=user.sub)
await send_json(ctx, 200, {"results": [serialize_token(token) for token in rows]})
@app.POST("/api/tokens")
@operation(summary="Create an API token", tags=["auth"],
responses={201: json_response("Token created", schema_ref("CreatedApiToken")),
400: error_response("Invalid body or limit reached"),
401: error_response("Authentication required")})
@require_session_auth(kca.oidc)
async def create_token(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
values = parse_token_payload(body)
except TokenValidationError as exc:
await send_error(ctx, 400, str(exc))
return
if await ApiToken.filter(user_sub=user.sub).count() >= MAX_TOKENS_PER_USER:
await send_error(ctx, 400, f"you already have {MAX_TOKENS_PER_USER} tokens; revoke unused ones first")
return
secret, prefix, digest = generate_token()
token = await ApiToken.create(
user_sub=user.sub,
name=values["name"],
token_prefix=prefix,
token_hash=digest,
expires_at=values["expires_at"],
)
log.info("created api token sub=%s name=%r prefix=%s", user.sub, token.name, token.token_prefix)
payload = serialize_token(token)
payload["token"] = secret
await send_json(ctx, 201, payload)
@app.DELETE("/api/tokens/${token_id}")
@operation(summary="Revoke an API token", tags=["auth"],
responses={204: {"description": "Revoked"},
401: error_response("Authentication required"),
404: error_response("No such token")})
@require_session_auth(kca.oidc)
async def revoke_token(ctx: HttpContext, token_id: str) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
token_uuid = _parse_uuid(token_id)
token = await ApiToken.get_or_none(id=token_uuid, user_sub=user.sub) if token_uuid else None
if token is None:
await send_error(ctx, 404, "token not found")
return
await ApiToken.filter(id=token.id).delete()
await ctx.send_empty(204)
@@ -0,0 +1,247 @@
"""Vaults (XCA databases) and their settings."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Certificate, Crl, Key, Request, Template, Vault
from ..openapi import error_response, json_response, schema_ref
from ..service import (
DEFAULT_VAULT_SETTINGS,
ValidationError,
iso,
merge_settings,
parse_uuid,
serialize_certificate_summary,
serialize_crl_summary,
serialize_key_summary,
serialize_request_summary,
serialize_template_summary,
serialize_vault_detail,
serialize_vault_summary,
vault_for_owner,
vault_settings,
)
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
if TYPE_CHECKING:
from ..mixin import Kca
def _parse_vault_id(value: str):
return parse_uuid(value)
async def _vault_or_error(kca: "Kca", ctx: HttpContext, vault_id: str) -> Optional[Vault]:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, _parse_vault_id(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults")
@operation(summary="List my vaults", tags=["vaults"],
responses={200: json_response("Vaults", schema_ref("VaultsPage")),
401: error_response("Authentication required")})
@require_auth(kca.oidc)
async def list_vaults(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vaults = await Vault.filter(owner_sub=user.sub).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_vault_summary(v) for v in vaults]})
@app.POST("/api/vaults")
@operation(summary="Create a vault", tags=["vaults"],
responses={201: json_response("Vault created", schema_ref("VaultDetail")),
400: error_response("Invalid body"),
401: error_response("Authentication required")})
@require_auth(kca.oidc)
async def create_vault(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
name = body.get("name")
if not isinstance(name, str) or not name.strip():
await send_error(ctx, 400, "name is required")
return
description = body.get("description", "")
if not isinstance(description, str):
await send_error(ctx, 400, "description must be a string")
return
try:
settings_values = dict(DEFAULT_VAULT_SETTINGS)
if isinstance(body.get("settings"), dict):
settings_values = merge_settings(settings_values, body["settings"])
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
vault = await Vault.create(
owner_sub=user.sub,
name=name.strip()[:200],
description=description[:4000],
settings=settings_values,
)
await send_json(ctx, 201, await serialize_vault_detail(vault))
@app.GET("/api/vaults/${vault_id}")
@operation(summary="Vault detail", tags=["vaults"],
responses={200: json_response("Vault", schema_ref("VaultDetail")),
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def get_vault(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await send_json(ctx, 200, await serialize_vault_detail(vault))
@app.PATCH("/api/vaults/${vault_id}")
@operation(summary="Update a vault", tags=["vaults"],
responses={200: json_response("Vault updated", schema_ref("VaultDetail")),
400: error_response("Invalid body"),
401: error_response("Authentication required"),
404: error_response("Vault not found"),
409: error_response("Stale version")})
@require_auth(kca.oidc)
async def update_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
if "version" in body and int(body["version"]) != vault.version:
await send_error(ctx, 409, "the vault changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
vault.name = body["name"].strip()[:200]
if "description" in body:
if not isinstance(body["description"], str):
await send_error(ctx, 400, "description must be a string")
return
vault.description = body["description"][:4000]
vault.version += 1
await vault.save()
await send_json(ctx, 200, await serialize_vault_detail(vault))
@app.DELETE("/api/vaults/${vault_id}")
@operation(summary="Delete a vault", tags=["vaults"],
responses={204: {"description": "Deleted"},
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def delete_vault(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await Vault.filter(id=vault.id).delete()
await ctx.send_empty(204)
@app.GET("/api/vaults/${vault_id}/settings")
@operation(summary="Vault settings", tags=["vaults"])
@require_auth(kca.oidc)
async def get_settings(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version})
@app.PATCH("/api/vaults/${vault_id}/settings")
@operation(summary="Update vault settings", tags=["vaults"])
@require_auth(kca.oidc)
async def update_settings(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
if "version" in body and int(body["version"]) != vault.version:
await send_error(ctx, 409, "the vault changed since it was loaded")
return
patch = body.get("settings", body)
if not isinstance(patch, dict):
await send_error(ctx, 400, "settings must be an object")
return
try:
vault.settings = merge_settings(vault_settings(vault), patch)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
vault.version += 1
await vault.save()
await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version})
@app.GET("/api/vaults/${vault_id}/summary")
@operation(summary="Vault dashboard summary", tags=["vaults"])
@require_auth(kca.oidc)
async def vault_summary(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
now = datetime.now(timezone.utc)
expiring = await Certificate.filter(
vault_id=vault.id, not_after__gte=now, not_after__lte=now + timedelta(days=30)
).order_by("not_after").limit(10)
due_crls = await Crl.filter(
vault_id=vault.id, next_update__lte=now + timedelta(days=7)
).order_by("next_update").limit(10)
recent_certs = await Certificate.filter(vault_id=vault.id).order_by("-created_at").limit(8)
recent_keys = await Key.filter(vault_id=vault.id).order_by("-created_at").limit(8)
await send_json(ctx, 200, {
"summary": await serialize_vault_summary(vault),
"expiring": [await serialize_certificate_summary(c) for c in expiring],
"crls_due": [await serialize_crl_summary(c) for c in due_crls],
"recent_certificates": [await serialize_certificate_summary(c) for c in recent_certs],
"recent_keys": [await serialize_key_summary(k) for k in recent_keys],
})
@app.GET("/api/vaults/${vault_id}/options")
@operation(summary="Reference data for editors", tags=["vaults"])
@require_auth(kca.oidc)
async def options(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await send_json(ctx, 200, {
"key_types": key_core.key_type_options(),
"hash_algorithms": list(key_core.HASH_ALGORITHMS),
"curves": list(key_core.EC_CURVES),
"dn_attributes": name_core.name_attribute_options(),
"eku": name_core.eku_options(),
"key_usages": ext_core.KEY_USAGES,
"revocation_reasons": crl_core.reason_options(),
"defaults": vault_settings(vault),
"names": {
"extensions": ext_core.EXTENSION_NAMES,
"general_name_types": list(ext_core.GENERAL_NAME_TYPES),
"netscape_cert_types": [name for name, _bit in ext_core.NETSCAPE_CERT_TYPES],
},
"templates": [
*[await serialize_template_summary(t) for t in template_core.builtin_templates()],
*[await serialize_template_summary(t) for t in await Template.filter(vault_id=vault.id)],
],
})
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
"""SPA shell hosting for the compiled single-page application."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Mapping
from kaya.core import HttpContext
from kaya.openapi import operation
from .app import app
from .config import settings
_ICON_CONTENT_TYPES: Mapping[str, str] = {
"apple-touch-icon.png": "image/png",
"icon-192.png": "image/png",
"icon-512.png": "image/png",
"icon-maskable-512.png": "image/png",
"icon.svg": "image/svg+xml",
}
async def _send_file(ctx: HttpContext, name: str, content_type: str, cache_control: str) -> None:
path = Path(settings.static_dir) / name
if not path.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(path.read_bytes)
await ctx.send_bytes(200, body, {"content-type": (content_type,), "cache-control": (cache_control,)})
async def _send_shell(ctx: HttpContext) -> None:
index = Path(settings.static_dir) / "index.html"
if not index.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(index.read_bytes)
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
@app.GET("/sw.js")
@operation(hidden=True)
async def service_worker(ctx: HttpContext) -> None:
await _send_file(ctx, "sw.js", "text/javascript; charset=utf-8", "no-cache")
@app.GET("/manifest.webmanifest")
@operation(hidden=True)
async def web_manifest(ctx: HttpContext) -> None:
await _send_file(ctx, "manifest.webmanifest", "application/manifest+json", "no-cache")
@app.GET("/icons/${name}")
@operation(hidden=True)
async def icon(ctx: HttpContext, name: str) -> None:
content_type = _ICON_CONTENT_TYPES.get(name)
if content_type is None:
await ctx.send_empty(404)
return
await _send_file(ctx, f"icons/{name}", content_type, "public, max-age=86400")
@app.GET("/")
async def index(ctx: HttpContext) -> None:
await _send_shell(ctx)
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
await _send_shell(ctx)
+107
View File
@@ -0,0 +1,107 @@
"""Personal access tokens: generation, hashing and verification."""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, Mapping, Optional, Tuple
from kaya.core import HttpContext
from .models import ApiToken, User
TOKEN_PREFIX = "kca_"
PREFIX_LENGTH = 12
TOKEN_ENTROPY_BYTES = 32
MAX_TOKENS_PER_USER = 50
LAST_USED_REFRESH = timedelta(minutes=1)
MAX_TOKEN_NAME = 100
class TokenValidationError(ValueError):
pass
def generate_token() -> Tuple[str, str, str]:
secret = TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)
return secret, secret[:PREFIX_LENGTH], hash_token(secret)
def hash_token(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def bearer_token(ctx: HttpContext) -> Optional[str]:
values = ctx.headers.get("authorization")
if not values:
return None
parts = values[0].split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
credential = parts[1].strip()
return credential or None
async def resolve_token(secret: str) -> Optional[User]:
token = await ApiToken.get_or_none(token_hash=hash_token(secret))
if token is None:
return None
if token.expires_at is not None and token.expires_at < date.today():
return None
now = datetime.now(timezone.utc)
last_used = token.last_used_at
if last_used is not None and last_used.tzinfo is None:
last_used = last_used.replace(tzinfo=timezone.utc)
if last_used is None or now - last_used >= LAST_USED_REFRESH:
token.last_used_at = now
await token.save(update_fields=["last_used_at"])
return await User.get_or_none(sub=token.user_sub)
def parse_token_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise TokenValidationError("name is required")
name = name.strip()
if len(name) > MAX_TOKEN_NAME:
raise TokenValidationError(f"name must be at most {MAX_TOKEN_NAME} characters")
expires_at: Optional[date] = None
raw_expiry = payload.get("expires_at")
if raw_expiry is not None:
if not isinstance(raw_expiry, str) or not raw_expiry:
raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null")
try:
expires_at = date.fromisoformat(raw_expiry)
except ValueError as exc:
raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null") from exc
if expires_at < date.today():
raise TokenValidationError("expires_at must not be in the past")
return {"name": name, "expires_at": expires_at}
def serialize_token(token: ApiToken) -> Dict[str, Any]:
return {
"id": str(token.id),
"name": token.name,
"prefix": token.token_prefix,
"created_at": token.created_at.isoformat(),
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
}
__all__ = [
"LAST_USED_REFRESH",
"MAX_TOKEN_NAME",
"MAX_TOKENS_PER_USER",
"PREFIX_LENGTH",
"TOKEN_ENTROPY_BYTES",
"TOKEN_PREFIX",
"TokenValidationError",
"bearer_token",
"generate_token",
"hash_token",
"parse_token_payload",
"resolve_token",
"serialize_token",
]
@@ -0,0 +1,145 @@
"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle.
kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a
running event loop. Tortoise 1.x binds database connections to a
:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and
kaya dispatches each HTTP request as a separate ``loop.create_task``, so a
context set by an early request does not automatically reach later
requests.
This mixin therefore:
1. Lazily builds a :class:`TortoiseContext` for the active event loop
(rebuilding it if the running loop changes, which happens in tests that
use a fresh ``asyncio.run`` per test).
2. Per HTTP request, binds that context to the current task via the
``_current_context`` contextvar so the handler — running in the same
task as the ``before_request`` hook — sees an active context.
3. Exposes :meth:`ensure_context` for background work that belongs to no
request (used by the test suite and any future worker).
It deliberately avoids the global-fallback singleton
(``_enable_global_fallback``), which Tortoise only allows to be set once
per process and would therefore break across event loops.
Schema management is split by backend: in-memory sqlite databases (the
test suite) get ``generate_schemas`` on every fresh context; Postgres
schemas are owned by aerich migrations and must be applied externally
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
the app serves requests.
"""
from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger
from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin
from tortoise.context import TortoiseContext, _current_context
log = getLogger(__name__)
def _safe_url(database_url: str) -> str:
"""The database URL with any credentials stripped, for logging."""
parts = urlsplit(database_url)
host = parts.hostname or ""
try:
if parts.port:
host = f"{host}:{parts.port}"
except ValueError:
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
pass
return urlunsplit((parts.scheme, host, parts.path, "", ""))
class TortoiseMixin(KayaMixin):
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
def __init__(self,
database_url: str,
models_modules: Sequence[str],
skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None:
self._database_url = database_url
self._models_modules = list(models_modules)
self._skip_paths = skip_paths
self._ctx: "Optional[TortoiseContext]" = None
self._init_loop: "Optional[AbstractEventLoop]" = None
def apply(self, app: KayaApp) -> None:
app.add_before_request_hook(self._ensure_context)
def setup(self, loop: AbstractEventLoop) -> None:
pass
def shutdown(self, loop: AbstractEventLoop) -> None:
if self._init_loop is loop and self._ctx is not None:
log.info("closing database connections")
loop.create_task(self._ctx.close_connections())
self._ctx = None
self._init_loop = None
async def aclose(self) -> None:
"""Close the current context's connections and forget it.
Must be called from the event loop that owns the context. When a
loop goes away without this, its aiosqlite connections are orphaned;
their non-daemon worker threads then block interpreter shutdown
forever. The test suite calls this at the end of every test because
each test runs in a fresh event loop.
"""
ctx = self._ctx
self._ctx = None
self._init_loop = None
if ctx is not None:
log.info("closing database connections")
await ctx.close_connections()
async def _build_context(self) -> TortoiseContext:
ctx = TortoiseContext()
with ctx:
await ctx.init(
db_url=self._database_url,
modules={"models": self._models_modules},
)
log.info("database context initialized (%s)", _safe_url(self._database_url))
# Schema creation is only done for sqlite (in-memory test
# databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"):
await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx
async def ensure_context(self) -> None:
"""Bind this mixin's context to the calling task.
Background work with no request of its own calls this before
touching the database. It is cheap when the context is already
built for the running loop and idempotent for callers that already
have it bound (HTTP requests).
"""
await self._bind()
async def _bind(self) -> None:
loop = get_running_loop()
if self._init_loop is not loop:
if self._ctx is not None:
# A previous test loop went away; drop its context.
self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context()
self._init_loop = loop
assert self._ctx is not None
_current_context.set(self._ctx)
async def _ensure_context(self, ctx: HttpContext):
if ctx.path in self._skip_paths:
return None
# Only API endpoints touch the database: auth callbacks and the
# static SPA catch-all must not pay for a Tortoise context.
if not ctx.path.startswith("/api/"):
return None
await self._bind()
return None
+7
View File
@@ -0,0 +1,7 @@
# kca-core
Pure, I/O-free PKI domain logic for KCA. It knows how to generate and parse
keys, build distinguished names, encode X.509 extensions, issue certificates,
create CSRs and CRLs, apply templates, and convert every supported import and
export format. It has no kaya, database or filesystem dependencies and is
unit-tested in isolation.
+18
View File
@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "kca-core"
version = "0.1.0"
description = "Pure PKI domain logic for KCA (keys, names, extensions, certificates, CSRs, CRLs, templates, imports/exports)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"cryptography>=44",
"paramiko>=3.5",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
@@ -0,0 +1 @@
"""Pure PKI domain logic for KCA."""
@@ -0,0 +1,522 @@
"""Certificate construction, parsing, validation and export helpers."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa
from cryptography.x509.oid import ExtensionOID, NameOID
from .extensions import (
build_extensions,
eku_name,
extension_config_lines,
extension_rows,
extension_specs,
)
from .keys import PrivateKey, PublicKey, hash_algorithm, public_der
from .names import entries_from_name, name_from_entries, name_hash, one_line
#: XCA's "no well-defined expiration" sentinel.
FAR_FUTURE = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
MAX_SERIAL_BYTES = 20
@dataclass(frozen=True)
class CertificateInfo:
subject: x509.Name
issuer: x509.Name
serial_hex: str
not_before: datetime
not_after: datetime
is_ca: bool
path_length: Optional[int]
signature_algorithm: str
public_key_der: bytes
fingerprint_md5: str
fingerprint_sha1: str
fingerprint_sha256: str
subject_hash: str
issuer_hash: str
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def normalize_datetime(value: datetime) -> datetime:
return _as_utc(value)
def serial_to_hex(value: int) -> str:
return format(value, "x")
def parse_serial_hex(value: str) -> int:
text = (value or "").strip().lower().replace(":", "")
if text.startswith("0x"):
text = text[2:]
if not text:
raise ValueError("serial number is empty")
if len(text) > MAX_SERIAL_BYTES * 2:
raise ValueError("serial number must be at most 20 octets")
serial = int(text, 16)
if serial <= 0:
raise ValueError("serial number must be positive")
return serial
def build_certificate(
*,
subject: x509.Name,
public_key: PublicKey,
issuer_cert: Optional[x509.Certificate],
issuer_private_key: PrivateKey,
serial: int,
not_before: datetime,
not_after: datetime,
extensions: Iterable[x509.Extension],
signature_hash: Optional[str] = "sha256",
) -> x509.Certificate:
"""Issue a certificate (self-signed when ``issuer_cert`` is None)."""
if serial <= 0:
raise ValueError("serial number must be positive")
if serial.bit_length() > MAX_SERIAL_BYTES * 8:
raise ValueError("serial number is too large")
not_before = normalize_datetime(not_before)
not_after = normalize_datetime(not_after)
if not_after <= not_before:
raise ValueError("notAfter must be later than notBefore")
issuer_name = issuer_cert.subject if issuer_cert is not None else subject
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer_name)
.public_key(public_key)
.serial_number(serial)
.not_valid_before(not_before)
.not_valid_after(not_after)
)
for extension in extensions:
builder = builder.add_extension(extension.value, extension.critical)
algorithm = hash_algorithm(signature_hash, issuer_private_key)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm)
def parse_certificate(data: bytes) -> x509.Certificate:
return x509.load_der_x509_certificate(data)
def certificate_der(cert: x509.Certificate) -> bytes:
return cert.public_bytes(serialization.Encoding.DER)
def certificate_pem(cert: x509.Certificate) -> str:
return cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
def certificate_info(cert: x509.Certificate) -> CertificateInfo:
try:
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value
is_ca = bool(bc.ca)
path_length = bc.path_length
except x509.ExtensionNotFound:
is_ca = False
path_length = None
return CertificateInfo(
subject=cert.subject,
issuer=cert.issuer,
serial_hex=serial_to_hex(cert.serial_number),
not_before=normalize_datetime(cert.not_valid_before_utc),
not_after=normalize_datetime(cert.not_valid_after_utc),
is_ca=is_ca,
path_length=path_length,
signature_algorithm=signature_algorithm_name(cert),
public_key_der=cert.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
),
fingerprint_md5=fingerprint(cert, "md5"),
fingerprint_sha1=fingerprint(cert, "sha1"),
fingerprint_sha256=fingerprint(cert, "sha256"),
subject_hash=name_hash(cert.subject),
issuer_hash=name_hash(cert.issuer),
)
_SIGNATURE_OIDS = {
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.1.14": "sha224WithRSAEncryption",
"1.2.840.10045.4.1": "ecdsa-with-SHA1",
"1.2.840.10045.4.3.2": "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3": "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4": "ecdsa-with-SHA512",
"1.3.101.112": "Ed25519",
}
def signature_algorithm_name(cert: x509.Certificate) -> str:
algorithm = cert.signature_algorithm_oid
dotted = algorithm.dotted_string
if dotted in _SIGNATURE_OIDS:
return _SIGNATURE_OIDS[dotted]
name = getattr(algorithm, "name", None)
if name and name != "Undefined":
return name
return dotted
def fingerprint(cert: x509.Certificate, algorithm: str = "sha256") -> str:
data = certificate_der(cert)
try:
digest = hashlib.new(algorithm.lower())
except ValueError as exc:
raise ValueError(f"unsupported fingerprint algorithm {algorithm!r}") from exc
digest.update(data)
return digest.hexdigest()
def is_self_signed(cert: x509.Certificate) -> bool:
if cert.subject != cert.issuer:
return False
try:
cert.verify_directly_issued_by(cert)
return True
except Exception: # noqa: BLE001 - invalid signature
return False
def verify_directly_issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool:
try:
cert.verify_directly_issued_by(issuer)
return True
except Exception: # noqa: BLE001
return False
def find_issuer(cert: x509.Certificate, candidates: Iterable[x509.Certificate]) -> Optional[x509.Certificate]:
"""Find the best issuer among ``candidates`` (latest expiry wins)."""
if is_self_signed(cert):
return cert
matches = [
candidate
for candidate in candidates
if candidate is not cert
and candidate.subject == cert.issuer
and verify_directly_issued_by(cert, candidate)
]
if not matches:
return None
return max(matches, key=lambda candidate: candidate.not_valid_after_utc)
def chain_for(
cert: x509.Certificate,
lookup: Dict[str, x509.Certificate],
max_depth: int = 20,
) -> List[x509.Certificate]:
"""Follow issuer links in ``lookup`` (issuer_hash -> cert) up to a root."""
chain = [cert]
current = cert
seen = {cert.fingerprint(hashes.SHA256())}
for _ in range(max_depth):
if is_self_signed(current):
break
issuer = lookup.get(name_hash(current.issuer))
if issuer is None or issuer.fingerprint(hashes.SHA256()) in seen:
break
if not verify_directly_issued_by(current, issuer):
break
chain.append(issuer)
seen.add(issuer.fingerprint(hashes.SHA256()))
current = issuer
return chain
def validation_errors(cert: x509.Certificate, chain: Sequence[x509.Certificate]) -> List[Dict[str, Any]]:
"""OpenSSL-backed chain validation, returning error code/message pairs.
XCA validates end-entity certificates with the X.509 strict and policy
flags and lists every error. pyOpenSSL surfaces one error per context, so
we verify the chain once and, for missing issuers, report that explicitly.
"""
errors: List[Dict[str, Any]] = []
if is_self_signed(cert):
return errors
if len(chain) < 2:
return [{"code": 20, "message": "unable to get local issuer certificate"}]
try:
from OpenSSL import crypto
store = crypto.X509Store()
# The root of the supplied chain is trusted; intermediates are
# untrusted inputs, exactly like XCA's trusted/untrusted stacks.
for item in chain[1:]:
converted = crypto.X509.from_cryptography(item)
if is_self_signed(item):
store.add_cert(converted)
else:
store.add_cert(converted)
context = crypto.X509StoreContext(
store,
crypto.X509.from_cryptography(cert),
[crypto.X509.from_cryptography(item) for item in chain[1:]],
)
context.verify_certificate()
except Exception as exc: # noqa: BLE001 - pyOpenSSL raises rich errors
code = getattr(exc, "args", [None])[-1] if getattr(exc, "args", None) else None
if not isinstance(code, int):
code = None
errors.append({"code": code, "message": str(exc)})
return errors
def purposes(cert: x509.Certificate) -> List[str]:
"""A practical purpose list derived from EKU/KU (XCA uses OpenSSL's checks)."""
result: List[str] = []
try:
eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
oids = {oid.dotted_string for oid in eku}
except x509.ExtensionNotFound:
oids = set()
labels = {
"1.3.6.1.5.5.7.3.1": "SSL server",
"1.3.6.1.5.5.7.3.2": "SSL client",
"1.3.6.1.5.5.7.3.3": "Code signing",
"1.3.6.1.5.5.7.3.4": "E-mail protection",
"1.3.6.1.5.5.7.3.8": "Time stamping",
"1.3.6.1.5.5.7.3.9": "OCSP helper",
}
for oid, label in labels.items():
if oid in oids:
result.append(label)
if not oids:
result.append("Any purpose")
return result
def certificate_spec(cert: x509.Certificate) -> Dict[str, Any]:
"""A certificate-creation spec for "similar certificate" / renewal."""
info = certificate_info(cert)
return {
"subject": [entry.as_dict() for entry in entries_from_name(cert.subject)],
"serial_hex": info.serial_hex,
"not_before": info.not_before.isoformat(),
"not_after": info.not_after.isoformat(),
"signature_hash": _signature_hash_name(cert),
"extensions": extension_specs(cert.extensions),
"public_key_der": info.public_key_der.hex(),
}
def signature_hash_name(cert: x509.Certificate) -> Optional[str]:
try:
algorithm = cert.signature_hash_algorithm
except Exception: # noqa: BLE001 - Ed25519
return None
if algorithm is None:
return None
return algorithm.name
# Backwards-compatible private alias used by the app layer.
_signature_hash_name = signature_hash_name
def index_entry(cert: x509.Certificate, *, revoked_at: Optional[datetime] = None) -> str:
"""One line in OpenSSL's ``index.txt`` format."""
now = datetime.now(timezone.utc)
if revoked_at is not None:
flag = "R"
elif cert.not_valid_before_utc <= now <= cert.not_valid_after_utc:
flag = "V"
else:
flag = "E"
expiry = cert.not_valid_after_utc.strftime("%Y%m%d%H%M%SZ")
revoked = revoked_at.astimezone(timezone.utc).strftime("%Y%m%d%H%M%SZ") if revoked_at else ""
subject = cert.subject.rfc4514_string()
return f"{flag}\t{expiry}\t{revoked}\t{serial_to_hex(cert.serial_number)}\tunknown\t{subject}\n"
def openssl_config(cert: x509.Certificate) -> str:
"""An OpenSSL configuration fragment reproducing the certificate."""
lines = [
"[req]",
"distinguished_name = req_distinguished_name",
"prompt = no",
"",
"[req_distinguished_name]",
]
for entry in entries_from_name(cert.subject):
lines.append(f"{entry.name} = {entry.value}")
lines.extend(["", "[v3_ext]"])
config = extension_config_lines(cert.extensions)
if config:
lines.append(config)
return "\n".join(lines) + "\n"
def ics_event(cert: x509.Certificate, name: str = "") -> str:
"""A vCalendar reminder for the certificate's expiry."""
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
start = cert.not_valid_after_utc.strftime("%Y%m%dT%H%M%SZ")
summary = f"Renew certificate: {name or cert.subject.rfc4514_string()}"
description = (
f"The certificate '{name or cert.subject.rfc4514_string()}' "
f"will expire on {cert.not_valid_after_utc.isoformat()}."
)
return "\r\n".join(
[
"BEGIN:VEVENT",
f"UID:{fingerprint(cert, 'sha256')}@kca",
f"DTSTAMP:{stamp}",
f"DTSTART:{start}",
f"SUMMARY:{_ics_escape(summary)}",
f"DESCRIPTION:{_ics_escape(description)}",
"END:VEVENT",
]
)
def _ics_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
def ics_calendar(events: Sequence[str]) -> str:
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//KCA//XCA Web//EN",
"CALSCALE:GREGORIAN",
]
for event in events:
lines.extend(event.split("\r\n"))
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
def certificate_public_key(cert: x509.Certificate) -> PublicKey:
return cert.public_key()
def subject_entries(cert: x509.Certificate) -> List[Dict[str, str]]:
return [entry.as_dict() for entry in entries_from_name(cert.subject)]
def issuer_entries(cert: x509.Certificate) -> List[Dict[str, str]]:
return [entry.as_dict() for entry in entries_from_name(cert.issuer)]
def certificate_extensions(cert: x509.Certificate) -> List[Dict[str, Any]]:
return extension_rows(cert.extensions)
def certificate_extension_specs(cert: x509.Certificate) -> Dict[str, Any]:
return extension_specs(cert.extensions)
def build_self_signed(
*,
subject: x509.Name,
private_key: PrivateKey,
serial: int,
not_before: datetime,
not_after: datetime,
extension_spec: Optional[Dict[str, Any]] = None,
signature_hash: Optional[str] = "sha256",
) -> x509.Certificate:
public_key = private_key.public_key()
extensions = build_extensions(
extension_spec,
public_key=public_key,
subject_name=subject,
issuer_cert=None,
)
return build_certificate(
subject=subject,
public_key=public_key,
issuer_cert=None,
issuer_private_key=private_key,
serial=serial,
not_before=not_before,
not_after=not_after,
extensions=extensions,
signature_hash=signature_hash,
)
def build_signed(
*,
subject: x509.Name,
public_key: PublicKey,
issuer_cert: x509.Certificate,
issuer_private_key: PrivateKey,
serial: int,
not_before: datetime,
not_after: datetime,
extension_spec: Optional[Dict[str, Any]] = None,
signature_hash: Optional[str] = "sha256",
) -> x509.Certificate:
extensions = build_extensions(
extension_spec,
public_key=public_key,
subject_name=subject,
issuer_cert=issuer_cert,
)
return build_certificate(
subject=subject,
public_key=public_key,
issuer_cert=issuer_cert,
issuer_private_key=issuer_private_key,
serial=serial,
not_before=not_before,
not_after=not_after,
extensions=extensions,
signature_hash=signature_hash,
)
__all__ = [
"FAR_FUTURE",
"MAX_SERIAL_BYTES",
"CertificateInfo",
"build_certificate",
"build_self_signed",
"build_signed",
"certificate_der",
"certificate_extension_specs",
"certificate_extensions",
"certificate_info",
"certificate_pem",
"certificate_public_key",
"certificate_spec",
"chain_for",
"find_issuer",
"fingerprint",
"ics_calendar",
"ics_event",
"index_entry",
"is_self_signed",
"issuer_entries",
"normalize_datetime",
"openssl_config",
"parse_certificate",
"parse_serial_hex",
"purposes",
"serial_to_hex",
"signature_algorithm_name",
"signature_hash_name",
"subject_entries",
"validate_chain",
"validation_errors",
"verify_directly_issued_by",
]
@@ -0,0 +1,244 @@
"""Certificate revocation list construction and parsing."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ExtensionOID
from .certificates import normalize_datetime, signature_algorithm_name
from .extensions import extension_rows, extension_specs
from .keys import PrivateKey, hash_algorithm
#: XCA's revocation reason names, in OpenSSL order.
CRL_REASONS: List[Dict[str, Any]] = [
{"name": "unspecified", "label": "Unspecified", "flag": x509.ReasonFlags.unspecified},
{"name": "keyCompromise", "label": "Key Compromise", "flag": x509.ReasonFlags.key_compromise},
{"name": "CACompromise", "label": "CA Compromise", "flag": x509.ReasonFlags.ca_compromise},
{"name": "affiliationChanged", "label": "Affiliation Changed", "flag": x509.ReasonFlags.affiliation_changed},
{"name": "superseded", "label": "Superseded", "flag": x509.ReasonFlags.superseded},
{"name": "cessationOfOperation", "label": "Cessation Of Operation", "flag": x509.ReasonFlags.cessation_of_operation},
{"name": "certificateHold", "label": "Certificate Hold", "flag": x509.ReasonFlags.certificate_hold},
{"name": "removeFromCRL", "label": "Remove From CRL", "flag": x509.ReasonFlags.remove_from_crl},
{"name": "privilegeWithdrawn", "label": "Privilege Withdrawn", "flag": x509.ReasonFlags.privilege_withdrawn},
{"name": "AACompromise", "label": "AA Compromise", "flag": x509.ReasonFlags.aa_compromise},
]
_REASON_BY_NAME = {item["name"]: item for item in CRL_REASONS}
_REASON_BY_FLAG = {item["flag"]: item for item in CRL_REASONS}
@dataclass(frozen=True)
class CrlInfo:
issuer: x509.Name
last_update: datetime
next_update: Optional[datetime]
crl_number: Optional[int]
revoked_count: int
signature_algorithm: str
fingerprint_sha256: str
def reason_flag(name: str) -> x509.ReasonFlags:
return _REASON_BY_NAME.get(name, _REASON_BY_NAME["unspecified"])["flag"]
def reason_name(flag: x509.ReasonFlags) -> str:
return _REASON_BY_FLAG.get(flag, _REASON_BY_NAME["unspecified"])["name"]
def build_crl(
*,
issuer_cert: x509.Certificate,
issuer_private_key: PrivateKey,
revocations: Iterable[Mapping[str, Any]],
last_update: datetime,
next_update: datetime,
crl_number: Optional[int] = None,
signature_hash: Optional[str] = "sha256",
with_reason: bool = True,
include_aki: bool = True,
include_issuer_alt_name: bool = True,
) -> x509.CertificateRevocationList:
last_update = normalize_datetime(last_update)
next_update = normalize_datetime(next_update)
if next_update <= last_update:
raise ValueError("nextUpdate must be later than lastUpdate")
builder = (
x509.CertificateRevocationListBuilder()
.issuer_name(issuer_cert.subject)
.last_update(last_update)
.next_update(next_update)
)
for item in revocations:
serial = int(str(item.get("serial_hex", "0")).replace(":", ""), 16)
revoked_at = normalize_datetime(item.get("revoked_at") or last_update)
revoked = (
x509.RevokedCertificateBuilder()
.serial_number(serial)
.revocation_date(revoked_at)
)
if with_reason:
revoked = revoked.add_extension(
x509.CRLReason(reason_flag(str(item.get("reason", "unspecified")))),
critical=False,
)
invalidity = item.get("invalidity_date")
if invalidity:
revoked = revoked.add_extension(
x509.InvalidityDate(normalize_datetime(invalidity)), critical=False
)
builder = builder.add_revoked_certificate(revoked.build())
if crl_number is not None:
builder = builder.add_extension(x509.CRLNumber(int(crl_number)), critical=False)
if include_aki:
try:
ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski)
except x509.ExtensionNotFound:
aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_cert.public_key())
builder = builder.add_extension(aki, critical=False)
if include_issuer_alt_name:
try:
san = issuer_cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
builder = builder.add_extension(x509.IssuerAlternativeName(list(san)), critical=False)
except x509.ExtensionNotFound:
pass
algorithm = hash_algorithm(signature_hash, issuer_private_key)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm)
def parse_crl(data: bytes) -> x509.CertificateRevocationList:
return x509.load_der_x509_crl(data)
def crl_der(crl: x509.CertificateRevocationList) -> bytes:
return crl.public_bytes(serialization.Encoding.DER)
def crl_pem(crl: x509.CertificateRevocationList) -> str:
return crl.public_bytes(serialization.Encoding.PEM).decode("ascii")
def crl_number(crl: x509.CertificateRevocationList) -> Optional[int]:
try:
return crl.extensions.get_extension_for_class(x509.CRLNumber).value.crl_number
except x509.ExtensionNotFound:
return None
def crl_info(crl: x509.CertificateRevocationList) -> CrlInfo:
return CrlInfo(
issuer=crl.issuer,
last_update=normalize_datetime(crl.last_update_utc),
next_update=normalize_datetime(crl.next_update_utc) if crl.next_update_utc else None,
crl_number=crl_number(crl),
revoked_count=len(list(crl)),
signature_algorithm=signature_algorithm_name(crl),
fingerprint_sha256=hashlib.sha256(crl_der(crl)).hexdigest(),
)
def crl_revocations(crl: x509.CertificateRevocationList) -> List[Dict[str, Any]]:
rows = []
for revoked in crl:
reason = None
invalidity = None
try:
reason = reason_name(
revoked.extensions.get_extension_for_class(x509.CRLReason).value.reason
)
except x509.ExtensionNotFound:
pass
try:
invalidity = normalize_datetime(
revoked.extensions.get_extension_for_class(x509.InvalidityDate).value.invalidity_date
).isoformat()
except x509.ExtensionNotFound:
pass
rows.append(
{
"serial_hex": format(revoked.serial_number, "x"),
"revoked_at": normalize_datetime(revoked.revocation_date_utc).isoformat(),
"invalidity_date": invalidity,
"reason": reason,
}
)
return rows
def verify_crl(crl: x509.CertificateRevocationList, issuer_cert: x509.Certificate) -> bool:
if crl.issuer != issuer_cert.subject:
return False
try:
return bool(crl.is_signature_valid(issuer_cert.public_key()))
except Exception: # noqa: BLE001
return False
def crl_extensions(crl: x509.CertificateRevocationList) -> List[Dict[str, Any]]:
return extension_rows(crl.extensions)
def crl_spec(crl: x509.CertificateRevocationList) -> Dict[str, Any]:
return {
"issuer": crl.issuer.rfc4514_string(),
"last_update": normalize_datetime(crl.last_update_utc).isoformat(),
"next_update": normalize_datetime(crl.next_update_utc).isoformat() if crl.next_update_utc else None,
"crl_number": crl_number(crl),
"revocations": crl_revocations(crl),
"extensions": extension_specs(crl.extensions),
}
def ics_event(crl: x509.CertificateRevocationList, name: str = "") -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
next_update = crl.next_update_utc
if next_update is None:
return ""
start = next_update.strftime("%Y%m%dT%H%M%SZ")
summary = f"Renew CRL: {name or crl.issuer.rfc4514_string()}"
description = f"The CRL '{name or crl.issuer.rfc4514_string()}' expires on {next_update.isoformat()}."
return "\r\n".join(
[
"BEGIN:VEVENT",
f"UID:{hashlib.sha256(crl_der(crl)).hexdigest()}@kca",
f"DTSTAMP:{stamp}",
f"DTSTART:{start}",
f"SUMMARY:{summary.replace(',', '\\,')}",
f"DESCRIPTION:{description.replace(',', '\\,')}",
"END:VEVENT",
]
)
def reason_options() -> List[Dict[str, str]]:
return [{"name": item["name"], "label": item["label"]} for item in CRL_REASONS]
__all__ = [
"CRL_REASONS",
"CrlInfo",
"build_crl",
"crl_der",
"crl_extensions",
"crl_info",
"crl_number",
"crl_pem",
"crl_revocations",
"crl_spec",
"ics_event",
"parse_crl",
"reason_flag",
"reason_name",
"reason_options",
"verify_crl",
]
@@ -0,0 +1,83 @@
"""Small AES-256-GCM envelope used for private keys at rest."""
from __future__ import annotations
import base64
import os
from dataclasses import dataclass
from typing import Optional
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
NONCE_BYTES = 12
KEY_BYTES = 32
class DecryptionError(ValueError):
"""The ciphertext could not be authenticated with the configured key."""
@dataclass(frozen=True)
class EncryptedBlob:
ciphertext: bytes
nonce: bytes
def decode_master_key(value: str) -> bytes:
"""Decode a base64 (or hex) 32-byte master key from the environment."""
text = (value or "").strip()
if not text:
raise ValueError("KCA_MASTER_KEY is empty")
try:
raw = base64.b64decode(text, validate=True)
except Exception: # noqa: BLE001
try:
raw = bytes.fromhex(text)
except ValueError as exc:
raise ValueError("KCA_MASTER_KEY must be base64 or hex") from exc
if len(raw) != KEY_BYTES:
raise ValueError(f"KCA_MASTER_KEY must be {KEY_BYTES} bytes")
return raw
def generate_master_key() -> str:
return base64.b64encode(os.urandom(KEY_BYTES)).decode("ascii")
def encrypt_blob(plaintext: bytes, *, master_key: bytes, aad: bytes = b"") -> EncryptedBlob:
if len(master_key) != KEY_BYTES:
raise ValueError(f"master key must be {KEY_BYTES} bytes")
nonce = os.urandom(NONCE_BYTES)
ciphertext = AESGCM(master_key).encrypt(nonce, plaintext, aad)
return EncryptedBlob(ciphertext=ciphertext, nonce=nonce)
def decrypt_blob(blob: EncryptedBlob, *, master_key: bytes, aad: bytes = b"") -> bytes:
if len(master_key) != KEY_BYTES:
raise ValueError(f"master key must be {KEY_BYTES} bytes")
try:
return AESGCM(master_key).decrypt(blob.nonce, blob.ciphertext, aad)
except InvalidTag as exc:
raise DecryptionError("private key authentication failed") from exc
def encrypt_private_key(der: bytes, key_id: str, master_key: bytes) -> EncryptedBlob:
return encrypt_blob(der, master_key=master_key, aad=key_id.encode("utf-8"))
def decrypt_private_key(blob: EncryptedBlob, key_id: str, master_key: bytes) -> bytes:
return decrypt_blob(blob, master_key=master_key, aad=key_id.encode("utf-8"))
__all__ = [
"DecryptionError",
"EncryptedBlob",
"KEY_BYTES",
"NONCE_BYTES",
"decode_master_key",
"decrypt_blob",
"decrypt_private_key",
"encrypt_blob",
"encrypt_private_key",
"generate_master_key",
]
@@ -0,0 +1,324 @@
"""Export formats for certificates, keys, requests, CRLs and templates."""
from __future__ import annotations
import base64
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
Encoding,
NoEncryption,
pkcs7,
pkcs12,
)
from .certificates import certificate_der, certificate_pem, index_entry, ics_calendar, ics_event, openssl_config
from .crls import crl_der, crl_pem, ics_event as crl_ics_event
from .keys import (
PrivateKey,
jwk_private,
jwk_public,
private_der_bytes,
private_pem,
public_pem,
pvk_encode_private,
ssh_private,
ssh_public,
)
from .requests import csr_der, csr_pem, openssl_config as csr_openssl_config
from .templates import template_to_xca_data
OPENVPN_TA_BEGIN = "-----BEGIN OpenVPN Static key V1-----"
OPENVPN_TA_END = "-----END OpenVPN Static key V1-----"
@dataclass(frozen=True)
class ExportResult:
filename: str
content_type: str
data: bytes
def _safe_name(name: str, fallback: str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in (name or "")).strip("._")
return cleaned or fallback
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _password_encryption(password: Optional[str]) -> serialization.KeySerializationEncryption:
if password:
return BestAvailableEncryption(password.encode())
return NoEncryption()
def _pem_chain(cert: x509.Certificate, chain: Sequence[x509.Certificate]) -> bytes:
parts = [certificate_pem(cert)]
for item in chain:
if item is cert:
continue
parts.append(certificate_pem(item))
return "".join(parts).encode("ascii")
def export_certificate(
cert: x509.Certificate,
export_format: str,
*,
name: str = "",
chain: Sequence[x509.Certificate] = (),
private_key: Optional[PrivateKey] = None,
password: Optional[str] = None,
tls_auth_key: Optional[str] = None,
comment: str = "",
) -> ExportResult:
fmt = (export_format or "pem").lower()
stem = _safe_name(name, "certificate")
if fmt == "pem":
return ExportResult(f"{stem}.crt", "application/x-pem-file", certificate_pem(cert).encode("ascii"))
if fmt in ("pem-chain", "chain"):
return ExportResult(f"{stem}.pem", "application/x-pem-file", _pem_chain(cert, chain))
if fmt == "pem-key":
if private_key is None:
raise ValueError("this certificate has no private key")
data = certificate_pem(cert).encode("ascii") + private_pem(private_key)
return ExportResult(f"{stem}.pem", "application/x-pem-file", data)
if fmt == "pem-pkcs8":
if private_key is None:
raise ValueError("this certificate has no private key")
if not password:
raise ValueError("a password is required for encrypted PKCS#8 export")
data = certificate_pem(cert).encode("ascii") + private_pem(private_key, password=password)
return ExportResult(f"{stem}.pem", "application/x-pem-file", data)
if fmt in ("pkcs7", "pkcs7-chain", "pkcs7-multi"):
certs = [cert]
if fmt != "pkcs7":
certs.extend(item for item in chain if item is not cert)
data = pkcs7.serialize_certificates(certs, Encoding.DER)
return ExportResult(f"{stem}.p7b", "application/pkcs7-mime", data)
if fmt == "der":
return ExportResult(f"{stem}.cer", "application/pkix-cert", certificate_der(cert))
if fmt in ("pkcs12", "pfx", "pkcs12-chain"):
if private_key is None:
raise ValueError("PKCS#12 export requires the private key")
cas = [item for item in chain if item is not cert] if fmt == "pkcs12-chain" else []
data = pkcs12.serialize_key_and_certificates(
name=stem.encode(),
key=private_key,
cert=cert,
cas=cas or None,
encryption_algorithm=_password_encryption(password),
)
return ExportResult(f"{stem}.pfx", "application/x-pkcs12", data)
if fmt == "openvpn":
if private_key is None:
raise ValueError("OpenVPN export requires the private key")
ca_cert = next((item for item in reversed(chain) if item is not cert), None)
ca_pem = certificate_pem(ca_cert) if ca_cert is not None else certificate_pem(cert)
key_pem = private_pem(private_key).decode("ascii")
text = (
f"<ca>\n{ca_pem}</ca>\n"
f"<cert>\n{certificate_pem(cert)}</cert>\n"
f"<key>\n{key_pem}</key>\n"
)
return ExportResult(f"{stem}.ovpn", "text/plain", text.encode("utf-8"))
if fmt in ("openssl-config", "config"):
return ExportResult(f"{stem}.conf", "text/plain", openssl_config(cert).encode("utf-8"))
if fmt == "index":
return ExportResult(f"{stem}.txt", "text/plain", index_entry(cert).encode("utf-8"))
if fmt == "vcalendar":
return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([ics_event(cert, name)]).encode("utf-8"))
if fmt in ("jwk", "jwk-public", "jwk-chain"):
jwk = jwk_public(cert.public_key(), kid=name or None)
der = certificate_der(cert)
jwk["x5t"] = _b64u(hashlib.sha1(der).digest())
jwk["x5t#256"] = _b64u(hashlib.sha256(der).digest())
if fmt == "jwk-chain":
jwk["x5c"] = [base64.b64encode(certificate_der(item)).decode("ascii") for item in [cert, *chain]]
return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk, indent=2).encode("utf-8"))
if fmt == "tls-auth":
if not tls_auth_key:
raise ValueError("no OpenVPN tls-auth key is stored for this CA")
return ExportResult(f"{stem}.key", "text/plain", tls_auth_key.encode("ascii"))
raise ValueError(f"unsupported certificate export format {export_format!r}")
def export_key(
key: PrivateKey | Any,
export_format: str,
*,
name: str = "",
password: Optional[str] = None,
is_private: bool = True,
) -> ExportResult:
fmt = (export_format or "pem-public").lower()
stem = _safe_name(name, "key")
if fmt == "pem-public":
return ExportResult(f"{stem}.pem", "application/x-pem-file", public_pem(key).encode("ascii"))
if fmt in ("pem-private", "pem"):
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.pem", "application/x-pem-file", private_pem(key))
if fmt == "pem-encrypted":
if not is_private:
raise ValueError("this is a public key")
if not password:
raise ValueError("a password is required")
return ExportResult(f"{stem}.pem", "application/x-pem-file", private_pem(key, password=password, traditional=True))
if fmt == "der-public":
from .keys import public_der_bytes
return ExportResult(f"{stem}.der", "application/pkix-key", public_der_bytes(key))
if fmt == "der-private":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.der", "application/pkcs8", private_der_bytes(key))
if fmt in ("pkcs8", "pkcs8-private"):
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.pk8", "application/x-pem-file", private_pem(key))
if fmt == "pkcs8-encrypted":
if not is_private:
raise ValueError("this is a public key")
if not password:
raise ValueError("a password is required")
return ExportResult(f"{stem}.pk8", "application/x-pem-file", private_pem(key, password=password))
if fmt == "ssh-public":
return ExportResult(f"{stem}.pub", "text/plain", (ssh_public(key, name) + "\n").encode("utf-8"))
if fmt == "ssh-private":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.priv", "text/plain", ssh_private(key, password=password))
if fmt in ("jwk", "jwk-public"):
return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk_public(key, kid=name or None), indent=2).encode("utf-8"))
if fmt == "jwk-private":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk_private(key, kid=name or None), indent=2).encode("utf-8"))
if fmt == "pvk":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.pvk", "application/octet-stream", pvk_encode_private(key))
raise ValueError(f"unsupported key export format {export_format!r}")
def export_request(
csr: x509.CertificateSigningRequest,
export_format: str,
*,
name: str = "",
) -> ExportResult:
fmt = (export_format or "pem").lower()
stem = _safe_name(name, "request")
if fmt == "pem":
return ExportResult(f"{stem}.csr", "application/x-pem-file", csr_pem(csr).encode("ascii"))
if fmt == "der":
return ExportResult(f"{stem}.der", "application/pkcs10", csr_der(csr))
if fmt in ("openssl-config", "config"):
return ExportResult(f"{stem}.conf", "text/plain", csr_openssl_config(csr).encode("utf-8"))
raise ValueError(f"unsupported request export format {export_format!r}")
def export_crl(crl: x509.CertificateRevocationList, export_format: str, *, name: str = "") -> ExportResult:
fmt = (export_format or "pem").lower()
stem = _safe_name(name, "crl")
if fmt == "pem":
return ExportResult(f"{stem}.crl", "application/x-pem-file", crl_pem(crl).encode("ascii"))
if fmt == "der":
return ExportResult(f"{stem}.crl", "application/pkix-crl", crl_der(crl))
if fmt == "vcalendar":
return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([crl_ics_event(crl, name)]).encode("utf-8"))
raise ValueError(f"unsupported CRL export format {export_format!r}")
def export_template(template: Dict[str, Any], export_format: str = "xca", *, name: str = "") -> ExportResult:
fmt = (export_format or "xca").lower()
stem = _safe_name(name or str(template.get("name", "")), "template")
if fmt in ("xca", "pem"):
return ExportResult(f"{stem}.xca", "application/x-pem-file", template_to_xca_data(template))
if fmt == "json":
return ExportResult(f"{stem}.json", "application/json", json.dumps(template, indent=2).encode("utf-8"))
raise ValueError(f"unsupported template export format {export_format!r}")
def certificate_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem", "label": "PEM", "extension": "crt"},
{"id": "pem-chain", "label": "PEM chain", "extension": "pem"},
{"id": "pem-key", "label": "PEM + key", "extension": "pem"},
{"id": "pem-pkcs8", "label": "PEM + encrypted PKCS#8", "extension": "pem"},
{"id": "pkcs7", "label": "PKCS#7", "extension": "p7b"},
{"id": "pkcs7-chain", "label": "PKCS#7 chain", "extension": "p7b"},
{"id": "der", "label": "DER", "extension": "cer"},
{"id": "pkcs12", "label": "PKCS#12", "extension": "pfx"},
{"id": "pkcs12-chain", "label": "PKCS#12 chain", "extension": "pfx"},
{"id": "openvpn", "label": "OpenVPN", "extension": "ovpn"},
{"id": "openssl-config", "label": "OpenSSL config", "extension": "conf"},
{"id": "index", "label": "Certificate index file", "extension": "txt"},
{"id": "vcalendar", "label": "vCalendar", "extension": "ics"},
{"id": "jwk", "label": "JSON Web Key", "extension": "jwk"},
{"id": "jwk-chain", "label": "JSON Web Key chain", "extension": "jwk"},
{"id": "tls-auth", "label": "OpenVPN tls-auth key", "extension": "key"},
]
def key_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem-public", "label": "PEM public", "extension": "pem"},
{"id": "pem-private", "label": "PEM private", "extension": "pem"},
{"id": "pem-encrypted", "label": "PEM encrypted", "extension": "pem"},
{"id": "pkcs8", "label": "PKCS#8", "extension": "pk8"},
{"id": "pkcs8-encrypted", "label": "PKCS#8 encrypted", "extension": "pk8"},
{"id": "der-public", "label": "DER public", "extension": "der"},
{"id": "der-private", "label": "DER private", "extension": "der"},
{"id": "ssh-public", "label": "SSH2 public", "extension": "pub"},
{"id": "ssh-private", "label": "SSH2 private", "extension": "priv"},
{"id": "jwk-public", "label": "JSON Web Key public", "extension": "jwk"},
{"id": "jwk-private", "label": "JSON Web Key private", "extension": "jwk"},
{"id": "pvk", "label": "PVK private", "extension": "pvk"},
]
def request_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem", "label": "PEM", "extension": "csr"},
{"id": "der", "label": "DER", "extension": "der"},
{"id": "openssl-config", "label": "OpenSSL config", "extension": "conf"},
]
def crl_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem", "label": "PEM", "extension": "crl"},
{"id": "der", "label": "DER", "extension": "crl"},
{"id": "vcalendar", "label": "vCalendar", "extension": "ics"},
]
def template_format_options() -> List[Dict[str, str]]:
return [
{"id": "xca", "label": "XCA template", "extension": "xca"},
{"id": "json", "label": "JSON", "extension": "json"},
]
__all__ = [
"ExportResult",
"certificate_format_options",
"crl_format_options",
"export_certificate",
"export_crl",
"export_key",
"export_request",
"export_template",
"key_format_options",
"request_format_options",
"template_format_options",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
"""Import parsing for PEM bundles, DER items, PKCS#7, PKCS#12 and SSH keys."""
from __future__ import annotations
import base64
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Sequence
from cryptography import x509
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
from .certificates import certificate_info
from .crls import crl_info
from .keys import PrivateKey, PublicKey, is_private_key, key_info, load_key_bytes
from .names import entries_from_name
from .requests import request_info
from .templates import template_from_xca_data
_PEM_BLOCK = re.compile(
r"-----BEGIN ([^-]+)-----\s*(.*?)\s*-----END \1-----",
re.DOTALL,
)
@dataclass
class ParsedItem:
kind: str
name: str
summary: str
obj: Any
is_private: bool = False
warnings: List[str] = field(default_factory=list)
extra: Dict[str, Any] = field(default_factory=dict)
@property
def fingerprint(self) -> Optional[str]:
if self.kind == "certificate":
return certificate_info(self.obj).fingerprint_sha256
if self.kind == "key":
return key_info(self.obj).public_sha256
if self.kind == "request":
return request_info(self.obj).fingerprint_sha256
if self.kind == "crl":
return crl_info(self.obj).fingerprint_sha256
return None
def _guess_name(obj: Any, filename: str = "") -> str:
stem = filename.rsplit("/", 1)[-1]
if "." in stem:
stem = stem.rsplit(".", 1)[0]
if isinstance(obj, x509.Certificate):
names = [str(attr.value) for attr in obj.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)]
return names[0] if names else (stem or "certificate")
if isinstance(obj, x509.CertificateSigningRequest):
names = [str(attr.value) for attr in obj.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)]
return names[0] if names else (stem or "request")
if isinstance(obj, x509.CertificateRevocationList):
return stem or "crl"
if isinstance(obj, dict):
return str(obj.get("name") or stem or "template")
return stem or "key"
def _summary(obj: Any) -> str:
info: Any
if isinstance(obj, x509.Certificate):
info = certificate_info(obj)
return f"{info.subject.rfc4514_string()} · {info.serial_hex}"
if isinstance(obj, x509.CertificateSigningRequest):
info = request_info(obj)
return info.subject.rfc4514_string()
if isinstance(obj, x509.CertificateRevocationList):
info = crl_info(obj)
return f"{info.issuer.rfc4514_string()} · {info.revoked_count} revoked"
if isinstance(obj, dict):
return str(obj.get("comment") or "XCA template")
try:
info = key_info(obj)
return f"{info.key_type} {info.key_size}" + (f" {info.curve}" if info.curve else "")
except Exception: # noqa: BLE001
return ""
def _key_item(key: Any, filename: str, name: Optional[str] = None) -> ParsedItem:
private = is_private_key(key)
return ParsedItem(
kind="key",
name=name or _guess_name(key, filename),
summary=_summary(key),
obj=key,
is_private=private,
)
def _cert_item(cert: x509.Certificate, filename: str, name: Optional[str] = None) -> ParsedItem:
return ParsedItem(
kind="certificate",
name=name or _guess_name(cert, filename),
summary=_summary(cert),
obj=cert,
)
def _csr_item(csr: x509.CertificateSigningRequest, filename: str, name: Optional[str] = None) -> ParsedItem:
return ParsedItem(
kind="request",
name=name or _guess_name(csr, filename),
summary=_summary(csr),
obj=csr,
)
def _crl_item(crl: x509.CertificateRevocationList, filename: str, name: Optional[str] = None) -> ParsedItem:
return ParsedItem(
kind="crl",
name=name or _guess_name(crl, filename),
summary=_summary(crl),
obj=crl,
)
def _template_item(template: Dict[str, Any], filename: str) -> ParsedItem:
return ParsedItem(
kind="template",
name=str(template.get("name") or _guess_name(template, filename)),
summary=_summary(template),
obj=template,
)
def parse_pkcs12(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
try:
key, cert, cas = pkcs12.load_key_and_certificates(data, password.encode() if password else None)
except Exception as exc: # noqa: BLE001
raise ValueError("unable to read the PKCS#12 file (wrong password or unsupported encryption)") from exc
items: List[ParsedItem] = []
name = _guess_name(cert, filename) if cert is not None else _guess_name(None, filename)
if cert is not None:
items.append(_cert_item(cert, filename, name))
if key is not None:
items.append(_key_item(key, filename, f"{name}_key"))
for index, extra in enumerate(cas or []):
items.append(_cert_item(extra, filename, f"{name}_ca_{index}"))
if not items:
raise ValueError("the PKCS#12 file contains no keys or certificates")
return items
def parse_pkcs7(data: bytes, filename: str = "") -> List[ParsedItem]:
certs: Sequence[x509.Certificate] = []
try:
certs = pkcs7.load_der_pkcs7_certificates(data)
except Exception: # noqa: BLE001
try:
certs = pkcs7.load_pem_pkcs7_certificates(data)
except Exception as exc: # noqa: BLE001
raise ValueError("unable to read the PKCS#7 file") from exc
if not certs:
raise ValueError("the PKCS#7 file contains no certificates")
return [_cert_item(cert, filename, f"{_guess_name(cert, filename)}_{index}" if len(certs) > 1 else None) for index, cert in enumerate(certs)]
def parse_pem_bundle(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
text = data.decode("utf-8", "replace")
blocks = list(_PEM_BLOCK.finditer(text))
if not blocks:
return []
items: List[ParsedItem] = []
for match in blocks:
label = match.group(1).strip().upper()
block = match.group(0).encode("utf-8")
try:
if label == "CERTIFICATE":
items.append(_cert_item(x509.load_pem_x509_certificate(block), filename))
elif label in ("X509 CRL", "CRL"):
items.append(_crl_item(x509.load_pem_x509_crl(block), filename))
elif label in ("CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST"):
items.append(_csr_item(x509.load_pem_x509_csr(block), filename))
elif label in ("PKCS7", "PKCS #7"):
items.extend(parse_pkcs7(block, filename))
elif label == "XCA TEMPLATE":
items.append(_template_item(template_from_xca_data(block), filename))
elif "PRIVATE KEY" in label or "PUBLIC KEY" in label or label in ("OPENSSH PRIVATE KEY", "SSH2 PUBLIC KEY"):
key, private = load_key_bytes(block, password)
items.append(_key_item(key, filename))
else:
# Unknown PEM label: try every parser before giving up.
items.extend(_try_all(block, filename, password))
except Exception as exc: # noqa: BLE001
raise ValueError(f"unable to import the {label} block: {exc}") from exc
return items
def _try_all(data: bytes, filename: str, password: Optional[str]) -> List[ParsedItem]:
for parser in (
lambda: [_cert_item(x509.load_der_x509_certificate(data), filename)],
lambda: [_csr_item(x509.load_der_x509_csr(data), filename)],
lambda: [_crl_item(x509.load_der_x509_crl(data), filename)],
lambda: parse_pkcs12(data, filename, password),
lambda: parse_pkcs7(data, filename),
lambda: [_key_item(load_key_bytes(data, password)[0], filename)],
):
try:
return parser()
except Exception: # noqa: BLE001
continue
raise ValueError("unrecognized data")
def parse_file(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
"""Parse any supported file, detecting PEM vs DER automatically."""
if not data:
raise ValueError("the file is empty")
pem_items = parse_pem_bundle(data, filename, password)
if pem_items:
return pem_items
return _try_all(data, filename, password)
def parse_text(text: str, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
return parse_file(text.encode("utf-8"), filename, password)
def parse_xca_database(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
from .xca_db import import_xca_database
return import_xca_database(data, filename, password)
__all__ = [
"ParsedItem",
"parse_file",
"parse_pem_bundle",
"parse_pkcs7",
"parse_pkcs12",
"parse_text",
"parse_xca_database",
]
@@ -0,0 +1,543 @@
"""Key generation, parsing and serialization.
All key material handled here is in memory. The application layer is
responsible for encrypting private keys before they touch the database.
"""
from __future__ import annotations
import base64
import hashlib
import io
import struct
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
)
PrivateKey = (
rsa.RSAPrivateKey
| dsa.DSAPrivateKey
| ec.EllipticCurvePrivateKey
| ed25519.Ed25519PrivateKey
)
PublicKey = (
rsa.RSAPublicKey
| dsa.DSAPublicKey
| ec.EllipticCurvePublicKey
| ed25519.Ed25519PublicKey
)
#: Curves offered by the UI. ``cryptography`` supports all of them on every
#: platform we target.
EC_CURVES: Dict[str, ec.EllipticCurve] = {
"secp192r1": ec.SECP192R1(),
"secp224r1": ec.SECP224R1(),
"secp256r1": ec.SECP256R1(),
"secp384r1": ec.SECP384R1(),
"secp521r1": ec.SECP521R1(),
"secp256k1": ec.SECP256K1(),
"brainpoolP256r1": ec.BrainpoolP256R1(),
"brainpoolP384r1": ec.BrainpoolP384R1(),
"brainpoolP512r1": ec.BrainpoolP512R1(),
}
_CURVE_NAMES = {curve.name: name for name, curve in EC_CURVES.items()}
KEY_TYPES = ("rsa", "dsa", "ec", "ed25519")
DEFAULT_KEY_SIZE = 2048
MAX_KEY_SIZE = 8192
MIN_KEY_SIZE = 512
#: Hash algorithms selectable for signatures. Ed25519 has no separate hash.
HASH_ALGORITHMS: Dict[str, Optional[hashes.HashAlgorithm]] = {
"sha1": hashes.SHA1(),
"sha224": hashes.SHA224(),
"sha256": hashes.SHA256(),
"sha384": hashes.SHA384(),
"sha512": hashes.SHA512(),
}
@dataclass(frozen=True)
class KeyInfo:
key_type: str
key_size: int
curve: Optional[str]
public_der: bytes
private_der: Optional[bytes]
is_private: bool
@property
def public_sha256(self) -> str:
return hashlib.sha256(self.public_der).hexdigest()
@property
def fingerprint_sha256(self) -> str:
return self.public_sha256
def _require_private(key: Any) -> PrivateKey:
if not isinstance(
key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)
):
raise ValueError("a private key is required")
return key
def _public_key(key: PrivateKey | PublicKey) -> PublicKey:
if isinstance(key, (rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)):
return key
return key.public_key()
def generate_key(key_type: str, size: int = DEFAULT_KEY_SIZE, curve: Optional[str] = None) -> PrivateKey:
"""Generate a private key of the requested type."""
key_type = (key_type or "").lower()
if key_type == "rsa":
if not (MIN_KEY_SIZE <= int(size) <= MAX_KEY_SIZE):
raise ValueError(f"RSA key size must be between {MIN_KEY_SIZE} and {MAX_KEY_SIZE} bits")
return rsa.generate_private_key(public_exponent=65537, key_size=int(size))
if key_type == "dsa":
if not (MIN_KEY_SIZE <= int(size) <= 3072):
raise ValueError("DSA key size must be between 512 and 3072 bits")
return dsa.generate_private_key(key_size=int(size))
if key_type == "ec":
curve_name = curve or "secp256r1"
if curve_name not in EC_CURVES:
raise ValueError(f"unsupported EC curve {curve_name!r}")
return ec.generate_private_key(EC_CURVES[curve_name])
if key_type == "ed25519":
return ed25519.Ed25519PrivateKey.generate()
raise ValueError(f"unsupported key type {key_type!r}")
def key_type_name(key: PrivateKey | PublicKey) -> str:
if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)):
return "RSA"
if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return "DSA"
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return "EC"
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return "ED25519"
raise ValueError("unsupported key type")
def key_size(key: PrivateKey | PublicKey) -> int:
if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey, dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return key.key_size
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return key.curve.key_size
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return 256
raise ValueError("unsupported key type")
def curve_name(key: PrivateKey | PublicKey) -> Optional[str]:
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return _CURVE_NAMES.get(key.curve.name, key.curve.name)
return None
def is_private_key(key: Any) -> bool:
return isinstance(
key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)
)
def public_der(key: PrivateKey | PublicKey) -> bytes:
return _public_key(key).public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
def private_der(key: PrivateKey) -> bytes:
return _require_private(key).private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
def key_info(key: PrivateKey | PublicKey) -> KeyInfo:
private = is_private_key(key)
return KeyInfo(
key_type=key_type_name(key),
key_size=key_size(key),
curve=curve_name(key),
public_der=public_der(key),
private_der=private_der(key) if private else None,
is_private=private,
)
def possible_hashes(key: PrivateKey | PublicKey) -> List[str]:
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return []
if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return ["sha1", "sha224", "sha256"]
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
# Every supported curve is large enough for SHA-512 in practice.
return ["sha1", "sha224", "sha256", "sha384", "sha512"]
return ["sha1", "sha224", "sha256", "sha384", "sha512"]
def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional[hashes.HashAlgorithm]:
"""The signature hash for ``key``; ``None`` means the key's built-in hash."""
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return None
name = (name or "sha256").lower()
if name not in HASH_ALGORITHMS:
raise ValueError(f"unsupported hash algorithm {name!r}")
return HASH_ALGORITHMS[name]
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
def public_pem(key: PrivateKey | PublicKey) -> str:
return _public_key(key).public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii")
def public_der_bytes(key: PrivateKey | PublicKey) -> bytes:
return public_der(key)
def private_pem(key: PrivateKey, password: Optional[str] = None, traditional: bool = False) -> bytes:
key = _require_private(key)
encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
fmt = PrivateFormat.TraditionalOpenSSL if traditional else PrivateFormat.PKCS8
if traditional and isinstance(key, ed25519.Ed25519PrivateKey):
raise ValueError("Ed25519 keys cannot be exported in traditional PEM format")
return key.private_bytes(Encoding.PEM, fmt, encryption)
def private_der_bytes(key: PrivateKey, password: Optional[str] = None) -> bytes:
key = _require_private(key)
encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
return key.private_bytes(Encoding.DER, PrivateFormat.PKCS8, encryption)
def ssh_public(key: PrivateKey | PublicKey, comment: str = "") -> str:
encoded = _public_key(key).public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode("ascii")
return f"{encoded} {comment}".strip()
def ssh_private(key: PrivateKey, password: Optional[str] = None) -> bytes:
"""OpenSSH private key format, via paramiko."""
import paramiko
key = _require_private(key)
if isinstance(key, rsa.RSAPrivateKey):
pkey = paramiko.RSAKey(key=key)
elif isinstance(key, dsa.DSAPrivateKey):
pkey = paramiko.DSSKey(key=key)
elif isinstance(key, ec.EllipticCurvePrivateKey):
pkey = paramiko.ECDSAKey(key=key)
elif isinstance(key, ed25519.Ed25519PrivateKey):
pkey = paramiko.Ed25519Key(key=key)
else: # pragma: no cover - defensive
raise ValueError("unsupported key type for SSH export")
buffer = io.StringIO()
pkey.write_private_key(buffer, password=password)
return buffer.getvalue().encode("utf-8")
# ---------------------------------------------------------------------------
# JSON Web Key (RFC 7517)
# ---------------------------------------------------------------------------
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _int_bytes(value: int) -> bytes:
return value.to_bytes((value.bit_length() + 7) // 8 or 1, "big")
def jwk_public(key: PrivateKey | PublicKey, *, kid: Optional[str] = None) -> Dict[str, Any]:
pub = _public_key(key)
if isinstance(pub, rsa.RSAPublicKey):
numbers = pub.public_numbers()
result: Dict[str, Any] = {
"kty": "RSA",
"n": _b64u(_int_bytes(numbers.n)),
"e": _b64u(_int_bytes(numbers.e)),
}
elif isinstance(pub, ec.EllipticCurvePublicKey):
numbers = pub.public_numbers()
curve = {
"secp256r1": "P-256",
"secp384r1": "P-384",
"secp521r1": "P-521",
"secp256k1": "secp256k1",
}.get(pub.curve.name, pub.curve.name)
result = {
"kty": "EC",
"crv": curve,
"x": _b64u(numbers.x.to_bytes((numbers.x.bit_length() + 7) // 8 or 1, "big")),
"y": _b64u(numbers.y.to_bytes((numbers.y.bit_length() + 7) // 8 or 1, "big")),
}
elif isinstance(pub, ed25519.Ed25519PublicKey):
result = {
"kty": "OKP",
"crv": "Ed25519",
"x": _b64u(pub.public_bytes(Encoding.Raw, PublicFormat.Raw)),
}
else:
raise ValueError("JWK is not supported for this key type")
if kid:
result["kid"] = kid
return result
def jwk_private(key: PrivateKey, *, kid: Optional[str] = None) -> Dict[str, Any]:
key = _require_private(key)
result = jwk_public(key, kid=kid)
if isinstance(key, rsa.RSAPrivateKey):
numbers = key.private_numbers()
result.update(
{
"d": _b64u(_int_bytes(numbers.d)),
"p": _b64u(_int_bytes(numbers.p)),
"q": _b64u(_int_bytes(numbers.q)),
"dp": _b64u(_int_bytes(numbers.dmp1)),
"dq": _b64u(_int_bytes(numbers.dmq1)),
"qi": _b64u(_int_bytes(numbers.iqmp)),
}
)
elif isinstance(key, ec.EllipticCurvePrivateKey):
result["d"] = _b64u(_int_bytes(key.private_numbers().private_value))
elif isinstance(key, ed25519.Ed25519PrivateKey):
result["d"] = _b64u(key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()))
else:
raise ValueError("JWK private export is not supported for this key type")
return result
# ---------------------------------------------------------------------------
# Microsoft PVK (RSA)
# ---------------------------------------------------------------------------
_PVK_MAGIC = 0xB0B5F11E
_PVK_KEYTYPE = 2
_CALG_RSA_KEYX = 0x00002400
_RSA2 = 0x32415352
def pvk_encode_private(key: PrivateKey) -> bytes:
if not isinstance(key, rsa.RSAPrivateKey):
raise ValueError("PVK export supports RSA keys only")
numbers = key.private_numbers()
pub = numbers.public_numbers
size = (pub.n.bit_length() + 7) // 8
blob = struct.pack("<BBBBL", 0x07, 0x02, 0, 0, _CALG_RSA_KEYX)
blob += struct.pack("<LLL", _RSA2, size * 8, pub.e)
for value in (
pub.n,
numbers.p,
numbers.q,
numbers.dmp1,
numbers.dmq1,
numbers.iqmp,
numbers.d,
):
blob += value.to_bytes(size, "little")
header = struct.pack("<LLLLLL", _PVK_MAGIC, 0, _PVK_KEYTYPE, 0, 0, len(blob))
return header + blob
def pvk_decode_private(data: bytes) -> rsa.RSAPrivateKey:
if len(data) < 24:
raise ValueError("not a PVK file")
magic, _reserved, key_type, encrypted, encrypted_len, blob_len = struct.unpack("<LLLLLL", data[:24])
if magic != _PVK_MAGIC or key_type != _PVK_KEYTYPE or encrypted:
raise ValueError("unsupported PVK file")
blob = data[24 : 24 + blob_len]
if len(blob) < 20:
raise ValueError("truncated PVK file")
_btype, _bversion, _r1, _r2, _alg = struct.unpack("<BBBBL", blob[:8])
magic2, bits, pubexp = struct.unpack("<LLL", blob[8:20])
if magic2 != _RSA2:
raise ValueError("unsupported PVK key blob")
size = (bits + 7) // 8
pos = 20
values = []
for _ in range(7):
chunk = blob[pos : pos + size]
if len(chunk) != size:
raise ValueError("truncated PVK key")
values.append(int.from_bytes(chunk, "little"))
pos += size
n, p, q, dmp1, dmq1, iqmp, d = values
return rsa.RSAPrivateNumbers(
p=p,
q=q,
d=d,
dmp1=dmp1,
dmq1=dmq1,
iqmp=iqmp,
public_numbers=rsa.RSAPublicNumbers(e=pubexp, n=n),
).private_key()
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def load_key_bytes(data: bytes, password: Optional[str] = None) -> Tuple[PrivateKey | PublicKey, bool]:
"""Load a key from PEM/DER/OpenSSH/PVK bytes.
Returns ``(key, is_private)``. Raises :class:`ValueError` when nothing
matches.
"""
password_bytes = password.encode() if password else None
errors: List[str] = []
# PEM private / public
try:
key = serialization.load_pem_private_key(data, password=password_bytes)
return key, True
except Exception as exc: # noqa: BLE001 - probing
errors.append(str(exc))
try:
key = serialization.load_pem_public_key(data)
return key, False
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# DER private / public
try:
key = serialization.load_der_private_key(data, password=password_bytes)
return key, True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
try:
key = serialization.load_der_public_key(data)
return key, False
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# OpenSSH private / public
try:
key = serialization.load_ssh_private_key(data, password=password_bytes)
return key, True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
try:
key = serialization.load_ssh_public_key(data)
return key, False
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# Microsoft PVK
try:
return pvk_decode_private(data), True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# Encrypted OpenSSH keys are not supported by cryptography; paramiko can
# read them. Convert the common key types back to cryptography objects.
try:
return _load_ssh_private_with_paramiko(data, password), True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
raise ValueError("unable to parse key: " + (errors[-1] if errors else "unknown format"))
def _load_ssh_private_with_paramiko(data: bytes, password: Optional[str]) -> PrivateKey:
import paramiko
text = data.decode("utf-8", "replace")
last: Optional[Exception] = None
for cls in (paramiko.RSAKey, paramiko.ECDSAKey, paramiko.Ed25519Key, paramiko.DSSKey):
try:
pkey = cls.from_private_key(io.StringIO(text), password=password)
break
except Exception as exc: # noqa: BLE001
last = exc
else: # pragma: no cover - defensive
raise ValueError(f"unable to parse OpenSSH private key: {last}")
# Paramiko keeps the cryptography signing key on its instances. This is
# stable across 3.x and avoids re-implementing key conversion.
signing_key = getattr(pkey, "_signing_key", None)
if signing_key is not None and is_private_key(signing_key):
return signing_key
if isinstance(pkey, paramiko.RSAKey):
pub = rsa.RSAPublicNumbers(e=pkey.e, n=pkey.n)
return rsa.RSAPrivateNumbers(
p=pkey.p, q=pkey.q, d=pkey.d, dmp1=pkey.dmp1, dmq1=pkey.dmq1, iqmp=pkey.iqmp,
public_numbers=pub,
).private_key()
if isinstance(pkey, paramiko.DSSKey):
pub = dsa.DSAPublicNumbers(parameter_numbers=dsa.DSAParameterNumbers(pkey.p, pkey.q, pkey.g), y=pkey.y)
return dsa.DSAPrivateNumbers(x=pkey.x, public_numbers=pub).private_key()
raise ValueError("unable to convert the SSH private key")
def public_key_from_der(data: bytes) -> PublicKey:
return serialization.load_der_public_key(data)
def private_key_from_der(data: bytes, password: Optional[str] = None) -> PrivateKey:
return serialization.load_der_private_key(data, password=password.encode() if password else None)
def public_key_sha256(key: PrivateKey | PublicKey) -> str:
return hashlib.sha256(public_der(key)).hexdigest()
def key_type_options() -> List[Dict[str, Any]]:
return [
{"type": "rsa", "label": "RSA", "sizes": [1024, 2048, 3072, 4096, 8192]},
{"type": "dsa", "label": "DSA", "sizes": [1024, 2048, 3072]},
{"type": "ec", "label": "EC", "curves": list(EC_CURVES)},
{"type": "ed25519", "label": "ED25519", "sizes": []},
]
__all__ = [
"DEFAULT_KEY_SIZE",
"EC_CURVES",
"HASH_ALGORITHMS",
"KEY_TYPES",
"KeyInfo",
"MAX_KEY_SIZE",
"MIN_KEY_SIZE",
"PrivateKey",
"PublicKey",
"curve_name",
"generate_key",
"hash_algorithm",
"is_private_key",
"jwk_private",
"jwk_public",
"key_info",
"key_size",
"key_type_name",
"key_type_options",
"load_key_bytes",
"possible_hashes",
"private_der",
"private_der_bytes",
"private_key_from_der",
"private_pem",
"public_der",
"public_der_bytes",
"public_key_from_der",
"public_key_sha256",
"public_pem",
"pvk_decode_private",
"pvk_encode_private",
"ssh_private",
"ssh_public",
]
@@ -0,0 +1,260 @@
"""Distinguished names and the small OID registry used by the UI.
XCA stores a subject as an ordered sequence of X.509 name entries and lets the
user add the same attribute more than once. ``cryptography`` models that as a
sequence of RDNs; we build one RDN per entry so order and duplicates survive a
round-trip.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from cryptography import x509
from cryptography.x509.oid import NameOID, ObjectIdentifier
#: Common distinguished-name attributes. The value is ``(short name, long name)``.
DN_ATTRIBUTES: Dict[str, Tuple[str, str]] = {
"2.5.4.3": ("CN", "commonName"),
"2.5.4.4": ("SN", "surname"),
"2.5.4.5": ("serialNumber", "serialNumber"),
"2.5.4.6": ("C", "countryName"),
"2.5.4.7": ("L", "localityName"),
"2.5.4.8": ("ST", "stateOrProvinceName"),
"2.5.4.9": ("street", "streetAddress"),
"2.5.4.10": ("O", "organizationName"),
"2.5.4.11": ("OU", "organizationalUnitName"),
"2.5.4.12": ("title", "title"),
"2.5.4.17": ("postalCode", "postalCode"),
"2.5.4.42": ("GN", "givenName"),
"2.5.4.43": ("initials", "initials"),
"2.5.4.44": ("generationQualifier", "generationQualifier"),
"2.5.4.46": ("dnQualifier", "dnQualifier"),
"2.5.4.65": ("pseudonym", "pseudonym"),
"2.5.4.97": ("organizationIdentifier", "organizationIdentifier"),
"1.2.840.113549.1.9.1": ("emailAddress", "emailAddress"),
"0.9.2342.19200300.100.1.1": ("UID", "userId"),
"0.9.2342.19200300.100.1.25": ("DC", "domainComponent"),
}
#: Aliases accepted from the API / template format.
DN_ALIASES: Dict[str, str] = {
"cn": "2.5.4.3",
"commonname": "2.5.4.3",
"surname": "2.5.4.4",
"sn": "2.5.4.4",
"serialnumber": "2.5.4.5",
"c": "2.5.4.6",
"countryname": "2.5.4.6",
"l": "2.5.4.7",
"localityname": "2.5.4.7",
"st": "2.5.4.8",
"stateorprovincename": "2.5.4.8",
"street": "2.5.4.9",
"streetaddress": "2.5.4.9",
"o": "2.5.4.10",
"organizationname": "2.5.4.10",
"ou": "2.5.4.11",
"organizationalunitname": "2.5.4.11",
"title": "2.5.4.12",
"postalcode": "2.5.4.17",
"gn": "2.5.4.42",
"givenname": "2.5.4.42",
"initials": "2.5.4.43",
"generationqualifier": "2.5.4.44",
"dnqualifier": "2.5.4.46",
"pseudonym": "2.5.4.65",
"organizationidentifier": "2.5.4.97",
"email": "1.2.840.113549.1.9.1",
"emailaddress": "1.2.840.113549.1.9.1",
"uid": "0.9.2342.19200300.100.1.1",
"userid": "0.9.2342.19200300.100.1.1",
"dc": "0.9.2342.19200300.100.1.25",
"domaincomponent": "0.9.2342.19200300.100.1.25",
}
#: Extended-key-usage names accepted in specs and templates.
EKU_OIDS: Dict[str, str] = {
"serverAuth": "1.3.6.1.5.5.7.3.1",
"clientAuth": "1.3.6.1.5.5.7.3.2",
"codeSigning": "1.3.6.1.5.5.7.3.3",
"emailProtection": "1.3.6.1.5.5.7.3.4",
"ipsecEndSystem": "1.3.6.1.5.5.7.3.5",
"ipsecTunnel": "1.3.6.1.5.5.7.3.6",
"ipsecUser": "1.3.6.1.5.5.7.3.7",
"timeStamping": "1.3.6.1.5.5.7.3.8",
"OCSPSigning": "1.3.6.1.5.5.7.3.9",
"id-kp-eapOverPPP": "1.3.6.1.5.5.7.3.13",
"id-kp-eapOverLAN": "1.3.6.1.5.5.7.3.14",
"secureShellClient": "1.3.6.1.5.5.7.3.21",
"secureShellServer": "1.3.6.1.5.5.7.3.22",
"msCodeInd": "1.3.6.1.4.1.311.2.1.21",
"msCodeCom": "1.3.6.1.4.1.311.2.1.22",
"msCTLSign": "1.3.6.1.4.1.311.10.3.1",
"msSGC": "1.3.6.1.4.1.311.10.3.3",
"msEFS": "1.3.6.1.4.1.311.10.3.4",
"nsSGC": "2.16.840.1.113730.4.1",
"pkInitKDC": "1.3.6.1.5.2.3.5",
"adobePDFSigning": "1.2.840.113583.1.1.5",
"msofficeSigning": "1.3.6.1.4.1.311.10.3.12",
}
_EKU_BY_OID = {oid: name for name, oid in EKU_OIDS.items()}
@dataclass(frozen=True)
class DnEntry:
oid: str
name: str
value: str
def as_dict(self) -> Dict[str, str]:
return {"oid": self.oid, "name": self.name, "value": self.value}
def resolve_oid(value: str) -> str:
"""Resolve a short name, long name or dotted OID to a dotted OID."""
value = (value or "").strip()
if not value:
raise ValueError("empty attribute name")
if value[0].isdigit() and value.count(".") >= 1:
# Validate through cryptography so malformed OIDs fail early.
ObjectIdentifier(value)
return value
key = value.lower().replace(" ", "").replace("-", "").replace("_", "")
if key in DN_ALIASES:
return DN_ALIASES[key]
if value in DN_ATTRIBUTES:
return value
raise ValueError(f"unknown distinguished-name attribute {value!r}")
def oid_name(oid: str) -> str:
"""The short display name for an attribute OID."""
return DN_ATTRIBUTES.get(oid, (oid, oid))[0]
def oid_long_name(oid: str) -> str:
return DN_ATTRIBUTES.get(oid, (oid, oid))[1]
def entry_from_dict(entry: Mapping[str, Any]) -> DnEntry:
oid = resolve_oid(str(entry.get("oid") or entry.get("name") or ""))
value = entry.get("value", "")
if not isinstance(value, str):
value = str(value)
return DnEntry(oid=oid, name=oid_name(oid), value=value.strip())
def entries_from_json(entries: Iterable[Mapping[str, Any]]) -> List[DnEntry]:
return [entry_from_dict(entry) for entry in entries]
def name_from_entries(entries: Sequence[DnEntry]) -> x509.Name:
"""Build a Name, one RDN per entry, preserving order and duplicates."""
rdns = []
for entry in entries:
if not entry.value:
continue
rdns.append(
x509.RelativeDistinguishedName(
[x509.NameAttribute(ObjectIdentifier(entry.oid), entry.value)]
)
)
return x509.Name(rdns)
def entries_from_name(name: x509.Name) -> List[DnEntry]:
"""Flatten a Name into ordered entries (one per attribute)."""
result: List[DnEntry] = []
for rdn in name.rdns:
for attribute in rdn:
oid = attribute.oid.dotted_string
result.append(DnEntry(oid=oid, name=oid_name(oid), value=str(attribute.value)))
return result
def name_from_der(data: bytes) -> x509.Name:
return x509.Name.from_bytes(data)
def name_to_der(name: x509.Name) -> bytes:
return name.public_bytes()
def rfc4514(name: x509.Name) -> str:
return name.rfc4514_string()
def one_line(name: x509.Name) -> str:
"""A stable, human-readable one-line rendering (RFC 4514 with commas)."""
return name.rfc4514_string()
def name_hash(name: x509.Name) -> str:
"""SHA-256 of the DER encoding, used for fast issuer lookup."""
import hashlib
return hashlib.sha256(name.public_bytes()).hexdigest()
def common_names(name: x509.Name) -> List[str]:
return [str(a.value) for a in name.get_attributes_for_oid(NameOID.COMMON_NAME)]
def eku_name(oid: str) -> str:
return _EKU_BY_OID.get(oid, oid)
def eku_oid(name: str) -> str:
if name in EKU_OIDS:
return EKU_OIDS[name]
return resolve_oid(name)
def name_attribute_options() -> List[Dict[str, str]]:
"""The DN attributes offered by the UI, in the usual XCA order."""
order = [
"2.5.4.6",
"2.5.4.8",
"2.5.4.7",
"2.5.4.10",
"2.5.4.11",
"2.5.4.3",
"1.2.840.113549.1.9.1",
]
seen = set(order)
ordered = order + [oid for oid in DN_ATTRIBUTES if oid not in seen]
return [
{"oid": oid, "name": DN_ATTRIBUTES[oid][0], "long_name": DN_ATTRIBUTES[oid][1]}
for oid in ordered
]
def eku_options() -> List[Dict[str, str]]:
return [{"name": name, "oid": oid} for name, oid in EKU_OIDS.items()]
__all__ = [
"DN_ALIASES",
"DN_ATTRIBUTES",
"EKU_OIDS",
"DnEntry",
"common_names",
"eku_name",
"eku_oid",
"eku_options",
"entries_from_json",
"entries_from_name",
"entry_from_dict",
"name_attribute_options",
"name_from_der",
"name_from_entries",
"name_hash",
"name_to_der",
"oid_long_name",
"oid_name",
"one_line",
"resolve_oid",
"rfc4514",
]
@@ -0,0 +1,224 @@
"""PKCS#10 certificate signing request helpers."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ObjectIdentifier
from .extensions import build_extensions, extension_config_lines, extension_rows, extension_specs
from .keys import PrivateKey, PublicKey, hash_algorithm, public_der
from .names import entries_from_name, name_from_entries
CHALLENGE_PASSWORD_OID = "1.2.840.113549.1.9.7"
UNSTRUCTURED_NAME_OID = "1.2.840.113549.1.9.2"
ATTRIBUTE_NAMES = {
CHALLENGE_PASSWORD_OID: "Challenge Password",
UNSTRUCTURED_NAME_OID: "Unstructured Name",
}
@dataclass(frozen=True)
class RequestInfo:
subject: x509.Name
signature_algorithm: str
public_key_der: bytes
fingerprint_sha256: str
attributes: List[Dict[str, Any]]
def build_csr(
*,
subject: x509.Name,
private_key: PrivateKey,
extension_spec: Optional[Mapping[str, Any]] = None,
signature_hash: Optional[str] = "sha256",
attributes: Optional[Mapping[str, str]] = None,
) -> x509.CertificateSigningRequest:
public_key = private_key.public_key()
extensions = build_extensions(
extension_spec,
public_key=public_key,
subject_name=subject,
include_aki=False,
)
# A CSR must not carry an Authority Key Identifier or an issuer alt name;
# XCA removes them as well.
extensions = [
ext
for ext in extensions
if ext.oid
not in (
x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME,
)
]
builder = x509.CertificateSigningRequestBuilder().subject_name(subject)
for extension in extensions:
builder = builder.add_extension(extension.value, extension.critical)
for oid, value in (attributes or {}).items():
if value:
builder = builder.add_attribute(ObjectIdentifier(oid), str(value).encode("utf-8"))
algorithm = hash_algorithm(signature_hash, private_key)
return builder.sign(private_key, algorithm)
def parse_csr(data: bytes) -> x509.CertificateSigningRequest:
return x509.load_der_x509_csr(data)
def csr_der(csr: x509.CertificateSigningRequest) -> bytes:
return csr.public_bytes(serialization.Encoding.DER)
def csr_pem(csr: x509.CertificateSigningRequest) -> str:
return csr.public_bytes(serialization.Encoding.PEM).decode("ascii")
def verify_csr(csr: x509.CertificateSigningRequest) -> bool:
return bool(csr.is_signature_valid)
def request_info(csr: x509.CertificateSigningRequest) -> RequestInfo:
public_key = csr.public_key()
return RequestInfo(
subject=csr.subject,
signature_algorithm=_signature_name(csr),
public_key_der=public_der(public_key),
fingerprint_sha256=hashlib.sha256(csr_der(csr)).hexdigest(),
attributes=attribute_rows(csr),
)
_SIGNATURE_OIDS = {
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.1.14": "sha224WithRSAEncryption",
"1.2.840.10045.4.1": "ecdsa-with-SHA1",
"1.2.840.10045.4.3.2": "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3": "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4": "ecdsa-with-SHA512",
"1.3.101.112": "Ed25519",
}
def _signature_name(csr: x509.CertificateSigningRequest) -> str:
oid = csr.signature_algorithm_oid
return _SIGNATURE_OIDS.get(oid.dotted_string, getattr(oid, "name", None) or oid.dotted_string)
def _decode_attribute_value(value: bytes) -> str:
# cryptography returns the DER-encoded value bytes. Try UTF-8 first,
# then strip a simple ASN.1 string tag/length header.
try:
return value.decode("utf-8")
except UnicodeDecodeError:
pass
if len(value) >= 2 and value[0] in (0x0C, 0x13, 0x16, 0x1E):
length = value[1]
offset = 2
if length & 0x80:
count = length & 0x7F
length = int.from_bytes(value[2 : 2 + count], "big")
offset = 2 + count
raw = value[offset : offset + length]
if value[0] == 0x1E:
return raw.decode("utf-16-be", "replace")
return raw.decode("utf-8", "replace")
return value.hex()
def attribute_rows(csr: x509.CertificateSigningRequest) -> List[Dict[str, Any]]:
rows = []
for attribute in csr.attributes:
oid = attribute.oid.dotted_string
rows.append(
{
"oid": oid,
"name": ATTRIBUTE_NAMES.get(oid, oid),
"values": [_decode_attribute_value(attribute.value)],
}
)
return rows
def attribute_values(csr: x509.CertificateSigningRequest) -> Dict[str, str]:
result = {}
for row in attribute_rows(csr):
if row["values"]:
result[row["oid"]] = row["values"][0]
return result
def csr_spec(csr: x509.CertificateSigningRequest) -> Dict[str, Any]:
return {
"subject": [entry.as_dict() for entry in entries_from_name(csr.subject)],
"signature_hash": _hash_name(csr),
"extensions": extension_specs(csr.extensions),
"attributes": attribute_values(csr),
"public_key_der": public_der(csr.public_key()).hex(),
}
def _hash_name(csr: x509.CertificateSigningRequest) -> Optional[str]:
try:
algorithm = csr.signature_hash_algorithm
except Exception: # noqa: BLE001 - Ed25519
return None
return algorithm.name if algorithm is not None else None
def csr_extensions(csr: x509.CertificateSigningRequest) -> List[Dict[str, Any]]:
return extension_rows(csr.extensions)
def csr_extension_specs(csr: x509.CertificateSigningRequest) -> Dict[str, Any]:
return extension_specs(csr.extensions)
def csr_public_key(csr: x509.CertificateSigningRequest) -> PublicKey:
return csr.public_key()
def openssl_config(csr: x509.CertificateSigningRequest) -> str:
lines = [
"[req]",
"distinguished_name = req_distinguished_name",
"prompt = no",
"",
"[req_distinguished_name]",
]
for entry in entries_from_name(csr.subject):
lines.append(f"{entry.name} = {entry.value}")
lines.extend(["", "[v3_ext]"])
config = extension_config_lines(csr.extensions)
if config:
lines.append(config)
return "\n".join(lines) + "\n"
__all__ = [
"ATTRIBUTE_NAMES",
"CHALLENGE_PASSWORD_OID",
"RequestInfo",
"UNSTRUCTURED_NAME_OID",
"attribute_rows",
"attribute_values",
"build_csr",
"csr_der",
"csr_extension_specs",
"csr_extensions",
"csr_pem",
"csr_public_key",
"csr_spec",
"openssl_config",
"parse_csr",
"request_info",
"verify_csr",
]
@@ -0,0 +1,583 @@
"""XCA-style certificate/request templates.
A template is a subject plus a set of extension defaults and a relative
validity range. Built-in templates mirror XCA's ``CA``, ``TLS_server`` and
``TLS_client`` templates; user templates are stored as JSON by the app.
"""
from __future__ import annotations
import base64
import binascii
import struct
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from cryptography import x509
from .extensions import (
default_extension_spec,
parse_aia_descriptions,
parse_crl_distribution_urls,
parse_eku_names,
parse_general_name_text,
parse_key_usage_names,
parse_name_constraints_text,
)
from .names import DnEntry, entries_from_json, entries_from_name, name_from_entries
TEMPLATE_VERSION = 10
PEM_STRING_XCA_TEMPLATE = "XCA TEMPLATE"
@dataclass(frozen=True)
class Template:
id: str
name: str
comment: str
subject: List[Dict[str, str]]
settings: Dict[str, Any]
builtin: bool = False
def as_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"comment": self.comment,
"subject": self.subject,
"settings": self.settings,
"builtin": self.builtin,
}
def default_settings() -> Dict[str, Any]:
extensions = default_extension_spec()
extensions["basic_constraints"] = {"critical": True, "ca": None, "path_length": None}
return {
"extensions": extensions,
"validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False},
}
def _base_settings() -> Dict[str, Any]:
return default_settings()
def builtin_templates() -> List[Template]:
ca = _base_settings()
ca["extensions"].update(
{
"basic_constraints": {"critical": True, "ca": True, "path_length": None},
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"]},
"extended_key_usage": {"critical": False, "usages": []},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
)
ca["validity"] = {"amount": 10, "unit": "years", "midnight": False, "no_expiration": False}
server = _base_settings()
server["extensions"].update(
{
"basic_constraints": {"critical": True, "ca": False, "path_length": None},
"key_usage": {
"critical": True,
"usages": ["digital_signature", "content_commitment", "key_encipherment", "key_agreement"],
},
"extended_key_usage": {"critical": False, "usages": ["serverAuth"]},
"subject_alt_name": {"critical": False, "entries": [{"type": "dns", "value": "copycn"}]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
)
server["validity"] = {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}
client = _base_settings()
client["extensions"].update(
{
"basic_constraints": {"critical": True, "ca": False, "path_length": None},
"key_usage": {
"critical": True,
"usages": ["digital_signature", "key_encipherment", "data_encipherment", "key_agreement"],
},
"extended_key_usage": {"critical": False, "usages": ["clientAuth"]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
)
client["validity"] = {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}
return [
Template(
id="builtin:ca",
name="CA",
comment="Default XCA CA template",
subject=[],
settings=ca,
builtin=True,
),
Template(
id="builtin:tls-server",
name="TLS server",
comment="Default XCA TLS server template",
subject=[],
settings=server,
builtin=True,
),
Template(
id="builtin:tls-client",
name="TLS client",
comment="Default XCA TLS client template",
subject=[],
settings=client,
builtin=True,
),
]
def apply_template(
template: Mapping[str, Any],
target: Mapping[str, Any],
*,
apply_subject: bool = True,
apply_extensions: bool = True,
) -> Dict[str, Any]:
"""Merge a template into a certificate/request creation spec."""
result = dict(target)
if apply_subject and template.get("subject"):
result["subject"] = [dict(item) for item in template["subject"]]
if apply_extensions:
settings = template.get("settings") or {}
extensions = settings.get("extensions") or {}
merged = dict(result.get("extensions") or {})
for key, value in extensions.items():
if value is not None:
merged[key] = value
result["extensions"] = merged
if settings.get("validity"):
result["validity"] = dict(settings["validity"])
return result
def template_from_certificate(cert: x509.Certificate, name: str, comment: str = "") -> Dict[str, Any]:
from .certificates import certificate_extension_specs
return {
"name": name,
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(cert.subject)],
"settings": {
"extensions": certificate_extension_specs(cert),
"validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False},
},
}
def template_from_request(csr: x509.CertificateSigningRequest, name: str, comment: str = "") -> Dict[str, Any]:
from .requests import csr_extension_specs
return {
"name": name,
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(csr.subject)],
"settings": {
"extensions": csr_extension_specs(csr),
"validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False},
},
}
# ---------------------------------------------------------------------------
# XCA template binary format (Qt QDataStream, version 10)
# ---------------------------------------------------------------------------
def _qstring(value: Optional[str]) -> bytes:
if value is None:
return struct.pack(">I", 0xFFFFFFFF)
raw = value.encode("utf-16-be")
return struct.pack(">I", len(raw)) + raw
def _read_qstring(data: bytes, offset: int) -> tuple[Optional[str], int]:
if offset + 4 > len(data):
raise ValueError("truncated template")
length = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
if length == 0xFFFFFFFF:
return None, offset
if offset + length > len(data):
raise ValueError("truncated template string")
raw = data[offset : offset + length]
offset += length
return raw.decode("utf-16-be", "replace"), offset
def _qmap(values: Mapping[str, str]) -> bytes:
# QMap iterates in key order.
keys = sorted(values)
out = struct.pack(">I", len(keys))
for key in keys:
out += _qstring(key) + _qstring(values[key])
return out
def _read_qmap(data: bytes, offset: int) -> tuple[Dict[str, str], int]:
if offset + 4 > len(data):
raise ValueError("truncated template map")
count = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
result: Dict[str, str] = {}
for _ in range(count):
key, offset = _read_qstring(data, offset)
value, offset = _read_qstring(data, offset)
if key is not None:
result[key] = value or ""
return result, offset
def _der_name_length(data: bytes) -> int:
if len(data) < 2 or data[0] != 0x30:
raise ValueError("template does not start with an X.509 name")
first = data[1]
if first < 0x80:
return 2 + first
count = first & 0x7F
if count == 0 or len(data) < 2 + count:
raise ValueError("invalid DER length")
length = int.from_bytes(data[2 : 2 + count], "big")
return 2 + count + length
def template_to_xca_data(template: Mapping[str, Any]) -> bytes:
"""Serialize a template in XCA's PEM-like template format."""
subject = name_from_entries(entries_from_json(template.get("subject") or []))
name_der = subject.public_bytes()
settings = template_to_xca_settings(template)
settings["internal_name"] = str(template.get("name") or "")
settings["internal_comment"] = str(template.get("comment") or "")
data = name_der + _qmap(settings)
payload = struct.pack(">I", len(data)) + struct.pack(">I", TEMPLATE_VERSION) + data
body = base64.encodebytes(payload).decode("ascii").replace("\n", "")
wrapped = "\n".join(body[i : i + 64] for i in range(0, len(body), 64))
return (
f"-----BEGIN {PEM_STRING_XCA_TEMPLATE}-----\n"
f"{wrapped}\n"
f"-----END {PEM_STRING_XCA_TEMPLATE}-----\n"
).encode("ascii")
def template_from_xca_data(data: bytes | str) -> Dict[str, Any]:
"""Parse XCA's PEM-like template format (version 10)."""
if isinstance(data, bytes):
text = data.decode("ascii", "replace")
else:
text = data
begin = f"-----BEGIN {PEM_STRING_XCA_TEMPLATE}-----"
end = f"-----END {PEM_STRING_XCA_TEMPLATE}-----"
if begin not in text or end not in text:
raise ValueError("not an XCA template")
body = text.split(begin, 1)[1].split(end, 1)[0]
try:
payload = base64.b64decode("".join(body.split()))
except binascii.Error as exc:
raise ValueError("invalid base64 in XCA template") from exc
if len(payload) < 8:
raise ValueError("truncated XCA template")
_size, version = struct.unpack(">II", payload[:8])
if version != TEMPLATE_VERSION:
raise ValueError(f"unsupported XCA template version {version}")
content = payload[8:]
name_len = _der_name_length(content)
name = x509.Name.from_bytes(content[:name_len])
settings, _offset = _read_qmap(content, name_len)
name_value = settings.pop("internal_name", "")
comment = settings.pop("internal_comment", "")
settings_json = xca_settings_to_template(settings)
return {
"name": name_value or "XCA template",
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(name)],
"settings": settings_json,
}
def template_from_xca_payload(data: bytes, version: int = TEMPLATE_VERSION) -> Dict[str, Any]:
"""Parse the raw ``templates.template`` payload from an XCA database."""
if version != TEMPLATE_VERSION:
raise ValueError(f"unsupported XCA template version {version}")
name_len = _der_name_length(data)
name = x509.Name.from_bytes(data[:name_len])
settings, _offset = _read_qmap(data, name_len)
name_value = settings.pop("internal_name", "")
comment = settings.pop("internal_comment", "")
settings_json = xca_settings_to_template(settings)
return {
"name": name_value or "XCA template",
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(name)],
"settings": settings_json,
}
def xca_settings_to_template(settings: Mapping[str, str]) -> Dict[str, Any]:
"""Convert XCA's ``pki_temp`` settings to the KCA template JSON."""
result = default_settings()
extensions = result["extensions"]
extensions["basic_constraints"] = None
ca = settings.get("ca", "")
if ca == "1":
extensions["basic_constraints"] = {
"critical": settings.get("bcCritical") == "1",
"ca": True,
"path_length": int(settings["basicPath"]) if settings.get("basicPath", "").isdigit() else None,
}
elif ca == "2":
extensions["basic_constraints"] = {
"critical": settings.get("bcCritical") == "1",
"ca": False,
"path_length": None,
}
key_use = int(settings.get("keyUse") or 0)
usage_names = [
name
for index, name in enumerate(
[
"digital_signature",
"content_commitment",
"key_encipherment",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only",
]
)
if key_use & (1 << index)
]
extensions["key_usage"] = {
"critical": settings.get("kuCritical") == "1",
"usages": usage_names,
}
extensions["extended_key_usage"] = {
"critical": settings.get("ekuCritical") == "1",
"usages": parse_eku_names(settings.get("eKeyUse", "")),
}
if settings.get("subAltName"):
try:
extensions["subject_alt_name"] = {
"critical": False,
"entries": [
{"type": "dns", "value": "copycn"}
]
if settings.get("subAltName") == "DNS:copycn"
else [
{"type": _kind, "value": _value}
for _kind, _value in _parse_general_text(settings["subAltName"])
],
}
except ValueError:
extensions["subject_alt_name"] = {"critical": False, "entries": []}
if settings.get("issAltName"):
try:
extensions["issuer_alt_name"] = {
"critical": False,
"entries": [
{"type": _kind, "value": _value}
for _kind, _value in _parse_general_text(settings["issAltName"])
],
}
except ValueError:
pass
if settings.get("crlDist"):
extensions["crl_distribution_points"] = {
"critical": False,
"urls": parse_crl_distribution_urls(settings["crlDist"]),
}
if settings.get("authInfAcc"):
extensions["authority_information_access"] = {
"critical": False,
"descriptions": parse_aia_descriptions(settings["authInfAcc"]),
}
if settings.get("nameCons"):
extensions["name_constraints"] = {
"critical": True,
**parse_name_constraints_text(settings["nameCons"]),
}
extensions["subject_key_identifier"] = {"critical": False} if settings.get("subKey") == "1" else None
extensions["authority_key_identifier"] = {"critical": False} if settings.get("authKey") == "1" else None
if settings.get("OCSPstaple") == "1":
extensions["tls_feature"] = {"critical": False, "features": ["status_request"]}
ns_type = int(settings.get("nsCertType") or 0)
ns_names = [
name
for index, name in enumerate(["client", "server", "email", "objsign", "sslCA", "emailCA", "objCA"])
if ns_type & (1 << index)
]
netscape: Dict[str, Any] = {}
if ns_names:
netscape["cert_type"] = ns_names
ns_fields = {
"nsComment": "comment",
"nsBaseUrl": "base_url",
"nsRevocationUrl": "revocation_url",
"nsCARevocationUrl": "ca_revocation_url",
"nsRenewalUrl": "renewal_url",
"nsCaPolicyUrl": "ca_policy_url",
"nsSslServerName": "ssl_server_name",
}
for source, target in ns_fields.items():
if settings.get(source):
netscape[target] = settings[source]
if netscape:
extensions["netscape"] = netscape
if settings.get("adv_ext"):
# Kept for display/export; the typed editor does not interpret the
# OpenSSL config text.
result["advanced_config"] = settings["adv_ext"]
valid_n = int(settings.get("validN") or 365)
valid_m = int(settings.get("validM") or 0)
result["validity"] = {
"amount": valid_n,
"unit": "days" if valid_m == 0 else ("months" if valid_m == 1 else "years"),
"midnight": settings.get("validMidn") == "1",
"no_expiration": settings.get("noWellDefinedExpDate") == "1",
}
return result
def _parse_general_text(text: str) -> List[tuple[str, str]]:
result = []
for chunk in str(text).split(","):
chunk = chunk.strip()
if not chunk:
continue
if ":" not in chunk:
continue
prefix, value = chunk.split(":", 1)
prefix = prefix.strip().lower()
kind = {
"dns": "dns",
"ip": "ip",
"uri": "uri",
"email": "email",
"rid": "rid",
"othername": "other_name",
"dirname": "directory_name",
}.get(prefix)
if kind:
result.append((kind, value.strip()))
return result
def template_to_xca_settings(template: Mapping[str, Any]) -> Dict[str, str]:
"""Convert the KCA template JSON to XCA's ``pki_temp`` settings map."""
settings = dict(template.get("settings") or {})
extensions = settings.get("extensions") or {}
result: Dict[str, str] = {}
bc = extensions.get("basic_constraints") or {}
if bc.get("ca") is True:
result["ca"] = "1"
result["bcCritical"] = "1" if bc.get("critical") else "0"
result["basicPath"] = str(bc.get("path_length") or "")
elif bc.get("ca") is False:
result["ca"] = "2"
result["bcCritical"] = "1" if bc.get("critical") else "0"
result["basicPath"] = ""
else:
result["ca"] = ""
result["bcCritical"] = ""
result["basicPath"] = ""
usage_index = {
"digital_signature": 0,
"content_commitment": 1,
"key_encipherment": 2,
"data_encipherment": 3,
"key_agreement": 4,
"key_cert_sign": 5,
"crl_sign": 6,
"encipher_only": 7,
"decipher_only": 8,
}
ku = extensions.get("key_usage") or {}
result["keyUse"] = str(sum(1 << usage_index[name] for name in ku.get("usages", []) if name in usage_index))
result["kuCritical"] = "1" if ku.get("critical") else "0"
eku = extensions.get("extended_key_usage") or {}
result["eKeyUse"] = ", ".join(eku.get("usages", []))
result["ekuCritical"] = "1" if eku.get("critical") else "0"
result["subKey"] = "1" if extensions.get("subject_key_identifier") else "0"
result["authKey"] = "1" if extensions.get("authority_key_identifier") else "0"
san = extensions.get("subject_alt_name") or {}
result["subAltName"] = _general_text(san.get("entries", []))
ian = extensions.get("issuer_alt_name") or {}
result["issAltName"] = _general_text(ian.get("entries", []))
result["crlDist"] = ", ".join(
f"URI:{url}" for url in (extensions.get("crl_distribution_points") or {}).get("urls", [])
)
result["authInfAcc"] = ", ".join(
f"{'OCSP' if item.get('method') == 'ocsp' else 'caIssuers'};URI:{(item.get('location') or {}).get('value', '')}"
for item in (extensions.get("authority_information_access") or {}).get("descriptions", [])
)
nc = extensions.get("name_constraints") or {}
result["nameCons"] = ", ".join(
[f"permitted;{_one_general(item)}" for item in nc.get("permitted", [])]
+ [f"excluded;{_one_general(item)}" for item in nc.get("excluded", [])]
)
result["OCSPstaple"] = "1" if extensions.get("tls_feature") else "0"
ns = extensions.get("netscape") or {}
ns_index = {name: index for index, name in enumerate(["client", "server", "email", "objsign", "sslCA", "emailCA", "objCA"])}
result["nsCertType"] = str(sum(1 << ns_index[name] for name in ns.get("cert_type", []) if name in ns_index))
result["nsComment"] = str(ns.get("comment", ""))
result["nsBaseUrl"] = str(ns.get("base_url", ""))
result["nsRevocationUrl"] = str(ns.get("revocation_url", ""))
result["nsCARevocationUrl"] = str(ns.get("ca_revocation_url", ""))
result["nsRenewalUrl"] = str(ns.get("renewal_url", ""))
result["nsCaPolicyUrl"] = str(ns.get("ca_policy_url", ""))
result["nsSslServerName"] = str(ns.get("ssl_server_name", ""))
result["adv_ext"] = str(settings.get("advanced_config", ""))
validity = settings.get("validity") or {}
result["validN"] = str(validity.get("amount", 365))
result["validM"] = {"days": "0", "months": "1", "years": "2"}.get(str(validity.get("unit", "days")), "0")
result["validMidn"] = "1" if validity.get("midnight") else "0"
result["noWellDefinedExpDate"] = "1" if validity.get("no_expiration") else "0"
return result
def _general_text(entries: Sequence[Mapping[str, Any]]) -> str:
return ", ".join(_one_general(entry) for entry in entries)
def _one_general(entry: Mapping[str, Any]) -> str:
kind = str(entry.get("type", "dns"))
value = entry.get("value", "")
prefix = {
"dns": "DNS",
"ip": "IP",
"uri": "URI",
"email": "email",
"rid": "RID",
"other_name": "otherName",
"directory_name": "dirName",
}.get(kind, kind)
if kind == "other_name":
return f"{prefix}:{entry.get('oid', '')};UTF8:{value}"
return f"{prefix}:{value}"
__all__ = [
"PEM_STRING_XCA_TEMPLATE",
"TEMPLATE_VERSION",
"Template",
"apply_template",
"builtin_templates",
"default_settings",
"template_from_certificate",
"template_from_request",
"template_from_xca_data",
"template_from_xca_payload",
"template_to_xca_data",
"template_to_xca_settings",
"xca_settings_to_template",
]
@@ -0,0 +1,334 @@
"""Import an XCA SQLite database (``.xdb``).
XCA stores every cryptographic object as base64 DER in a small relational
schema. This reader supports schema 7/8 (XCA 2.x), software keys and the
normal item types; PKCS#11 token rows are skipped because a browser app cannot
use a card reader. Private keys are decrypted with the database password
(individual key passwords are tried with the same value and otherwise the
public part is imported).
"""
from __future__ import annotations
import base64
import hashlib
import sqlite3
import tempfile
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.ciphers import Cipher, modes
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
from .certificates import parse_certificate
from .crls import parse_crl
from .imports import ParsedItem
from .keys import is_private_key, load_key_bytes
from .requests import parse_csr
from .templates import template_from_xca_payload
_PKI_SOURCE = {
0: "unknown",
1: "imported",
2: "generated",
3: "transformed",
4: "token",
5: "legacy",
6: "renewed",
}
def _parse_date(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
text = str(value).strip()
if not text:
return None
for fmt in ("%Y%m%d%H%M%SZ", "%Y%m%d%H%M%S"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def _evp_bytes_to_key(password: bytes, salt: bytes, key_len: int = 24) -> bytes:
"""OpenSSL's EVP_BytesToKey with SHA-1 and one iteration."""
result = b""
previous = b""
while len(result) < key_len:
digest = hashlib.sha1()
digest.update(previous)
digest.update(password)
digest.update(salt)
previous = digest.digest()
result += previous
return result[:key_len]
def _legacy_decrypt(data: bytes, password: Optional[str]) -> Optional[bytes]:
if not password or len(data) <= 8:
return None
iv = data[:8]
ciphertext = data[8:]
key = _evp_bytes_to_key(password.encode(), iv, 24)
try:
decryptor = Cipher(TripleDES(key), modes.CBC(iv)).decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
pad = padded[-1]
if pad < 1 or pad > 8:
return None
return padded[:-pad]
except Exception: # noqa: BLE001
return None
def _decrypt_private(encoded: str, password: Optional[str]) -> Optional[Any]:
try:
data = base64.b64decode(encoded)
except Exception: # noqa: BLE001
return None
# Modern XCA: PKCS#8 EncryptedPrivateKeyInfo.
try:
return serialization.load_der_private_key(
data, password=password.encode() if password else None
)
except Exception: # noqa: BLE001
pass
# Pre-2.0 legacy: 8-byte IV + 3DES-CBC(traditional DER private key).
legacy = _legacy_decrypt(data, password)
if legacy:
try:
return serialization.load_der_private_key(legacy, password=None)
except Exception: # noqa: BLE001
pass
return None
def _item_extra(row: sqlite3.Row, item_id: int) -> Dict[str, Any]:
return {
"xca_item_id": item_id,
"source": _PKI_SOURCE.get(int(row["source"] or 0), "unknown"),
"xca_date": _parse_date(row["date"]),
"comment": str(row["comment"] or ""),
}
def import_xca_database(
data: bytes,
filename: str = "",
password: Optional[str] = None,
) -> List[ParsedItem]:
with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle:
handle.write(data)
path = handle.name
try:
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
try:
schema_row = connection.execute(
"SELECT value FROM settings WHERE key_='schema'"
).fetchone()
if schema_row is None:
raise ValueError("not an XCA database (missing schema setting)")
schema = int(str(schema_row[0]))
if schema < 7:
raise ValueError(f"unsupported XCA database schema {schema}")
return _read_database(connection, filename, password)
finally:
connection.close()
except sqlite3.DatabaseError as exc:
raise ValueError("not a valid SQLite XCA database") from exc
finally:
try:
import os
os.unlink(path)
except OSError:
pass
def _read_database(
connection: sqlite3.Connection,
filename: str,
password: Optional[str],
) -> List[ParsedItem]:
items: Dict[int, sqlite3.Row] = {
int(row["id"]): row
for row in connection.execute(
"SELECT id, name, type, source, date, comment FROM items WHERE del=0"
)
}
if not items:
return []
# XCA's x509super table maps certificates/requests to key item ids.
key_refs: Dict[int, int] = {}
try:
for row in connection.execute("SELECT item, pkey FROM x509super"):
if row["pkey"] is not None:
key_refs[int(row["item"])] = int(row["pkey"])
except sqlite3.DatabaseError:
pass
result: List[ParsedItem] = []
key_objects: Dict[int, Any] = {}
# --- keys --------------------------------------------------------------
public_rows = {int(row["item"]): row for row in connection.execute("SELECT item, type, len, \"public\" FROM public_keys")}
private_rows = {int(row["item"]): row for row in connection.execute("SELECT item, ownPass, private FROM private_keys")}
for item_id, row in items.items():
if int(row["type"]) != 1 or item_id not in public_rows:
continue
public_row = public_rows[item_id]
try:
public_key = serialization.load_der_public_key(base64.b64decode(public_row["public"]))
except Exception: # noqa: BLE001
continue
key = public_key
private_row = private_rows.get(item_id)
if private_row is not None:
own_pass = int(private_row["ownPass"] or 0)
# ptCommon (0) and ptPrivate (1) are both tried with the supplied
# database password; ptBogus/ptPin cannot be used here.
if own_pass in (0, 1):
decrypted = _decrypt_private(str(private_row["private"]), password)
if decrypted is not None and is_private_key(decrypted):
key = decrypted
item = ParsedItem(
kind="key",
name=str(row["name"] or "key"),
summary=f"{public_row['type']} {public_row['len']}",
obj=key,
is_private=is_private_key(key),
extra=_item_extra(row, item_id),
)
item.extra["has_private"] = is_private_key(key)
result.append(item)
key_objects[item_id] = key
# --- certificates ------------------------------------------------------
cert_rows = {int(row["item"]): row for row in connection.execute("SELECT item, issuer, ca, cert FROM certs")}
authority_rows = {int(row["item"]): row for row in connection.execute("SELECT item, template, crlExpire, crlNo, crlDays FROM authority")}
try:
takey_rows = {int(row["item"]): row for row in connection.execute("SELECT item, value FROM takeys")}
except sqlite3.DatabaseError:
takey_rows = {}
revocations: Dict[int, List[Dict[str, Any]]] = {}
try:
for row in connection.execute(
"SELECT caId, serial, date, invaldate, crlNo, reasonBit FROM revocations"
):
revocations.setdefault(int(row["caId"]), []).append(
{
"serial_hex": str(row["serial"] or ""),
"revoked_at": _parse_date(row["date"]),
"invalidity_date": _parse_date(row["invaldate"]),
"crl_number": int(row["crlNo"] or 0),
"reason_bit": int(row["reasonBit"] or 0),
}
)
except sqlite3.DatabaseError:
pass
for item_id, row in items.items():
if int(row["type"]) != 3 or item_id not in cert_rows:
continue
cert_row = cert_rows[item_id]
try:
cert = parse_certificate(base64.b64decode(cert_row["cert"]))
except Exception: # noqa: BLE001
continue
item = ParsedItem(
kind="certificate",
name=str(row["name"] or "certificate"),
summary=cert.subject.rfc4514_string(),
obj=cert,
extra=_item_extra(row, item_id),
)
item.extra["xca_issuer_id"] = int(cert_row["issuer"]) if cert_row["issuer"] is not None else None
item.extra["xca_key_id"] = key_refs.get(item_id)
item.extra["serial_hex"] = format(cert.serial_number, "x")
authority = authority_rows.get(item_id)
if authority is not None:
item.extra["authority"] = {
"crl_expire": _parse_date(authority["crlExpire"]),
"crl_number": int(authority["crlNo"] or 0),
"crl_days": int(authority["crlDays"] or 30),
}
if item_id in takey_rows:
try:
item.extra["tls_auth_key"] = base64.b64decode(takey_rows[item_id]["value"])
except Exception: # noqa: BLE001
pass
if item_id in revocations:
item.extra["revocations"] = revocations[item_id]
result.append(item)
# --- requests ----------------------------------------------------------
request_rows = {int(row["item"]): row for row in connection.execute("SELECT item, signed, request FROM requests")}
for item_id, row in items.items():
if int(row["type"]) != 2 or item_id not in request_rows:
continue
request_row = request_rows[item_id]
try:
csr = parse_csr(base64.b64decode(request_row["request"]))
except Exception: # noqa: BLE001
continue
item = ParsedItem(
kind="request",
name=str(row["name"] or "request"),
summary=csr.subject.rfc4514_string(),
obj=csr,
extra=_item_extra(row, item_id),
)
item.extra["xca_key_id"] = key_refs.get(item_id)
item.extra["signed"] = bool(request_row["signed"])
result.append(item)
# --- CRLs --------------------------------------------------------------
crl_rows = {int(row["item"]): row for row in connection.execute("SELECT item, issuer, crl FROM crls")}
for item_id, row in items.items():
if int(row["type"]) != 4 or item_id not in crl_rows:
continue
crl_row = crl_rows[item_id]
try:
crl = parse_crl(base64.b64decode(crl_row["crl"]))
except Exception: # noqa: BLE001
continue
item = ParsedItem(
kind="crl",
name=str(row["name"] or "crl"),
summary=crl.issuer.rfc4514_string(),
obj=crl,
extra=_item_extra(row, item_id),
)
item.extra["xca_issuer_id"] = int(crl_row["issuer"]) if crl_row["issuer"] is not None else None
result.append(item)
# --- templates ---------------------------------------------------------
template_rows = {int(row["item"]): row for row in connection.execute("SELECT item, version, template FROM templates")}
for item_id, row in items.items():
if int(row["type"]) != 5 or item_id not in template_rows:
continue
template_row = template_rows[item_id]
try:
payload = base64.b64decode(template_row["template"])
template = template_from_xca_payload(payload, int(template_row["version"] or 10))
except Exception: # noqa: BLE001
continue
template["name"] = str(row["name"] or template.get("name") or "template")
template["comment"] = str(row["comment"] or "")
item = ParsedItem(
kind="template",
name=str(row["name"] or "template"),
summary=str(row["comment"] or ""),
obj=template,
extra=_item_extra(row, item_id),
)
result.append(item)
return result
__all__ = ["import_xca_database"]
+302
View File
@@ -0,0 +1,302 @@
"""Core domain tests: names, extensions, keys, certificates, CRLs and exports."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from kca.core import certificates, crls, crypto, exports, imports, keys, names, requests, templates
from kca.core.extensions import (
build_extensions,
default_extension_spec,
extension_specs,
)
def _name(cn: str = "Test CA") -> x509.Name:
return names.name_from_entries(
names.entries_from_json(
[
{"name": "C", "value": "DE"},
{"name": "O", "value": "KCA"},
{"name": "CN", "value": cn},
]
)
)
def _ca_spec() -> dict:
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
spec["key_usage"] = {"critical": True, "usages": ["key_cert_sign", "crl_sign"]}
spec["subject_key_identifier"] = {"critical": False}
spec["authority_key_identifier"] = {"critical": False}
return spec
class NamesTest(unittest.TestCase):
def test_order_and_duplicates_survive(self) -> None:
name = _name()
entries = names.entries_from_name(name)
self.assertEqual([e.name for e in entries], ["C", "O", "CN"])
self.assertEqual(name.rfc4514_string(), "CN=Test CA,O=KCA,C=DE")
def test_der_roundtrip(self) -> None:
name = _name()
self.assertEqual(names.name_from_der(names.name_to_der(name)), name)
class ExtensionTest(unittest.TestCase):
def test_roundtrip(self) -> None:
key = keys.generate_key("rsa", 2048)
spec = _ca_spec()
spec["subject_alt_name"] = {
"critical": False,
"entries": [
{"type": "dns", "value": "example.com"},
{"type": "ip", "value": "192.0.2.1"},
{"type": "email", "value": "ca@example.com"},
],
}
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name())
parsed = extension_specs(exts)
self.assertTrue(parsed["basic_constraints"]["ca"])
self.assertEqual(parsed["basic_constraints"]["path_length"], None)
self.assertEqual(len(parsed["subject_alt_name"]["entries"]), 3)
self.assertIn("key_cert_sign", parsed["key_usage"]["usages"])
def test_raw_extension(self) -> None:
key = keys.generate_key("ec", curve="secp256r1")
spec = default_extension_spec()
spec["raw"] = [{"oid": "1.2.3.4", "critical": True, "der_hex": "0c03616263"}]
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name())
self.assertEqual(exts[0].oid.dotted_string, "1.2.3.4")
self.assertTrue(exts[0].critical)
class KeyTest(unittest.TestCase):
def test_all_types(self) -> None:
for key_type, kwargs in [
("rsa", {"size": 2048}),
("ec", {"curve": "secp256r1"}),
("ed25519", {}),
]:
key = keys.generate_key(key_type, **kwargs)
info = keys.key_info(key)
self.assertTrue(info.is_private)
self.assertEqual(info.key_type.upper() if key_type != "ed25519" else "ED25519", info.key_type)
loaded, private = keys.load_key_bytes(keys.private_pem(key))
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), info.public_der)
def test_encrypted_export_roundtrip(self) -> None:
key = keys.generate_key("rsa", 2048)
encrypted = keys.private_pem(key, password="secret")
loaded, private = keys.load_key_bytes(encrypted, password="secret")
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
def test_ssh_and_pvk(self) -> None:
key = keys.generate_key("rsa", 2048)
public = keys.ssh_public(key, "test")
loaded, private = keys.load_key_bytes(public.encode())
self.assertFalse(private)
pvk = keys.pvk_encode_private(key)
loaded, private = keys.load_key_bytes(pvk)
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
class CertificateTest(unittest.TestCase):
def test_issue_and_verify(self) -> None:
ca_key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
ca = certificates.build_self_signed(
subject=_name("Root CA"),
private_key=ca_key,
serial=1,
not_before=now,
not_after=now + timedelta(days=3650),
extension_spec=_ca_spec(),
)
self.assertTrue(certificates.is_self_signed(ca))
self.assertTrue(certificates.certificate_info(ca).is_ca)
leaf_key = keys.generate_key("ec", curve="secp256r1")
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": False, "path_length": None}
spec["key_usage"] = {"critical": True, "usages": ["digital_signature", "key_encipherment"]}
spec["extended_key_usage"] = {"critical": False, "usages": ["serverAuth"]}
spec["subject_alt_name"] = {"critical": False, "entries": [{"type": "dns", "value": "example.com"}]}
leaf = certificates.build_signed(
subject=_name("example.com"),
public_key=leaf_key.public_key(),
issuer_cert=ca,
issuer_private_key=ca_key,
serial=2,
not_before=now,
not_after=now + timedelta(days=365),
extension_spec=spec,
)
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
self.assertFalse(certificates.verify_directly_issued_by(leaf, leaf))
self.assertIn("SSL server", certificates.purposes(leaf))
parsed = certificates.parse_certificate(certificates.certificate_der(leaf))
self.assertEqual(parsed.serial_number, 2)
self.assertEqual(certificates.find_issuer(parsed, [ca]), ca)
self.assertEqual(certificates.validation_errors(parsed, [parsed, ca]), [])
def test_renew_spec(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
cert = certificates.build_self_signed(
subject=_name("Renew Me"),
private_key=key,
serial=3,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
spec = certificates.certificate_spec(cert)
self.assertEqual(spec["serial_hex"], "3")
self.assertEqual(spec["subject"][-1]["name"], "CN")
def test_index_and_config(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
cert = certificates.build_self_signed(
subject=_name("Index"),
private_key=key,
serial=4,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
self.assertIn("V\t", certificates.index_entry(cert))
self.assertIn("basicConstraints", certificates.openssl_config(cert))
self.assertIn("VCALENDAR", certificates.ics_calendar([certificates.ics_event(cert, "Index")]))
class RequestTest(unittest.TestCase):
def test_csr_roundtrip(self) -> None:
key = keys.generate_key("rsa", 2048)
csr = requests.build_csr(
subject=_name("CSR"),
private_key=key,
extension_spec=_ca_spec(),
attributes={requests.CHALLENGE_PASSWORD_OID: "secret"},
)
self.assertTrue(requests.verify_csr(csr))
loaded = requests.parse_csr(requests.csr_der(csr))
self.assertEqual(requests.request_info(loaded).subject, csr.subject)
self.assertEqual(requests.attribute_values(loaded)[requests.CHALLENGE_PASSWORD_OID], "secret")
class CrlTest(unittest.TestCase):
def test_build_and_parse(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
ca = certificates.build_self_signed(
subject=_name("CRL CA"),
private_key=key,
serial=5,
not_before=now,
not_after=now + timedelta(days=3650),
extension_spec=_ca_spec(),
)
crl = crls.build_crl(
issuer_cert=ca,
issuer_private_key=key,
revocations=[
{
"serial_hex": "10",
"revoked_at": now,
"reason": "keyCompromise",
"invalidity_date": now - timedelta(days=1),
}
],
last_update=now,
next_update=now + timedelta(days=30),
crl_number=7,
)
info = crls.crl_info(crl)
self.assertEqual(info.crl_number, 7)
self.assertEqual(info.revoked_count, 1)
self.assertTrue(crls.verify_crl(crl, ca))
parsed = crls.parse_crl(crls.crl_der(crl))
rows = crls.crl_revocations(parsed)
self.assertEqual(rows[0]["serial_hex"], "10")
self.assertEqual(rows[0]["reason"], "keyCompromise")
class TemplateTest(unittest.TestCase):
def test_xca_roundtrip(self) -> None:
template = templates.builtin_templates()[0].as_dict()
data = templates.template_to_xca_data(template)
parsed = templates.template_from_xca_data(data)
self.assertEqual(parsed["name"], "CA")
self.assertTrue(parsed["settings"]["extensions"]["basic_constraints"]["ca"])
def test_apply(self) -> None:
template = templates.builtin_templates()[1].as_dict()
merged = templates.apply_template(template, {"subject": [], "extensions": {}})
self.assertIn("serverAuth", merged["extensions"]["extended_key_usage"]["usages"])
class ExportImportTest(unittest.TestCase):
def setUp(self) -> None:
self.key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
self.cert = certificates.build_self_signed(
subject=_name("Export"),
private_key=self.key,
serial=6,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
def test_certificate_exports(self) -> None:
for fmt, prefix in [
("pem", b"-----BEGIN CERTIFICATE"),
("der", b"0"),
("pkcs7", b"0"),
("pkcs12", b"0"),
]:
result = exports.export_certificate(
self.cert, fmt, name="export", private_key=self.key, password="secret"
)
self.assertTrue(result.data.startswith(prefix), fmt)
pem = exports.export_certificate(self.cert, "pem", name="export")
items = imports.parse_file(pem.data, "export.crt")
self.assertEqual(items[0].kind, "certificate")
def test_key_exports(self) -> None:
for fmt in ("pem-public", "pem-private", "pkcs8", "ssh-public", "jwk-public", "pvk"):
result = exports.export_key(self.key, fmt, name="export")
self.assertTrue(result.data)
encrypted = exports.export_key(self.key, "pkcs8-encrypted", name="export", password="secret")
loaded, private = keys.load_key_bytes(encrypted.data, password="secret")
self.assertTrue(private)
def test_pkcs12_contains_chain(self) -> None:
result = exports.export_certificate(
self.cert, "pkcs12-chain", name="export", chain=[self.cert], private_key=self.key, password="secret"
)
items = imports.parse_file(result.data, "export.pfx", "secret")
self.assertGreaterEqual(len(items), 2)
class CryptoTest(unittest.TestCase):
def test_envelope(self) -> None:
master = crypto.generate_master_key()
blob = crypto.encrypt_private_key(b"secret", "key-id", crypto.decode_master_key(master))
self.assertEqual(crypto.decrypt_private_key(blob, "key-id", crypto.decode_master_key(master)), b"secret")
with self.assertRaises(crypto.DecryptionError):
crypto.decrypt_private_key(blob, "other-id", crypto.decode_master_key(master))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,135 @@
"""XCA .xdb import tests using a synthetic schema-compatible database."""
from __future__ import annotations
import base64
import sqlite3
import struct
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives import serialization
from kca.core import certificates, crls, keys, names, requests, templates
from kca.core.extensions import build_extensions, default_extension_spec
from kca.core.xca_db import import_xca_database
def _name(cn: str) -> object:
return names.name_from_entries(names.entries_from_json([{"name": "CN", "value": cn}]))
class XcaDbTest(unittest.TestCase):
def test_import_synthetic_database(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name("Synthetic CA"))
cert = certificates.build_certificate(
subject=_name("Synthetic CA"),
public_key=key.public_key(),
issuer_cert=None,
issuer_private_key=key,
serial=42,
not_before=now,
not_after=now + timedelta(days=365),
extensions=exts,
)
csr = requests.build_csr(subject=_name("Synthetic CSR"), private_key=key, extension_spec=spec)
crl = crls.build_crl(
issuer_cert=cert,
issuer_private_key=key,
revocations=[{"serial_hex": "2a", "revoked_at": now, "reason": "keyCompromise"}],
last_update=now,
next_update=now + timedelta(days=30),
crl_number=3,
)
tmpl = templates.builtin_templates()[0].as_dict()
tmpl_pem = templates.template_to_xca_data(tmpl)
body = tmpl_pem.decode().split("-----BEGIN XCA TEMPLATE-----")[1].split("-----END XCA TEMPLATE-----")[0]
payload = base64.b64decode("".join(body.split()))
tmpl_data = payload[8:]
date = now.strftime("%Y%m%d%H%M%SZ")
with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle:
path = handle.name
try:
db = sqlite3.connect(path)
db.executescript(
"""
CREATE TABLE settings (key_ CHAR(20) UNIQUE, value TEXT);
CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(128), type INTEGER, source INTEGER, date CHAR(15), comment VARCHAR(2048), stamp INTEGER DEFAULT 0, del SMALLINT DEFAULT 0);
CREATE TABLE public_keys (item INTEGER, type CHAR(4), hash INTEGER, len INTEGER, "public" TEXT);
CREATE TABLE private_keys (item INTEGER, ownPass INTEGER, private TEXT);
CREATE TABLE x509super (item INTEGER, subj_hash INTEGER, pkey INTEGER, key_hash INTEGER);
CREATE TABLE requests (item INTEGER, hash INTEGER, signed INTEGER, request TEXT);
CREATE TABLE certs (item INTEGER, hash INTEGER, iss_hash INTEGER, serial VARCHAR(64), issuer INTEGER, ca INTEGER, cert TEXT);
CREATE TABLE authority (item INTEGER, template INTEGER, crlExpire CHAR(15), crlNo INTEGER, crlDays INTEGER);
CREATE TABLE crls (item INTEGER, hash INTEGER, num INTEGER, iss_hash INTEGER, issuer INTEGER, crl TEXT);
CREATE TABLE revocations (caId INTEGER, serial VARCHAR(64), date CHAR(15), invaldate CHAR(15), crlNo INTEGER, reasonBit INTEGER);
CREATE TABLE templates (item INTEGER, version INTEGER, template TEXT);
CREATE TABLE takeys (item INTEGER UNIQUE, value TEXT);
"""
)
db.execute("INSERT INTO settings VALUES ('schema', '8')")
db.execute("INSERT INTO items VALUES (1, 'Synthetic key', 1, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (2, 'Synthetic CA', 3, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (3, 'Synthetic CSR', 2, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (4, 'Synthetic CRL', 4, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (5, 'Synthetic template', 5, 2, ?, '', 0, 0)", (date,))
encrypted = key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8,
serialization.BestAvailableEncryption(b"dbpass"),
)
db.execute(
"INSERT INTO public_keys VALUES (1, 'RSA', 0, 2048, ?)",
(base64.b64encode(keys.public_der(key)).decode(),),
)
db.execute(
"INSERT INTO private_keys VALUES (1, 0, ?)",
(base64.b64encode(encrypted).decode(),),
)
db.execute("INSERT INTO x509super VALUES (2, 0, 1, 0)")
db.execute("INSERT INTO x509super VALUES (3, 0, 1, 0)")
db.execute(
"INSERT INTO certs VALUES (2, 0, 0, '2a', NULL, 1, ?)",
(base64.b64encode(certificates.certificate_der(cert)).decode(),),
)
db.execute("INSERT INTO authority VALUES (2, NULL, NULL, 0, 30)")
db.execute(
"INSERT INTO revocations VALUES (2, '2a', ?, NULL, 0, 1)",
(date,),
)
db.execute(
"INSERT INTO requests VALUES (3, 0, 0, ?)",
(base64.b64encode(requests.csr_der(csr)).decode(),),
)
db.execute(
"INSERT INTO crls VALUES (4, 0, 1, 0, 2, ?)",
(base64.b64encode(crls.crl_der(crl)).decode(),),
)
db.execute(
"INSERT INTO templates VALUES (5, 10, ?)",
(base64.b64encode(tmpl_data).decode(),),
)
db.commit()
db.close()
with open(path, "rb") as handle:
items = import_xca_database(handle.read(), "synthetic.xdb", "dbpass")
finally:
import os
os.unlink(path)
kinds = [item.kind for item in items]
self.assertEqual(kinds, ["key", "certificate", "request", "crl", "template"])
self.assertTrue(items[0].is_private)
self.assertEqual(items[1].name, "Synthetic CA")
self.assertEqual(items[3].name, "Synthetic CRL")
self.assertEqual(items[4].obj["name"], "Synthetic template")
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
# KCA backend workspace.
#
# The pure domain logic lives in packages/kca-core (no kaya, no database);
# the deployable application lives in packages/kca-app. This file is the uv
# workspace root and also holds the tooling configuration read from server/
# (uv, aerich, mypy).
[tool.uv]
index-strategy = "unsafe-best-match"
[[tool.uv.index]]
name = "gitea"
url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple"
default = true
[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple"
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv.sources]
kca-core = { workspace = true }
[tool.aerich]
tortoise_orm = "kca.aerich_config.TORTOISE_ORM"
location = "./migrations"
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
[[tool.mypy.overrides]]
module = "kca.models"
disable_error_code = ["attr-defined", "assignment", "var-annotated"]
[[tool.mypy.overrides]]
module = "kca.service"
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
[[tool.mypy.overrides]]
module = "kca.core.imports"
disable_error_code = ["assignment", "attr-defined"]
[[tool.mypy.overrides]]
module = "kca.core.exports"
disable_error_code = ["arg-type"]
[[tool.mypy.overrides]]
module = "kca.routes.*"
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
[[tool.mypy.overrides]]
module = "kca.core.*"
disable_error_code = ["arg-type", "assignment", "attr-defined", "index", "return-value", "var-annotated"]
+12
View File
@@ -0,0 +1,12 @@
"""Test package init: set environment overrides before importing kca.app."""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_0_PROVIDER", "default")
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/kca")
os.environ.setdefault("OIDC_0_CLIENT_ID", "kca")
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
os.environ.setdefault("KCA_MASTER_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
os.environ.pop("REDIS_URL", None)
View File
+17
View File
@@ -0,0 +1,17 @@
"""In-process HTTP client for the app."""
from __future__ import annotations
import contextlib
from typing import AsyncIterator
from httpx import ASGITransport, AsyncClient
from kca.app import app, tortoise_mixin
@contextlib.asynccontextmanager
async def api_client() -> AsyncIterator[AsyncClient]:
await tortoise_mixin.ensure_context()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
yield client
+23
View File
@@ -0,0 +1,23 @@
"""``async_test`` that closes the app's per-loop Tortoise context."""
from __future__ import annotations
import asyncio
from functools import wraps
from typing import Any, Callable, Coroutine
from kca.app import tortoise_mixin
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
@wraps(coro)
def wrapper(*args: Any, **kwargs: Any) -> None:
async def run() -> None:
try:
await coro(*args, **kwargs)
finally:
await tortoise_mixin.aclose()
with asyncio.Runner() as runner:
runner.run(run())
return wrapper
+35
View File
@@ -0,0 +1,35 @@
"""Fake the OIDC-authenticated user during tests."""
from __future__ import annotations
import contextlib
import unittest.mock as _mock
from typing import Any, Iterator, Optional
from kaya.oidc import OIDCUser
from kca.app import oidc
_PROVIDER = next(iter(oidc.providers))
def make_user(sub: str, name: Optional[str] = None, **claims: Any) -> OIDCUser:
data: dict = {"sub": sub, "preferred_username": name or sub}
data.update(claims)
return OIDCUser(data)
@contextlib.contextmanager
def oidc_user(
sub: str,
name: Optional[str] = None,
**claims: Any,
) -> Iterator[OIDCUser]:
data: dict = {"sub": sub, "preferred_username": name or sub}
data.update(claims)
user = OIDCUser(data)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
patcher.start()
try:
yield user
finally:
patcher.stop()
+195
View File
@@ -0,0 +1,195 @@
"""Additional route-level tests: CSRs, tokens, import/export and templates."""
from __future__ import annotations
import base64
import unittest
from datetime import datetime, timedelta, timezone
from tests.helpers.api import api_client
from tests.helpers.asynctest import async_test
from tests.helpers.oidc import oidc_user
def _subject(cn: str) -> list[dict]:
return [{"name": "C", "value": "DE"}, {"name": "O", "value": "KCA"}, {"name": "CN", "value": cn}]
def _extensions(ca: bool = False) -> dict:
return {
"basic_constraints": {"critical": True, "ca": ca, "path_length": None},
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"] if ca else ["digital_signature"]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
async def _vault(client) -> str:
response = await client.post("/api/vaults", json={"name": "Test"})
assert response.status_code == 201, response.text
return response.json()["id"]
async def _key(client, vault_id: str, name: str = "key") -> dict:
response = await client.post(
f"/api/vaults/{vault_id}/keys", json={"name": name, "key_type": "rsa", "size": 2048}
)
assert response.status_code == 201, response.text
return response.json()
async def _cert(client, vault_id: str, key_id: str, name: str, ca: bool = False, signer_id: str | None = None) -> dict:
now = datetime.now(timezone.utc)
response = await client.post(
f"/api/vaults/{vault_id}/certificates",
json={
"name": name,
"key_id": key_id,
"signer_id": signer_id,
"subject": _subject(name),
"not_before": now.isoformat(),
"not_after": (now + timedelta(days=365)).isoformat(),
"extensions": _extensions(ca),
},
)
assert response.status_code == 201, response.text
return response.json()
class RouteExtraTests(unittest.TestCase):
@async_test
async def test_csr_creation_and_signing(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 key")
ca = await _cert(client, vault_id, ca_key["id"], "Root CA", ca=True)
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "web request",
"subject": _subject("web.example.com"),
"new_key": {"key_type": "ec", "curve": "secp256r1", "name": "web key"},
"extensions": _extensions(False),
},
)
assert response.status_code == 201, response.text
request = response.json()
assert request["signature_valid"] is True
response = await client.post(
f"/api/vaults/{vault_id}/certificates",
json={
"name": "web cert",
"request_id": request["id"],
"signer_id": ca["id"],
"not_before": datetime.now(timezone.utc).isoformat(),
"not_after": (datetime.now(timezone.utc) + timedelta(days=30)).isoformat(),
},
)
assert response.status_code == 201, response.text
cert = response.json()
assert cert["issuer_id"] == ca["id"]
response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}")
assert response.json()["signed"] is True
@async_test
async def test_token_authentication(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
response = await client.post("/api/tokens", json={"name": "script"})
assert response.status_code == 201, response.text
secret = response.json()["token"]
assert secret.startswith("kca_")
# Session is no longer used: the bearer token authenticates.
response = await client.get("/api/vaults", headers={"authorization": f"Bearer {secret}"})
assert response.status_code == 200
response = await client.post("/api/tokens", headers={"authorization": f"Bearer {secret}"}, json={"name": "x"})
assert response.status_code == 401
@async_test
async def test_import_preview_and_commit(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
key = await _key(client, vault_id, "import key")
cert = await _cert(client, vault_id, key["id"], "Import Me", ca=True)
export = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/export", json={"format": "pem"}
)
pem = base64.b64decode(export.json()["content_base64"])
response = await client.post(
f"/api/vaults/{vault_id}/import",
json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode(), "dry_run": True},
)
assert response.status_code == 200, response.text
assert response.json()["items"][0]["duplicate"] is True
response = await client.post(
f"/api/vaults/{vault_id}/import",
json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode()},
)
assert response.status_code == 200
assert len(response.json()["skipped"]) == 1
@async_test
async def test_settings_and_templates(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
response = await client.get(f"/api/vaults/{vault_id}/settings")
assert response.status_code == 200
version = response.json()["version"]
response = await client.patch(
f"/api/vaults/{vault_id}/settings",
json={"settings": {"default_hash": "sha384", "serial_len_bits": 80}, "version": version},
)
assert response.status_code == 200, response.text
assert response.json()["settings"]["default_hash"] == "sha384"
response = await client.get(f"/api/vaults/{vault_id}/templates")
templates = response.json()["results"]
assert any(t["id"] == "builtin:ca" for t in templates)
response = await client.post(
f"/api/vaults/{vault_id}/templates/builtin:ca/apply",
json={"target": {"subject": [], "extensions": {}}},
)
assert response.status_code == 200
assert response.json()["extensions"]["basic_constraints"]["ca"] is True
@async_test
async def test_revocation_and_crl(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"], "Root", ca=True)
leaf_key = await _key(client, vault_id, "leaf")
leaf = await _cert(
client, vault_id, leaf_key["id"], "Leaf", signer_id=ca["id"]
)
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
json={"reason": "keyCompromise"},
)
assert response.status_code == 200, response.text
response = await client.get(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}"
)
assert response.json()["revoked"] is True
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{ca['id']}/crl",
json={"days": 30},
)
assert response.status_code == 201, response.text
crl = response.json()
assert crl["revoked_count"] == 1
assert crl["crl_number"] == 1
@async_test
async def test_key_delete_unlinks(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
key = await _key(client, vault_id)
cert = await _cert(client, vault_id, key["id"], "Unlink")
response = await client.delete(f"/api/vaults/{vault_id}/keys/{key['id']}")
assert response.status_code == 204
response = await client.get(f"/api/vaults/{vault_id}/certificates/{cert['id']}")
assert response.json()["key_id"] is None
+126
View File
@@ -0,0 +1,126 @@
"""End-to-end smoke tests for the core PKI workflow."""
from __future__ import annotations
import base64
import unittest
from datetime import datetime, timedelta, timezone
from tests.helpers.api import api_client
from tests.helpers.asynctest import async_test
from tests.helpers.oidc import oidc_user
def _subject(cn: str = "Test CA") -> list[dict]:
return [
{"name": "C", "value": "DE"},
{"name": "O", "value": "KCA"},
{"name": "CN", "value": cn},
]
def _extensions() -> dict:
return {
"basic_constraints": {"critical": True, "ca": True, "path_length": None},
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
class SmokeTests(unittest.TestCase):
@async_test
async def test_health_and_auth(self) -> None:
async with api_client() as client:
response = await client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
response = await client.get("/api/me")
assert response.status_code == 401
@async_test
async def test_core_workflow(self) -> None:
now = datetime.now(timezone.utc)
async with api_client() as client:
with oidc_user("alice", "Alice"):
response = await client.get("/api/me")
assert response.status_code == 200
assert response.json()["display_name"] == "Alice"
response = await client.post("/api/vaults", json={"name": "Test Vault"})
assert response.status_code == 201, response.text
vault = response.json()
vault_id = vault["id"]
response = await client.post(
f"/api/vaults/{vault_id}/keys",
json={"name": "CA key", "key_type": "rsa", "size": 2048},
)
assert response.status_code == 201, response.text
key = response.json()
assert key["has_private"] is True
response = await client.post(
f"/api/vaults/{vault_id}/certificates",
json={
"name": "Test CA",
"comment": "",
"key_id": key["id"],
"subject": _subject(),
"not_before": now.isoformat(),
"not_after": (now + timedelta(days=3650)).isoformat(),
"signature_hash": "sha256",
"extensions": _extensions(),
},
)
assert response.status_code == 201, response.text
cert = response.json()
assert cert["is_ca"] is True
assert cert["subject_rfc4514"].startswith("CN=Test CA")
assert cert["validation_errors"] == []
response = await client.get(f"/api/vaults/{vault_id}/certificates")
assert response.status_code == 200
assert len(response.json()["results"]) == 1
response = await client.get(f"/api/vaults/{vault_id}/search?q=test")
assert response.status_code == 200
assert len(response.json()["results"]) >= 1
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/crl",
json={"days": 30},
)
assert response.status_code == 201, response.text
crl = response.json()
assert crl["crl_number"] == 1
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/export",
json={"format": "pem"},
)
assert response.status_code == 200, response.text
payload = response.json()
assert base64.b64decode(payload["content_base64"]).startswith(b"-----BEGIN CERTIFICATE")
response = await client.post(
f"/api/vaults/{vault_id}/import",
json={
"filename": "ca.crt",
"content_base64": payload["content_base64"],
"dry_run": True,
},
)
assert response.status_code == 200, response.text
assert response.json()["items"][0]["duplicate"] is True
@async_test
async def test_ownership_isolation(self) -> None:
async with api_client() as client:
with oidc_user("alice", "Alice"):
response = await client.post("/api/vaults", json={"name": "Alice Vault"})
vault_id = response.json()["id"]
with oidc_user("bob", "Bob"):
response = await client.get(f"/api/vaults/{vault_id}")
assert response.status_code == 404
response = await client.get("/api/vaults")
assert response.json()["results"] == []
+1949
View File
File diff suppressed because it is too large Load Diff
+568
View File
@@ -0,0 +1,568 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gloo-net"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils",
"http",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "html-escape"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "kca-web"
version = "0.1.0"
dependencies = [
"base64",
"console_error_panic_hook",
"gloo-net",
"gloo-timers",
"js-sys",
"serde",
"serde_json",
"sycamore",
"sycamore-router",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "sycamore"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa76a5ba796aa25357d70adf027a171b9da18478cf1ab64a9ed0c06f8fca0e49"
dependencies = [
"sycamore-core",
"sycamore-macro",
"sycamore-reactive",
"sycamore-web",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "sycamore-core"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fce3012a86a7d1ae7ca06db4d2172747a574904eed10808286071cef469f975"
dependencies = [
"hashbrown",
"paste",
"sycamore-reactive",
]
[[package]]
name = "sycamore-macro"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc13361313005ba633fbf1f973c2f2a2e9f31cda659f102eb362361065b450b"
dependencies = [
"proc-macro2",
"quote",
"sycamore-view-parser",
"syn 3.0.9",
]
[[package]]
name = "sycamore-reactive"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aca468d1209c2d1ae3059fb93aa005e67bbe1f17375a3c0a65135e98a1b58829"
dependencies = [
"paste",
"slotmap",
"smallvec",
"wasm-bindgen",
]
[[package]]
name = "sycamore-router"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6b53bacb0dfbb0665613b758ae24caf8ca8b221387077aee34bd76fefd7b77f"
dependencies = [
"sycamore",
"sycamore-router-macro",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "sycamore-router-macro"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4912026c5892851b743a601d7d52275b325e0f2cd245f856f646259fbef06730"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "sycamore-view-parser"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11fcefa71d9fe1100c90903c837224848441712cd90f34570bb44327a5598ca"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "sycamore-web"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7fdad6ed1ec34d7e6a2699b4bf76059b3c56f97b9b94f1ab69edc9b04696f4"
dependencies = [
"html-escape",
"js-sys",
"once_cell",
"paste",
"smallvec",
"sycamore-core",
"sycamore-macro",
"sycamore-reactive",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tokio"
version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "kca-web"
version = "0.1.0"
edition = "2021"
description = "Sycamore/WASM frontend for KCA"
[dependencies]
sycamore = "0.9"
sycamore-router = "0.9"
gloo-net = "0.6"
gloo-timers = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Window",
"Document",
"Location",
"Storage",
"HtmlInputElement",
"HtmlSelectElement",
"HtmlTextAreaElement",
"HtmlAnchorElement",
"HtmlElement",
"Element",
"Event",
"EventTarget",
"File",
"FileList",
"Blob",
"BlobPropertyBag",
"Url",
"Navigator",
"Clipboard",
"HtmlDetailsElement",
"HtmlFormElement",
"DomTokenList",
] }
js-sys = "0.3"
base64 = "0.22"
console_error_panic_hook = "0.1"
[profile.release]
opt-level = "z"
lto = true
+12
View File
@@ -0,0 +1,12 @@
# Development server for the KCA SPA. Proxies the backend prefixes to the
# app on :8080 so requests are same-origin and the session cookie works.
[serve]
port = 8000
[[proxy]]
rewrite = "/api"
backend = "http://127.0.0.1:8080/api"
[[proxy]]
rewrite = "/auth"
backend = "http://127.0.0.1:8080/auth"
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KCA — Key &amp; Certificate Authority</title>
<meta name="description" content="Web PKI management: keys, CSRs, certificates, CRLs and templates.">
<meta name="theme-color" content="#0a1720">
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="KCA">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
<link data-trunk rel="copy-file" href="pwa/manifest.webmanifest" data-target-path=".">
<link data-trunk rel="copy-file" href="pwa/sw.js" data-target-path=".">
<link data-trunk rel="copy-file" href="pwa/icons/icon.svg" data-target-path="icons">
<link data-trunk rel="rust" href="Cargo.toml">
<link data-trunk rel="css" href="vendor/bootstrap.min.css">
<link data-trunk rel="css" href="theme.css">
<link data-trunk rel="css" href="style.css">
</head>
<body></body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a1720"/>
<path d="M256 72l150 58v112c0 92-62 166-150 198-88-32-150-106-150-198V130z" fill="none" stroke="#5eead4" stroke-width="26"/>
<path d="M198 206h116v34H198zm0 66h116v34H198z" fill="#fbbf24"/>
<circle cx="256" cy="300" r="18" fill="#fbbf24"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

+18
View File
@@ -0,0 +1,18 @@
{
"name": "KCA — Key & Certificate Authority",
"short_name": "KCA",
"description": "Web PKI management: keys, CSRs, certificates, CRLs and templates.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0a1720",
"theme_color": "#0a1720",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
/* KCA app-shell service worker. It caches only the static shell, never API
* responses: certificate and key material must not be served stale. */
const CACHE = "kca-shell-v1";
const SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return;
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request).catch(() => caches.match("/")))
);
});
+445
View File
@@ -0,0 +1,445 @@
//! REST client for the KCA API.
use gloo_net::http::{Request, Response};
use serde::de::DeserializeOwned;
use serde_json::json;
use crate::auth;
use crate::model::{
ApiTokensPage, AuthProvidersPage, CertificateDetail, CertificatesPage, CreatedApiToken,
CrlDetail, CrlsPage, ExportPayload, ImportPreview, ImportResult, KeyDetail, KeysPage, Me,
OptionsPage, RequestDetail, RequestsPage, RevocationsPage, SearchPage, SummaryPage,
TemplateSummary, TemplatesPage, VaultDetail, VaultsPage,
};
#[derive(serde::Deserialize)]
struct ErrorEnvelope {
#[serde(default)]
error: String,
}
fn server_error(status: u16) -> String {
format!("server returned {status}")
}
async fn error_message(response: Response) -> String {
let status = response.status();
match response.json::<ErrorEnvelope>().await {
Ok(envelope) if !envelope.error.is_empty() => envelope.error,
_ => server_error(status),
}
}
async fn check<T: DeserializeOwned>(response: Response) -> Result<T, String> {
if response.status() == 401 {
auth::redirect_to_login();
return Err(LOGIN_REQUIRED.to_string());
}
if !response.ok() {
return Err(error_message(response).await);
}
response.json().await.map_err(|e| e.to_string())
}
async fn send(method: &str, url: &str, body: Option<&serde_json::Value>) -> Result<Response, String> {
let builder = match method {
"POST" => Request::post(url),
"PUT" => Request::put(url),
"PATCH" => Request::patch(url),
"DELETE" => Request::delete(url),
_ => Request::get(url),
};
let request = match body {
Some(payload) => builder.json(payload).map_err(|e| e.to_string())?,
None => builder.build().map_err(|e| e.to_string())?,
};
request.send().await.map_err(|e| e.to_string())
}
async fn expect_empty(response: Response) -> Result<(), String> {
if response.status() == 401 {
auth::redirect_to_login();
return Err(LOGIN_REQUIRED.to_string());
}
if !response.ok() {
return Err(error_message(response).await);
}
Ok(())
}
pub const LOGIN_REQUIRED: &str = "login required";
fn url_encode(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
// ---------------------------------------------------------------------------
// Auth / identity
// ---------------------------------------------------------------------------
pub async fn me_optional() -> Result<Option<Me>, String> {
let response = Request::get("/api/me").send().await.map_err(|e| e.to_string())?;
if response.status() == 401 {
return Ok(None);
}
if !response.ok() {
return Err(server_error(response.status()));
}
response.json().await.map(Some).map_err(|e| e.to_string())
}
pub async fn me() -> Result<Me, String> {
match me_optional().await? {
Some(user) => Ok(user),
None => {
auth::redirect_to_login();
Err(LOGIN_REQUIRED.to_string())
}
}
}
pub async fn auth_providers() -> Result<AuthProvidersPage, String> {
let response = Request::get("/api/auth/providers").send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn tokens() -> Result<ApiTokensPage, String> {
let response = Request::get("/api/tokens").send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_token(name: &str, expires_at: Option<&str>) -> Result<CreatedApiToken, String> {
let response = send("POST", "/api/tokens", Some(&json!({"name": name, "expires_at": expires_at}))).await?;
check(response).await
}
pub async fn delete_token(id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/tokens/{id}"), None).await?;
expect_empty(response).await
}
// ---------------------------------------------------------------------------
// Vaults
// ---------------------------------------------------------------------------
pub async fn vaults() -> Result<VaultsPage, String> {
let response = Request::get("/api/vaults").send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn vault(id: &str) -> Result<VaultDetail, String> {
let response = Request::get(&format!("/api/vaults/{id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_vault(name: &str, description: &str) -> Result<VaultDetail, String> {
let response = send("POST", "/api/vaults", Some(&json!({"name": name, "description": description}))).await?;
check(response).await
}
pub async fn update_vault(id: &str, name: &str, description: &str, version: i64) -> Result<VaultDetail, String> {
let response = send(
"PATCH",
&format!("/api/vaults/{id}"),
Some(&json!({"name": name, "description": description, "version": version})),
)
.await?;
check(response).await
}
pub async fn delete_vault(id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{id}"), None).await?;
expect_empty(response).await
}
pub async fn vault_settings(id: &str) -> Result<serde_json::Value, String> {
let response = Request::get(&format!("/api/vaults/{id}/settings")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn update_vault_settings(id: &str, settings: &serde_json::Value, version: i64) -> Result<serde_json::Value, String> {
let response = send(
"PATCH",
&format!("/api/vaults/{id}/settings"),
Some(&json!({"settings": settings, "version": version})),
)
.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
}
pub async fn options(id: &str) -> Result<OptionsPage, String> {
let response = Request::get(&format!("/api/vaults/{id}/options")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn search(id: &str, query: &str, kind: &str) -> Result<SearchPage, String> {
let url = format!(
"/api/vaults/{id}/search?q={}&type={}",
url_encode(query),
url_encode(kind)
);
let response = Request::get(&url).send().await.map_err(|e| e.to_string())?;
check(response).await
}
// ---------------------------------------------------------------------------
// Keys
// ---------------------------------------------------------------------------
pub async fn keys(vault_id: &str) -> Result<KeysPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/keys")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn key(vault_id: &str, key_id: &str) -> Result<KeyDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/keys/{key_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn generate_key(vault_id: &str, payload: &serde_json::Value) -> Result<KeyDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/keys"), Some(payload)).await?;
check(response).await
}
pub async fn update_key(vault_id: &str, key_id: &str, payload: &serde_json::Value) -> Result<KeyDetail, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/keys/{key_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_key(vault_id: &str, key_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/keys/{key_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_key(vault_id: &str, key_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/keys/{key_id}/export"), Some(payload)).await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
pub async fn requests(vault_id: &str) -> Result<RequestsPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/requests")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn request(vault_id: &str, request_id: &str) -> Result<RequestDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/requests/{request_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_request(vault_id: &str, payload: &serde_json::Value) -> Result<RequestDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/requests"), Some(payload)).await?;
check(response).await
}
pub async fn update_request(vault_id: &str, request_id: &str, payload: &serde_json::Value) -> Result<RequestDetail, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/requests/{request_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_request(vault_id: &str, request_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/requests/{request_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_request(vault_id: &str, request_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/requests/{request_id}/export"), Some(payload)).await?;
check(response).await
}
pub async fn transform_request(vault_id: &str, request_id: &str, action: &str) -> Result<serde_json::Value, String> {
let response = send(
"POST",
&format!("/api/vaults/{vault_id}/requests/{request_id}/transform/{action}"),
Some(&json!({})),
)
.await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Certificates
// ---------------------------------------------------------------------------
pub async fn certificates(vault_id: &str) -> Result<CertificatesPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}")).send().await.map_err(|e| e.to_string())?;
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
}
pub async fn update_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<CertificateDetail, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_certificate(vault_id: &str, cert_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/certificates/{cert_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/export"), Some(payload)).await?;
check(response).await
}
pub async fn renew_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<CertificateDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/renew"), Some(payload)).await?;
check(response).await
}
pub async fn revoke_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revoke"), Some(payload)).await?;
check(response).await
}
pub async fn ca_info(vault_id: &str, cert_id: &str) -> Result<serde_json::Value, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn update_ca(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca"), Some(payload)).await?;
check(response).await
}
pub async fn revocations(vault_id: &str, cert_id: &str) -> Result<RevocationsPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn add_revocation(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations"), Some(payload)).await?;
check(response).await
}
pub async fn update_revocation(vault_id: &str, cert_id: &str, rev_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations/{rev_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_revocation(vault_id: &str, cert_id: &str, rev_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations/{rev_id}"), None).await?;
expect_empty(response).await
}
pub async fn generate_crl(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<CrlDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/crl"), Some(payload)).await?;
check(response).await
}
pub async fn tls_auth(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/tls-auth"), Some(payload)).await?;
check(response).await
}
pub async fn transform_certificate(vault_id: &str, cert_id: &str, action: &str) -> Result<serde_json::Value, String> {
let response = send(
"POST",
&format!("/api/vaults/{vault_id}/certificates/{cert_id}/transform/{action}"),
Some(&json!({})),
)
.await?;
check(response).await
}
// ---------------------------------------------------------------------------
// CRLs
// ---------------------------------------------------------------------------
pub async fn crls(vault_id: &str) -> Result<CrlsPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/crls")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn crl(vault_id: &str, crl_id: &str) -> Result<CrlDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/crls/{crl_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn delete_crl(vault_id: &str, crl_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/crls/{crl_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_crl(vault_id: &str, crl_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/crls/{crl_id}/export"), Some(payload)).await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Templates
// ---------------------------------------------------------------------------
pub async fn templates(vault_id: &str) -> Result<TemplatesPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/templates")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn template(vault_id: &str, template_id: &str) -> Result<TemplateSummary, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/templates/{template_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_template(vault_id: &str, payload: &serde_json::Value) -> Result<TemplateSummary, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/templates"), Some(payload)).await?;
check(response).await
}
pub async fn update_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result<TemplateSummary, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/templates/{template_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_template(vault_id: &str, template_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/templates/{template_id}"), None).await?;
expect_empty(response).await
}
pub async fn apply_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/templates/{template_id}/apply"), Some(payload)).await?;
check(response).await
}
pub async fn export_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/templates/{template_id}/export"), Some(payload)).await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
pub async fn import_items(vault_id: &str, payload: &serde_json::Value) -> Result<ImportPreview, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/import"), Some(payload)).await?;
check(response).await
}
pub async fn import_commit(vault_id: &str, payload: &serde_json::Value) -> Result<ImportResult, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/import"), Some(payload)).await?;
check(response).await
}
+56
View File
@@ -0,0 +1,56 @@
//! Login redirect and return-to-page handling.
use gloo_timers::callback::Timeout;
use sycamore_router::navigate_replace;
const RETURN_TO_KEY: &str = "kca.login.return_to";
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.session_storage().ok().flatten()
}
fn current_path() -> String {
match web_sys::window() {
Some(window) => {
let location = window.location();
let path = location.pathname().unwrap_or_default();
let search = location.search().unwrap_or_default();
format!("{path}{search}")
}
None => "/".to_string(),
}
}
fn is_restorable(path: &str) -> bool {
!path.is_empty()
&& path.starts_with('/')
&& !path.starts_with("/api/")
&& !path.starts_with("/auth/")
&& path != "/login"
}
pub fn redirect_to_login() {
let path = current_path();
if is_restorable(&path) {
if let Some(storage) = storage() {
let _ = storage.set_item(RETURN_TO_KEY, &path);
}
}
Timeout::new(0, move || {
if current_path() != "/login" {
navigate_replace("/login");
}
})
.forget();
}
pub fn saved_return_to() -> Option<String> {
storage()?.get_item(RETURN_TO_KEY).ok().flatten()
}
pub fn clear_return_to() {
if let Some(storage) = storage() {
let _ = storage.remove_item(RETURN_TO_KEY);
}
}
pub const LOGOUT_URL: &str = "/auth/logout";
+3
View File
@@ -0,0 +1,3 @@
pub mod nav;
pub mod toast;
pub mod ui;
+88
View File
@@ -0,0 +1,88 @@
//! Navigation bar and user slot.
use sycamore::prelude::*;
use crate::auth::LOGOUT_URL;
use crate::model::Me;
pub use super::ui::spinner;
fn nav_link(href: String, label: &'static str, active: &'static str) -> View {
let class = if href == active { "nav-link active" } else { "nav-link" };
view! {
li(class="nav-item") {
a(class=class, href=href) { (label) }
}
}
}
pub fn app_nav(active: &'static str, vault_id: Option<&'static str>, whoami: impl Fn() -> View + 'static) -> View {
let open = create_signal(false);
let toggle = move |_| open.set(!open.get());
let collapse_class = move || {
if open.get() {
"collapse navbar-collapse show"
} else {
"collapse navbar-collapse"
}
};
let expanded = move || if open.get() { "true" } else { "false" };
let vault_links = move || match vault_id {
Some(id) => view! {
(nav_link(format!("/vaults/{id}"), "Overview", active))
(nav_link(format!("/vaults/{id}/certificates"), "Certificates", active))
(nav_link(format!("/vaults/{id}/requests"), "Requests", active))
(nav_link(format!("/vaults/{id}/keys"), "Keys", active))
(nav_link(format!("/vaults/{id}/crls"), "CRLs", active))
(nav_link(format!("/vaults/{id}/templates"), "Templates", active))
(nav_link(format!("/vaults/{id}/search"), "Search", active))
},
None => view! {
(nav_link("/".to_string(), "Vaults", active))
},
};
view! {
nav(class="navbar navbar-expand-lg") {
div(class="container-fluid") {
a(class="navbar-brand", href="/") {
span(class="brand-glyph") { "⬡" } " KCA"
}
button(class="navbar-toggler", r#type="button",
aria-label="Toggle navigation",
aria-controls="app-nav",
aria-expanded=expanded,
on:click=toggle) {
span(class="navbar-toggler-icon") {}
}
div(id="app-nav", class=collapse_class) {
ul(class="navbar-nav me-auto") {
(vault_links)
}
(whoami())
}
}
}
}
}
pub fn whoami(me: &Me) -> View {
let name = me.display_name.clone();
let initial = name.chars().next().unwrap_or('?').to_string();
let picture = me.picture.clone();
view! {
span(class="navbar-text d-flex align-items-center gap-3") {
span(class="d-flex align-items-center gap-2") {
(if let Some(picture) = picture {
view! { img(class="avatar", src=picture, alt="") }
} else {
view! {
span(class="avatar bg-secondary d-inline-flex align-items-center justify-content-center") {
(initial)
}
}
})
span() { (name) }
}
a(class="btn btn-sm btn-outline-light", href=LOGOUT_URL, rel="external") { "Sign out" }
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! A transient error/success toast.
use gloo_timers::callback::Timeout;
use sycamore::prelude::*;
const TOAST_MS: u32 = 10_000;
pub fn toast(message: Signal<Option<String>>, success: bool) -> View {
create_effect(move || {
if message.get_clone().is_some() {
let timeout = Timeout::new(TOAST_MS, move || message.set(None));
on_cleanup(move || drop(timeout));
}
});
let class = if success { "app-toast success" } else { "app-toast" };
view! {
(move || message.get_clone().map(|text| view! {
div(class=class, role="alert") { (text) }
}))
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Shared small UI fragments.
use sycamore::prelude::*;
pub fn toast_error(message: Signal<Option<String>>) -> View {
crate::components::toast::toast(message, false)
}
pub fn spinner() -> View {
view! {
div(class="d-flex justify-content-center p-5") {
div(class="spinner-border text-warning", role="status") {
span(class="visually-hidden") { "Loading…" }
}
}
}
}
pub fn empty_state(message: impl Into<String>) -> View {
let message = message.into();
view! { p(class="empty-state") { (message) } }
}
pub fn page_header(title: impl Into<String>, subtitle: impl Into<String>, actions: View) -> View {
let title = title.into();
let subtitle = subtitle.into();
view! {
div(class="d-flex justify-content-between align-items-end flex-wrap gap-2 mb-4") {
div() {
h1(class="h3 mb-1") { (title) }
p(class="text-secondary mb-0") { (subtitle) }
}
div(class="d-flex gap-2 flex-wrap") { (actions) }
}
}
}
pub fn error_alert(message: Signal<Option<String>>) -> View {
view! {
(if message.get_clone().is_some() {
view! {
div(class="alert alert-danger py-2 mb-3") { (message.get_clone().unwrap_or_default()) }
}
} else {
view! {}
})
}
}
pub fn status_badge(label: impl Into<String>, class: impl Into<String>) -> View {
let label = label.into();
let class = class.into();
view! { span(class=format!("badge {class}")) { (label) } }
}
pub fn copy_button(text: String, label: &'static str) -> View {
let feedback = create_signal(None::<String>);
let click = move |_| {
let value = text.clone();
let feedback = feedback;
wasm_bindgen_futures::spawn_local(async move {
match crate::dom::copy_text(&value).await {
Ok(()) => feedback.set(Some("Copied".to_string())),
Err(error) => feedback.set(Some(error)),
}
});
};
view! {
div(class="d-inline-flex align-items-center gap-2") {
button(class="btn btn-sm btn-outline-light", r#type="button", on:click=click) { (label) }
(if let Some(text) = feedback.get_clone() {
view! { span(class="text-secondary small") { (text) } }
} else {
view! {}
})
}
}
}
pub fn kv_row(label: impl Into<String>, value: View) -> View {
let label = label.into();
view! {
div(class="row g-1 py-1 border-bottom border-secondary-subtle") {
div(class="col-sm-4 text-secondary small") { (label) }
div(class="col-sm-8") { (value) }
}
}
}
pub fn mono(text: String) -> View {
view! { span(class="mono fingerprint") { (text) } }
}
+119
View File
@@ -0,0 +1,119 @@
//! Small DOM helpers shared by the pages.
use base64::Engine;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
pub fn input_value(event: &web_sys::Event) -> String {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|input| input.value())
.unwrap_or_default()
}
pub fn input_checked(event: &web_sys::Event) -> bool {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|input| input.checked())
.unwrap_or(false)
}
pub fn textarea_value(event: &web_sys::Event) -> String {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlTextAreaElement>().ok())
.map(|input| input.value())
.unwrap_or_default()
}
pub fn select_value(event: &web_sys::Event) -> String {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|select| select.value())
.unwrap_or_default()
}
pub fn input_file(event: &web_sys::Event) -> Option<web_sys::File> {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
.and_then(|input| input.files())
.and_then(|files| files.get(0))
}
pub fn reset_file_input(event: &web_sys::Event) {
if let Some(input) = event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
{
input.set_value("");
}
}
pub async fn read_file(file: &web_sys::File) -> Result<Vec<u8>, String> {
let promise = file.array_buffer();
let buffer = JsFuture::from(promise)
.await
.map_err(|_| "the selected file could not be read".to_string())?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}
pub async fn read_file_base64(file: &web_sys::File) -> Result<String, String> {
let bytes = read_file(file).await?;
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
}
pub fn confirm(message: &str) -> bool {
web_sys::window()
.map(|window| window.confirm_with_message(message).unwrap_or(false))
.unwrap_or(false)
}
pub async fn copy_text(text: &str) -> Result<(), String> {
let window = web_sys::window().ok_or("no browser window")?;
let promise = window.navigator().clipboard().write_text(text);
JsFuture::from(promise)
.await
.map(|_| ())
.map_err(|_| "the browser blocked clipboard access".to_string())
}
/// Decode an API export payload and trigger a browser download.
pub fn download_base64(filename: &str, content_type: &str, content_base64: &str) -> Result<(), String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(content_base64)
.map_err(|_| "the exported file is not valid base64".to_string())?;
download_bytes(filename, content_type, &bytes)
}
pub fn download_bytes(filename: &str, content_type: &str, bytes: &[u8]) -> Result<(), String> {
let window = web_sys::window().ok_or("no browser window")?;
let document = window.document().ok_or("no document")?;
let array = js_sys::Uint8Array::from(bytes);
let parts = js_sys::Array::new();
parts.push(&array.buffer());
let options = web_sys::BlobPropertyBag::new();
options.set_type(content_type);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
.map_err(|_| "could not create the download".to_string())?;
let url = web_sys::Url::create_object_url_with_blob(&blob)
.map_err(|_| "could not create the download URL".to_string())?;
let anchor = document
.create_element("a")
.map_err(|_| "could not create the download link".to_string())?
.dyn_into::<web_sys::HtmlAnchorElement>()
.map_err(|_| "could not create the download link".to_string())?;
anchor.set_href(&url);
anchor.set_download(filename);
anchor.click();
web_sys::Url::revoke_object_url(&url).ok();
Ok(())
}
pub fn navigate(path: &str) {
if let Some(window) = web_sys::window() {
let _ = window.location().set_href(path);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! KCA: a web reimplementation of XCA's software-key PKI workflows.
mod api;
mod auth;
mod components;
mod dom;
mod model;
mod pages;
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
#[derive(Route, Clone)]
enum AppRoutes {
#[to("/")]
Dashboard,
#[to("/login")]
Login,
#[to("/tokens")]
Tokens,
#[to("/vaults/<id>/certificates/new")]
CertificateNew { id: String },
#[to("/vaults/<id>/certificates/<cert_id>/renew")]
CertificateRenew { id: String, cert_id: String },
#[to("/vaults/<id>/certificates/<cert_id>")]
CertificateDetail { id: String, cert_id: String },
#[to("/vaults/<id>/certificates")]
Certificates { id: String },
#[to("/vaults/<id>/requests/new")]
RequestNew { id: String },
#[to("/vaults/<id>/requests/<request_id>")]
RequestDetail { id: String, request_id: String },
#[to("/vaults/<id>/requests")]
Requests { id: String },
#[to("/vaults/<id>/keys/new")]
KeyNew { id: String },
#[to("/vaults/<id>/keys/<key_id>")]
KeyDetail { id: String, key_id: String },
#[to("/vaults/<id>/keys")]
Keys { id: String },
#[to("/vaults/<id>/crls/<crl_id>")]
CrlDetail { id: String, crl_id: String },
#[to("/vaults/<id>/crls")]
Crls { id: String },
#[to("/vaults/<id>/templates/new")]
TemplateNew { id: String },
#[to("/vaults/<id>/templates/<template_id>")]
TemplateDetail { id: String, template_id: String },
#[to("/vaults/<id>/templates")]
Templates { id: String },
#[to("/vaults/<id>/import")]
Import { id: String },
#[to("/vaults/<id>/search")]
Search { id: String },
#[to("/vaults/<id>/settings")]
Settings { id: String },
#[to("/vaults/<id>")]
VaultOverview { id: String },
#[not_found]
NotFound,
}
fn main() {
console_error_panic_hook::set_once();
sycamore::render(|| {
view! {
Router(
integration=HistoryIntegration::new(),
view=|route: ReadSignal<AppRoutes>| {
view! {
div(class="app") {
(match route.get_clone() {
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::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())) },
AppRoutes::RequestNew { id } => view! { (pages::request_new::RequestNewPage(id.clone())) },
AppRoutes::RequestDetail { id, request_id } => view! { (pages::request_detail::RequestDetailPage(id.clone(), request_id.clone())) },
AppRoutes::Requests { id } => view! { (pages::requests::RequestsPage(id.clone())) },
AppRoutes::KeyNew { id } => view! { (pages::key_new::KeyNewPage(id.clone())) },
AppRoutes::KeyDetail { id, key_id } => view! { (pages::key_detail::KeyDetailPage(id.clone(), key_id.clone())) },
AppRoutes::Keys { id } => view! { (pages::keys::KeysPage(id.clone())) },
AppRoutes::CrlDetail { id, crl_id } => view! { (pages::crl_detail::CrlDetailPage(id.clone(), crl_id.clone())) },
AppRoutes::Crls { id } => view! { (pages::crls::CrlsPage(id.clone())) },
AppRoutes::TemplateNew { id } => view! { (pages::template_editor::TemplateEditorPage(id.clone(), None)) },
AppRoutes::TemplateDetail { id, template_id } => view! { (pages::template_editor::TemplateEditorPage(id.clone(), Some(template_id.clone()))) },
AppRoutes::Templates { id } => view! { (pages::templates::TemplatesPage(id.clone())) },
AppRoutes::Import { id } => view! { (pages::import::ImportPage(id.clone())) },
AppRoutes::Search { id } => view! { (pages::search::SearchPage(id.clone())) },
AppRoutes::Settings { id } => view! { (pages::settings::SettingsPage(id.clone())) },
AppRoutes::VaultOverview { id } => view! { (pages::vault::VaultPage(id.clone())) },
AppRoutes::NotFound => view! { pages::not_found::NotFoundPage() },
})
}
}
}
)
}
});
restore_page_after_login();
}
fn restore_page_after_login() {
let return_to = auth::saved_return_to();
let Some(path) = return_to else { return };
if path == "/login" {
auth::clear_return_to();
return;
}
wasm_bindgen_futures::spawn_local(async move {
if let Ok(Some(_)) = api::me_optional().await {
auth::clear_return_to();
sycamore_router::navigate(&path);
}
});
}
+738
View File
@@ -0,0 +1,738 @@
//! JSON models mirroring the KCA API and small display helpers.
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct Me {
pub sub: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub picture: Option<String>,
#[serde(default)]
pub master_key_is_dev: bool,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub preferred_username: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AuthProvider {
pub name: String,
pub login_url: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct AuthProvidersPage {
#[serde(default)]
pub providers: Vec<AuthProvider>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ApiToken {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub last_used_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ApiTokensPage {
#[serde(default)]
pub results: Vec<ApiToken>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CreatedApiToken {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub last_used_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub token: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultCounts {
#[serde(default)]
pub keys: i64,
#[serde(default)]
pub certificates: i64,
#[serde(default)]
pub requests: i64,
#[serde(default)]
pub crls: i64,
#[serde(default)]
pub templates: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub counts: VaultCounts,
#[serde(default)]
pub expiring_soon: i64,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultDetail {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub counts: VaultCounts,
#[serde(default)]
pub expiring_soon: i64,
#[serde(default)]
pub settings: Value,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultsPage {
#[serde(default)]
pub results: Vec<VaultSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeySummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub key_type: String,
#[serde(default)]
pub key_size: Option<i64>,
#[serde(default)]
pub curve: Option<String>,
#[serde(default)]
pub has_private: bool,
#[serde(default)]
pub fingerprint_sha256: String,
#[serde(default)]
pub use_count: i64,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeyDetail {
#[serde(flatten)]
pub summary: KeySummary,
#[serde(default)]
pub public_pem: String,
#[serde(default)]
pub possible_hashes: Vec<String>,
#[serde(default)]
pub can_export_private: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeysPage {
#[serde(default)]
pub results: Vec<KeySummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CertificateSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub subject: String,
#[serde(default)]
pub issuer_id: Option<String>,
#[serde(default)]
pub issuer_name: String,
#[serde(default)]
pub serial_hex: String,
#[serde(default)]
pub not_before: Option<String>,
#[serde(default)]
pub not_after: Option<String>,
#[serde(default)]
pub is_ca: bool,
#[serde(default)]
pub path_length: Option<i64>,
#[serde(default)]
pub has_private_key: bool,
#[serde(default)]
pub revoked: bool,
#[serde(default)]
pub revoked_at: Option<String>,
#[serde(default)]
pub fingerprint_sha256: String,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CertificateDetail {
#[serde(flatten)]
pub summary: CertificateSummary,
#[serde(default)]
pub subject_entries: Vec<DnEntry>,
#[serde(default)]
pub issuer_entries: Vec<DnEntry>,
#[serde(default)]
pub subject_rfc4514: String,
#[serde(default)]
pub issuer_rfc4514: String,
#[serde(default)]
pub subject_der_hex: String,
#[serde(default)]
pub issuer_der_hex: String,
#[serde(default)]
pub signature_algorithm: String,
#[serde(default)]
pub fingerprint_md5: String,
#[serde(default)]
pub fingerprint_sha1: String,
#[serde(default)]
pub public_key_der_hex: String,
#[serde(default)]
pub extensions: Vec<ExtensionRow>,
#[serde(default)]
pub extension_spec: Value,
#[serde(default)]
pub openssl_config: String,
#[serde(default)]
pub validation_errors: Vec<ValidationError>,
#[serde(default)]
pub purposes: Vec<String>,
#[serde(default)]
pub chain: Vec<ChainItem>,
#[serde(default)]
pub revocation: Option<Revocation>,
#[serde(default)]
pub key_name: Option<String>,
#[serde(default)]
pub key_type: Option<String>,
#[serde(default)]
pub key_size: Option<i64>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub request_name: Option<String>,
#[serde(default)]
pub can_sign: bool,
#[serde(default)]
pub authority: Option<AuthorityInfo>,
#[serde(default)]
pub cert_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct DnEntry {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ExtensionRow {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub critical: bool,
#[serde(default)]
pub value: String,
#[serde(default)]
pub der_hex: String,
#[serde(default)]
pub spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ValidationError {
#[serde(default)]
pub code: Option<i64>,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ChainItem {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub subject: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct AuthorityInfo {
#[serde(default)]
pub crl_expire: Option<String>,
#[serde(default)]
pub crl_number: Option<i64>,
#[serde(default)]
pub crl_days: Option<i64>,
#[serde(default)]
pub default_template_id: Option<String>,
#[serde(default)]
pub has_tls_auth_key: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CertificatesPage {
#[serde(default)]
pub results: Vec<CertificateSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RequestSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub subject: String,
#[serde(default)]
pub signed: bool,
#[serde(default)]
pub signed_at: Option<String>,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub fingerprint_sha256: String,
#[serde(default)]
pub certificate_count: i64,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RequestDetail {
#[serde(flatten)]
pub summary: RequestSummary,
#[serde(default)]
pub subject_entries: Vec<DnEntry>,
#[serde(default)]
pub subject_rfc4514: String,
#[serde(default)]
pub signature_algorithm: String,
#[serde(default)]
pub signature_valid: bool,
#[serde(default)]
pub attributes: Vec<AttributeRow>,
#[serde(default)]
pub extensions: Vec<ExtensionRow>,
#[serde(default)]
pub extension_spec: Value,
#[serde(default)]
pub openssl_config: String,
#[serde(default)]
pub csr_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct AttributeRow {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RequestsPage {
#[serde(default)]
pub results: Vec<RequestSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub issuer_id: Option<String>,
#[serde(default)]
pub issuer_name: String,
#[serde(default)]
pub crl_number: Option<i64>,
#[serde(default)]
pub last_update: Option<String>,
#[serde(default)]
pub next_update: Option<String>,
#[serde(default)]
pub revoked_count: i64,
#[serde(default)]
pub signature_algorithm: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlDetail {
#[serde(flatten)]
pub summary: CrlSummary,
#[serde(default)]
pub issuer_entries: Vec<DnEntry>,
#[serde(default)]
pub issuer_rfc4514: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub extensions: Vec<ExtensionRow>,
#[serde(default)]
pub revocations: Vec<CrlRevocation>,
#[serde(default)]
pub crl_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlRevocation {
#[serde(default)]
pub serial_hex: String,
#[serde(default)]
pub revoked_at: String,
#[serde(default)]
pub invalidity_date: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlsPage {
#[serde(default)]
pub results: Vec<CrlSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct TemplateSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub subject: Value,
#[serde(default)]
pub settings: Value,
#[serde(default)]
pub builtin: bool,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct TemplatesPage {
#[serde(default)]
pub results: Vec<TemplateSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct Revocation {
pub id: String,
#[serde(default)]
pub ca_id: String,
#[serde(default)]
pub serial_hex: String,
#[serde(default)]
pub revoked_at: Option<String>,
#[serde(default)]
pub invalidity_date: Option<String>,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub crl_number: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RevocationsPage {
#[serde(default)]
pub results: Vec<Revocation>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct SearchResult {
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub item: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct SearchPage {
#[serde(default)]
pub results: Vec<SearchResult>,
#[serde(default)]
pub counts: HashMap<String, i64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ImportItem {
#[serde(default)]
pub index: i64,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub is_private: bool,
#[serde(default)]
pub duplicate: bool,
#[serde(default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ImportPreview {
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub items: Vec<ImportItem>,
#[serde(default)]
pub count: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ImportResult {
#[serde(default)]
pub imported: Vec<ImportItem>,
#[serde(default)]
pub skipped: Vec<ImportItem>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ExportPayload {
#[serde(default)]
pub filename: String,
#[serde(default)]
pub content_type: String,
#[serde(default)]
pub content_base64: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct SummaryPage {
#[serde(default)]
pub summary: VaultSummary,
#[serde(default)]
pub expiring: Vec<CertificateSummary>,
#[serde(default)]
pub crls_due: Vec<CrlSummary>,
#[serde(default)]
pub recent_certificates: Vec<CertificateSummary>,
#[serde(default)]
pub recent_keys: Vec<KeySummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct OptionsPage {
#[serde(default)]
pub key_types: Vec<Value>,
#[serde(default)]
pub hash_algorithms: Vec<String>,
#[serde(default)]
pub curves: Vec<String>,
#[serde(default)]
pub dn_attributes: Vec<DnAttribute>,
#[serde(default)]
pub eku: Vec<DnAttribute>,
#[serde(default)]
pub key_usages: Vec<NamedOption>,
#[serde(default)]
pub revocation_reasons: Vec<NamedOption>,
#[serde(default)]
pub defaults: Value,
#[serde(default)]
pub names: Value,
#[serde(default)]
pub templates: Vec<TemplateSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct DnAttribute {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub long_name: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct NamedOption {
#[serde(default)]
pub name: String,
#[serde(default)]
pub label: String,
}
impl std::ops::Deref for KeyDetail {
type Target = KeySummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
impl std::ops::Deref for CertificateDetail {
type Target = CertificateSummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
impl std::ops::Deref for RequestDetail {
type Target = RequestSummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
impl std::ops::Deref for CrlDetail {
type Target = CrlSummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
pub fn fmt_date(value: &Option<String>) -> String {
match value {
Some(text) if !text.is_empty() => {
// Keep the date and time to the minute; certificates are not
// sub-second artifacts and the full ISO string is noisy.
let cleaned = text.replace('T', " ");
let trimmed = cleaned.trim_end_matches('Z');
let mut parts = trimmed.splitn(2, '.');
let base = parts.next().unwrap_or(trimmed);
base.chars().take(19).collect()
}
_ => "—".to_string(),
}
}
pub fn fmt_fp(value: &str) -> String {
if value.is_empty() {
"—".to_string()
} else if value.len() > 24 {
format!("{}…{}", &value[..16], &value[value.len() - 8..])
} else {
value.to_string()
}
}
pub fn status_badge(cert: &CertificateSummary) -> (&'static str, &'static str) {
if cert.revoked {
("badge-danger", "revoked")
} else if let Some(after) = &cert.not_after {
if after < &now_iso() {
("badge-danger", "expired")
} else {
("badge-soft", "valid")
}
} else {
("badge-soft", "valid")
}
}
fn now_iso() -> String {
// The browser's clock is enough for a visual badge; the server remains
// authoritative for validation and revocation.
js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default()
}
pub fn item_total(counts: &VaultCounts) -> i64 {
counts.keys + counts.certificates + counts.requests + counts.crls + counts.templates
}
pub fn json_str(value: &Value, key: &str) -> String {
value.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string()
}
pub fn json_bool(value: &Value, key: &str) -> bool {
value.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
}
pub fn json_array(value: &Value, key: &str) -> Vec<Value> {
value
.get(key)
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn formats_fingerprints() {
assert_eq!(fmt_fp(""), "—");
assert_eq!(fmt_fp("abcdefghijklmnopqrstuvwxyz"), "abcdefghijklmnop…stuvwxyz");
}
#[test]
fn formats_dates() {
assert_eq!(fmt_date(&None), "—");
assert_eq!(
fmt_date(&Some("2026-10-23T01:49:02.123Z".to_string())),
"2026-10-23 01:49:02"
);
}
#[test]
fn reads_json_values() {
let value = json!({"name": "CA", "ca": true, "items": [1, 2]});
assert_eq!(json_str(&value, "name"), "CA");
assert!(json_bool(&value, "ca"));
assert_eq!(json_array(&value, "items").len(), 2);
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Certificate detail, validation and CA actions.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
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};
pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let cert = create_signal(None::<CertificateDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let export_format = create_signal("pem".to_string());
let export_password = create_signal(String::new());
let revocations = create_signal(Vec::<Revocation>::new());
let subject_entries = create_signal(Vec::<crate::model::DnEntry>::new());
let issuer_entries = create_signal(Vec::<crate::model::DnEntry>::new());
let extensions = create_signal(Vec::<crate::model::ExtensionRow>::new());
let validation_errors = create_signal(Vec::<crate::model::ValidationError>::new());
let purposes = create_signal(Vec::<String>::new());
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 ca_days = create_signal("30".to_string());
let crl_days = create_signal("30".to_string());
let crl_hash = create_signal("sha256".to_string());
let tls_auth_text = create_signal(String::new());
let new_name = create_signal(String::new());
let new_comment = create_signal(String::new());
let load = move || {
spawn_local(async move {
match api::certificate(id, cert_id_value).await {
Ok(value) => {
new_name.set(value.summary.name.clone());
new_comment.set(value.summary.comment.clone());
if let Some(days) = value.authority.as_ref().and_then(|a| a.crl_days) {
ca_days.set(days.to_string());
crl_days.set(days.to_string());
}
subject_entries.set(value.subject_entries.clone());
issuer_entries.set(value.issuer_entries.clone());
extensions.set(value.extensions.clone());
validation_errors.set(value.validation_errors.clone());
purposes.set(value.purposes.clone());
chain.set(value.chain.clone());
cert.set(Some(value));
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
}
Err(message) => error.set(Some(message)),
}
});
};
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
load();
let do_export = move |_| {
let mut payload = json!({"format": export_format.get_clone()});
if !export_password.get_clone().is_empty() {
payload["password"] = json!(export_password.get_clone());
}
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) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let revoke = move |_| {
let payload = json!({"reason": rev_reason.get_clone()});
spawn_local(async move {
match api::revoke_certificate(id, cert_id_value, &payload).await {
Ok(_) => load(),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this certificate?") {
return;
}
spawn_local(async move {
match api::delete_certificate(id, cert_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/certificates")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let save_meta = move |_| {
let value = cert.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_certificate(id, cert_id_value, &payload).await {
Ok(updated) => cert.set(Some(updated)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let transform = move |action: &'static str| {
spawn_local(async move {
match api::transform_certificate(id, cert_id_value, action).await {
Ok(value) => {
if let Some(item_id) = value.get("id").and_then(|v| v.as_str()) {
let route = match action {
"open-key" => "keys",
"request" => "requests",
"template" => "templates",
_ => "certificates",
};
sycamore_router::navigate(&format!("/vaults/{id}/{route}/{item_id}"));
}
}
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),
"signature_hash": crl_hash.get_clone(),
"with_reason": true,
"include_aki": true,
});
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)),
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()});
spawn_local(async move {
match api::add_revocation(id, cert_id_value, &payload).await {
Ok(_) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
rev_serial.set(String::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let delete_revocation = move |rev_id: String| {
spawn_local(async move {
match api::delete_revocation(id, cert_id_value, &rev_id).await {
Ok(()) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let make_tls_auth = move |_| {
spawn_local(async move {
match api::tls_auth(id, cert_id_value, &json!({})).await {
Ok(value) => {
if let Some(key) = value.get("key").and_then(|v| v.as_str()) {
tls_auth_text.set(key.to_string());
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = cert.get_clone() else {
return empty_state("Certificate not found.");
};
let summary = value.summary.clone();
let is_ca = summary.is_ca;
let header_serial = summary.serial_hex.clone();
view! {
(page_header(
summary.name.clone(),
format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" }
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" }
(if !summary.revoked && !is_ca {
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } }
} else { view! {} })
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
},
))
div(class="card mb-3") {
div(class="card-body") {
div(class="row g-2") {
div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } }
div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
}
}
}
div(class="row g-3 mb-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Subject" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
}
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Issuer" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
}
}
div(class="card mb-3") {
div(class="card-header d-flex justify-content-between align-items-center") {
span() { "Extensions" }
(copy_button(value.openssl_config.clone(), "Copy OpenSSL config"))
}
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) }
}
}
}
div(class="card mb-3") {
div(class="card-header") { "Validation" }
div(class="card-body") {
(if validation_errors.get_clone().is_empty() {
view! { div(class="alert alert-success mb-2") { "No verification errors found." } }
} else {
view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } }
})
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
}
}
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card mb-3") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=export_format) {
option(value="pem") { "PEM" }
option(value="pem-chain") { "PEM chain" }
option(value="pem-key") { "PEM + key" }
option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" }
option(value="pkcs7") { "PKCS#7" }
option(value="pkcs7-chain") { "PKCS#7 chain" }
option(value="der") { "DER" }
option(value="pkcs12") { "PKCS#12" }
option(value="pkcs12-chain") { "PKCS#12 chain" }
option(value="openvpn") { "OpenVPN" }
option(value="openssl-config") { "OpenSSL config" }
option(value="index") { "Certificate index" }
option(value="vcalendar") { "vCalendar" }
option(value="jwk") { "JWK" }
option(value="jwk-chain") { "JWK chain" }
option(value="tls-auth") { "OpenVPN tls-auth key" }
}
div(class="mb-2") {
label(class="form-label") { "Password (encrypted formats)" }
input(class="form-control", r#type="password", bind:value=export_password)
}
button(class="btn btn-primary", on:click=do_export) { "Download" }
}
}
div(class="card") {
div(class="card-header") { "Metadata" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=new_name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=new_comment) {}
}
button(class="btn btn-primary", on:click=save_meta) { "Save" }
}
}
}
div(class="col-12 col-lg-6") {
div(class="card mb-3") {
div(class="card-header") { "Revoke" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) }
button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" }
}
}
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" }
button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" }
button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" }
}
}
}
}
(if is_ca {
view! {
div(class="card mt-3") {
div(class="card-header") { "CA operations" }
div(class="card-body") {
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CRL days" }
input(class="form-control", bind:value=crl_days)
}
div(class="col-md-3") {
label(class="form-label") { "Hash" }
select(class="form-select", bind:value=crl_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
}
}
div(class="col-md-3") {
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
}
div(class="col-md-3") {
button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" }
}
}
(if tls_auth_text.get_clone().is_empty() {
view! {}
} else {
view! { pre(class="pem") { (tls_auth_text.get_clone()) } }
})
h3(class="h6 mt-3") { "Revocations" }
div(class="row g-2 align-items-end mb-2") {
div(class="col-md-4") {
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
}
div(class="col-md-4") {
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" }
}
}
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" } } } } }) }
}
}
}
}
} else {
view! {}
})
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn reason_options() -> Vec<View> {
["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}
+727
View File
@@ -0,0 +1,727 @@
//! Certificate creation wizard and renewal form.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
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 {
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());
let signers = create_signal(Vec::<CertificateSummary>::new());
let templates = create_signal(Vec::<TemplateSummary>::new());
let error = create_signal(None::<String>);
let saving = create_signal(false);
let request_id = query_param("request").unwrap_or_default();
let request_mode = !request_id.is_empty();
let request_id_for_load = request_id.clone();
let name = create_signal(String::new());
let comment = create_signal(String::new());
let signer_id = create_signal("self".to_string());
let key_id = create_signal(String::new());
let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string());
let template_id = create_signal(String::new());
let signature_hash = create_signal("sha256".to_string());
let cn = create_signal(String::new());
let country = create_signal(String::new());
let state = create_signal(String::new());
let locality = create_signal(String::new());
let organization = create_signal(String::new());
let org_unit = create_signal(String::new());
let email = create_signal(String::new());
let ca_mode = create_signal("end".to_string());
let path_length = create_signal(String::new());
let bc_critical = create_signal(true);
let ku_digital = create_signal(true);
let ku_encipher = create_signal(false);
let ku_key_agreement = create_signal(false);
let ku_cert_sign = create_signal(false);
let ku_crl_sign = create_signal(false);
let ku_critical = create_signal(true);
let eku_server = create_signal(false);
let eku_client = create_signal(false);
let eku_code = create_signal(false);
let eku_email = create_signal(false);
let eku_time = create_signal(false);
let eku_ocsp = create_signal(false);
let eku_critical = create_signal(false);
let san_text = create_signal(String::new());
let ski = create_signal(true);
let aki = create_signal(true);
let ocsp_staple = create_signal(false);
let validity_amount = create_signal("365".to_string());
let validity_unit = create_signal("days".to_string());
let midnight = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
me.set(Some(user));
}
}
if let Ok(page) = api::keys(&id).await {
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());
}
if let Ok(page) = api::templates(&id).await {
templates.set(page.results);
}
if request_mode {
if let Ok(value) = api::request(&id, &request_id_for_load).await {
name.set(format!("{} certificate", value.summary.name));
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "CN") {
cn.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "O") {
organization.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "OU") {
org_unit.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "C") {
country.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "ST") {
state.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "L") {
locality.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "emailAddress") {
email.set(first.value.clone());
}
}
}
});
let apply_template = {
let id = id.clone();
move |_| {
let template = template_id.get_clone();
if template.is_empty() {
return;
}
let id = id.clone();
spawn_local(async move {
match api::template(&id, &template).await {
Ok(template) => {
if let Some(settings) = template.settings.get("extensions") {
if let Some(bc) = settings.get("basic_constraints") {
if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) {
ca_mode.set("ca".to_string());
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
path_length.set(len.to_string());
}
} else if bc.get("ca").is_some() {
ca_mode.set("end".to_string());
}
bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(ku) = settings.get("key_usage") {
let usages = string_array(ku.get("usages"));
ku_digital.set(usages.iter().any(|s| s == "digital_signature"));
ku_encipher.set(usages.iter().any(|s| s == "key_encipherment"));
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(eku) = settings.get("extended_key_usage") {
let usages = string_array(eku.get("usages"));
eku_server.set(usages.iter().any(|s| s == "serverAuth"));
eku_client.set(usages.iter().any(|s| s == "clientAuth"));
eku_code.set(usages.iter().any(|s| s == "codeSigning"));
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
eku_time.set(usages.iter().any(|s| s == "timeStamping"));
eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning"));
eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false));
}
if let Some(san) = settings.get("subject_alt_name") {
let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let text = entries.iter().filter_map(|entry| {
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) }
}).collect::<Vec<_>>().join(", ");
san_text.set(text);
}
ski.set(settings.get("subject_key_identifier").is_some());
aki.set(settings.get("authority_key_identifier").is_some());
}
if let Some(subject) = template.subject.as_array() {
for entry in subject {
let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
match key {
"CN" => cn.set(value),
"O" => organization.set(value),
"OU" => org_unit.set(value),
"C" => country.set(value),
"ST" => state.set(value),
"L" => locality.set(value),
"emailAddress" => email.set(value),
_ => {}
}
}
}
if let Some(validity) = template.settings.get("validity") {
if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) {
validity_amount.set(amount.to_string());
}
if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) {
validity_unit.set(unit.to_string());
}
midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
}
};
let submit = {
let id = id.clone();
let request_id = request_id.clone();
move |_| {
let cert_name = name.get_clone();
if cert_name.trim().is_empty() {
error.set(Some("certificate name is required".to_string()));
return;
}
let subject = build_subject(&[
("C", country.get_clone()),
("ST", state.get_clone()),
("L", locality.get_clone()),
("O", organization.get_clone()),
("OU", org_unit.get_clone()),
("CN", cn.get_clone()),
("emailAddress", email.get_clone()),
]);
if subject.is_empty() {
error.set(Some("the subject must not be empty".to_string()));
return;
}
let mut extensions = json!({});
let mode = ca_mode.get_clone();
let crit = bc_critical.get();
if mode == "ca" {
extensions["basic_constraints"] = json!({
"critical": crit,
"ca": true,
"path_length": path_length.get_clone().parse::<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"]});
}
let signer = signer_id.get_clone();
let mut payload = json!({
"name": cert_name,
"comment": comment.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": {
"amount": validity_amount.get_clone().parse::<i64>().unwrap_or(365),
"unit": validity_unit.get_clone(),
"midnight": midnight.get(),
},
});
if !request_id.is_empty() {
payload["request_id"] = json!(request_id);
payload["modify_subject"] = json!(true);
payload["copy_request_extensions"] = json!(false);
} else if new_key.get() {
payload["new_key"] = json!({
"key_type": key_type.get_clone(),
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
"name": format!("{} key", name.get_clone()),
});
} else if !key_id.get_clone().is_empty() {
payload["key_id"] = json!(key_id.get_clone());
} else {
error.set(Some("select a key or generate a new one".to_string()));
return;
}
saving.set(true);
let id = id.clone();
spawn_local(async move {
match api::create_certificate(&id, &payload).await {
Ok(cert) => {
saving.set(false);
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); }
});
}
};
let signer_options = move || {
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>>()
};
let template_options = move || {
templates.get_clone().into_iter().map(|template| view! {
option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) }
}).collect::<Vec<View>>()
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(page_header(
if request_mode { "Sign certificate request" } else { "New certificate" },
"Certificates can be self-signed or issued by a CA with a private key in this vault.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Cancel" } },
))
(if request_mode {
view! { div(class="alert alert-info") { "The subject and public key come from the selected request; the fields below override the subject." } }
} else {
view! {}
})
div(class="card") {
div(class="card-body") {
div(class="form-section") {
div(class="form-section-title") { "Source" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Issuer" }
select(class="form-select", bind:value=signer_id) {
option(value="self") { "Self-signed" }
(signer_options())
}
}
div(class="col-md-6") {
label(class="form-label") { "Signature hash" }
select(class="form-select", bind:value=signature_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
option(value="sha1") { "SHA-1" }
}
}
div(class="col-md-6") {
label(class="form-label") { "Template" }
div(class="input-group") {
select(class="form-select", bind:value=template_id) {
option(value="") { "— no template —" }
(template_options())
}
button(class="btn btn-outline-light", on:click=apply_template) { "Apply" }
}
}
div(class="col-md-6") {
label(class="form-label") { "Internal name" }
input(class="form-control", bind:value=name)
}
div(class="col-12") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", bind:value=comment) {}
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Subject" }
div(class="row g-3") {
(subject_input("Common name (CN)", cn))
(subject_input("Organization (O)", organization))
(subject_input("Organizational unit (OU)", org_unit))
(subject_input("Country (C)", country))
(subject_input("State / province (ST)", state))
(subject_input("Locality (L)", locality))
(subject_input("E-mail address", email))
}
}
(if request_mode {
view! {}
} else {
view! {
div(class="form-section") {
div(class="form-section-title") { "Private key" }
div(class="form-check mb-2") {
input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key)
label(class="form-check-label", r#for="new-key") { "Generate a new key" }
}
(if new_key.get() {
view! {
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) {
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Key size" }
input(class="form-control", bind:value=key_size)
}
}
}
} else {
view! {
label(class="form-label") { "Existing key" }
select(class="form-select", bind:value=key_id) {
option(value="") { "— select a key —" }
(key_options())
}
}
})
}
}
})
div(class="form-section") {
div(class="form-section-title") { "Extensions" }
div(class="row g-3" ) {
div(class="col-md-4") {
label(class="form-label") { "Basic constraints" }
select(class="form-select", bind:value=ca_mode) {
option(value="end") { "End entity (CA:FALSE)" }
option(value="ca") { "Certification authority (CA:TRUE)" }
option(value="none") { "Not defined" }
}
}
div(class="col-md-3") {
label(class="form-label") { "Path length" }
input(class="form-control", placeholder="unlimited", bind:value=path_length)
}
div(class="col-md-3 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=bc_critical)
label(class="form-check-label") { "Critical" }
}
}
div(class="col-md-2 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=ski)
label(class="form-check-label") { "SKI" }
}
}
}
div(class="row g-3 mt-1") {
div(class="col-md-6") {
div(class="form-section-title") { "Key usage" }
(check("Digital signature", ku_digital))
(check("Key encipherment", ku_encipher))
(check("Key agreement", ku_key_agreement))
(check("Certificate sign", ku_cert_sign))
(check("CRL sign", ku_crl_sign))
(check("Critical", ku_critical))
}
div(class="col-md-6") {
div(class="form-section-title") { "Extended key usage" }
(check("TLS server", eku_server))
(check("TLS client", eku_client))
(check("Code signing", eku_code))
(check("E-mail protection", eku_email))
(check("Time stamping", eku_time))
(check("OCSP signing", eku_ocsp))
(check("Critical", eku_critical))
}
div(class="col-md-6") {
div(class="form-section-title") { "Subject alternative names" }
input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text)
}
div(class="col-md-6") {
div(class="form-section-title") { "Key identifier / OCSP" }
(check("Authority key identifier", aki))
(check("OCSP stapling", ocsp_staple))
}
}
}
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" }
}
}
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" }
}
}
}
}
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" })
}
}
}
}
}
}
pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let cert = create_signal(None::<CertificateDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let saving = create_signal(false);
let name = create_signal(String::new());
let amount = create_signal("365".to_string());
let unit = create_signal("days".to_string());
let keep_serial = create_signal(false);
let revoke_old = create_signal(false);
let replace_old = create_signal(false);
spawn_local(async move {
match api::me().await {
Ok(user) => {
me.set(Some(user));
match api::certificate(&id, &cert_id_value).await {
Ok(value) => {
name.set(value.summary.name.clone());
cert.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let submit = {
let id = id.clone();
let cert_id_value = cert_id_value.clone();
move |_| {
let id = id.clone();
let cert_id_value = cert_id_value.clone();
let payload = json!({
"name": name.get_clone(),
"validity": {"amount": amount.get_clone().parse::<i64>().unwrap_or(365), "unit": unit.get_clone()},
"keep_serial": keep_serial.get(),
"revoke_old": revoke_old.get(),
"replace_old": replace_old.get(),
});
saving.set(true);
spawn_local(async move {
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));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
});
}
};
let content = move || {
if loading.get() {
return crate::components::ui::spinner();
}
let Some(value) = cert.get_clone() else {
return crate::components::ui::empty_state("Certificate not found.");
};
view! {
(page_header("Renew certificate", &format!("Issued by {}", value.issuer_name), view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}")) { "Cancel" }
}))
div(class="card") {
div(class="card-body") {
div(class="row g-3") {
div(class="col-md-8") {
label(class="form-label") { "New internal name" }
input(class="form-control", bind:value=name)
}
div(class="col-md-2") {
label(class="form-label") { "Duration" }
input(class="form-control", bind:value=amount)
}
div(class="col-md-2") {
label(class="form-label") { "Unit" }
select(class="form-select", bind:value=unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
}
}
}
div(class="form-check mt-3") {
input(class="form-check-input", r#type="checkbox", id="keep-serial", bind:checked=keep_serial)
label(class="form-check-label", r#for="keep-serial") { "Keep serial number" }
}
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", id="revoke-old", bind:checked=revoke_old)
label(class="form-check-label", r#for="revoke-old") { "Revoke old certificate" }
}
div(class="form-check mb-3") {
input(class="form-check-input", r#type="checkbox", id="replace-old", bind:checked=replace_old)
label(class="form-check-label", r#for="replace-old") { "Replace old certificate" }
}
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Renewing…" } else { "Renew certificate" })
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn subject_input(label: &'static str, value: Signal<String>) -> View {
view! {
div(class="col-md-6") {
label(class="form-label") { (label) }
input(class="form-control", bind:value=value)
}
}
}
fn check(label: &'static str, value: Signal<bool>) -> View {
view! {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=value)
label(class="form-check-label") { (label) }
}
}
}
fn build_subject(fields: &[(&str, String)]) -> Vec<serde_json::Value> {
fields
.iter()
.filter(|(_, value)| !value.trim().is_empty())
.map(|(name, value)| json!({"name": name, "value": value.trim()}))
.collect()
}
fn parse_san(text: &str) -> Vec<serde_json::Value> {
text.split(',')
.map(|chunk| chunk.trim())
.filter(|chunk| !chunk.is_empty())
.map(|chunk| {
if let Some((prefix, value)) = chunk.split_once(':') {
let kind = match prefix.trim().to_lowercase().as_str() {
"ip" => "ip",
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"dns" => "dns",
_ => "dns",
};
json!({"type": kind, "value": value.trim()})
} else {
json!({"type": "dns", "value": chunk})
}
})
.collect()
}
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())
.unwrap_or_default()
}
fn query_param(name: &str) -> Option<String> {
let search = web_sys::window()?.location().search().ok()?;
let search = search.trim_start_matches('?');
for pair in search.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if key == name {
return Some(value.to_string());
}
}
None
}
+204
View File
@@ -0,0 +1,204 @@
//! Certificate list, flat or as an issuer tree.
use std::collections::HashMap;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me};
pub fn CertificatesPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let certificates = create_signal(Vec::<CertificateSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let filter = create_signal(String::new());
let plain = create_signal(false);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::certificates(id).await {
Ok(page) => {
if certificates.is_alive() {
certificates.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let rows = certificates.get_clone();
let query = filter.get_clone().to_lowercase();
let visible: Vec<CertificateSummary> = rows
.into_iter()
.filter(|cert| {
query.is_empty()
|| cert.name.to_lowercase().contains(&query)
|| cert.subject.to_lowercase().contains(&query)
|| cert.issuer_name.to_lowercase().contains(&query)
|| cert.serial_hex.to_lowercase().contains(&query)
|| cert.fingerprint_sha256.to_lowercase().contains(&query)
})
.collect();
let html = if plain.get() {
flat_html(&visible, id)
} else {
tree_html(&visible, id)
};
view! {
(page_header(
"Certificates",
"The tree follows issuer links; use the plain view for a flat table.",
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new")) { "New certificate" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
button(class="btn btn-outline-light", on:click=move |_| plain.set(!plain.get())) {
(if plain.get() { "Tree view" } else { "Plain view" })
}
},
))
div(class="card mb-3") {
div(class="card-body py-2") {
input(class="form-control", placeholder="Filter by name, subject, issuer, serial or fingerprint…",
bind:value=filter)
}
}
div(class="card", dangerously_set_inner_html=html) {}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
let mut body = String::new();
for cert in rows {
let (badge_class, badge_label) = status_badge(cert);
body.push_str(&format!(
"<tr><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
escape(&cert.subject),
escape(&cert.issuer_name),
if cert.is_ca { "CA" } else { "end entity" },
escape(&cert.serial_hex),
fmt_date(&cert.not_after),
badge_class,
badge_label,
if cert.has_private_key { " <span class=\"badge badge-soft\">key</span>" } else { "" },
fmt_fp(&cert.fingerprint_sha256),
));
}
format!(
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
)
}
fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
let mut children: HashMap<String, Vec<CertificateSummary>> = HashMap::new();
let mut roots: Vec<CertificateSummary> = Vec::new();
let ids: Vec<String> = rows.iter().map(|cert| cert.id.clone()).collect();
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());
}
_ => roots.push(cert.clone()),
}
}
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let mut html = String::from("<div class=\"card-body\"><ul class=\"cert-tree\">");
for root in &roots {
html.push_str(&node_html(root, &children, vault_id));
}
html.push_str("</ul></div>");
html
}
fn node_html(
cert: &CertificateSummary,
children: &HashMap<String, Vec<CertificateSummary>>,
vault_id: &str,
) -> String {
let (badge_class, badge_label) = status_badge(cert);
let sub = if cert.subject == cert.issuer_name {
"self-signed".to_string()
} else {
format!("issuer: {}", cert.issuer_name)
};
let mut row = format!(
"<div class=\"cert-row\"><a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
if cert.is_ca { " <span class=\"badge badge-ca\">CA</span>" } else { "" },
badge_class,
badge_label,
escape(&sub),
fmt_date(&cert.not_after),
fmt_fp(&cert.fingerprint_sha256),
);
let kids = children.get(&cert.id);
if let Some(kids) = kids {
if !kids.is_empty() {
row = format!(
"<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>",
kids.iter().map(|child| node_html(child, children, vault_id)).collect::<String>()
);
} else {
row = format!("<li>{row}</li>");
}
} else {
row = format!("<li>{row}</li>");
}
row
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+162
View File
@@ -0,0 +1,162 @@
//! CRL detail.
use serde_json::json;
use sycamore::prelude::*;
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, CrlDetail, CrlRevocation, ExtensionRow, Me};
pub fn CrlDetailPage(vault_id: String, crl_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let crl_id_value: &'static str = Box::leak(crl_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let crl = create_signal(None::<CrlDetail>);
let revocations = create_signal(Vec::<CrlRevocation>::new());
let extensions = create_signal(Vec::<ExtensionRow>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let format = create_signal("pem".to_string());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::crl(id, crl_id_value).await {
Ok(value) => {
revocations.set(value.revocations.clone());
extensions.set(value.extensions.clone());
crl.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let export = move |_| {
let fmt = format.get_clone();
spawn_local(async move {
match api::export_crl(id, crl_id_value, &json!({"format": fmt})).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 remove = move |_| {
if !dom::confirm("Delete this revocation list?") {
return;
}
spawn_local(async move {
match api::delete_crl(id, crl_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/crls")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = crl.get_clone() else {
return empty_state("CRL not found.");
};
let summary = value.summary.clone();
let name = summary.name.clone();
let number = summary.crl_number.map(|n| n.to_string()).unwrap_or_else(|| "—".to_string());
let issuer = summary.issuer_name.clone();
let last = fmt_date(&summary.last_update);
let next = fmt_date(&summary.next_update);
let sig = summary.signature_algorithm.clone();
let revoked_count = summary.revoked_count;
view! {
(page_header(name.clone(), format!("CRL number {number} · {revoked_count} revoked"), view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/crls")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="row g-3") {
div(class="col-12 col-lg-4") {
div(class="card mb-3") {
div(class="card-header") { "Properties" }
div(class="card-body") {
div(class="py-1") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (issuer) } }
div(class="py-1") { div(class="small text-secondary") { "Last update" } div() { (last) } }
div(class="py-1") { div(class="small text-secondary") { "Next update" } div() { (next) } }
div(class="py-1") { div(class="small text-secondary") { "Signature" } div() { (sig) } }
div(class="py-1") { div(class="small text-secondary") { "Revoked" } div() { (revoked_count) } }
}
}
div(class="card") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=format) {
option(value="pem") { "PEM" }
option(value="der") { "DER" }
option(value="vcalendar") { "vCalendar" }
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
}
div(class="col-12 col-lg-8") {
div(class="card mb-3") {
div(class="card-header") { "Revoked certificates" }
div(class="table-responsive") {
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } } }
tbody() {
Indexed(list=revocations, view=|rev| view! {
tr() {
td(class="fingerprint") { (rev.serial_hex.clone()) }
td() { (fmt_date(&Some(rev.revoked_at.clone()))) }
td() { (rev.reason.clone().unwrap_or_else(|| "unspecified".to_string())) }
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
}
})
}
}
}
}
div(class="card") {
div(class="card-header") { "Extensions" }
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody() {
Indexed(list=extensions, view=|ext| view! {
tr() {
td() { (ext.name.clone()) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) }
td(class="extension-value") { (ext.value.clone()) }
}
})
}
}
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Revocation list overview.
use sycamore::prelude::*;
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::model::{fmt_date, CrlSummary, Me};
pub fn CrlsPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let crls = create_signal(Vec::<CrlSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::crls(&id).await {
Ok(page) => {
if crls.is_alive() {
crls.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = crls.get_clone().is_empty();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No CRLs yet. Generate one from a CA certificate.")) } } }
} else {
let html = crls.get_clone().into_iter().map(|crl| {
format!(
"<tr><td><a href=\"/vaults/{}/crls/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
id,
escape(&crl.id),
escape(&crl.name),
escape(&crl.issuer_name),
crl.crl_number.map(|n| n.to_string()).unwrap_or_else(|| "—".to_string()),
crl.revoked_count,
fmt_date(&crl.last_update),
fmt_date(&crl.next_update),
)
}).collect::<String>();
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Number" } th() { "Revoked" } th() { "Last update" } th() { "Next update" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Revocation lists",
"CRLs are generated from a CA certificate's revocations; importing a CRL merges its serials.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Choose a CA" } },
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+206
View File
@@ -0,0 +1,206 @@
//! Vault list and creation.
use sycamore::prelude::*;
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::{item_total, Me, VaultSummary};
#[component]
pub fn DashboardPage() -> View {
let me = create_signal(None::<Me>);
let vaults = create_signal(Vec::<VaultSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let show_form = create_signal(false);
let name = create_signal(String::new());
let description = create_signal(String::new());
let saving = create_signal(false);
let reload = move || {
spawn_local(async move {
match api::vaults().await {
Ok(page) => {
if vaults.is_alive() {
vaults.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
});
};
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let create = move |_| {
let vault_name = name.get_clone();
let vault_description = description.get_clone();
if vault_name.trim().is_empty() {
error.set(Some("vault name is required".to_string()));
return;
}
saving.set(true);
spawn_local(async move {
match api::create_vault(&vault_name, &vault_description).await {
Ok(_) => {
name.set(String::new());
description.set(String::new());
show_form.set(false);
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
saving.set(false);
});
};
let content = move || {
if loading.get() {
return spinner();
}
let rows = vaults.get_clone();
let is_empty = rows.is_empty();
let cards: Vec<View> = rows
.into_iter()
.map(|vault| {
let id = vault.id.clone();
let name = vault.name.clone();
let description = vault.description.clone();
let total = item_total(&vault.counts);
let expiring = vault.expiring_soon;
let counts = vault.counts.clone();
let delete_id = vault.id.clone();
let delete_name = vault.name.clone();
let remove = move |_| {
if !dom::confirm(&format!("Delete the vault '{}' and everything in it?", delete_name)) {
return;
}
let id = delete_id.clone();
spawn_local(async move {
match api::delete_vault(&id).await {
Ok(()) => reload(),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
view! {
div(class="col-12 col-md-6 col-xl-4") {
div(class="card vault-card") {
div(class="card-body d-flex flex-column") {
div(class="d-flex justify-content-between align-items-start gap-2") {
div() {
a(class="h5 stretched-link text-decoration-none", href=format!("/vaults/{id}")) { (name) }
div(class="item-sub") { (description) }
}
button(class="btn btn-sm btn-outline-danger position-relative",
style="z-index: 2;",
on:click=remove) { "Delete" }
}
div(class="row g-2 mt-3") {
div(class="col-4") { div(class="stat-number") { (counts.certificates) } div(class="stat-label") { "Certs" } }
div(class="col-4") { div(class="stat-number") { (counts.keys) } div(class="stat-label") { "Keys" } }
div(class="col-4") { div(class="stat-number") { (total) } div(class="stat-label") { "Items" } }
}
(if expiring > 0 {
view! { div(class="badge badge-warn mt-3") { (expiring) " expiring within 30 days" } }
} else {
view! { div(class="text-secondary small mt-3") { "No certificates expiring soon" } }
})
}
}
}
}
})
.collect();
let empty = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No vaults yet. Create one to start managing keys and certificates.")) } } }
} else {
view! {}
};
let form = if show_form.get() {
view! {
div(class="card mb-4") {
div(class="card-body") {
h2(class="h5 mb-3") { "New vault" }
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", placeholder="Production PKI", bind:value=name)
}
div(class="mb-3") {
label(class="form-label") { "Description" }
textarea(class="form-control", rows="2", bind:value=description) {}
}
button(class="btn btn-primary", disabled=saving.get(), on:click=create) {
(if saving.get() { "Creating…" } else { "Create vault" })
}
button(class="btn btn-outline-light ms-2", on:click=move |_| show_form.set(false)) { "Cancel" }
}
}
}
} else {
view! {}
};
view! {
(page_header(
"Vaults",
"Each vault is a private XCA-style database of keys, certificates, requests, CRLs and templates.",
view! {
button(class="btn btn-primary", on:click=move |_| show_form.set(true)) { "New vault" }
a(class="btn btn-outline-light", href="/tokens") { "API tokens" }
},
))
(form)
(empty)
div(class="row g-3") { (cards) }
(if me.get_clone().map(|u| u.master_key_is_dev).unwrap_or(false) {
view! {
div(class="alert alert-info mt-4 mb-0") {
"This server uses the built-in development master key. Set KCA_MASTER_KEY before storing real private keys."
}
}
} else {
view! {}
})
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", None, nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Import files, PEM bundles and XCA databases.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::dom;
use crate::model::{ImportItem, Me};
pub fn ImportPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let error = create_signal(None::<String>);
let info = create_signal(None::<String>);
let loading = create_signal(true);
let busy = create_signal(false);
let filename = create_signal(String::new());
let content_base64 = create_signal(String::new());
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 result_text = create_signal(String::new());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
let choose_file = move |event: web_sys::Event| {
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());
result_text.set(String::new());
content_base64.set(String::new());
spawn_local(async move {
match dom::read_file_base64(&file).await {
Ok(value) => content_base64.set(value),
Err(message) => error.set(Some(message)),
}
});
};
let build_payload = move |dry_run: bool| {
let mut payload = json!({
"filename": if filename.get_clone().is_empty() { "paste.pem".to_string() } else { filename.get_clone() },
"password": if password.get_clone().is_empty() { serde_json::Value::Null } else { json!(password.get_clone()) },
"dry_run": dry_run,
"xca_db": xca_db.get(),
});
if !pem_text.get_clone().trim().is_empty() {
payload["pem"] = json!(pem_text.get_clone());
} else if !content_base64.get_clone().is_empty() {
payload["content_base64"] = json!(content_base64.get_clone());
}
payload
};
let dry_run = move |_| {
let payload = build_payload(true);
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;
}
busy.set(true);
preview_text.set(String::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)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
busy.set(false);
});
};
let commit = move |_| {
let payload = build_payload(false);
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;
}
busy.set(true);
result_text.set(String::new());
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())));
result_text.set(import_result_lines(&value.imported, &value.skipped));
preview_text.set(String::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
busy.set(false);
});
};
let content = move || {
if loading.get() {
return spinner();
}
view! {
(page_header(
"Import",
"PEM bundles, DER items, PKCS#7, PKCS#12, SSH keys, XCA templates and XCA .xdb databases.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } },
))
(if let Some(message) = info.get_clone() {
view! { div(class="alert alert-success") { (message) } }
} else {
view! {}
})
div(class="row g-3") {
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "File" }
div(class="card-body") {
input(class="form-control mb-2", r#type="file", on:change=choose_file)
(if filename.get_clone().is_empty() {
view! {}
} else {
view! { div(class="text-secondary small") { (filename.get_clone()) } }
})
div(class="form-check mt-2") {
input(class="form-check-input", r#type="checkbox", id="xca-db", bind:checked=xca_db)
label(class="form-check-label", r#for="xca-db") { "This is an XCA .xdb database" }
}
div(class="mt-2") {
label(class="form-label") { "Password (encrypted keys / PKCS#12 / XCA database)" }
input(class="form-control", r#type="password", bind:value=password)
}
}
}
div(class="card mb-3") {
div(class="card-header") { "Paste PEM" }
div(class="card-body") {
textarea(class="form-control", rows="8", placeholder="-----BEGIN CERTIFICATE-----", bind:value=pem_text) {}
}
}
div(class="d-flex gap-2") {
button(class="btn btn-outline-light", disabled=busy.get(), on:click=dry_run) { "Preview" }
button(class="btn btn-primary", disabled=busy.get(), on:click=commit) { "Import" }
}
}
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") {
div(class="card-header") { "Result" }
div(class="card-body") {
pre(class="pem mb-0") { (result_text.get_clone()) }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn preview_lines(items: &[ImportItem]) -> String {
items.iter().map(|item| {
format!(
"[{}] {} {}{}{}",
item.index,
item.kind,
item.name,
if item.is_private { " (private)" } else { "" },
if item.duplicate { " [duplicate]" } else { "" },
)
}).collect::<Vec<_>>().join("\n")
}
fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> String {
let mut lines = Vec::new();
for item in imported {
lines.push(format!("imported {} {}", item.kind, item.name));
}
for item in skipped {
lines.push(format!("skipped {} {} ({})", item.kind, item.name, item.summary));
}
lines.join("\n")
}
+208
View File
@@ -0,0 +1,208 @@
//! Key detail, rename, export and delete.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{copy_button, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_fp, KeyDetail, Me};
const KEY_FORMATS: &[(&str, &str, bool)] = &[
("pem-public", "PEM public", false),
("pem-private", "PEM private", true),
("pem-encrypted", "PEM encrypted", true),
("pkcs8", "PKCS#8", true),
("pkcs8-encrypted", "PKCS#8 encrypted", true),
("der-public", "DER public", false),
("der-private", "DER private", true),
("ssh-public", "SSH2 public", false),
("ssh-private", "SSH2 private", true),
("jwk-public", "JWK public", false),
("jwk-private", "JWK private", true),
("pvk", "PVK private", true),
];
pub fn KeyDetailPage(vault_id: String, key_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let key_id_value: &'static str = Box::leak(key_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let key = create_signal(None::<KeyDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let format = create_signal("pem-public".to_string());
let password = create_signal(String::new());
let header_sub = create_signal(String::new());
let public_pem = create_signal(String::new());
let name_value = create_signal(String::new());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::key(id, key_id_value).await {
Ok(value) => {
name.set(value.summary.name.clone());
comment.set(value.summary.comment.clone());
name_value.set(value.summary.name.clone());
header_sub.set(format!("{}{}", value.summary.key_type, value.summary.key_size.map(|s| format!(" · {s} bits")).unwrap_or_default()));
public_pem.set(value.public_pem.clone());
key.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let save = move |_| {
let value = key.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_key(id, key_id_value, &payload).await {
Ok(updated) => {
name.set(updated.summary.name.clone());
comment.set(updated.summary.comment.clone());
key.set(Some(updated));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let export = move |_| {
let mut payload = json!({"format": format.get_clone()});
if !password.get_clone().is_empty() {
payload["password"] = json!(password.get_clone());
}
spawn_local(async move {
match api::export_key(id, key_id_value, &payload).await {
Ok(export) => {
if let Err(message) = dom::download_base64(&export.filename, &export.content_type, &export.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this key? Certificates and requests referencing it keep their data but lose the key link.") {
return;
}
spawn_local(async move {
match api::delete_key(id, key_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/keys")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = key.get_clone() else {
return crate::components::ui::empty_state("Key not found.");
};
let summary = value.summary.clone();
let key_type = summary.key_type.clone();
let key_size = summary.key_size.map(|s| format!("{s} bits")).unwrap_or_else(|| "—".to_string());
let curve = summary.curve.clone().unwrap_or_else(|| "—".to_string());
let protection = if summary.has_private { "private key, encrypted at rest" } else { "public only" };
let use_count = summary.use_count;
let fingerprint = fmt_fp(&summary.fingerprint_sha256);
let source = summary.source.clone();
let has_private = summary.has_private;
let formats = KEY_FORMATS.iter().map(|(value, label, needs_private)| {
view! { option(value=*value, disabled=*needs_private && !has_private) { (*label) } }
}).collect::<Vec<View>>();
view! {
(page_header(name_value.get_clone(), header_sub.get_clone(), view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="row g-3") {
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Properties" }
div(class="card-body") {
table(class="table table-sm mb-0") {
tbody() {
tr() { td(class="text-secondary") { "Type" } td() { (key_type) } }
tr() { td(class="text-secondary") { "Size" } td() { (key_size) } }
tr() { td(class="text-secondary") { "Curve" } td() { (curve) } }
tr() { td(class="text-secondary") { "Protection" } td() { (protection) } }
tr() { td(class="text-secondary") { "Used by" } td() { (use_count) " certificate(s)/request(s)" } }
tr() { td(class="text-secondary") { "SHA-256" } td(class="fingerprint") { (fingerprint) } }
tr() { td(class="text-secondary") { "Source" } td() { (source) } }
}
}
}
}
div(class="card") {
div(class="card-header") { "Public key" }
div(class="card-body") {
(copy_button(public_pem.get_clone(), "Copy PEM"))
pre(class="pem mt-2") { (public_pem.get_clone()) }
}
}
}
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "Edit metadata" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=comment) {}
}
button(class="btn btn-primary", on:click=save) { "Save" }
}
}
div(class="card") {
div(class="card-header") { "Export" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Format" }
select(class="form-select", bind:value=format) { (formats) }
}
div(class="mb-3") {
label(class="form-label") { "Password (encrypted formats)" }
input(class="form-control", r#type="password", bind:value=password)
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn value_private(summary: &crate::model::KeySummary) -> bool {
summary.has_private
}
+152
View File
@@ -0,0 +1,152 @@
//! Generate a key.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::Me;
const RSA_SIZES: &[&str] = &["1024", "2048", "3072", "4096", "8192"];
const DSA_SIZES: &[&str] = &["1024", "2048", "3072"];
const CURVES: &[&str] = &[
"secp256r1",
"secp384r1",
"secp521r1",
"secp256k1",
"brainpoolP256r1",
"brainpoolP384r1",
"brainpoolP512r1",
];
pub fn KeyNewPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let error = create_signal(None::<String>);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let key_type = create_signal("rsa".to_string());
let size = create_signal("2048".to_string());
let curve = create_signal("secp256r1".to_string());
let saving = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
me.set(Some(user));
}
}
});
let submit = move |_| {
let key_name = name.get_clone();
if key_name.trim().is_empty() {
error.set(Some("key name is required".to_string()));
return;
}
let key_type_value = key_type.get_clone();
let mut payload = json!({
"name": key_name,
"comment": comment.get_clone(),
"key_type": key_type_value,
});
if key_type_value == "ec" {
payload["curve"] = json!(curve.get_clone());
} else if key_type_value == "rsa" || key_type_value == "dsa" {
payload["size"] = json!(size.get_clone().parse::<i64>().unwrap_or(2048));
}
saving.set(true);
let id = id.clone();
spawn_local(async move {
match api::generate_key(&id, &payload).await {
Ok(key) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/keys/{}", id, key.summary.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() {
saving.set(false);
}
});
};
let type_options = move || {
["rsa", "dsa", "ec", "ed25519"]
.iter()
.map(|value| {
view! {
option(value=*value) {
(if *value == "ed25519" { "ED25519".to_string() } else { value.to_uppercase() })
}
}
})
.collect::<Vec<View>>()
};
let parameter_input = move || {
let kind = key_type.get_clone();
if kind == "ec" {
view! {
label(class="form-label") { "Curve" }
select(class="form-select", bind:value=curve) {
(CURVES.iter().map(|value| view! {
option(value=*value) { (*value) }
}).collect::<Vec<View>>())
}
}
} else if kind == "ed25519" {
view! { div(class="alert alert-info py-2 mb-0") { "Ed25519 keys have a fixed 256-bit size." } }
} else {
let sizes = if kind == "dsa" { DSA_SIZES } else { RSA_SIZES };
view! {
label(class="form-label") { "Key size (bits)" }
select(class="form-select", bind:value=size) {
(sizes.iter().map(|value| view! {
option(value=*value) { (*value) }
}).collect::<Vec<View>>())
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(page_header("New key", "Generate a key and store it encrypted in this vault.", view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys")) { "Cancel" }
}))
div(class="card") {
div(class="card-body") {
div(class="mb-3") {
label(class="form-label") { "Internal name" }
input(class="form-control", placeholder="webserver-key", bind:value=name)
}
div(class="row g-3 mb-3") {
div(class="col-md-4") {
label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) { (type_options()) }
}
div(class="col-md-4") { (move || parameter_input()) }
}
div(class="mb-3") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", bind:value=comment) {}
}
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Generating…" } else { "Generate key" })
}
}
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Key list.
use sycamore::prelude::*;
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::model::{fmt_fp, KeySummary, Me};
pub fn KeysPage(vault_id: 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());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let reload = move || {
let id = id.clone();
spawn_local(async move {
match api::keys(&id).await {
Ok(page) => {
if keys.is_alive() {
keys.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
});
};
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = keys.get_clone().is_empty();
let html = keys.get_clone().into_iter().map(|key| {
format!(
"<tr><td><a href=\"/vaults/{}/keys/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td></tr>",
id,
escape(&key.id),
escape(&key.name),
escape(&key.comment),
escape(&key.key_type),
key.key_size.map(|v| v.to_string()).unwrap_or_else(|| "—".to_string()),
escape(key.curve.as_deref().unwrap_or("—")),
if key.has_private { "private" } else { "public" },
key.use_count,
fmt_fp(&key.fingerprint_sha256),
)
}).collect::<String>();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No keys yet. Generate one or import a PEM/DER key.")) } } }
} else {
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Type" } th() { "Size" } th() { "Curve" } th() { "Protection" } th() { "Use" } th() { "SHA-256" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Private keys",
"Keys are encrypted with the server master key. Private material never leaves the server except through an explicit export.",
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/keys/new")) { "New key" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
},
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+72
View File
@@ -0,0 +1,72 @@
//! OIDC provider choice.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::app_nav;
use crate::components::ui::{spinner, toast_error};
use crate::model::AuthProvider;
#[component]
pub fn LoginPage() -> View {
let providers = create_signal(Vec::<AuthProvider>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::auth_providers().await {
Ok(page) => {
if providers.is_alive() {
providers.set(page.providers);
}
}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let buttons: Vec<View> = providers
.get_clone()
.into_iter()
.map(|provider| {
view! {
a(class="btn btn-primary btn-lg w-100 mb-2", href=provider.login_url, rel="external") {
"Sign in with " (provider.name)
}
}
})
.collect();
view! {
div(class="card mx-auto", style="max-width: 28rem;") {
div(class="card-body p-4") {
h1(class="h4 mb-2") { "Sign in to KCA" }
p(class="text-secondary") {
"KCA manages private keys and certificates. Sign in with your configured identity provider."
}
(buttons)
p(class="text-secondary small mb-0 mt-3") {
"The development stack accepts any username at the mock provider."
}
}
}
}
};
view! {
(app_nav("/login", None, || view! {}))
main(class="container page-shell py-5") {
(toast_error(error))
(content)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
pub mod certificate_detail;
pub mod certificate_new;
pub mod certificates;
pub mod crl_detail;
pub mod crls;
pub mod dashboard;
pub mod import;
pub mod key_detail;
pub mod key_new;
pub mod keys;
pub mod login;
pub mod not_found;
pub mod request_detail;
pub mod request_new;
pub mod requests;
pub mod search;
pub mod settings;
pub mod template_editor;
pub mod templates;
pub mod tokens;
pub mod vault;
+19
View File
@@ -0,0 +1,19 @@
use sycamore::prelude::*;
use crate::components::nav::app_nav;
#[component]
pub fn NotFoundPage() -> View {
view! {
(app_nav("", None, || view! {}))
main(class="container page-shell py-5") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3") { "Page not found" }
p(class="text-secondary") { "The page you requested does not exist." }
a(class="btn btn-primary", href="/") { "Back to vaults" }
}
}
}
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Certificate signing request detail.
use serde_json::json;
use sycamore::prelude::*;
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_fp, Me, RequestDetail};
pub fn RequestDetailPage(vault_id: String, request_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let req_id: &'static str = Box::leak(request_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let request = create_signal(None::<RequestDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let request_name = create_signal(String::new());
let request_subject = create_signal(String::new());
let signature_label = create_signal(String::new());
let export_format = create_signal("pem".to_string());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::request(id, req_id).await {
Ok(value) => {
name.set(value.summary.name.clone());
comment.set(value.summary.comment.clone());
request_name.set(value.summary.name.clone());
request_subject.set(value.summary.subject.clone());
signature_label.set(if value.signature_valid { "PKCS#10 signature valid".to_string() } else { "PKCS#10 signature INVALID".to_string() });
request.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let save = move |_| {
let value = request.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_request(id, req_id, &payload).await {
Ok(updated) => {
request_name.set(updated.summary.name.clone());
request.set(Some(updated));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let export = move |_| {
let fmt = export_format.get_clone();
spawn_local(async move {
match api::export_request(id, req_id, &json!({"format": fmt})).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 remove = move |_| {
if !dom::confirm("Delete this certificate signing request?") {
return;
}
spawn_local(async move {
match api::delete_request(id, req_id).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/requests")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let extract = move |_| {
spawn_local(async move {
match api::transform_request(id, req_id, "open-key").await {
Ok(value) => {
if let Some(key_id) = value.get("id").and_then(|v| v.as_str()) {
sycamore_router::navigate(&format!("/vaults/{id}/keys/{key_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let to_template = move |_| {
spawn_local(async move {
match api::transform_request(id, req_id, "template").await {
Ok(value) => {
if let Some(template_id) = value.get("id").and_then(|v| v.as_str()) {
sycamore_router::navigate(&format!("/vaults/{id}/templates/{template_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = request.get_clone() else {
return empty_state("Request not found.");
};
let mut html = String::new();
html.push_str("<table class=\"table table-sm mb-0\"><tbody>");
html.push_str(&format!("<tr><td class=\"text-secondary\">Subject</td><td class=\"mono\">{}</td></tr>", escape(&value.subject_rfc4514)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Signature</td><td>{}</td></tr>", escape(&value.signature_algorithm)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Fingerprint</td><td class=\"fingerprint\">{}</td></tr>", fmt_fp(&value.summary.fingerprint_sha256)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Certificates issued</td><td>{}</td></tr>", value.summary.certificate_count));
html.push_str("</tbody></table>");
let mut subject_html = String::new();
for entry in &value.subject_entries {
subject_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&entry.name), escape(&entry.value)));
}
let mut ext_html = String::new();
for ext in &value.extensions {
ext_html.push_str(&format!(
"<tr><td>{}{}</td><td class=\"extension-value\">{}</td></tr>",
escape(&ext.name),
if ext.critical { " <span class=\"badge badge-critical\">critical</span>" } else { "" },
escape(&ext.value),
));
}
let mut attr_html = String::new();
for attr in &value.attributes {
attr_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&attr.name), escape(&attr.values.join(", "))));
}
view! {
(page_header(request_name.get_clone(), request_subject.get_clone(), view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new?request={req_id}")) { "Sign request" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="alert alert-info") { (signature_label.get_clone()) }
div(class="row g-3") {
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Request" }
div(class="card-body", dangerously_set_inner_html=html) {}
}
div(class="card mb-3") {
div(class="card-header") { "Subject" }
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=subject_html) {} }
}
div(class="card mb-3") {
div(class="card-header") { "Requested extensions" }
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody(dangerously_set_inner_html=ext_html) {}
}
}
}
div(class="card") {
div(class="card-header") { "Attributes" }
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=attr_html) {} }
}
}
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "Edit" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=comment) {}
}
button(class="btn btn-primary", on:click=save) { "Save" }
}
}
div(class="card mb-3") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=export_format) {
option(value="pem") { "PEM" }
option(value="der") { "DER" }
option(value="openssl-config") { "OpenSSL config" }
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=extract) { "Extract public key" }
button(class="btn btn-outline-light", on:click=to_template) { "Create template" }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+322
View File
@@ -0,0 +1,322 @@
//! Create a PKCS#10 certificate signing request.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{KeySummary, Me};
pub fn RequestNewPage(vault_id: 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());
let error = create_signal(None::<String>);
let saving = create_signal(false);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let key_id = create_signal(String::new());
let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string());
let signature_hash = create_signal("sha256".to_string());
let cn = create_signal(String::new());
let country = create_signal(String::new());
let state = create_signal(String::new());
let locality = create_signal(String::new());
let organization = create_signal(String::new());
let org_unit = create_signal(String::new());
let email = create_signal(String::new());
let san_text = create_signal(String::new());
let challenge = create_signal(String::new());
let unstructured = create_signal(String::new());
let ku_digital = create_signal(true);
let ku_encipher = create_signal(false);
let ku_cert_sign = create_signal(false);
let ku_crl_sign = create_signal(false);
let eku_server = create_signal(false);
let eku_client = create_signal(false);
let eku_code = create_signal(false);
let eku_email = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
me.set(Some(user));
}
}
if let Ok(page) = api::keys(&id).await {
if keys.is_alive() {
keys.set(page.results.into_iter().filter(|key| key.has_private).collect());
}
}
});
let submit = move |_| {
let request_name = name.get_clone();
if request_name.trim().is_empty() {
error.set(Some("request name is required".to_string()));
return;
}
let mut subject = Vec::new();
for (key, value) in [
("C", country.get_clone()),
("ST", state.get_clone()),
("L", locality.get_clone()),
("O", organization.get_clone()),
("OU", org_unit.get_clone()),
("CN", cn.get_clone()),
("emailAddress", email.get_clone()),
] {
if !value.trim().is_empty() {
subject.push(json!({"name": key, "value": value.trim()}));
}
}
if subject.is_empty() {
error.set(Some("the subject must not be empty".to_string()));
return;
}
let usages = [
(ku_digital.get(), "digital_signature"),
(ku_encipher.get(), "key_encipherment"),
(ku_cert_sign.get(), "key_cert_sign"),
(ku_crl_sign.get(), "crl_sign"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
let eku = [
(eku_server.get(), "serverAuth"),
(eku_client.get(), "clientAuth"),
(eku_code.get(), "codeSigning"),
(eku_email.get(), "emailProtection"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
let mut extensions = json!({
"key_usage": {"critical": true, "usages": usages},
"extended_key_usage": {"critical": false, "usages": eku},
});
let san = parse_san(&san_text.get_clone());
if !san.is_empty() {
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
}
let mut payload = json!({
"name": request_name,
"comment": comment.get_clone(),
"subject": subject,
"extensions": extensions,
"signature_hash": signature_hash.get_clone(),
});
if new_key.get() {
payload["new_key"] = json!({
"key_type": key_type.get_clone(),
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
"name": format!("{} key", name.get_clone()),
});
} else if !key_id.get_clone().is_empty() {
payload["key_id"] = json!(key_id.get_clone());
} else {
error.set(Some("select a key or generate a new one".to_string()));
return;
}
let mut attributes = serde_json::Map::new();
if !challenge.get_clone().is_empty() {
attributes.insert("1.2.840.113549.1.9.7".to_string(), json!(challenge.get_clone()));
}
if !unstructured.get_clone().is_empty() {
attributes.insert("1.2.840.113549.1.9.2".to_string(), json!(unstructured.get_clone()));
}
payload["attributes"] = serde_json::Value::Object(attributes);
saving.set(true);
let id = id.clone();
spawn_local(async move {
match api::create_request(&id, &payload).await {
Ok(request) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/requests/{}", id, request.summary.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
});
};
let key_options = move || {
keys.get_clone()
.into_iter()
.map(|key| view! { option(value=key.id.clone()) { (key.name.clone()) " (" (key.key_type.clone()) ")" } })
.collect::<Vec<View>>()
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(page_header("New certificate signing request",
"The CSR contains the public key and requested extensions; it is signed by the private key.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Cancel" } }))
div(class="card") {
div(class="card-body") {
div(class="form-section") {
div(class="form-section-title") { "General" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Internal name" }
input(class="form-control", bind:value=name)
}
div(class="col-md-6") {
label(class="form-label") { "Signature hash" }
select(class="form-select", bind:value=signature_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
option(value="sha1") { "SHA-1" }
}
}
div(class="col-12") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", bind:value=comment) {}
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Subject" }
div(class="row g-3") {
(subject_input("Common name (CN)", cn))
(subject_input("Organization (O)", organization))
(subject_input("Organizational unit (OU)", org_unit))
(subject_input("Country (C)", country))
(subject_input("State / province (ST)", state))
(subject_input("Locality (L)", locality))
(subject_input("E-mail address", email))
}
}
div(class="form-section") {
div(class="form-section-title") { "Private key" }
div(class="form-check mb-2") {
input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key)
label(class="form-check-label", r#for="new-key") { "Generate a new key" }
}
(if new_key.get() {
view! {
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) {
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Key size" }
input(class="form-control", bind:value=key_size)
}
}
}
} else {
view! {
label(class="form-label") { "Existing key" }
select(class="form-select", bind:value=key_id) {
option(value="") { "— select a key —" }
(key_options())
}
}
})
}
div(class="form-section") {
div(class="form-section-title") { "Requested extensions" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Subject alternative names" }
input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text)
}
div(class="col-md-6") {
label(class="form-label") { "Challenge password" }
input(class="form-control", bind:value=challenge)
}
div(class="col-md-6") {
label(class="form-label") { "Unstructured name" }
input(class="form-control", bind:value=unstructured)
}
}
div(class="row g-3 mt-1") {
div(class="col-md-6") {
div(class="form-section-title") { "Key usage" }
(check("Digital signature", ku_digital))
(check("Key encipherment", ku_encipher))
(check("Certificate sign", ku_cert_sign))
(check("CRL sign", ku_crl_sign))
}
div(class="col-md-6") {
div(class="form-section-title") { "Extended key usage" }
(check("TLS server", eku_server))
(check("TLS client", eku_client))
(check("Code signing", eku_code))
(check("E-mail protection", eku_email))
}
}
}
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Creating…" } else { "Create request" })
}
}
}
}
}
}
fn subject_input(label: &'static str, value: Signal<String>) -> View {
view! {
div(class="col-md-6") {
label(class="form-label") { (label) }
input(class="form-control", bind:value=value)
}
}
}
fn check(label: &'static str, value: Signal<bool>) -> View {
view! {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=value)
label(class="form-check-label") { (label) }
}
}
}
fn parse_san(text: &str) -> Vec<serde_json::Value> {
text.split(',')
.map(|chunk| chunk.trim())
.filter(|chunk| !chunk.is_empty())
.map(|chunk| {
if let Some((prefix, value)) = chunk.split_once(':') {
let kind = match prefix.trim().to_lowercase().as_str() {
"ip" => "ip",
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"dns" => "dns",
_ => "dns",
};
json!({"type": kind, "value": value.trim()})
} else {
json!({"type": "dns", "value": chunk})
}
})
.collect()
}
+110
View File
@@ -0,0 +1,110 @@
//! Certificate signing request list.
use sycamore::prelude::*;
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::model::{fmt_fp, Me, RequestSummary};
pub fn RequestsPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let requests = create_signal(Vec::<RequestSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::requests(&id).await {
Ok(page) => {
if requests.is_alive() {
requests.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = requests.get_clone().is_empty();
let html = requests.get_clone().into_iter().map(|request| {
format!(
"<tr><td><a href=\"/vaults/{}/requests/{}\">{}</a><div class=\"item-sub\">{}</div></td><td class=\"truncate\">{}</td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td></tr>",
id,
escape(&request.id),
escape(&request.name),
escape(&request.comment),
escape(&request.subject),
if request.signed { "signed" } else { "new" },
request.certificate_count,
fmt_fp(&request.fingerprint_sha256),
)
}).collect::<String>();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No certificate signing requests yet.")) } } }
} else {
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Subject" } th() { "Status" } th() { "Certs" } th() { "SHA-256" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Certificate signing requests",
"PKCS#10 requests can be created here or imported; signing one marks it as used.",
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/requests/new")) { "New request" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
},
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}

Some files were not shown because too many files have changed in this diff Show More