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:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
server/.venv
|
||||
server/.mypy_cache
|
||||
server/tests
|
||||
web/target
|
||||
web/dist
|
||||
web/.trunk
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
*.log
|
||||
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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=="
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
Generated
+1949
File diff suppressed because it is too large
Load Diff
Generated
+568
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 & 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>
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod nav;
|
||||
pub mod toast;
|
||||
pub mod ui;
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user