Compare commits

..
1 Commits
Author SHA1 Message Date
KCA 8717c7021e Move certificate export and revoke actions into header 2026-10-27 00:48:03 +08:00
36 changed files with 1669 additions and 1730 deletions

No files matched your search

+23 -68
View File
@@ -7,10 +7,10 @@ organised in private **vaults** (each vault is the web equivalent of one XCA
database). database).
- **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend: - **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend:
OIDC login, Redis sessions, personal API tokens, PostgreSQL or SQLite OIDC login, Redis sessions, personal API tokens, Postgres (Tortoise ORM +
(Tortoise ORM + aerich migrations), AES-256-GCM private-key encryption, and aerich migrations), AES-256-GCM private-key encryption, and a complete REST
a complete REST API. The pure PKI domain logic lives in `packages/kca-core`; API. The pure PKI domain logic lives in `packages/kca-core`; the deployable
the deployable app is `packages/kca-app`. app is `packages/kca-app`.
- **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly - **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly
by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under
`web/vendor/`) and a dark PKI-console theme. `web/vendor/`) and a dark PKI-console theme.
@@ -50,45 +50,6 @@ OIDC_0_REDIRECT_URI=https://kca.example.com/auth/default/callback
`server/.env.example` documents every setting. `server/.env.example` documents every setting.
## Database backends
PostgreSQL is the default production backend. KCA also supports persistent,
file-backed SQLite for single-writer deployments. Configure it from `server/`
with either the component settings:
```sh
export DATABASE_ENGINE=sqlite
export DATABASE_NAME=/data/kca.db
export DATABASE_OPTIONS=busy_timeout=5000
```
or one URL:
```sh
export DATABASE_URL='sqlite:///data/kca.db?busy_timeout=5000'
```
Run migrations before starting or upgrading the app:
```sh
uv run aerich upgrade
```
Tortoise enables WAL journaling and foreign-key enforcement by default; the
busy timeout allows concurrent readers and a writer to wait briefly for locks.
Only in-memory SQLite test databases create their schemas implicitly.
For containers, mount a persistent volume at `/data` for both the migration
and application containers and use the same SQLite URL for each. The image's
`/data` directory is writable by its non-root application user. When invoking
`aerich upgrade` directly in the image, set its working directory to `/app`
(for example, `docker run -w /app ...`). Keep SQLite to one application
writer/replica, and use SQLite-aware backups rather than independently copying
live database, WAL and shared-memory files.
When changing models, migration SQL must support both PostgreSQL and SQLite.
The PostgreSQL migration commands below remain the default development flow.
## What it does ## What it does
### Vaults ### Vaults
@@ -136,8 +97,8 @@ explicit/mandatory DN entries, default key type/size/curve, default validity).
extensions, and a server-side **Validate extensions** action. OpenSSL config extensions, and a server-side **Validate extensions** action. OpenSSL config
text is available on export. text is available on export.
- Certificate details are split into Overview, Extensions, Validation, - Certificate details are split into Overview, Extensions, Validation,
Metadata and CA tabs, with hash deep links. Header actions provide export, Metadata, Transform and CA tabs, with hash deep links. Header actions provide
renewal, revocation, deletion and certificate transformation. They show export, renewal, revocation and deletion. They show
subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256 subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256
fingerprints, parsed extensions, OpenSSL validation errors, purposes and the fingerprints, parsed extensions, OpenSSL validation errors, purposes and the
issuer chain. issuer chain.
@@ -193,42 +154,36 @@ only as SHA-256 digests, and cannot mint/revoke tokens.
## OCSP responder ## OCSP responder
KCA publishes full per-CA certificate-status snapshots to Redis and ships a KCA can publish full per-CA certificate-status snapshots to any S3-compatible
standalone Rust/Axum responder on port `2560`. By default, snapshots use the bucket and ships a standalone Rust/Axum responder on port `2560`.
same Redis instance and logical database as sessions; setting
`KCA_OCSP_REDIS_URL` selects a dedicated OCSP instance instead.
- KCA publishes `ocsp:<KCA CA UUID>` whenever certificates are issued, renewed, - KCA publishes `ocsp/<KCA CA UUID>.json` whenever certificates are issued,
revoked, deleted or imported. Revocation reasons are included; this is a full renewed, revoked, deleted or imported. Revocation reasons are
CBOR-encoded status snapshot, not a revoked-only list. OCSP keys are separate included; this is a full status snapshot, not a revoked-only list.
from session keys through their prefix. - The responder has no Postgres/Redis/KCA master-key access. It only reads the
- The responder has no Postgres or KCA master-key access. It reads only OCSP configured S3 prefix and uses a delegated OCSP signing certificate/key per
snapshots from Redis and uses a delegated OCSP signing certificate/key per CA.
CA. Where Redis ACLs are available, restrict the responder to `ocsp:*` so it
cannot read session entries.
- Each CA snapshot is cached for one second with a single-flight refresh, so - Each CA snapshot is cached for one second with a single-flight refresh, so
concurrent requests share one atomic Redis `GET` + `PTTL` lookup per CA. concurrent requests cause at most one S3 `GetObject` per CA per second.
- The publisher refreshes unchanged snapshots every 30 seconds by default and - The publisher refreshes unchanged snapshots every 30 seconds by default. The
sets each key's expiry to 60 seconds. A missing or expired snapshot produces responder rejects snapshots older than its configured grace period even when
`unknown`; Redis failures produce `tryLater` when no unexpired cached S3 is readable, and returns `tryLater` when no fresh snapshot is available.
snapshot remains. - OCSP `thisUpdate` reflects the snapshot generation time and `nextUpdate` never
- OCSP `thisUpdate` is the response time and `nextUpdate` never extends beyond extends beyond the snapshot's hard expiry.
the snapshot's Redis expiry.
- Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same - Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same
`release/*` tags as the server image. The responder image is a statically `release/*` tags as the server image. The responder image is a statically
linked musl executable running `FROM scratch` with only the CA bundle. linked musl executable running `FROM scratch` with only the CA bundle.
Configure `REDIS_URL`, optional `KCA_OCSP_REDIS_URL`, and the other Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a `KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
configuration derived from `ocsp-responder/responder.example.yaml`, setting configuration derived from `ocsp-responder/responder.example.yaml`, setting
each issuer's `ca_id` to its CA certificate UUID in KCA and using the OCSP each issuer's `ca_id` to its CA certificate UUID in KCA, or with Compose:
Redis URL, or with Compose:
```sh ```sh
docker compose --profile ocsp up --build docker compose --profile ocsp up --build
``` ```
See `ocsp-responder/README.md` for the CBOR layout, signer requirements and See `ocsp-responder/README.md` for the JSON layout, signer requirements and
`openssl ocsp` verification. `openssl ocsp` verification.
## REST API ## REST API
+1 -3
View File
@@ -1,9 +1,7 @@
# KCA — Kubernetes deployment sketch (namespace: kca). # KCA — Kubernetes deployment sketch (namespace: kca).
# #
# Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis # Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis
# stores disposable sessions and expiring OCSP snapshots. Set # is included because sessions are disposable. KCA_MASTER_KEY is the AES key
# KCA_OCSP_REDIS_URL to publish OCSP snapshots to a separate instance instead.
# KCA_MASTER_KEY is the AES key
# protecting private keys at rest: generate it once and never lose it. # protecting private keys at rest: generate it once and never lose it.
# #
# kubectl apply -f deploy/k8s/kca.yaml # kubectl apply -f deploy/k8s/kca.yaml
+17 -13
View File
@@ -105,20 +105,24 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health} OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health}
# OCSP snapshots default to the session Redis. Set KCA_OCSP_REDIS_URL to # OCSP status publisher (optional). The bucket must be writable by
# use a dedicated instance instead. # KCA; the responder later reads the same prefix with read-only access.
KCA_OCSP_REDIS_URL: ${KCA_OCSP_REDIS_URL:-} KCA_OCSP_S3_BUCKET: ${KCA_OCSP_S3_BUCKET:-}
KCA_OCSP_REDIS_PREFIX: ${KCA_OCSP_REDIS_PREFIX:-ocsp} KCA_OCSP_S3_PREFIX: ${KCA_OCSP_S3_PREFIX:-ocsp}
KCA_OCSP_S3_REGION: ${KCA_OCSP_S3_REGION:-}
KCA_OCSP_S3_ENDPOINT: ${KCA_OCSP_S3_ENDPOINT:-}
KCA_OCSP_S3_FORCE_PATH_STYLE: ${KCA_OCSP_S3_FORCE_PATH_STYLE:-false}
KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1} KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1}
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30} KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30}
KCA_OCSP_REDIS_EXPIRY_SECONDS: ${KCA_OCSP_REDIS_EXPIRY_SECONDS:-60} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
AWS_REGION: ${AWS_REGION:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
# Optional standalone OCSP responder. It has no database or KCA master-key # Optional standalone OCSP responder. It deliberately has no database,
# environment, but reads OCSP keys from the configured Redis (the session # Redis or KCA master-key environment. Enable with:
# Redis by default).
# Enable with:
# docker compose --profile ocsp up --build # docker compose --profile ocsp up --build
ocsp: ocsp:
profiles: ["ocsp"] profiles: ["ocsp"]
@@ -126,12 +130,12 @@ services:
build: build:
context: . context: .
dockerfile: ocsp-responder/Dockerfile dockerfile: ocsp-responder/Dockerfile
depends_on:
redis:
condition: service_healthy
environment: environment:
OCSP_CONFIG: /etc/ocsp/responder.yaml OCSP_CONFIG: /etc/ocsp/responder.yaml
OCSP_REDIS_URL: ${OCSP_REDIS_URL:-redis://redis:6379/0} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
AWS_REGION: ${AWS_REGION:-}
volumes: volumes:
- ${OCSP_CONFIG_FILE:-./ocsp-responder/responder.example.yaml}:/etc/ocsp/responder.yaml:ro - ${OCSP_CONFIG_FILE:-./ocsp-responder/responder.example.yaml}:/etc/ocsp/responder.yaml:ro
- ${OCSP_CA_DIR:-./ocsp-responder/ca}:/etc/ocsp/ca:ro - ${OCSP_CA_DIR:-./ocsp-responder/ca}:/etc/ocsp/ca:ro
+1 -28
View File
@@ -101,13 +101,11 @@ test("core PKI workflow", async ({ page }) => {
await page.getByRole("button", { name: "Search" }).click(); await page.getByRole("button", { name: "Search" }).click();
await expect(page.getByRole("link", { name: leafName, exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: leafName, exact: true })).toBeVisible();
// Certificate header controls replace the Export, Revoke and Transform tabs. // Certificate header actions replace the Export and Revoke tabs.
await page.getByRole("link", { name: leafName, exact: true }).click(); await page.getByRole("link", { name: leafName, exact: true }).click();
const leafUrl = page.url();
const certificateTabs = page.locator(".nav-tabs"); const certificateTabs = page.locator(".nav-tabs");
await expect(certificateTabs.getByRole("tab", { name: "Export", exact: true })).toHaveCount(0); await expect(certificateTabs.getByRole("tab", { name: "Export", exact: true })).toHaveCount(0);
await expect(certificateTabs.getByRole("tab", { name: "Revoke", exact: true })).toHaveCount(0); await expect(certificateTabs.getByRole("tab", { name: "Revoke", exact: true })).toHaveCount(0);
await expect(certificateTabs.getByRole("tab", { name: "Transform", exact: true })).toHaveCount(0);
const downloadPromise = page.waitForEvent("download"); const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export", exact: true }).click(); await page.getByRole("button", { name: "Export", exact: true }).click();
@@ -119,31 +117,6 @@ test("core PKI workflow", async ({ page }) => {
expect(download.suggestedFilename()).toBe(`${leafName}.crt`); expect(download.suggestedFilename()).toBe(`${leafName}.crt`);
await expect(exportDialog).toHaveCount(0); await expect(exportDialog).toHaveCount(0);
const transformButton = page.getByRole("button", { name: "Transform", exact: true });
await transformButton.click();
for (const name of ["Extract public key", "Create request", "Create template", "Similar certificate"]) {
await expect(page.getByRole("menuitem", { name, exact: true })).toBeVisible();
}
await page.getByRole("menuitem", { name: "Extract public key", exact: true }).click();
await expect(page).toHaveURL(/\/keys\/[^/]+$/);
await page.goto(leafUrl);
await transformButton.click();
await page.getByRole("menuitem", { name: "Create request", exact: true }).click();
await expect(page).toHaveURL(/\/requests\/[^/]+$/);
await page.goto(leafUrl);
await transformButton.click();
await page.getByRole("menuitem", { name: "Create template", exact: true }).click();
await expect(page).toHaveURL(/\/templates\/[^/]+$/);
await page.goto(leafUrl);
await transformButton.click();
await page.getByRole("menuitem", { name: "Similar certificate", exact: true }).click();
await expect(page).toHaveURL(`${leafUrl}/similar`);
await expect(page.getByRole("heading", { name: "New certificate" })).toBeVisible();
await page.goto(leafUrl);
await page.getByRole("button", { name: "Actions", exact: true }).click(); await page.getByRole("button", { name: "Actions", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Renew", exact: true })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Renew", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Delete", exact: true })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete", exact: true })).toBeVisible();
-84
View File
@@ -1,84 +0,0 @@
// Certificate key selection mirrors XCA's unused-key filtering.
const { test, expect } = require("@playwright/test");
function unique(prefix) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
async function field(page, label) {
return page.locator("label").filter({ hasText: label }).first().locator("..").locator("input, select, textarea").first();
}
async function login(page, username) {
await page.goto("/login");
await page.getByRole("link", { name: "Sign in with default" }).click();
await page.getByPlaceholder("Enter any user/subject").fill(username);
await page.getByRole("button", { name: "Sign-in" }).click();
await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible();
}
function existingKeySelect(page) {
return page.locator("label").filter({ hasText: "Existing key" }).locator("xpath=following-sibling::select[1]");
}
test("certificate key selection hides used keys unless requested", async ({ page }) => {
const user = unique("key-selection");
const vaultName = unique("Key Selection Vault");
const usedKeyName = unique("used-key");
const unusedKeyName = unique("unused-key");
const certName = unique("key-selection.example");
await login(page, user);
await page.getByRole("button", { name: "New vault" }).click();
await page.getByPlaceholder("Production PKI").fill(vaultName);
await page.getByRole("button", { name: "Create vault" }).click();
await page.getByRole("link", { name: vaultName }).click();
const vaultUrl = page.url();
for (const keyName of [usedKeyName, unusedKeyName]) {
await page.goto(`${vaultUrl}/keys/new`);
await (await field(page, "Internal name")).fill(keyName);
await page.getByRole("button", { name: "Generate key" }).click();
await expect(page.getByRole("heading", { name: keyName })).toBeVisible();
}
// Use one key in a certificate so subsequent forms can distinguish it.
await page.goto(`${vaultUrl}/certificates/new`);
let keySelect = existingKeySelect(page);
const usedKeyOption = keySelect.locator("option", { hasText: usedKeyName });
await expect(usedKeyOption).toHaveCount(1);
const usedKeyId = await usedKeyOption.getAttribute("value");
expect(usedKeyId).toBeTruthy();
await keySelect.selectOption(usedKeyId);
await (await field(page, "Internal name")).fill(certName);
await (await field(page, "Common name (CN)")).fill(certName);
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: certName })).toBeVisible();
const certUrl = page.url();
// Copying a certificate includes used keys and retains the original key.
await page.goto(`${certUrl}/similar`);
keySelect = existingKeySelect(page);
let includeUsed = page.getByLabel("Include used keys", { exact: true });
await expect(includeUsed).toBeChecked();
await expect(keySelect).toHaveValue(usedKeyId);
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(1);
// New certificates show only unused private keys by default.
await page.goto(`${vaultUrl}/certificates/new`);
keySelect = existingKeySelect(page);
includeUsed = page.getByLabel("Include used keys", { exact: true });
await expect(includeUsed).not.toBeChecked();
await expect(keySelect.locator("option", { hasText: unusedKeyName })).toHaveCount(1);
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(0);
await includeUsed.check();
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(1);
await expect(keySelect.locator("option", { hasText: unusedKeyName })).toHaveCount(1);
// Hiding a selected used key also clears the form value.
await keySelect.selectOption(usedKeyId);
await includeUsed.uncheck();
await expect(keySelect).toHaveValue("");
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(0);
});
+1032 -159
View File
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -11,7 +11,7 @@ tower-http = { version = "0.6", features = ["limit", "trace", "timeout"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
cbor2 = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = "2" thiserror = "2"
@@ -30,4 +30,14 @@ ecdsa = { version = "0.16", features = ["der"] }
pkcs8 = { version = "0.10", features = ["pem"] } pkcs8 = { version = "0.10", features = ["pem"] }
x509-ocsp = { version = "0.2.1", features = ["builder", "std"] } x509-ocsp = { version = "0.2.1", features = ["builder", "std"] }
x509-cert = { version = "0.2", features = ["std"] } x509-cert = { version = "0.2", features = ["std"] }
redis = { version = "1", features = ["tokio-comp", "connection-manager", "script"] } aws-config = { version = "1", default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"legacy-client",
"rt-tokio",
] }
aws-sdk-s3 = { version = "1", default-features = false, features = [
"http-1x",
"legacy-https-client",
"rt-tokio",
] }
+3 -3
View File
@@ -2,9 +2,9 @@
# Standalone Rust/Axum OCSP responder. # Standalone Rust/Axum OCSP responder.
# #
# The final image is scratch: the binary is a statically linked musl # The final image is scratch: the binary is a statically linked musl
# executable and the only file besides it is the CA bundle needed for TLS # executable and the only file besides it is the CA bundle needed for
# connections. Configuration, CA certificates and delegated signer material # outbound HTTPS to S3. Configuration, CA certificates and delegated signer
# are mounted at runtime. # material are mounted at runtime.
FROM rust:1-alpine AS builder FROM rust:1-alpine AS builder
+45 -48
View File
@@ -1,51 +1,50 @@
# KCA OCSP responder # KCA OCSP responder
Standalone Rust/Axum responder. It reads one full CBOR status snapshot per CA Standalone Rust/Axum responder. It reads one full JSON status snapshot per CA
from Redis, caches it for one second with a single-flight refresh, and signs from S3-compatible object storage, caches it for one second with a single-flight
OCSP responses with a delegated responder certificate. refresh, and signs OCSP responses with a delegated responder certificate.
It has **no KCA database access** and no KCA master key. It only needs: It has **no KCA database access**, no Redis access, and no KCA master key. It
only needs:
- read access to OCSP keys in the Redis instance selected by KCA (the session - `s3:GetObject` on the configured bucket/prefix;
Redis by default, or a dedicated instance when `KCA_OCSP_REDIS_URL` is set);
- the CA certificate and its KCA CA UUID for each issuer; - the CA certificate and its KCA CA UUID for each issuer;
- a delegated OCSP signer certificate and private key for each CA. The - a delegated OCSP signer certificate and private key for each CA. The
responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8, responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8,
PKCS#1 RSA, or SEC1 EC PEM form. PKCS#1 RSA, or SEC1 EC PEM form.
Where Redis ACLs are available, restrict the responder's credentials to the
OCSP prefix so it cannot read session entries. When sharing an instance, KCA
needs access to both `ocsp:*` and `kaya:session:*`; a dedicated OCSP instance
can use separate credentials.
## Snapshot layout ## Snapshot layout
KCA publishes keys as: KCA publishes objects as:
``` ```
<prefix>:<KCA CA UUID> <prefix>/<KCA CA UUID>.json
``` ```
The default prefix is `ocsp`. Set each issuer's required `ca_id` to the UUID of Set each issuer's required `ca_id` to the UUID of its CA certificate row in KCA.
its CA certificate row in KCA. This isolates snapshots when the same public CA This isolates snapshots when the same public CA certificate exists in multiple
certificate exists in multiple vaults. The responder validates both this ID and vaults. The responder validates both this ID and the CA certificate fingerprint
the CA certificate fingerprint against every downloaded snapshot. against every downloaded snapshot.
KCA publishes these keys when either `REDIS_URL` or `KCA_OCSP_REDIS_URL` is KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON
configured, preferring the dedicated OCSP URL when both are present. The contains all certificates issued by that CA, both valid and revoked, and a
`kca-ocsp-status/v2` payload is encoded with CBOR and contains all certificates required `generated_at` timestamp. The responder never infers status from a
issued by that CA, both valid and revoked, without a generation timestamp. partial revoked-only list.
Freshness is carried by the Redis key TTL. The responder never infers status
from a partial revoked-only list. Publisher and responder upgrades must be ## Upgrading from fingerprint-named snapshots
coordinated because legacy JSON payloads are rejected until republished.
Deploy the updated responder with `ca_id` before or together with the updated
KCA publisher. The responder deliberately does not fall back to legacy
`<prefix>/<CA certificate SHA-256>.json` objects. Until KCA publishes the new
objects, OCSP requests return `tryLater`. After verifying the new snapshots,
the obsolete fingerprint-named objects can be removed.
## Run locally ## Run locally
1. Create a config based on `responder.example.yaml`, setting each issuer's 1. Create a config based on `responder.example.yaml`, setting each issuer's
`ca_id` to its CA certificate UUID in KCA. `ca_id` to its CA certificate UUID in KCA.
2. Set `redis.url` or `OCSP_REDIS_URL` to the OCSP Redis URL used by KCA (the 2. Put your CA certificates and delegated signer files where the config points.
session `REDIS_URL` unless `KCA_OCSP_REDIS_URL` overrides it). 3. Export standard AWS credentials / endpoint variables as needed.
3. Put your CA certificates and delegated signer files where the config points.
4. Make the signer key readable by the container user (`65532:65532` in the 4. Make the signer key readable by the container user (`65532:65532` in the
published scratch image), or mount it via a Docker secret. published scratch image), or mount it via a Docker secret.
5. Run: 5. Run:
@@ -63,12 +62,12 @@ docker run --rm -p 2560:2560 \
-v "$PWD/responder.yaml:/etc/ocsp/responder.yaml:ro" \ -v "$PWD/responder.yaml:/etc/ocsp/responder.yaml:ro" \
-v "$PWD/ca:/etc/ocsp/ca:ro" \ -v "$PWD/ca:/etc/ocsp/ca:ro" \
-v "$PWD/signers:/etc/ocsp/signers:ro" \ -v "$PWD/signers:/etc/ocsp/signers:ro" \
-e OCSP_REDIS_URL \ -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
gitea.woggioni.net/woggioni/kca-ocsp:latest gitea.woggioni.net/woggioni/kca-ocsp:latest
``` ```
It runs `FROM scratch` and contains only a statically linked musl executable It runs `FROM scratch` and contains only a statically linked musl executable
and a CA bundle for TLS connections. There is no shell or package manager in and a CA bundle for outbound S3 HTTPS. There is no shell or package manager in
the image. the image.
## Verify with OpenSSL ## Verify with OpenSSL
@@ -97,23 +96,21 @@ build a trust store containing the CA or validate with your normal PKI tooling.
## Cache and availability ## Cache and availability
- Per-CA cache TTL: `redis.cache_ttl_ms` (default 1000 ms). - Per-CA cache TTL: `s3.cache_ttl_ms` (default 1000 ms).
- Concurrent requests for the same CA share one refresh. A Redis Lua script - Concurrent requests for the same CA share one S3 `GetObject`.
performs `GET` and `PTTL` atomically, keeping the snapshot and its expiry - `s3.stale_grace_seconds` is also the hard maximum snapshot age, measured from
consistent. `generated_at`. Expired or future-dated snapshots are rejected even when S3
- A missing or expired key produces `unknown`, as does a serial absent from an remains readable; if no valid cached snapshot remains, OCSP returns
existing snapshot. A key that exists without a TTL is rejected as a data `tryLater`.
error. Redis connection and command failures produce `tryLater` when no - If a refresh fails, a cached snapshot is served only while both its time since
unexpired cached snapshot remains. the last successful fetch and its `generated_at` age remain within
- If a refresh fails, a cached snapshot is served only until its Redis expiry. `s3.stale_grace_seconds`.
A missing key clears cached status so it cannot be revived by a later Redis
failure.
- KCA republishes unchanged snapshots every - KCA republishes unchanged snapshots every
`KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds) and sets each `KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds). Keep that interval
key's expiry using `KCA_OCSP_REDIS_EXPIRY_SECONDS` (default 60 seconds). comfortably below the responder's `s3.stale_grace_seconds` (default 60). For
Keep the heartbeat comfortably below the key expiry. a rolling upgrade, enable publisher heartbeats before the freshness-enforcing
- OCSP `thisUpdate` and `producedAt` use the response time. `nextUpdate` is responder so existing snapshots remain within the age limit.
capped at the snapshot's Redis expiry when a snapshot is present. - OCSP `thisUpdate` is the snapshot's `generated_at`. `nextUpdate` is capped at
- With multiple CAs, the cache TTL is per CA. If you need a global the snapshot's hard expiry, while `producedAt` is the response signing time.
one-request-per-second limit across all CAs, that requires a shared rate - With multiple CAs, the TTL is per CA. If you need a global one-request-per-
limiter. second limit across all CAs, that requires a shared rate limiter.
+8 -4
View File
@@ -3,12 +3,16 @@
listen: 0.0.0.0:2560 listen: 0.0.0.0:2560
next_update_seconds: 10 next_update_seconds: 10
redis: s3:
# OCSP Redis used by KCA (the session Redis unless KCA_OCSP_REDIS_URL is set). bucket: kca-ocsp
# OCSP_REDIS_URL overrides this value at runtime.
url: redis://redis:6379/0
prefix: ocsp prefix: ocsp
region: eu-central-1
# Optional for MinIO / other S3-compatible stores:
# endpoint: https://s3.example.com
# force_path_style: true
cache_ttl_ms: 1000 cache_ttl_ms: 1000
# Hard maximum snapshot age. Keep comfortably above KCA's heartbeat interval.
stale_grace_seconds: 60
issuers: issuers:
- name: Example Root CA - name: Example Root CA
+121 -112
View File
@@ -1,8 +1,9 @@
//! One-second, single-flight cache of per-CA status snapshots. //! One-second, single-flight cache of per-CA status snapshots.
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime}; use std::time::{Duration, Instant};
use chrono::Utc;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use crate::snapshot::Snapshot; use crate::snapshot::Snapshot;
@@ -10,8 +11,7 @@ use crate::store::ObjectStore;
#[derive(Clone)] #[derive(Clone)]
pub struct CachedSnapshot { pub struct CachedSnapshot {
pub snapshot: Arc<Snapshot>, snapshot: Arc<Snapshot>,
pub expires_at: SystemTime,
fetched_at: Instant, fetched_at: Instant,
} }
@@ -26,10 +26,10 @@ impl SnapshotCache {
Self::default() Self::default()
} }
/// Return a snapshot, refreshing from Redis at most once per `ttl`. /// Return a snapshot, refreshing from S3 at most once per `ttl`.
/// ///
/// A cached snapshot is never used after its Redis key expires, /// Snapshots older than `stale_grace` are never used. On refresh failure,
/// including when a refresh fails. /// cached data is also limited to `stale_grace` since its last fetch.
pub async fn get<S: ObjectStore + ?Sized>( pub async fn get<S: ObjectStore + ?Sized>(
&self, &self,
store: &S, store: &S,
@@ -37,10 +37,12 @@ impl SnapshotCache {
expected_sha256: &str, expected_sha256: &str,
expected_ca_id: &str, expected_ca_id: &str,
ttl: Duration, ttl: Duration,
) -> Result<CachedSnapshot, CacheError> { stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = self.cached.read().await.as_ref() { if let Some(value) = self.cached.read().await.as_ref() {
if fresh_cache_entry_valid(value, ttl) { if value.fetched_at.elapsed() < ttl && snapshot_age_valid(&value.snapshot, stale_grace)
return Ok(value.clone()); {
return Ok(value.snapshot.clone());
} }
} }
@@ -50,8 +52,10 @@ impl SnapshotCache {
let stale = { let stale = {
let cache = self.cached.read().await; let cache = self.cached.read().await;
if let Some(value) = cache.as_ref() { if let Some(value) = cache.as_ref() {
if fresh_cache_entry_valid(value, ttl) { if value.fetched_at.elapsed() < ttl
return Ok(value.clone()); && snapshot_age_valid(&value.snapshot, stale_grace)
{
return Ok(value.snapshot.clone());
} }
Some(value.clone()) Some(value.clone())
} else { } else {
@@ -60,31 +64,21 @@ impl SnapshotCache {
}; };
match store.get(key).await { match store.get(key).await {
Ok(crate::store::StoredSnapshot::Missing) => { Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) {
// A key that expired or was deleted must not be replaced by Ok(snapshot) => match validate_snapshot_age(&snapshot, stale_grace) {
// an older cached status on a later Redis failure. Ok(()) => {
*self.cached.write().await = None; let snapshot = Arc::new(snapshot);
Err(CacheError::Missing) *self.cached.write().await = Some(CachedSnapshot {
} snapshot: snapshot.clone(),
Ok(crate::store::StoredSnapshot::Present { body, expires_at }) => {
let parsed = validate_expiry(expires_at).and_then(|()| {
Snapshot::parse(&body, expected_sha256, expected_ca_id)
.map_err(|error| error.to_string())
});
match parsed {
Ok(snapshot) => {
let cached = CachedSnapshot {
snapshot: Arc::new(snapshot),
expires_at,
fetched_at: Instant::now(), fetched_at: Instant::now(),
}; });
*self.cached.write().await = Some(cached.clone()); Ok(snapshot)
Ok(cached)
} }
Err(error) => self.fallback(stale, error), Err(error) => self.fallback(stale, error, stale_grace),
} },
} Err(error) => self.fallback(stale, error.to_string(), stale_grace),
Err(error) => self.fallback(stale, error.to_string()), },
Err(error) => self.fallback(stale, error.to_string(), stale_grace),
} }
} }
@@ -92,37 +86,45 @@ impl SnapshotCache {
&self, &self,
stale: Option<CachedSnapshot>, stale: Option<CachedSnapshot>,
error: String, error: String,
) -> Result<CachedSnapshot, CacheError> { stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = stale { if let Some(value) = stale {
if expiry_valid(value.expires_at) { if value.fetched_at.elapsed() <= stale_grace
tracing::warn!(error = %error, "serving unexpired cached OCSP snapshot after refresh failure"); && snapshot_age_valid(&value.snapshot, stale_grace)
return Ok(value); {
tracing::warn!(error = %error, "serving stale OCSP snapshot");
return Ok(value.snapshot);
} }
} }
Err(CacheError::Unavailable(error)) Err(CacheError::Unavailable(error))
} }
} }
fn fresh_cache_entry_valid(value: &CachedSnapshot, ttl: Duration) -> bool { fn snapshot_age_valid(snapshot: &Snapshot, stale_grace: Duration) -> bool {
value.fetched_at.elapsed() < ttl && expiry_valid(value.expires_at) validate_snapshot_age(snapshot, stale_grace).is_ok()
} }
fn expiry_valid(expires_at: SystemTime) -> bool { fn validate_snapshot_age(snapshot: &Snapshot, stale_grace: Duration) -> Result<(), String> {
expires_at > SystemTime::now() let age = Utc::now()
} .signed_duration_since(snapshot.generated_at)
.to_std()
fn validate_expiry(expires_at: SystemTime) -> Result<(), String> { .map_err(|_| {
if expiry_valid(expires_at) { format!(
Ok(()) "snapshot generation time {} is in the future",
} else { snapshot.generated_at
Err(format!("snapshot Redis key expired at {expires_at:?}")) )
})?;
if age > stale_grace {
return Err(format!(
"snapshot generated at {} exceeds maximum age {stale_grace:?}",
snapshot.generated_at
));
} }
Ok(())
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CacheError { pub enum CacheError {
#[error("OCSP snapshot is missing")]
Missing,
#[error("OCSP snapshot unavailable: {0}")] #[error("OCSP snapshot unavailable: {0}")]
Unavailable(String), Unavailable(String),
} }
@@ -135,97 +137,119 @@ mod tests {
use bytes::Bytes; use bytes::Bytes;
use super::*; use super::*;
use crate::snapshot::{sha256_hex, test_body as body}; use crate::snapshot::sha256_hex;
use crate::store::{ObjectStore, StoreError, StoredSnapshot}; use crate::store::{ObjectStore, StoreError};
struct FakeStore { struct FakeStore {
body: Vec<u8>, body: String,
expires_at: SystemTime,
calls: AtomicUsize, calls: AtomicUsize,
fail: AtomicBool, fail: AtomicBool,
missing: AtomicBool,
} }
impl FakeStore { impl FakeStore {
fn new(body: Vec<u8>, lifetime: Duration) -> Self { fn new(body: String) -> Self {
Self { Self {
body, body,
expires_at: SystemTime::now() + lifetime,
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
fail: AtomicBool::new(false), fail: AtomicBool::new(false),
missing: AtomicBool::new(false),
}
}
fn expired(body: Vec<u8>) -> Self {
Self {
body,
expires_at: SystemTime::now() - Duration::from_secs(1),
calls: AtomicUsize::new(0),
fail: AtomicBool::new(false),
missing: AtomicBool::new(false),
} }
} }
} }
#[async_trait] #[async_trait]
impl ObjectStore for FakeStore { impl ObjectStore for FakeStore {
async fn get(&self, _key: &str) -> Result<StoredSnapshot, StoreError> { async fn get(&self, _key: &str) -> Result<Bytes, StoreError> {
self.calls.fetch_add(1, Ordering::SeqCst); self.calls.fetch_add(1, Ordering::SeqCst);
if self.fail.load(Ordering::SeqCst) { if self.fail.load(Ordering::SeqCst) {
return Err(StoreError::Redis("offline".to_string())); return Err(StoreError::S3("offline".to_string()));
} }
if self.missing.load(Ordering::SeqCst) { Ok(Bytes::from(self.body.clone()))
return Ok(StoredSnapshot::Missing);
}
Ok(StoredSnapshot::Present {
body: Bytes::from(self.body.clone()),
expires_at: self.expires_at,
})
} }
} }
fn body(sha: &str, generated_at: chrono::DateTime<Utc>) -> String {
let generated_at = generated_at.to_rfc3339();
format!(
r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"generated_at":"{generated_at}","entries":[{{"serial_hex":"01","status":"valid"}}]}}"#
)
}
#[tokio::test] #[tokio::test]
async fn single_flight_refreshes_once() { async fn single_flight_refreshes_once() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha), Duration::from_secs(1)); let store = FakeStore::new(body(&sha, Utc::now()));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
let (a, b) = tokio::join!( let (a, b) = tokio::join!(
cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),), cache.get(
cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),) &store,
"key",
&sha,
"ca",
Duration::from_millis(100),
Duration::from_secs(1)
),
cache.get(
&store,
"key",
&sha,
"ca",
Duration::from_millis(100),
Duration::from_secs(1)
)
); );
assert!(a.is_ok()); assert!(a.is_ok());
assert!(b.is_ok()); assert!(b.is_ok());
assert_eq!(store.calls.load(Ordering::SeqCst), 1); assert_eq!(store.calls.load(Ordering::SeqCst), 1);
assert_eq!(a.unwrap().expires_at, store.expires_at);
} }
#[tokio::test] #[tokio::test]
async fn refreshes_after_ttl_and_serves_unexpired_cache_on_error() { async fn refreshes_after_ttl_and_serves_stale_on_error() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha), Duration::from_secs(1)); let store = FakeStore::new(body(&sha, Utc::now()));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
cache cache
.get(&store, "key", &sha, "ca", Duration::from_millis(20)) .get(
&store,
"key",
&sha,
"ca",
Duration::from_millis(20),
Duration::from_secs(1),
)
.await .await
.unwrap(); .unwrap();
tokio::time::sleep(Duration::from_millis(30)).await; tokio::time::sleep(Duration::from_millis(30)).await;
store.fail.store(true, Ordering::SeqCst); store.fail.store(true, Ordering::SeqCst);
let stale = cache let stale = cache
.get(&store, "key", &sha, "ca", Duration::from_millis(20)) .get(
&store,
"key",
&sha,
"ca",
Duration::from_millis(20),
Duration::from_secs(1),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(stale.snapshot.entries.len(), 1); assert_eq!(stale.entries.len(), 1);
assert_eq!(store.calls.load(Ordering::SeqCst), 2); assert_eq!(store.calls.load(Ordering::SeqCst), 2);
} }
#[tokio::test] #[tokio::test]
async fn rejects_expired_snapshot_from_healthy_store() { async fn rejects_expired_snapshot_from_healthy_store() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let store = FakeStore::expired(body(&sha)); let generated_at = Utc::now() - chrono::Duration::seconds(30);
let store = FakeStore::new(body(&sha, generated_at));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
let result = cache let result = cache
.get(&store, "key", &sha, "ca", Duration::from_millis(1)) .get(
&store,
"key",
&sha,
"ca",
Duration::from_millis(1),
Duration::from_secs(1),
)
.await; .await;
assert!(matches!(result, Err(CacheError::Unavailable(_)))); assert!(matches!(result, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 1); assert_eq!(store.calls.load(Ordering::SeqCst), 1);
@@ -234,37 +258,22 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn successful_fetches_do_not_extend_snapshot_lifetime() { async fn successful_fetches_do_not_extend_snapshot_lifetime() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha), Duration::from_millis(100)); let store = FakeStore::new(body(&sha, Utc::now()));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
let ttl = Duration::from_secs(1); let ttl = Duration::from_secs(1);
cache.get(&store, "key", &sha, "ca", ttl).await.unwrap(); let stale_grace = Duration::from_millis(100);
cache
.get(&store, "key", &sha, "ca", ttl, stale_grace)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(120)).await; tokio::time::sleep(Duration::from_millis(120)).await;
let expired = cache.get(&store, "key", &sha, "ca", ttl).await; let expired = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await;
assert!(matches!(expired, Err(CacheError::Unavailable(_)))); assert!(matches!(expired, Err(CacheError::Unavailable(_))));
store.fail.store(true, Ordering::SeqCst); store.fail.store(true, Ordering::SeqCst);
let unavailable = cache.get(&store, "key", &sha, "ca", ttl).await; let unavailable = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await;
assert!(matches!(unavailable, Err(CacheError::Unavailable(_)))); assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 3); assert_eq!(store.calls.load(Ordering::SeqCst), 3);
} }
#[tokio::test]
async fn missing_key_clears_cached_status_instead_of_reviving_it() {
let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha), Duration::from_secs(1));
let cache = SnapshotCache::new();
let ttl = Duration::from_millis(20);
cache.get(&store, "key", &sha, "ca", ttl).await.unwrap();
store.missing.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(30)).await;
let missing = cache.get(&store, "key", &sha, "ca", ttl).await;
assert!(matches!(missing, Err(CacheError::Missing)));
store.missing.store(false, Ordering::SeqCst);
store.fail.store(true, Ordering::SeqCst);
let unavailable = cache.get(&store, "key", &sha, "ca", ttl).await;
assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
}
} }
+30 -19
View File
@@ -10,7 +10,7 @@ pub struct Config {
#[serde(default = "default_listen")] #[serde(default = "default_listen")]
pub listen: SocketAddr, pub listen: SocketAddr,
#[serde(default)] #[serde(default)]
pub redis: RedisConfig, pub s3: S3Config,
#[serde(default)] #[serde(default)]
pub issuers: Vec<IssuerConfig>, pub issuers: Vec<IssuerConfig>,
#[serde(default = "default_ocsp_next_update_seconds")] #[serde(default = "default_ocsp_next_update_seconds")]
@@ -27,32 +27,42 @@ pub struct IssuerConfig {
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct RedisConfig { pub struct S3Config {
pub url: String, pub bucket: String,
#[serde(default = "default_prefix")] #[serde(default = "default_prefix")]
pub prefix: String, pub prefix: String,
pub region: Option<String>,
pub endpoint: Option<String>,
#[serde(default)]
pub force_path_style: bool,
#[serde(default = "default_cache_ttl_ms")] #[serde(default = "default_cache_ttl_ms")]
pub cache_ttl_ms: u64, pub cache_ttl_ms: u64,
#[serde(default = "default_stale_grace_seconds")]
pub stale_grace_seconds: u64,
} }
impl Default for RedisConfig { impl Default for S3Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
url: String::new(), bucket: String::new(),
prefix: default_prefix(), prefix: default_prefix(),
region: None,
endpoint: None,
force_path_style: false,
cache_ttl_ms: default_cache_ttl_ms(), cache_ttl_ms: default_cache_ttl_ms(),
stale_grace_seconds: default_stale_grace_seconds(),
} }
} }
} }
impl RedisConfig { impl S3Config {
pub fn key(&self, ca_id: &str) -> String { pub fn object_key(&self, ca_id: &str) -> String {
let prefix = self.prefix.trim_matches(['/', ':']); let prefix = self.prefix.trim_matches('/');
let name = ca_id.to_lowercase(); let name = format!("{}.json", ca_id.to_lowercase());
if prefix.is_empty() { if prefix.is_empty() {
name name
} else { } else {
format!("{prefix}:{name}") format!("{prefix}/{name}")
} }
} }
} }
@@ -71,6 +81,10 @@ fn default_cache_ttl_ms() -> u64 {
1000 1000
} }
fn default_stale_grace_seconds() -> u64 {
60
}
fn default_ocsp_next_update_seconds() -> u64 { fn default_ocsp_next_update_seconds() -> u64 {
10 10
} }
@@ -83,11 +97,8 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>>
if config.issuers.is_empty() { if config.issuers.is_empty() {
return Err("configuration must contain at least one issuer".into()); return Err("configuration must contain at least one issuer".into());
} }
if let Ok(url) = std::env::var("OCSP_REDIS_URL") { if config.s3.bucket.trim().is_empty() {
config.redis.url = url; return Err("s3.bucket must be configured".into());
}
if config.redis.url.trim().is_empty() {
return Err("redis.url must be configured".into());
} }
if let Ok(listen) = std::env::var("OCSP_LISTEN") { if let Ok(listen) = std::env::var("OCSP_LISTEN") {
config.listen = listen.parse()?; config.listen = listen.parse()?;
@@ -100,14 +111,14 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn redis_key_uses_ca_id() { fn object_key_uses_ca_id() {
let config = RedisConfig { let config = S3Config {
prefix: "ocsp".to_string(), prefix: "ocsp".to_string(),
..Default::default() ..Default::default()
}; };
assert_eq!( assert_eq!(
config.key("123E4567-E89B-12D3-A456-426614174000"), config.object_key("123E4567-E89B-12D3-A456-426614174000"),
"ocsp:123e4567-e89b-12d3-a456-426614174000" "ocsp/123e4567-e89b-12d3-a456-426614174000.json"
); );
} }
} }
+4 -3
View File
@@ -111,9 +111,9 @@ impl Issuer {
pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>( pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>(
&self, &self,
store: &S, store: &S,
config: &crate::config::RedisConfig, config: &crate::config::S3Config,
) -> Result<crate::cache::CachedSnapshot, crate::cache::CacheError> { ) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> {
let key = config.key(&self.ca_id); let key = config.object_key(&self.ca_id);
self.cache self.cache
.get( .get(
store, store,
@@ -121,6 +121,7 @@ impl Issuer {
&self.fingerprint, &self.fingerprint,
&self.ca_id, &self.ca_id,
Duration::from_millis(config.cache_ttl_ms), Duration::from_millis(config.cache_ttl_ms),
Duration::from_secs(config.stale_grace_seconds),
) )
.await .await
} }
+1 -1
View File
@@ -32,7 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.init(); .init();
let config = config::load_config()?; let config = config::load_config()?;
let store = StatusStore::new(&config.redis).await?; let store = StatusStore::new(&config.s3).await;
let mut issuers = Vec::with_capacity(config.issuers.len()); let mut issuers = Vec::with_capacity(config.issuers.len());
for issuer_config in &config.issuers { for issuer_config in &config.issuers {
let issuer = Issuer::load(issuer_config)?; let issuer = Issuer::load(issuer_config)?;
+29 -25
View File
@@ -8,6 +8,7 @@ use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use base64::Engine; use base64::Engine;
use chrono::{DateTime, Utc};
use der::{Decode, Encode}; use der::{Decode, Encode};
use x509_cert::ext::pkix::CrlReason; use x509_cert::ext::pkix::CrlReason;
use x509_ocsp::builder::OcspResponseBuilder; use x509_ocsp::builder::OcspResponseBuilder;
@@ -66,11 +67,8 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
return ocsp_response(OcspResponse::unauthorized()); return ocsp_response(OcspResponse::unauthorized());
} }
let (snapshot, expires_at) = match issuer.snapshot(&state.store, &state.config.redis).await { let snapshot = match issuer.snapshot(&state.store, &state.config.s3).await {
Ok(cached) => (cached.snapshot, Some(cached.expires_at)), Ok(snapshot) => snapshot,
Err(crate::cache::CacheError::Missing) => {
(Arc::new(crate::snapshot::Snapshot::default()), None)
}
Err(error) => { Err(error) => {
tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable"); tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable");
return ocsp_response(OcspResponse::try_later()); return ocsp_response(OcspResponse::try_later());
@@ -85,8 +83,12 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
return ocsp_response(OcspResponse::internal_error()); return ocsp_response(OcspResponse::internal_error());
} }
}; };
let (this_update, next_update) = let (this_update, next_update) = response_update_times(
response_update_times(expires_at, state.config.next_update_seconds, now); snapshot.generated_at,
state.config.next_update_seconds,
state.config.s3.stale_grace_seconds,
now,
);
let this_update = match OcspGeneralizedTime::try_from(this_update) { let this_update = match OcspGeneralizedTime::try_from(this_update) {
Ok(value) => value, Ok(value) => value,
Err(error) => { Err(error) => {
@@ -141,17 +143,17 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
fn response_update_times( fn response_update_times(
expires_at: Option<SystemTime>, generated_at: DateTime<Utc>,
next_update_seconds: u64, next_update_seconds: u64,
stale_grace_seconds: u64,
now: SystemTime, now: SystemTime,
) -> (SystemTime, Option<SystemTime>) { ) -> (SystemTime, Option<SystemTime>) {
let this_update = system_time(generated_at);
let expires_at = this_update.checked_add(Duration::from_secs(stale_grace_seconds));
let next_update = now let next_update = now
.checked_add(Duration::from_secs(next_update_seconds)) .checked_add(Duration::from_secs(next_update_seconds))
.map(|next| match expires_at { .and_then(|next| expires_at.map(|expires| next.min(expires)));
Some(expires_at) => next.min(expires_at), (this_update, next_update)
None => next,
});
(now, next_update)
} }
fn ocsp_response(response: OcspResponse) -> Response { fn ocsp_response(response: OcspResponse) -> Response {
@@ -177,20 +179,22 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn update_times_use_response_time_and_bound_next_update_to_expiry() { fn update_times_use_snapshot_age_and_bound_next_update() {
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_225_600); let generated_at = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
let expires_at = base + Duration::from_secs(60); .unwrap()
.with_timezone(&Utc);
let generated_at_system = system_time(generated_at);
let now = base + Duration::from_secs(5); let now = generated_at_system + Duration::from_secs(5);
let (this_update, next_update) = response_update_times(Some(expires_at), 10, now); let (this_update, next_update) = response_update_times(generated_at, 10, 60, now);
assert_eq!(this_update, now); assert_eq!(this_update, generated_at_system);
assert_eq!(next_update, Some(now + Duration::from_secs(10))); assert_eq!(next_update, Some(now + Duration::from_secs(10)));
let now = base + Duration::from_secs(55); let now = generated_at_system + Duration::from_secs(55);
let (_, next_update) = response_update_times(Some(expires_at), 10, now); let (_, next_update) = response_update_times(generated_at, 10, 60, now);
assert_eq!(next_update, Some(expires_at)); assert_eq!(
next_update,
let (_, next_update) = response_update_times(None, 10, now); Some(generated_at_system + Duration::from_secs(60))
assert_eq!(next_update, Some(now + Duration::from_secs(10))); );
} }
} }
+51 -74
View File
@@ -3,12 +3,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use sha1::{Digest as _, Sha1}; use sha1::{Digest as _, Sha1};
use sha2::Sha256; use sha2::Sha256;
use x509_cert::ext::pkix::CrlReason; use x509_cert::ext::pkix::CrlReason;
pub const SCHEMA: &str = "kca-ocsp-status/v2"; pub const SCHEMA: &str = "kca-ocsp-status/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status { pub enum Status {
@@ -23,20 +23,22 @@ pub struct StatusEntry {
pub reason: Option<CrlReason>, pub reason: Option<CrlReason>,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone)]
pub struct Snapshot { pub struct Snapshot {
pub generated_at: DateTime<Utc>,
pub entries: HashMap<Vec<u8>, StatusEntry>, pub entries: HashMap<Vec<u8>, StatusEntry>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize)]
struct RawSnapshot { struct RawSnapshot {
schema: String, schema: String,
issuer: RawIssuer, issuer: RawIssuer,
generated_at: DateTime<Utc>,
#[serde(default)] #[serde(default)]
entries: Vec<RawEntry>, entries: Vec<RawEntry>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize)]
struct RawIssuer { struct RawIssuer {
#[serde(default)] #[serde(default)]
id: String, id: String,
@@ -44,7 +46,7 @@ struct RawIssuer {
sha256: String, sha256: String,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize)]
struct RawEntry { struct RawEntry {
serial_hex: String, serial_hex: String,
status: String, status: String,
@@ -61,12 +63,13 @@ impl Snapshot {
expected_sha256: &str, expected_sha256: &str,
expected_ca_id: &str, expected_ca_id: &str,
) -> Result<Self, SnapshotError> { ) -> Result<Self, SnapshotError> {
cbor2::validate_slice(bytes).map_err(|error| SnapshotError::Cbor(error.to_string()))?; let raw: RawSnapshot = serde_json::from_slice(bytes)?;
let raw: RawSnapshot =
cbor2::from_slice(bytes).map_err(|error| SnapshotError::Cbor(error.to_string()))?;
if raw.schema != SCHEMA { if raw.schema != SCHEMA {
return Err(SnapshotError::Schema(raw.schema)); return Err(SnapshotError::Schema(raw.schema));
} }
if raw.generated_at > Utc::now() {
return Err(SnapshotError::FutureGeneration(raw.generated_at));
}
if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) { if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) {
return Err(SnapshotError::IssuerIdMismatch { return Err(SnapshotError::IssuerIdMismatch {
expected: expected_ca_id.to_string(), expected: expected_ca_id.to_string(),
@@ -112,7 +115,10 @@ impl Snapshot {
} }
} }
let _ = raw.issuer.sha256; let _ = raw.issuer.sha256;
Ok(Self { entries }) Ok(Self {
generated_at: raw.generated_at,
entries,
})
} }
pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> { pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> {
@@ -168,10 +174,12 @@ pub fn hash_sha256(bytes: &[u8]) -> Vec<u8> {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SnapshotError { pub enum SnapshotError {
#[error("invalid CBOR: {0}")] #[error("invalid JSON: {0}")]
Cbor(String), Json(#[from] serde_json::Error),
#[error("unsupported snapshot schema {0:?}")] #[error("unsupported snapshot schema {0:?}")]
Schema(String), Schema(String),
#[error("snapshot generation time {0} is in the future")]
FutureGeneration(DateTime<Utc>),
#[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")] #[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")]
IssuerIdMismatch { expected: String, actual: String }, IssuerIdMismatch { expected: String, actual: String },
#[error("snapshot issuer {actual} does not match configured CA {expected}")] #[error("snapshot issuer {actual} does not match configured CA {expected}")]
@@ -186,64 +194,28 @@ pub enum SnapshotError {
DuplicateSerial(String), DuplicateSerial(String),
} }
#[cfg(test)]
pub(crate) fn test_body(sha: &str) -> Vec<u8> {
let raw = RawSnapshot {
schema: SCHEMA.to_string(),
issuer: RawIssuer {
id: "ca".to_string(),
sha256: sha.to_string(),
},
entries: vec![RawEntry {
serial_hex: "01".to_string(),
status: "valid".to_string(),
revoked_at: None,
reason: None,
}],
};
cbor2::to_vec(&raw).unwrap()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn raw_body(sha: &str, schema: &str) -> RawSnapshot { fn body(sha: &str) -> String {
RawSnapshot { format!(
schema: schema.to_string(), r#"{{
issuer: RawIssuer { "schema": "kca-ocsp-status/v1",
id: "ca".to_string(), "issuer": {{"id": "ca", "sha256": "{sha}", "subject": "CN=CA"}},
sha256: sha.to_string(), "generated_at": "2026-01-01T00:00:00Z",
}, "entries": [
entries: vec![ {{"serial_hex": "01ab", "status": "valid", "not_after": "2027-01-01T00:00:00Z"}},
RawEntry { {{"serial_hex": "00ff", "status": "revoked", "revoked_at": "2026-02-03T04:05:06Z", "reason": "keyCompromise"}}
serial_hex: "01ab".to_string(), ]
status: "valid".to_string(), }}"#
revoked_at: None, )
reason: None,
},
RawEntry {
serial_hex: "00ff".to_string(),
status: "revoked".to_string(),
revoked_at: Some(
DateTime::parse_from_rfc3339("2026-02-03T04:05:06Z")
.unwrap()
.with_timezone(&Utc),
),
reason: Some("keyCompromise".to_string()),
},
],
}
}
fn body(sha: &str) -> Vec<u8> {
cbor2::to_vec(&raw_body(sha, SCHEMA)).unwrap()
} }
#[test] #[test]
fn parses_statuses_and_reasons() { fn parses_statuses_and_reasons() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let snapshot = Snapshot::parse(&body(&sha), &sha, "ca").unwrap(); let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha, "ca").unwrap();
let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap(); let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap();
assert_eq!(good.status, Status::Good); assert_eq!(good.status, Status::Good);
let revoked = snapshot.status_for_serial(&[0xff]).unwrap(); let revoked = snapshot.status_for_serial(&[0xff]).unwrap();
@@ -255,27 +227,32 @@ mod tests {
#[test] #[test]
fn rejects_wrong_issuer_schema_and_duplicates() { fn rejects_wrong_issuer_schema_and_duplicates() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
assert!(Snapshot::parse(&body(&sha), "wrong", "ca").is_err()); assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong", "ca").is_err());
assert!(matches!( assert!(matches!(
Snapshot::parse(&body(&sha), &sha, "other"), Snapshot::parse(body(&sha).as_bytes(), &sha, "other"),
Err(SnapshotError::IssuerIdMismatch { .. }) Err(SnapshotError::IssuerIdMismatch { .. })
)); ));
let wrong_schema = cbor2::to_vec(&raw_body(&sha, "other")).unwrap(); let wrong_schema = body(&sha).replace(SCHEMA, "other");
assert!(Snapshot::parse(&wrong_schema, &sha, "ca").is_err()); assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha, "ca").is_err());
let mut duplicate = raw_body(&sha, SCHEMA); let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\"");
duplicate.entries[1].serial_hex = duplicate.entries[0].serial_hex.clone(); assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err());
let duplicate = cbor2::to_vec(&duplicate).unwrap();
assert!(Snapshot::parse(&duplicate, &sha, "ca").is_err());
} }
#[test] #[test]
fn rejects_legacy_json() { fn rejects_missing_or_future_generation_time() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let legacy = let missing = body(&sha)
br#"{"schema":"kca-ocsp-status/v2","issuer":{"id":"ca","sha256":"sha"},"entries":[]}"#; .lines()
.filter(|line| !line.contains("\"generated_at\""))
.collect::<Vec<_>>()
.join("\n");
assert!(Snapshot::parse(missing.as_bytes(), &sha, "ca").is_err());
let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let future = body(&sha).replace("2026-01-01T00:00:00Z", &future);
assert!(matches!( assert!(matches!(
Snapshot::parse(legacy, &sha, "ca"), Snapshot::parse(future.as_bytes(), &sha, "ca"),
Err(SnapshotError::Cbor(_)) Err(SnapshotError::FutureGeneration(_))
)); ));
} }
+37 -143
View File
@@ -1,170 +1,64 @@
//! Redis OCSP snapshot access. //! S3-compatible object store access.
use std::time::{Duration, SystemTime};
use async_trait::async_trait; use async_trait::async_trait;
use aws_sdk_s3::Client;
use bytes::Bytes; use bytes::Bytes;
use redis::aio::ConnectionManager;
use crate::config::RedisConfig; use crate::config::S3Config;
const FETCH_SNAPSHOT: &str = r#"
local body = redis.call('GET', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
return {body, ttl}
"#;
#[derive(Clone)] #[derive(Clone)]
pub struct StatusStore { pub struct StatusStore {
connection: ConnectionManager, client: Client,
bucket: String,
} }
impl StatusStore { impl StatusStore {
pub async fn new(config: &RedisConfig) -> Result<Self, StoreError> { pub async fn new(config: &S3Config) -> Self {
let client = redis::Client::open(config.url.as_str()) let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
.map_err(|error| StoreError::Redis(error.to_string()))?; if let Some(region) = config.region.as_ref() {
let connection = client loader = loader.region(aws_config::Region::new(region.clone()));
.get_connection_manager() }
.await let shared = loader.load().await;
.map_err(|error| StoreError::Redis(error.to_string()))?; let mut builder = aws_sdk_s3::config::Builder::from(&shared);
Ok(Self { connection }) if let Some(endpoint) = config.endpoint.as_ref() {
builder = builder.endpoint_url(endpoint);
}
builder = builder.force_path_style(config.force_path_style);
Self {
client: Client::from_conf(builder.build()),
bucket: config.bucket.clone(),
}
} }
} }
#[derive(Debug)]
pub enum StoredSnapshot {
Present { body: Bytes, expires_at: SystemTime },
Missing,
}
#[async_trait] #[async_trait]
pub trait ObjectStore: Send + Sync { pub trait ObjectStore: Send + Sync {
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError>; async fn get(&self, key: &str) -> Result<Bytes, StoreError>;
} }
#[async_trait] #[async_trait]
impl ObjectStore for StatusStore { impl ObjectStore for StatusStore {
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError> { async fn get(&self, key: &str) -> Result<Bytes, StoreError> {
let mut connection = self.connection.clone(); let response = self
let (body, pttl): (Option<Vec<u8>>, i64) = redis::Script::new(FETCH_SNAPSHOT) .client
.get_object()
.bucket(&self.bucket)
.key(key) .key(key)
.invoke_async(&mut connection) .send()
.await .await
.map_err(|error| StoreError::Redis(error.to_string()))?; .map_err(|error| StoreError::S3(format!("{error:?}")))?;
stored_snapshot(body, pttl, SystemTime::now()) let data = response
} .body
} .collect()
.await
fn stored_snapshot( .map_err(|error| StoreError::S3(format!("{error:?}")))?
body: Option<Vec<u8>>, .into_bytes();
pttl: i64, Ok(data)
now: SystemTime,
) -> Result<StoredSnapshot, StoreError> {
match body {
None => Ok(StoredSnapshot::Missing),
Some(_) if pttl == -1 => Err(StoreError::MissingTtl),
Some(_) if pttl <= 0 => Ok(StoredSnapshot::Missing),
Some(body) => {
let expires_at = now
.checked_add(Duration::from_millis(pttl as u64))
.ok_or(StoreError::InvalidTtl(pttl))?;
Ok(StoredSnapshot::Present {
body: Bytes::from(body),
expires_at,
})
}
} }
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StoreError { pub enum StoreError {
#[error("Redis request failed: {0}")] #[error("S3 GetObject failed: {0}")]
Redis(String), S3(String),
#[error("OCSP snapshot exists without an expiry")]
MissingTtl,
#[error("OCSP snapshot has an invalid TTL: {0}")]
InvalidTtl(i64),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_and_expired_keys_are_missing_snapshots() {
let now = SystemTime::now();
assert!(matches!(
stored_snapshot(None, -2, now),
Ok(StoredSnapshot::Missing)
));
assert!(matches!(
stored_snapshot(Some(b"stale".to_vec()), 0, now),
Ok(StoredSnapshot::Missing)
));
}
#[test]
fn present_snapshot_uses_pttl_for_expiry() {
let now = SystemTime::now();
let stored = stored_snapshot(Some(b"snapshot".to_vec()), 5_000, now).unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
assert_eq!(body, Bytes::from_static(b"snapshot"));
assert_eq!(expires_at, now + Duration::from_millis(5_000));
}
StoredSnapshot::Missing => panic!("snapshot should be present"),
}
}
#[test]
fn present_snapshot_without_ttl_is_rejected() {
let result = stored_snapshot(Some(b"snapshot".to_vec()), -1, SystemTime::now());
assert!(matches!(result, Err(StoreError::MissingTtl)));
}
#[tokio::test]
#[ignore = "requires KCA_TEST_REDIS_URL"]
async fn real_redis_round_trip() {
let Ok(url) = std::env::var("KCA_TEST_REDIS_URL") else {
return;
};
let config = RedisConfig {
url,
prefix: format!("ocsp:test:{}", std::process::id()),
..Default::default()
};
let key = config.key("ca");
let sha = crate::snapshot::sha256_hex(b"ca");
let store = StatusStore::new(&config).await.unwrap();
let client = redis::Client::open(config.url.as_str()).unwrap();
let mut connection = client.get_connection_manager().await.unwrap();
let _: () = redis::cmd("SET")
.arg(&key)
.arg(crate::snapshot::test_body(&sha))
.arg("EX")
.arg(30)
.query_async(&mut connection)
.await
.unwrap();
let stored = store.get(&key).await.unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
let snapshot = crate::snapshot::Snapshot::parse(&body, &sha, "ca").unwrap();
assert_eq!(snapshot.entries.len(), 1);
assert!(expires_at > SystemTime::now());
}
StoredSnapshot::Missing => panic!("snapshot should be present"),
}
let _: () = redis::cmd("DEL")
.arg(&key)
.query_async(&mut connection)
.await
.unwrap();
assert!(matches!(
store.get(&key).await.unwrap(),
StoredSnapshot::Missing
));
}
} }
+11 -19
View File
@@ -1,7 +1,6 @@
# Environment configuration for KCA. Copy to .env or export the variables. # Environment configuration for KCA. Copy to .env or export the variables.
# --- Database -------------------------------------------------------------- # --- Database --------------------------------------------------------------
# PostgreSQL (default). Run `aerich upgrade` before starting the app.
DATABASE_ENGINE=postgres DATABASE_ENGINE=postgres
DATABASE_HOST=localhost DATABASE_HOST=localhost
DATABASE_PORT=5432 DATABASE_PORT=5432
@@ -11,15 +10,6 @@ DATABASE_PASSWORD=password
#DATABASE_OPTIONS= #DATABASE_OPTIONS=
#DATABASE_URL= #DATABASE_URL=
# SQLite production alternative: DATABASE_NAME is an absolute or relative file
# path; host, port, user and password are ignored. Use a persistent writable
# filesystem and run `aerich upgrade` before starting the app.
#DATABASE_ENGINE=sqlite
#DATABASE_NAME=/data/kca.db
#DATABASE_OPTIONS=busy_timeout=5000
# Equivalent single-setting form:
#DATABASE_URL=sqlite:///data/kca.db?busy_timeout=5000
# --- OIDC ------------------------------------------------------------------ # --- OIDC ------------------------------------------------------------------
# Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0. # Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0.
OIDC_0_PROVIDER=default OIDC_0_PROVIDER=default
@@ -66,17 +56,19 @@ OTEL_EXCLUDED_PATHS=/api/health
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15 #OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# --- OCSP status publisher (optional) -------------------------------------- # --- OCSP status publisher (optional) --------------------------------------
# KCA publishes one full CBOR status snapshot per CA. By default it uses the # When a bucket is configured, KCA publishes one full JSON status snapshot
# same Redis instance and logical database as sessions. Set KCA_OCSP_REDIS_URL # per CA. The standalone OCSP responder reads these objects. Uses the normal
# to use a dedicated instance instead (also works when REDIS_URL is unset). # AWS credential chain (AWS_ACCESS_KEY_ID /
# Keys use a separate prefix and expire unless refreshed by the heartbeat. # AWS_SECRET_ACCESS_KEY, role, instance profile, ...).
#KCA_OCSP_REDIS_URL=redis://localhost:6380/0 #KCA_OCSP_S3_BUCKET=kca-ocsp
KCA_OCSP_REDIS_PREFIX=ocsp KCA_OCSP_S3_PREFIX=ocsp
#KCA_OCSP_S3_REGION=eu-central-1
#KCA_OCSP_S3_ENDPOINT=http://localhost:9000
KCA_OCSP_S3_FORCE_PATH_STYLE=false
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1 KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat # Republish unchanged snapshots as a liveness heartbeat. Keep this comfortably
# comfortably below the key expiry. # below the responder's s3.stale_grace_seconds (default 60).
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30 KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30
KCA_OCSP_REDIS_EXPIRY_SECONDS=60
# --- CORS (optional) ------------------------------------------------------- # --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS= #CORS_ALLOW_ORIGINS=
+3 -4
View File
@@ -49,11 +49,11 @@ COPY server/uv.lock server/pyproject.toml ./
COPY server/packages/kca-core/pyproject.toml packages/kca-core/ COPY server/packages/kca-core/pyproject.toml packages/kca-core/
COPY server/packages/kca-app/pyproject.toml packages/kca-app/ COPY server/packages/kca-app/pyproject.toml packages/kca-app/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-workspace --package kca-app --extra otel --no-dev uv sync --frozen --no-install-workspace --package kca-app --extra otel --extra s3 --no-dev
COPY server/packages/ ./packages/ COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --package kca-app --extra otel --no-dev \ uv sync --locked --no-editable --package kca-app --extra otel --extra s3 --no-dev \
--reinstall-package kca-core --reinstall-package kca-app --reinstall-package kca-core --reinstall-package kca-app
COPY server/migrations ./migrations COPY server/migrations ./migrations
@@ -67,8 +67,7 @@ RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
&& apt-get install -y --no-install-recommends ca-certificates tzdata libssl3 \ && apt-get install -y --no-install-recommends ca-certificates tzdata libssl3 \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& groupadd --system app \ && groupadd --system app \
&& useradd --system --gid app --no-create-home --shell /usr/sbin/nologin app \ && useradd --system --gid app --no-create-home --shell /usr/sbin/nologin app
&& install -d -o app -g app /data
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /repo/server/migrations /app/migrations COPY --from=builder /repo/server/migrations /app/migrations
@@ -3,223 +3,7 @@ from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True
# SQLite production support was introduced after migration 4. Bootstrap new
# SQLite databases directly at that current schema; migrations 1-4 are no-ops
# on SQLite. PostgreSQL retains the historical migration path below.
SQLITE_UPGRADE = """
CREATE TABLE IF NOT EXISTS "api_token" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"user_issuer" VARCHAR(2048) NOT NULL,
"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" TIMESTAMP NOT NULL,
"last_used_at" TIMESTAMP,
"expires_at" DATE
);
CREATE INDEX IF NOT EXISTS "idx_api_token_user_is_612f0d" ON "api_token" ("user_issuer", "user_sub");
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
"ca_id" CHAR(36) NOT NULL PRIMARY KEY,
"dirty" INT NOT NULL,
"published_at" TIMESTAMP,
"last_error" TEXT,
"updated_at" TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_ocsp_snapsh_dirty_a8d440" ON "ocsp_snapshot" ("dirty");
CREATE TABLE IF NOT EXISTS "app_user" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"issuer" VARCHAR(2048) NOT NULL,
"sub" VARCHAR(255) NOT NULL,
"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" TIMESTAMP NOT NULL,
"last_seen_at" TIMESTAMP NOT NULL,
CONSTRAINT "uid_app_user_issuer_d243fe" UNIQUE ("issuer", "sub")
);
CREATE TABLE IF NOT EXISTS "vault" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"owner_issuer" VARCHAR(2048) NOT NULL,
"owner_sub" VARCHAR(255) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"description" TEXT NOT NULL,
"settings" JSON NOT NULL,
"version" INT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_vault_owner_i_a035fb" ON "vault" ("owner_issuer", "owner_sub");
CREATE TABLE IF NOT EXISTS "key" (
"id" CHAR(36) 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" BLOB NOT NULL,
"public_key_sha256" VARCHAR(64) NOT NULL,
"private_der" BLOB,
"private_nonce" BLOB,
"has_private" INT NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"vault_id" CHAR(36) 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");
CREATE TABLE IF NOT EXISTS "request" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"subject_der" BLOB NOT NULL,
"req_der" BLOB NOT NULL,
"public_key_der" BLOB NOT NULL,
"request_sha256" VARCHAR(64) NOT NULL,
"signed" INT NOT NULL,
"signed_at" TIMESTAMP,
"attributes" JSON NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"key_id" CHAR(36) REFERENCES "key" ("id") ON DELETE SET NULL,
"vault_id" CHAR(36) 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");
CREATE TABLE IF NOT EXISTS "certificate" (
"id" CHAR(36) 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" BLOB NOT NULL,
"issuer_der" BLOB NOT NULL,
"subject_hash" VARCHAR(64) NOT NULL,
"issuer_hash" VARCHAR(64) NOT NULL,
"not_before" TIMESTAMP NOT NULL,
"not_after" TIMESTAMP NOT NULL,
"no_expiration" INT NOT NULL,
"is_ca" INT NOT NULL,
"path_length" INT,
"cert_der" BLOB NOT NULL,
"public_key_der" BLOB 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" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"issuer_id" CHAR(36) REFERENCES "certificate" ("id") ON DELETE SET NULL,
"key_id" CHAR(36) REFERENCES "key" ("id") ON DELETE SET NULL,
"request_id" CHAR(36) REFERENCES "request" ("id") ON DELETE SET NULL,
"vault_id" CHAR(36) 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");
CREATE TABLE IF NOT EXISTS "certificate_revocation" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"serial_hex" VARCHAR(128) NOT NULL,
"revoked_at" TIMESTAMP NOT NULL,
"invalidity_date" TIMESTAMP,
"reason" VARCHAR(50) NOT NULL,
"crl_number" INT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"ca_id" CHAR(36) 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");
CREATE TABLE IF NOT EXISTS "crl" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"crl_der" BLOB NOT NULL,
"crl_hash" VARCHAR(64) NOT NULL,
"crl_number" INT,
"last_update" TIMESTAMP NOT NULL,
"next_update" TIMESTAMP,
"revoked_count" INT NOT NULL,
"signature_algorithm" VARCHAR(100) NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"issuer_id" CHAR(36) REFERENCES "certificate" ("id") ON DELETE SET NULL,
"vault_id" CHAR(36) 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");
CREATE TABLE IF NOT EXISTS "template" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(200) NOT NULL,
"comment" TEXT NOT NULL,
"source" VARCHAR(20) NOT NULL,
"subject" JSON NOT NULL,
"settings" JSON NOT NULL,
"version" INT NOT NULL,
"search_text" TEXT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"vault_id" CHAR(36) NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_template_vault_i_18d8f5" ON "template" ("vault_id", "name");
CREATE TABLE IF NOT EXISTS "authority" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"template_key" VARCHAR(64),
"crl_expire" TIMESTAMP,
"crl_number" INT NOT NULL,
"crl_days" INT NOT NULL,
"tls_auth_key" BLOB,
"tls_auth_key_nonce" BLOB,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"template_id" CHAR(36) REFERENCES "template" ("id") ON DELETE SET NULL,
"certificate_id" CHAR(36) NOT NULL UNIQUE REFERENCES "certificate" ("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "aerich" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSON NOT NULL
);
"""
async def upgrade(db: BaseDBAsyncClient) -> str: async def upgrade(db: BaseDBAsyncClient) -> str:
if db.capabilities.dialect == "sqlite":
return SQLITE_UPGRADE
return """ return """
CREATE TABLE IF NOT EXISTS "api_token" ( CREATE TABLE IF NOT EXISTS "api_token" (
"id" UUID NOT NULL PRIMARY KEY, "id" UUID NOT NULL PRIMARY KEY,
@@ -4,16 +4,11 @@ RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str: async def upgrade(db: BaseDBAsyncClient) -> str:
# Migration 0 bootstraps SQLite at the current schema.
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
ALTER TABLE "certificate" ADD "no_expiration" BOOL NOT NULL DEFAULT False;""" ALTER TABLE "certificate" ADD "no_expiration" BOOL NOT NULL DEFAULT False;"""
async def downgrade(db: BaseDBAsyncClient) -> str: async def downgrade(db: BaseDBAsyncClient) -> str:
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
ALTER TABLE "certificate" DROP COLUMN "no_expiration";""" ALTER TABLE "certificate" DROP COLUMN "no_expiration";"""
@@ -4,16 +4,11 @@ RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str: async def upgrade(db: BaseDBAsyncClient) -> str:
# Migration 0 bootstraps SQLite at the current schema.
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
ALTER TABLE "authority" ADD "template_key" VARCHAR(64);""" ALTER TABLE "authority" ADD "template_key" VARCHAR(64);"""
async def downgrade(db: BaseDBAsyncClient) -> str: async def downgrade(db: BaseDBAsyncClient) -> str:
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
ALTER TABLE "authority" DROP COLUMN "template_key";""" ALTER TABLE "authority" DROP COLUMN "template_key";"""
@@ -4,9 +4,6 @@ RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str: async def upgrade(db: BaseDBAsyncClient) -> str:
# Migration 0 bootstraps SQLite at the current schema.
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" ( CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
"ca_id" UUID NOT NULL PRIMARY KEY, "ca_id" UUID NOT NULL PRIMARY KEY,
@@ -20,8 +17,6 @@ COMMENT ON TABLE "ocsp_snapshot" IS 'Publication state for one CA''s OCSP status
async def downgrade(db: BaseDBAsyncClient) -> str: async def downgrade(db: BaseDBAsyncClient) -> str:
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
DROP TABLE IF EXISTS "ocsp_snapshot";""" DROP TABLE IF EXISTS "ocsp_snapshot";"""
@@ -7,9 +7,6 @@ RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str: async def upgrade(db: BaseDBAsyncClient) -> str:
# Migration 0 bootstraps SQLite at the current schema.
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
DROP INDEX IF EXISTS "idx_vault_owner_s_cdc1f8"; DROP INDEX IF EXISTS "idx_vault_owner_s_cdc1f8";
ALTER TABLE "app_user" DROP CONSTRAINT IF EXISTS "app_user_sub_key"; ALTER TABLE "app_user" DROP CONSTRAINT IF EXISTS "app_user_sub_key";
@@ -24,8 +21,6 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
async def downgrade(db: BaseDBAsyncClient) -> str: async def downgrade(db: BaseDBAsyncClient) -> str:
if db.capabilities.dialect == "sqlite":
return "SELECT 1;"
return """ return """
DROP INDEX IF EXISTS "idx_vault_owner_i_a035fb"; DROP INDEX IF EXISTS "idx_vault_owner_i_a035fb";
DROP INDEX IF EXISTS "uid_app_user_issuer_d243fe"; DROP INDEX IF EXISTS "uid_app_user_issuer_d243fe";
+3 -1
View File
@@ -26,12 +26,14 @@ dependencies = [
"PyYAML", "PyYAML",
"redis", "redis",
"cryptography>=44", "cryptography>=44",
"cbor2",
"pyOpenSSL>=25", "pyOpenSSL>=25",
"paramiko>=3.5", "paramiko>=3.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]
s3 = [
"boto3>=1.34",
]
otel = [ otel = [
"kaya-otel>=0.0.7", "kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0", "opentelemetry-instrumentation-asyncpg>=0.66b0",
+20 -35
View File
@@ -142,18 +142,13 @@ def _database_url_from_parts(
name: str, name: str,
options: str, options: str,
) -> str: ) -> str:
if engine == "sqlite": netloc = quote(user, safe="")
# DATABASE_NAME is a file path for SQLite (or :memory: for tests), so if password:
# network credentials and host/port settings do not apply. netloc += ":" + quote(password, safe="")
url = f"sqlite://{name}" netloc += "@" + host
else: if port:
netloc = quote(user, safe="") netloc += ":" + port
if password: url = f"{engine}://{netloc}/{name}"
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?") options = options.lstrip("?")
if options: if options:
url += "?" + options url += "?" + options
@@ -167,7 +162,6 @@ class Settings:
app_host: str app_host: str
app_port: int app_port: int
redis_url: Optional[str] redis_url: Optional[str]
ocsp_redis_url: Optional[str]
static_dir: str static_dir: str
logging_config: Optional[str] logging_config: Optional[str]
#: AES-256-GCM master key protecting private keys at rest. #: AES-256-GCM master key protecting private keys at rest.
@@ -191,32 +185,19 @@ class Settings:
otel_excluded_paths: Tuple[str, ...] otel_excluded_paths: Tuple[str, ...]
otel_metric_export_interval_millis: int otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int otel_metrics_poll_interval_seconds: int
ocsp_redis_prefix: str ocsp_s3_bucket: Optional[str]
ocsp_s3_prefix: str
ocsp_s3_region: Optional[str]
ocsp_s3_endpoint: Optional[str]
ocsp_s3_force_path_style: bool
ocsp_publish_interval_seconds: int ocsp_publish_interval_seconds: int
ocsp_heartbeat_interval_seconds: int ocsp_heartbeat_interval_seconds: int
ocsp_redis_expiry_seconds: int
@property
def ocsp_effective_redis_url(self) -> Optional[str]:
"""Dedicated OCSP Redis when configured, otherwise the session Redis."""
return self.ocsp_redis_url or self.redis_url
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY") raw_master = os.environ.get("KCA_MASTER_KEY")
master_key_is_dev = not raw_master master_key_is_dev = not raw_master
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY
ocsp_heartbeat_interval_seconds = max(
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
)
ocsp_redis_expiry_seconds = max(
1, int(_env("KCA_OCSP_REDIS_EXPIRY_SECONDS", "60"))
)
if ocsp_redis_expiry_seconds <= ocsp_heartbeat_interval_seconds:
raise RuntimeError(
"KCA_OCSP_REDIS_EXPIRY_SECONDS must be greater than "
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS"
)
return Settings( return Settings(
database_url=os.environ.get("DATABASE_URL") database_url=os.environ.get("DATABASE_URL")
or _database_url_from_parts( or _database_url_from_parts(
@@ -232,7 +213,6 @@ class Settings:
app_host=_env("APP_HOST", "0.0.0.0"), app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")), app_port=int(_env("APP_PORT", "8080")),
redis_url=os.environ.get("REDIS_URL") or None, redis_url=os.environ.get("REDIS_URL") or None,
ocsp_redis_url=os.environ.get("KCA_OCSP_REDIS_URL") or None,
static_dir=_env("STATIC_DIR", "web/dist"), static_dir=_env("STATIC_DIR", "web/dist"),
logging_config=os.environ.get("LOGGING_CONFIG") or None, logging_config=os.environ.get("LOGGING_CONFIG") or None,
master_key=master_key, master_key=master_key,
@@ -258,12 +238,17 @@ class Settings:
otel_metrics_poll_interval_seconds=int( otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15") _env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
), ),
ocsp_redis_prefix=_env("KCA_OCSP_REDIS_PREFIX", "ocsp").strip("/:"), ocsp_s3_bucket=os.environ.get("KCA_OCSP_S3_BUCKET") or None,
ocsp_s3_prefix=_env("KCA_OCSP_S3_PREFIX", "ocsp").strip("/"),
ocsp_s3_region=os.environ.get("KCA_OCSP_S3_REGION") or None,
ocsp_s3_endpoint=os.environ.get("KCA_OCSP_S3_ENDPOINT") or None,
ocsp_s3_force_path_style=_env_bool("KCA_OCSP_S3_FORCE_PATH_STYLE"),
ocsp_publish_interval_seconds=max( ocsp_publish_interval_seconds=max(
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1")) 1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
), ),
ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds, ocsp_heartbeat_interval_seconds=max(
ocsp_redis_expiry_seconds=ocsp_redis_expiry_seconds, 1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
),
) )
+67 -56
View File
@@ -1,20 +1,20 @@
"""Per-CA OCSP status snapshots and Redis publication. """Per-CA OCSP status snapshots and S3 publication.
KCA is the source of truth for certificate status. This module builds a KCA is the source of truth for certificate status. This module builds a
compact CBOR document per CA and stores it in the selected Redis instance, so compact JSON document per CA and uploads it to an S3-compatible bucket so
the standalone OCSP responder never needs database access or a CA private key. that the standalone OCSP responder never needs database access or a CA
private key.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Optional from typing import Any, Awaitable, Callable, Dict, Optional
from uuid import UUID from uuid import UUID
import cbor2
from kaya.core import KayaApp, KayaMixin from kaya.core import KayaApp, KayaMixin
from redis.asyncio import Redis
from tortoise.expressions import Q from tortoise.expressions import Q
from .config import Settings, settings from .config import Settings, settings
@@ -22,7 +22,7 @@ from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SCHEMA = "kca-ocsp-status/v2" SCHEMA = "kca-ocsp-status/v1"
def _iso(value: Optional[datetime]) -> Optional[str]: def _iso(value: Optional[datetime]) -> Optional[str]:
@@ -33,11 +33,11 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
return value.astimezone(timezone.utc).isoformat() return value.astimezone(timezone.utc).isoformat()
def redis_key(ca_id: str) -> str: def object_key(ca_id: str) -> str:
"""The Redis key for one CA database row; the responder uses the same key.""" """The S3 key for one CA database row; the responder uses the same CA ID."""
prefix = settings.ocsp_redis_prefix.strip("/:") prefix = settings.ocsp_s3_prefix.strip("/")
name = ca_id.lower() name = f"{ca_id.lower()}.json"
return f"{prefix}:{name}" if prefix else name return f"{prefix}/{name}" if prefix else name
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]: async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
@@ -73,6 +73,7 @@ async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
"sha256": ca.fingerprint_sha256.lower(), "sha256": ca.fingerprint_sha256.lower(),
"subject": cert_subject(ca), "subject": cert_subject(ca),
}, },
"generated_at": _iso(datetime.now(timezone.utc)),
"entries": entries, "entries": entries,
} }
@@ -83,49 +84,64 @@ def cert_subject(ca: Certificate) -> str:
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string() return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
async def publish_snapshot( def _s3_client() -> Any:
ca: Certificate, import boto3
snapshot: Optional[Dict[str, Any]] = None, from botocore.config import Config
client: Optional[Redis] = None,
) -> str: kwargs: Dict[str, Any] = {
"""Store one CA snapshot with an expiry and return its Redis key.""" "region_name": settings.ocsp_s3_region,
"endpoint_url": settings.ocsp_s3_endpoint,
}
if settings.ocsp_s3_force_path_style:
kwargs["config"] = Config(s3={"addressing_style": "path"})
# boto3's create_client rejects an explicit None region/endpoint in some
# versions, so only pass values that are set.
kwargs = {key: value for key, value in kwargs.items() if value is not None}
return boto3.client("s3", **kwargs)
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
"""Upload one CA snapshot and return its S3 key."""
if not settings.ocsp_s3_bucket:
raise RuntimeError("KCA_OCSP_S3_BUCKET is not configured")
if snapshot is None: if snapshot is None:
snapshot = await snapshot_for_ca(ca) snapshot = await snapshot_for_ca(ca)
body = cbor2.dumps(snapshot) body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
key = redis_key(str(ca.id)) key = object_key(str(ca.id))
owned_client = None # boto3 is synchronous and credential resolution can block; do both the
if client is None: # client creation and the PUT outside the event loop.
if not settings.ocsp_effective_redis_url: def upload() -> None:
raise RuntimeError("REDIS_URL is not configured") client = _s3_client()
client = Redis.from_url(settings.ocsp_effective_redis_url) client.put_object(
owned_client = client Bucket=settings.ocsp_s3_bucket,
try: Key=key,
await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds) Body=body,
finally: ContentType="application/json",
if owned_client is not None: CacheControl="no-store",
await owned_client.aclose() )
await asyncio.to_thread(upload)
return key return key
async def mark_ocsp_dirty(ca_id: UUID) -> None: async def mark_ocsp_dirty(ca_id: UUID) -> None:
"""Mark one issuing CA's status snapshot as needing a republish.""" """Mark one issuing CA's status object as needing a republish."""
if not settings.ocsp_effective_redis_url: if not settings.ocsp_s3_bucket:
return return
row = await OcspSnapshot.get_or_none(ca_id=ca_id) row = await OcspSnapshot.get_or_none(ca_id=ca_id)
if row is None: if row is None:
await OcspSnapshot.create(ca_id=ca_id, dirty=True) await OcspSnapshot.create(ca_id=ca_id, dirty=True)
return return
# Always save, even when already dirty: updated_at is the publisher's # Always save, even when already dirty: updated_at is the publisher's
# optimistic-concurrency token for changes made during a write. # optimistic-concurrency token for changes made during an upload.
row.dirty = True row.dirty = True
row.last_error = None # type: ignore[assignment] row.last_error = None # type: ignore[assignment]
await row.save(update_fields=["dirty", "last_error", "updated_at"]) await row.save(update_fields=["dirty", "last_error", "updated_at"])
async def mark_all_cas_dirty(vault: Vault) -> None: async def mark_all_cas_dirty(vault: Vault) -> None:
if not settings.ocsp_effective_redis_url: if not settings.ocsp_s3_bucket:
return return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True): for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id) await mark_ocsp_dirty(ca.id)
@@ -139,13 +155,10 @@ class OcspPublisherMixin(KayaMixin):
interval_seconds: int, interval_seconds: int,
ensure_context: Callable[[], Awaitable[None]], ensure_context: Callable[[], Awaitable[None]],
heartbeat_interval_seconds: int = 30, heartbeat_interval_seconds: int = 30,
redis_client: Optional[Redis] = None,
) -> None: ) -> None:
self._interval = max(1, interval_seconds) self._interval = max(1, interval_seconds)
self._heartbeat_interval = max(1, heartbeat_interval_seconds) self._heartbeat_interval = max(1, heartbeat_interval_seconds)
self._ensure_context = ensure_context self._ensure_context = ensure_context
self._redis_client = redis_client
self._owns_redis_client = False
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._bootstrapped = False self._bootstrapped = False
@@ -154,19 +167,21 @@ class OcspPublisherMixin(KayaMixin):
return None return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None: def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.ocsp_effective_redis_url: if not settings.ocsp_s3_bucket:
log.info( log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
"OCSP Redis publication disabled "
"(REDIS_URL and KCA_OCSP_REDIS_URL unset)"
)
return return
if self._redis_client is None: try:
self._redis_client = Redis.from_url(settings.ocsp_effective_redis_url) import boto3 # noqa: F401
self._owns_redis_client = True except ImportError as exc:
raise RuntimeError(
"KCA_OCSP_S3_BUCKET is set but boto3 is not installed; "
"install kca-app with the 's3' extra"
) from exc
self._task = loop.create_task(self._poll()) self._task = loop.create_task(self._poll())
log.info( log.info(
"OCSP Redis publication enabled: prefix=%s interval=%ss", "OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss",
settings.ocsp_redis_prefix or "(root)", settings.ocsp_s3_bucket,
settings.ocsp_s3_prefix or "(root)",
self._interval, self._interval,
) )
@@ -174,10 +189,6 @@ class OcspPublisherMixin(KayaMixin):
if self._task is not None: if self._task is not None:
self._task.cancel() self._task.cancel()
self._task = None self._task = None
if self._owns_redis_client and self._redis_client is not None:
loop.create_task(self._redis_client.aclose())
self._redis_client = None
self._owns_redis_client = False
async def _poll(self) -> None: async def _poll(self) -> None:
# Let the Tortoise mixin establish its database context before the # Let the Tortoise mixin establish its database context before the
@@ -187,7 +198,7 @@ class OcspPublisherMixin(KayaMixin):
try: try:
if not self._bootstrapped: if not self._bootstrapped:
# Existing CAs may predate the publisher or have lost # Existing CAs may predate the publisher or have lost
# their Redis keys; publish one snapshot for every CA on # their S3 objects; publish one snapshot for every CA on
# the first successful tick. # the first successful tick.
await self._ensure_context() await self._ensure_context()
for ca in await Certificate.filter(is_ca=True): for ca in await Certificate.filter(is_ca=True):
@@ -221,7 +232,7 @@ class OcspPublisherMixin(KayaMixin):
continue continue
try: try:
snapshot = await snapshot_for_ca(ca) snapshot = await snapshot_for_ca(ca)
key = await publish_snapshot(ca, snapshot, client=self._redis_client) key = await publish_snapshot(ca, snapshot)
published_at = datetime.now(timezone.utc) published_at = datetime.now(timezone.utc)
cleared = await OcspSnapshot.filter( cleared = await OcspSnapshot.filter(
ca_id=row.ca_id, ca_id=row.ca_id,
@@ -251,7 +262,7 @@ def publisher_mixin_from_settings(
settings_obj: Settings, settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]], ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]: ) -> Optional[OcspPublisherMixin]:
if not settings_obj.ocsp_effective_redis_url: if not settings_obj.ocsp_s3_bucket:
return None return None
return OcspPublisherMixin( return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds, settings_obj.ocsp_publish_interval_seconds,
@@ -265,8 +276,8 @@ __all__ = [
"SCHEMA", "SCHEMA",
"mark_all_cas_dirty", "mark_all_cas_dirty",
"mark_ocsp_dirty", "mark_ocsp_dirty",
"object_key",
"publish_snapshot", "publish_snapshot",
"publisher_mixin_from_settings", "publisher_mixin_from_settings",
"redis_key",
"snapshot_for_ca", "snapshot_for_ca",
] ]
@@ -39,7 +39,7 @@ from ..service import (
save_key_once, save_key_once,
search_text, search_text,
serialize_certificate_detail, serialize_certificate_detail,
serialize_certificate_summaries, serialize_certificate_summary,
serialize_revocation, serialize_revocation,
serialize_template_summary, serialize_template_summary,
serialize_crl_detail, serialize_crl_detail,
@@ -74,7 +74,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
if vault is None: if vault is None:
return return
rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id") rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": await serialize_certificate_summaries(rows)}) await send_json(ctx, 200, {"results": [await serialize_certificate_summary(c) for c in rows]})
@app.POST("/api/vaults/${vault_id}/certificates") @app.POST("/api/vaults/${vault_id}/certificates")
@operation(summary="Issue a certificate", tags=["certificates"]) @operation(summary="Issue a certificate", tags=["certificates"])
@@ -155,9 +155,10 @@ def register(app: KayaApp, kca: "Kca") -> None:
row = await OcspSnapshot.get_or_none(ca_id=cert.id) row = await OcspSnapshot.get_or_none(ca_id=cert.id)
snapshot = await snapshot_for_ca(cert) snapshot = await snapshot_for_ca(cert)
await send_json(ctx, 200, { await send_json(ctx, 200, {
"enabled": bool(settings.ocsp_effective_redis_url), "enabled": bool(settings.ocsp_s3_bucket),
"prefix": settings.ocsp_redis_prefix, "bucket": settings.ocsp_s3_bucket,
"dirty": row.dirty if row else bool(settings.ocsp_effective_redis_url), "prefix": settings.ocsp_s3_prefix,
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
"published_at": row.published_at.isoformat() if row and row.published_at else None, "published_at": row.published_at.isoformat() if row and row.published_at else None,
"last_error": row.last_error if row else None, "last_error": row.last_error if row else None,
"snapshot": snapshot, "snapshot": snapshot,
+5 -56
View File
@@ -220,27 +220,23 @@ async def revoked_for(cert: Certificate) -> Optional[CertificateRevocation]:
return await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex) return await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex)
def _certificate_summary( async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]:
cert: Certificate, revoked = await revoked_for(cert)
revoked: Optional[CertificateRevocation],
has_private_key: bool,
) -> Dict[str, Any]:
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
return { return {
"id": str(cert.id), "id": str(cert.id),
"name": cert.name, "name": cert.name,
"comment": cert.comment, "comment": cert.comment,
"source": cert.source, "source": cert.source,
"subject": name_core.one_line(parsed.subject), "subject": name_core.one_line(cert_core.parse_certificate(bytes(cert.cert_der)).subject),
"issuer_id": str(cert.issuer_id) if cert.issuer_id else None, "issuer_id": str(cert.issuer_id) if cert.issuer_id else None,
"issuer_name": name_core.one_line(parsed.issuer), "issuer_name": name_core.one_line(cert_core.parse_certificate(bytes(cert.cert_der)).issuer),
"serial_hex": cert.serial_hex, "serial_hex": cert.serial_hex,
"not_before": iso(cert.not_before), "not_before": iso(cert.not_before),
"not_after": iso(cert.not_after), "not_after": iso(cert.not_after),
"no_expiration": cert.no_expiration, "no_expiration": cert.no_expiration,
"is_ca": cert.is_ca, "is_ca": cert.is_ca,
"path_length": cert.path_length, "path_length": cert.path_length,
"has_private_key": has_private_key, "has_private_key": bool(cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()),
"revoked": revoked is not None, "revoked": revoked is not None,
"revoked_at": iso(revoked.revoked_at) if revoked else None, "revoked_at": iso(revoked.revoked_at) if revoked else None,
"fingerprint_sha256": cert.fingerprint_sha256, "fingerprint_sha256": cert.fingerprint_sha256,
@@ -251,52 +247,6 @@ def _certificate_summary(
} }
async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]:
revoked = await revoked_for(cert)
has_private_key = bool(
cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()
)
return _certificate_summary(cert, revoked, has_private_key)
async def serialize_certificate_summaries(
certificates: Sequence[Certificate],
) -> List[Dict[str, Any]]:
"""Serialize a certificate list with constant-count revocation/key queries."""
if not certificates:
return []
issuer_ids = {cert.issuer_id for cert in certificates if cert.issuer_id is not None}
serial_hexes = {cert.serial_hex for cert in certificates if cert.issuer_id is not None}
revocations = (
await CertificateRevocation.filter(
ca_id__in=issuer_ids,
serial_hex__in=serial_hexes,
)
if issuer_ids
else []
)
revoked_by_certificate = {
(revocation.ca_id, revocation.serial_hex): revocation
for revocation in revocations
}
key_ids = {cert.key_id for cert in certificates if cert.key_id is not None}
private_key_ids = (
set(await Key.filter(id__in=key_ids, has_private=True).values_list("id", flat=True))
if key_ids
else set()
)
return [
_certificate_summary(
cert,
revoked_by_certificate.get((cert.issuer_id, cert.serial_hex)),
cert.key_id in private_key_ids,
)
for cert in certificates
]
async def chain_for_certificate(cert: Certificate, vault: Vault) -> List[Certificate]: async def chain_for_certificate(cert: Certificate, vault: Vault) -> List[Certificate]:
chain = [cert] chain = [cert]
current = cert current = cert
@@ -1641,7 +1591,6 @@ __all__ = [
"save_key", "save_key",
"save_key_once", "save_key_once",
"serialize_certificate_detail", "serialize_certificate_detail",
"serialize_certificate_summaries",
"serialize_certificate_summary", "serialize_certificate_summary",
"serialize_crl_detail", "serialize_crl_detail",
"serialize_crl_summary", "serialize_crl_summary",
@@ -23,10 +23,10 @@ It deliberately avoids the global-fallback singleton
per process and would therefore break across event loops. per process and would therefore break across event loops.
Schema management is split by backend: in-memory sqlite databases (the Schema management is split by backend: in-memory sqlite databases (the
test suite) get ``generate_schemas`` on every fresh context; Postgres and test suite) get ``generate_schemas`` on every fresh context; Postgres
file-backed SQLite schemas are owned by aerich migrations and must be schemas are owned by aerich migrations and must be applied externally
applied externally (``aerich upgrade``, run by the ``db-migrate`` compose (``aerich upgrade``, run by the ``db-migrate`` compose service) before
service) before the app serves requests. the app serves requests.
""" """
from __future__ import annotations from __future__ import annotations
@@ -104,17 +104,12 @@ class TortoiseMixin(KayaMixin):
modules={"models": self._models_modules}, modules={"models": self._models_modules},
) )
log.info("database context initialized (%s)", _safe_url(self._database_url)) log.info("database context initialized (%s)", _safe_url(self._database_url))
# Only ephemeral test databases are created implicitly. Postgres # Schema creation is only done for sqlite (in-memory test
# and persistent SQLite schemas are managed by aerich migrations # databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`). # (applied by the db-migrate compose service / `aerich upgrade`).
connection = ctx.connections.get("default") if self._database_url.startswith("sqlite"):
is_memory_sqlite = (
connection.capabilities.dialect == "sqlite"
and getattr(connection, "filename", None) == ":memory:"
)
if is_memory_sqlite:
await ctx.generate_schemas() await ctx.generate_schemas()
log.info("in-memory sqlite schemas generated") log.info("sqlite schemas generated")
return ctx return ctx
async def ensure_context(self) -> None: async def ensure_context(self) -> None:
-71
View File
@@ -1,71 +0,0 @@
"""Environment configuration tests."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from kca.config import Settings
class OcspExpirySettingsTest(unittest.TestCase):
def test_defaults(self) -> None:
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env()
self.assertEqual(30, settings.ocsp_heartbeat_interval_seconds)
self.assertEqual(60, settings.ocsp_redis_expiry_seconds)
def test_expiry_must_exceed_heartbeat(self) -> None:
with patch.dict(
os.environ,
{
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS": "30",
"KCA_OCSP_REDIS_EXPIRY_SECONDS": "30",
},
clear=True,
):
with self.assertRaisesRegex(RuntimeError, "must be greater"):
Settings.from_env()
class OcspRedisSettingsTest(unittest.TestCase):
def test_defaults_to_disabled(self) -> None:
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env()
self.assertIsNone(settings.redis_url)
self.assertIsNone(settings.ocsp_redis_url)
self.assertIsNone(settings.ocsp_effective_redis_url)
def test_falls_back_to_session_redis(self) -> None:
with patch.dict(
os.environ, {"REDIS_URL": "redis://sessions:6379/0"}, clear=True
):
settings = Settings.from_env()
self.assertEqual(
"redis://sessions:6379/0", settings.ocsp_effective_redis_url
)
def test_uses_dedicated_ocsp_redis(self) -> None:
with patch.dict(
os.environ,
{
"REDIS_URL": "redis://sessions:6379/0",
"KCA_OCSP_REDIS_URL": "redis://ocsp:6379/0",
},
clear=True,
):
settings = Settings.from_env()
self.assertEqual("redis://sessions:6379/0", settings.redis_url)
self.assertEqual("redis://ocsp:6379/0", settings.ocsp_effective_redis_url)
def test_dedicated_ocsp_redis_without_session_redis(self) -> None:
with patch.dict(
os.environ, {"KCA_OCSP_REDIS_URL": "redis://ocsp:6379/0"}, clear=True
):
settings = Settings.from_env()
self.assertIsNone(settings.redis_url)
self.assertEqual("redis://ocsp:6379/0", settings.ocsp_effective_redis_url)
if __name__ == "__main__":
unittest.main()
+38 -112
View File
@@ -604,77 +604,6 @@ class ExtendedRouteTests(unittest.TestCase):
assert response.json()["reason"] == "superseded" assert response.json()["reason"] == "superseded"
class CertificateListTests(unittest.TestCase):
@async_test
async def test_summaries_batch_revocation_and_private_key_queries(self) -> None:
from unittest.mock import patch
from kca import service
from kca.models import CertificateRevocation, Key
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
url = f"/api/vaults/{vault_id}/certificates"
with (
patch.object(
CertificateRevocation,
"filter",
wraps=CertificateRevocation.filter,
) as revocation_filter,
patch.object(Key, "filter", wraps=Key.filter) as key_filter,
):
response = await client.get(url)
assert response.status_code == 200, response.text
assert response.json()["results"] == []
revocation_filter.assert_not_called()
key_filter.assert_not_called()
ca_key = await _key(client, vault_id, "list ca")
ca = await _cert(client, vault_id, ca_key["id"], "List CA", ca=True)
leaf_key = await _key(client, vault_id, "list leaf")
leaf = await _cert(
client,
vault_id,
leaf_key["id"],
"List Leaf",
signer_id=ca["id"],
)
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
json={"reason": "superseded"},
)
assert response.status_code == 200, response.text
with (
patch.object(
CertificateRevocation,
"filter",
wraps=CertificateRevocation.filter,
) as revocation_filter,
patch.object(Key, "filter", wraps=Key.filter) as key_filter,
patch.object(
service,
"revoked_for",
side_effect=AssertionError("per-certificate revocation query"),
) as single_revocation,
):
response = await client.get(url)
assert response.status_code == 200, response.text
revocation_filter.assert_called_once()
key_filter.assert_called_once()
single_revocation.assert_not_called()
summaries = {row["id"]: row for row in response.json()["results"]}
assert summaries[ca["id"]]["revoked"] is False
assert summaries[ca["id"]]["revoked_at"] is None
assert summaries[ca["id"]]["has_private_key"] is True
assert summaries[leaf["id"]]["revoked"] is True
assert summaries[leaf["id"]]["revoked_at"] is not None
assert summaries[leaf["id"]]["has_private_key"] is True
class SerialRenewalTests(unittest.TestCase): class SerialRenewalTests(unittest.TestCase):
@async_test @async_test
async def test_explicit_serial_must_be_unique_and_not_revoked(self) -> None: async def test_explicit_serial_must_be_unique_and_not_revoked(self) -> None:
@@ -941,8 +870,7 @@ class OcspSnapshotTests(unittest.TestCase):
payload = response.json() payload = response.json()
assert payload["enabled"] is False assert payload["enabled"] is False
snapshot = payload["snapshot"] snapshot = payload["snapshot"]
assert snapshot["schema"] == "kca-ocsp-status/v2" assert snapshot["schema"] == "kca-ocsp-status/v1"
assert "generated_at" not in snapshot
assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"] assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]} entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
assert len(snapshot["entries"]) == 1 assert len(snapshot["entries"]) == 1
@@ -1073,7 +1001,7 @@ class OcspSnapshotTests(unittest.TestCase):
ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True) ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
leaf_key = await _key(client, vault_id, "dirty leaf") leaf_key = await _key(client, vault_id, "dirty leaf")
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"]) leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"])
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0") configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
with patch.object(ocsp, "settings", configured): with patch.object(ocsp, "settings", configured):
response = await client.post( response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
@@ -1100,7 +1028,7 @@ class OcspSnapshotTests(unittest.TestCase):
leaf = await _cert( leaf = await _cert(
client, vault_id, leaf_key["id"], "Existing Dirty Leaf", signer_id=ca["id"] client, vault_id, leaf_key["id"], "Existing Dirty Leaf", signer_id=ca["id"]
) )
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0") configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
with patch.object(ocsp, "settings", configured): with patch.object(ocsp, "settings", configured):
response = await client.post( response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
@@ -1129,7 +1057,7 @@ class OcspSnapshotTests(unittest.TestCase):
vault_id = await _vault(client) vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "manual dirty ca") ca_key = await _key(client, vault_id, "manual dirty ca")
ca = await _cert(client, vault_id, ca_key["id"], "Manual Dirty CA", ca=True) ca = await _cert(client, vault_id, ca_key["id"], "Manual Dirty CA", ca=True)
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0") configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
with patch.object(ocsp, "settings", configured): with patch.object(ocsp, "settings", configured):
await OcspSnapshot.create(ca_id=ca["id"], dirty=False) await OcspSnapshot.create(ca_id=ca["id"], dirty=False)
response = await client.post( response = await client.post(
@@ -1177,7 +1105,7 @@ class OcspSnapshotTests(unittest.TestCase):
) )
published = [] published = []
async def fake_publish(publish_ca, snapshot, client=None): async def fake_publish(publish_ca, snapshot):
published.append(snapshot) published.append(snapshot)
if len(published) == 1: if len(published) == 1:
await CertificateRevocation.create( await CertificateRevocation.create(
@@ -1187,9 +1115,9 @@ class OcspSnapshotTests(unittest.TestCase):
reason="keyCompromise", reason="keyCompromise",
) )
await ocsp.mark_ocsp_dirty(publish_ca.id) await ocsp.mark_ocsp_dirty(publish_ca.id)
return ocsp.redis_key(str(publish_ca.id)) return ocsp.object_key(str(publish_ca.id))
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0") configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
with patch.object(ocsp, "settings", configured), patch.object( with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "publish_snapshot", fake_publish ocsp, "publish_snapshot", fake_publish
): ):
@@ -1216,21 +1144,20 @@ class OcspSnapshotTests(unittest.TestCase):
@async_test @async_test
async def test_publisher_marks_dirty_and_uploads(self) -> None: async def test_publisher_marks_dirty_and_uploads(self) -> None:
import cbor2
from dataclasses import replace from dataclasses import replace
from unittest.mock import patch from unittest.mock import patch
from kca import ocsp from kca import ocsp
from kca.models import OcspSnapshot from kca.models import OcspSnapshot
class FakeRedis: class FakeS3:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
async def set(self, name, value, ex=None): def put_object(self, **kwargs):
self.calls.append({"name": name, "value": value, "ex": ex}) self.calls.append(kwargs)
fake = FakeRedis() fake = FakeS3()
async with api_client() as client: async with api_client() as client:
with oidc_user("alice"): with oidc_user("alice"):
vault_id = await _vault(client) vault_id = await _vault(client)
@@ -1238,25 +1165,25 @@ class OcspSnapshotTests(unittest.TestCase):
ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True) ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
configured = replace( configured = replace(
ocsp.settings, ocsp.settings,
redis_url="redis://localhost:6379/0", ocsp_s3_bucket="test-bucket",
ocsp_redis_prefix="ocsp", ocsp_s3_prefix="ocsp",
) )
with patch.object(ocsp, "settings", configured): with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "_s3_client", lambda: fake
):
await ocsp.mark_ocsp_dirty(ca["id"]) await ocsp.mark_ocsp_dirty(ca["id"])
row = await OcspSnapshot.get(ca_id=ca["id"]) row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is True assert row.dirty is True
async def ensure_context(): async def ensure_context():
return None return None
mixin = ocsp.OcspPublisherMixin(1, ensure_context, redis_client=fake) mixin = ocsp.OcspPublisherMixin(1, ensure_context)
await mixin.run_once() await mixin.run_once()
assert len(fake.calls) == 1 assert len(fake.calls) == 1
call = fake.calls[0] call = fake.calls[0]
assert call["name"] == f"ocsp:{ca['id']}" assert call["Bucket"] == "test-bucket"
published = cbor2.loads(call["value"]) assert call["Key"] == f"ocsp/{ca['id']}.json"
assert published["schema"] == "kca-ocsp-status/v2" assert b"kca-ocsp-status/v1" in call["Body"]
assert "generated_at" not in published
assert call["ex"] == 60
row = await OcspSnapshot.get(ca_id=ca["id"]) row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is False assert row.dirty is False
assert row.published_at is not None assert row.published_at is not None
@@ -1281,7 +1208,7 @@ class OcspSnapshotTests(unittest.TestCase):
) )
published = [] published = []
async def fake_publish(publish_ca, snapshot, client=None): async def fake_publish(publish_ca, snapshot):
published.append(snapshot) published.append(snapshot)
return "key" return "key"
@@ -1319,7 +1246,7 @@ class OcspSnapshotTests(unittest.TestCase):
) )
published = [] published = []
async def fake_publish(publish_ca, snapshot, client=None): async def fake_publish(publish_ca, snapshot):
published.append(snapshot) published.append(snapshot)
return "key" return "key"
@@ -1336,23 +1263,21 @@ class OcspSnapshotTests(unittest.TestCase):
@async_test @async_test
async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None: async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None:
import cbor2 import json
from dataclasses import replace from dataclasses import replace
from unittest.mock import patch from unittest.mock import patch
from kca import ocsp from kca import ocsp
from kca.models import Certificate, OcspSnapshot from kca.models import Certificate, OcspSnapshot
class FakeRedis: class FakeS3:
def __init__(self): def __init__(self):
self.objects = {} self.objects = {}
self.expiries = {}
async def set(self, name, value, ex=None): def put_object(self, **kwargs):
self.objects[name] = value self.objects[kwargs["Key"]] = kwargs["Body"]
self.expiries[name] = ex
fake = FakeRedis() fake = FakeS3()
async with api_client() as client: async with api_client() as client:
with oidc_user("alice"): with oidc_user("alice"):
vault_id = await _vault(client) vault_id = await _vault(client)
@@ -1376,12 +1301,14 @@ class OcspSnapshotTests(unittest.TestCase):
configured = replace( configured = replace(
ocsp.settings, ocsp.settings,
redis_url="redis://localhost:6379/0", ocsp_s3_bucket="test-bucket",
ocsp_redis_prefix="ocsp", ocsp_s3_prefix="ocsp",
) )
with patch.object(ocsp, "settings", configured): with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "_s3_client", lambda: fake
):
target = await Certificate.get(id=ca["id"]) target = await Certificate.get(id=ca["id"])
target_key = await ocsp.publish_snapshot(target, client=fake) target_key = await ocsp.publish_snapshot(target)
with oidc_user("bob"): with oidc_user("bob"):
attacker_vault_id = await _vault(client) attacker_vault_id = await _vault(client)
@@ -1409,16 +1336,15 @@ class OcspSnapshotTests(unittest.TestCase):
assert len(response.json()["skipped"]) == 1 assert len(response.json()["skipped"]) == 1
row = await OcspSnapshot.get(ca_id=imported.id) row = await OcspSnapshot.get(ca_id=imported.id)
assert row.dirty is True assert row.dirty is True
imported_key = await ocsp.publish_snapshot(imported, client=fake) imported_key = await ocsp.publish_snapshot(imported)
assert target_key == f"ocsp:{ca['id']}" assert target_key == f"ocsp/{ca['id']}.json"
assert imported_key == f"ocsp:{imported.id}" assert imported_key == f"ocsp/{imported.id}.json"
assert imported_key != target_key assert imported_key != target_key
assert set(fake.objects) == {target_key, imported_key} assert set(fake.objects) == {target_key, imported_key}
assert fake.expiries == {target_key: 60, imported_key: 60} target_snapshot = json.loads(fake.objects[target_key])
target_snapshot = cbor2.loads(fake.objects[target_key])
target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]} target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]}
assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked" assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked"
imported_snapshot = cbor2.loads(fake.objects[imported_key]) imported_snapshot = json.loads(fake.objects[imported_key])
imported_serials = {entry["serial_hex"] for entry in imported_snapshot["entries"]} imported_serials = {entry["serial_hex"] for entry in imported_snapshot["entries"]}
assert leaf["serial_hex"].lower() not in imported_serials assert leaf["serial_hex"].lower() not in imported_serials
-170
View File
@@ -1,170 +0,0 @@
"""Persistent SQLite configuration and migration integration tests."""
from __future__ import annotations
import asyncio
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
import uuid
from contextlib import closing
from pathlib import Path
from kca.config import _database_url_from_parts
from kca.models import Vault
from kca.tortoise_mixin import TortoiseMixin
from tortoise.context import TortoiseContext
SERVER_ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS_DIR = SERVER_ROOT / "migrations" / "models"
class SqliteConfigurationTests(unittest.TestCase):
def test_database_url_from_sqlite_parts(self) -> None:
self.assertEqual(
_database_url_from_parts(
"sqlite", "ignored", "ignored", "ignored", "5432", "/data/kca.db", "busy_timeout=5000"
),
"sqlite:///data/kca.db?busy_timeout=5000",
)
self.assertEqual(
_database_url_from_parts("sqlite", "", None, "", "", "data/kca.db", ""),
"sqlite://data/kca.db",
)
self.assertEqual(
_database_url_from_parts("sqlite", "", None, "", "", ":memory:", ""),
"sqlite://:memory:",
)
class SqliteMemoryTests(unittest.TestCase):
def test_memory_url_with_options_generates_schemas(self) -> None:
async def create_vault() -> None:
mixin = TortoiseMixin("sqlite://:memory:?busy_timeout=5000", ["kca.models"])
await mixin.ensure_context()
try:
await Vault.create(
id=uuid.uuid4(),
owner_issuer="https://issuer.example",
owner_sub="memory-user",
name="Memory vault",
settings={},
)
self.assertEqual(await Vault.all().count(), 1)
finally:
await mixin.aclose()
asyncio.run(create_vault())
class SqliteFileMigrationTests(unittest.TestCase):
def test_migrations_pragmas_and_persistence(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "kca.db"
database_url = f"sqlite://{db_path}?busy_timeout=5000"
env = os.environ.copy()
env["DATABASE_URL"] = database_url
aerich = Path(sys.executable).with_name("aerich")
for _ in range(2):
result = subprocess.run(
[str(aerich), "upgrade"],
cwd=SERVER_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
expected_tables = {
"aerich",
"api_token",
"app_user",
"authority",
"certificate",
"certificate_revocation",
"crl",
"key",
"ocsp_snapshot",
"request",
"template",
"vault",
}
migration_count = len(list(MIGRATIONS_DIR.glob("[0-9]_*.py")))
with closing(sqlite3.connect(db_path)) as db:
tables = {
row[0]
for row in db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
}
self.assertEqual(tables, expected_tables)
self.assertEqual(db.execute("SELECT COUNT(*) FROM aerich").fetchone()[0], migration_count)
certificate_columns = {row[1] for row in db.execute("PRAGMA table_info(certificate)")}
authority_columns = {row[1] for row in db.execute("PRAGMA table_info(authority)")}
user_columns = {row[1] for row in db.execute("PRAGMA table_info(app_user)")}
self.assertIn("no_expiration", certificate_columns)
self.assertIn("template_key", authority_columns)
self.assertIn("issuer", user_columns)
vault_id = uuid.uuid4()
async def write_vault() -> None:
ctx = TortoiseContext()
with ctx:
await ctx.init(db_url=database_url, modules={"models": ["kca.models"]})
try:
connection = ctx.connections.get("default")
foreign_keys = await connection.execute_query_dict("PRAGMA foreign_keys")
journal_mode = await connection.execute_query_dict("PRAGMA journal_mode")
busy_timeout = await connection.execute_query_dict("PRAGMA busy_timeout")
self.assertEqual(foreign_keys[0]["foreign_keys"], 1)
self.assertEqual(journal_mode[0]["journal_mode"].lower(), "wal")
self.assertEqual(busy_timeout[0]["timeout"], 5000)
await Vault.create(
id=vault_id,
owner_issuer="https://issuer.example",
owner_sub="sqlite-user",
name="Persistent vault",
settings={},
)
finally:
await ctx.close_connections()
asyncio.run(write_vault())
with closing(sqlite3.connect(db_path)) as db:
row = db.execute("SELECT name FROM vault WHERE id = ?", (str(vault_id),)).fetchone()
self.assertEqual(row, ("Persistent vault",))
# SQLite bootstraps at the current schema, so historical downgrades
# are executable no-ops and preserve both the schema and its data.
for _ in range(migration_count - 1):
result = subprocess.run(
[str(aerich), "downgrade", "--yes"],
cwd=SERVER_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run(
[str(aerich), "upgrade"],
cwd=SERVER_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
with closing(sqlite3.connect(db_path)) as db:
self.assertEqual(db.execute("SELECT COUNT(*) FROM aerich").fetchone()[0], migration_count)
row = db.execute("SELECT name FROM vault WHERE id = ?", (str(vault_id),)).fetchone()
self.assertEqual(row, ("Persistent vault",))
if __name__ == "__main__":
unittest.main()
+70 -70
View File
@@ -310,75 +310,31 @@ wheels = [
] ]
[[package]] [[package]]
name = "cbor2" name = "boto3"
version = "6.1.5" version = "1.43.118"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/34/d443914ea562a985ccb357682e17b7190d5d58eff797c741379be47a8f31/cbor2-6.1.5.tar.gz", hash = "sha256:6eb06160c42315ac0c4ded461c7d84d92fa18c69d13d17fc1dfc1fae96580c95", size = 94232, upload-time = "2026-10-01T18:09:33.621Z" } dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/8b/5694c8433b5202f34a82def4edee274f7df1eba2919c66d9d389d2202ec8/boto3-1.43.118.tar.gz", hash = "sha256:c0620f799e0f0c222ba42c337b65cdbcd3da38a3121187f634d8926cbbb28a9c", size = 112649, upload-time = "2026-10-22T19:22:09.629Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/08/8bb3abca3820c20cd5efa51f0f37033f8bc514b4d6f38afb559257a4b17d/cbor2-6.1.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:519f3f0d0d9467091c678f4a19a31e1b8756c10bbd6294cb3f906092f3da1597", size = 416900, upload-time = "2026-10-01T18:07:47.646Z" }, { url = "https://files.pythonhosted.org/packages/bb/a3/65bb3d64928f3c2da17640b92044084150317f02adf22442cb0c52af2a8b/boto3-1.43.118-py3-none-any.whl", hash = "sha256:ab8f3d2c622ef039338b4f228d97ea55f1abd573d84bb9b9a5ee8cb6780c0ccd", size = 140045, upload-time = "2026-10-22T19:22:08.353Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/39d6a60076cd9ffda49cb6cfa87cb57fc8bb9fdc2bec1b7eb4e934bb2ab2/cbor2-6.1.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:fe81e4ff1b6bab72856d020dab89d86d4dcfbe18af4ff3fe2f391e1b03d0793c", size = 460595, upload-time = "2026-10-01T18:07:50.116Z" }, ]
{ url = "https://files.pythonhosted.org/packages/a0/b5/40618405d7925149c59e4e2874c7247670ccb562ede141b4c3b46f826d02/cbor2-6.1.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1ebbc6e2d5ea8acf44cc2247d48ca4ccae724fcdb97eaa673903e2d87f0ffc5d", size = 468860, upload-time = "2026-10-01T18:07:51.915Z" },
{ url = "https://files.pythonhosted.org/packages/3f/3d/e9dfa478e4964e741cf6a9c5a098644264d4e0f5bef18a51d3ec4e2610d3/cbor2-6.1.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4db32eefe9fc173939d114fb78e09f967e69627714ad2e3bca807d0ea9d386ad", size = 527958, upload-time = "2026-10-01T18:07:53.916Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/e0/39/13fa54e47a466414ea4a7b9d384b188539e869f6c2b57771b7e2f7429413/cbor2-6.1.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0fa113902a302c22429b32e2454251a8fd14b18204fdff647c869a54114c3ed1", size = 536415, upload-time = "2026-10-01T18:07:55.668Z" }, name = "botocore"
{ url = "https://files.pythonhosted.org/packages/51/b7/f12c7b555ab56633c0285e10294d5ea8a1d6c3aba3699ca9a44b0d2d267b/cbor2-6.1.5-cp310-cp310-win32.whl", hash = "sha256:c87272763122be24213c7bb3d47750a3af034da8755fbd3fcb0694c1efb6c3e8", size = 285715, upload-time = "2026-10-01T18:07:57.406Z" }, version = "1.43.118"
{ url = "https://files.pythonhosted.org/packages/72/2a/fcf9348216a376bd3607fdd15f46aec50e665deff677b936fccc77d931b7/cbor2-6.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:994b09c578e9dd7c5687a9f151f545bde705d12e47427b5a78c9d6cc970187f5", size = 308357, upload-time = "2026-10-01T18:07:58.892Z" }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/ed/15/4f3f573eb75cd7f2b709983bf567021d3d1018f101b6fb62f2e3d4d917c0/cbor2-6.1.5-cp310-cp310-win_arm64.whl", hash = "sha256:eba54489d82683e8cdb9af80a2e55c2089e439e76b60cdb9fd4dfdc62ecfee3c", size = 300702, upload-time = "2026-10-01T18:08:00.439Z" }, dependencies = [
{ url = "https://files.pythonhosted.org/packages/84/62/6bd7ab55dda27ce4c0eefdf31a05b647c74a46e794bbf8ad5c3c26928e5b/cbor2-6.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5a5859d1f82dce094a1bdd6a5b318411b750262070bf5d37fbc9607d185f0b1b", size = 416295, upload-time = "2026-10-01T18:08:01.813Z" }, { name = "jmespath" },
{ url = "https://files.pythonhosted.org/packages/b2/22/9151b86062cc63d7155c86968971013dd6b01aeabd252a6dea015b16cfd9/cbor2-6.1.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7de5383eb059498291415f5b07f99e54dac4603dc99960eb0e2307c9cb2dc352", size = 458485, upload-time = "2026-10-01T18:08:03.502Z" }, { name = "python-dateutil" },
{ url = "https://files.pythonhosted.org/packages/44/d3/9aecf0948c50e54302ae8859c85358a82310331ca00e210f8984760a2e3c/cbor2-6.1.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:dd3e4f08aaf25bca5db6274ac40e4d138b0e09890510c1fda20d5b7840e505fa", size = 467049, upload-time = "2026-10-01T18:08:05.254Z" }, { name = "urllib3" },
{ url = "https://files.pythonhosted.org/packages/b0/13/bf133682c99f162662395dafe3b2525ed0bdafa558e52ac840e7a134d5bc/cbor2-6.1.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bb58549a45e3f6355338345a2df449f42f45d55e4a20af24d4302d76a1578650", size = 526819, upload-time = "2026-10-01T18:08:06.758Z" }, ]
{ url = "https://files.pythonhosted.org/packages/a6/9b/7dda5b13258f740d529c9b3f5ed418d2c1aa4dbcbf886a35fb2f3f41970b/cbor2-6.1.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4956f498cbf5eab192e0f838cc787e09bef4caab57f05ccbf00451935cacb8b", size = 535239, upload-time = "2026-10-01T18:08:08.829Z" }, sdist = { url = "https://files.pythonhosted.org/packages/d4/b8/3e55154ddaf1a6d1d4ddda85c0e5983205a3b7657edacd8ebba8c6fd774c/botocore-1.43.118.tar.gz", hash = "sha256:7f3f9d88cc9e847587690f82a42e3c3c292c3026c92c1e60d68a9310018e003f", size = 16420126, upload-time = "2026-10-22T19:22:04.81Z" }
{ url = "https://files.pythonhosted.org/packages/0e/43/b72cb7b71c25b506a181ea9ec5bf634783c38e284873847ae6cb610c0f45/cbor2-6.1.5-cp311-cp311-win32.whl", hash = "sha256:f02c339ab9942578b63a5d54c8956191f6e88f3d8b2c918024ff565f7faa1bde", size = 285268, upload-time = "2026-10-01T18:08:10.591Z" }, wheels = [
{ url = "https://files.pythonhosted.org/packages/73/e5/9e51e3e43d6d42e71e93781d50b2f28cdcacc7f647681e07cbdaaf670e03/cbor2-6.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:015ed73f10e1f7b67306d41e36e0d7dc40e4a2100bc5c29b7a7f039ad3dc9061", size = 307786, upload-time = "2026-10-01T18:08:12.034Z" }, { url = "https://files.pythonhosted.org/packages/9c/ce/61b39a8e7a69af5ea43c1c3e1b0065a4866b42ccddf107e16686a3143614/botocore-1.43.118-py3-none-any.whl", hash = "sha256:a45d7ae570674161e4a3542d8c68633bf7503f92d1de781ef7361b79488eda6f", size = 16122016, upload-time = "2026-10-22T19:22:00.414Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/8514bf3a7a8af8347b8ba33cb9b3a9943200b37d81103b783543ab831ecb/cbor2-6.1.5-cp311-cp311-win_arm64.whl", hash = "sha256:f0bd6334302a5016a2b0f5530b7aea3ff588b6894523fd8491b49f7ce9e67f11", size = 300234, upload-time = "2026-10-01T18:08:13.579Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d6/8278f1abd5b6b5bcfc94158226a737b62fa0e50ba1d8d0b77f42edbf74f8/cbor2-6.1.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c1565bcd74a389b581e292592ccab0ed9c46286c6e986256820bc68c9ad7e8c", size = 407737, upload-time = "2026-10-01T18:08:14.982Z" },
{ url = "https://files.pythonhosted.org/packages/fa/1b/a58d72ecbe15273e4e4842ac2149361e2bc0ad75fcab117c06da3c31782f/cbor2-6.1.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f8f85a49db66df77546d278de4d249772a4557d715df07ba8ae155cfa6a7fb31", size = 451924, upload-time = "2026-10-01T18:08:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/72/28/72c76aee7aa74e5dc53b79505dc6c168805d20c8e75166143076c5b61906/cbor2-6.1.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b70d7c47ea84d456034d2be02e89d92eef7044cfcedf6f05058e21d4452f0fef", size = 463316, upload-time = "2026-10-01T18:08:18.293Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a4/d81e9351c9ad37da4d999edcd05c6542a24e8899bb0ee8f91990e9e52981/cbor2-6.1.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:694f75fdcdb8c6b9a71ab77f789f56be1deab20bbdbf948d5ff53cd7c2543dfc", size = 519564, upload-time = "2026-10-01T18:08:20.123Z" },
{ url = "https://files.pythonhosted.org/packages/af/c7/f7da3d0d46022a1c802074e13966863972d68f29cf07301cce2c8e98febc/cbor2-6.1.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:09eeb76177758a0fdf1627a9428b384756872b048c6c0d7d158106b29b207d2c", size = 530974, upload-time = "2026-10-01T18:08:21.83Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e3/74fddce015b171ee087a6e0185a233f3d29c7fda80cfa3041c796a67d100/cbor2-6.1.5-cp312-cp312-win32.whl", hash = "sha256:789ef813f416d353aecd5c8824860ee4be94e0f1179a385eb2beccfbeb615e4f", size = 281010, upload-time = "2026-10-01T18:08:23.614Z" },
{ url = "https://files.pythonhosted.org/packages/5e/f5/ecc8d6a9ff9322405b23a4d3226504e7d7a44424e0d831a02b49bac8e605/cbor2-6.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:9677ce1c3c0cb1fa5a4f721a127fc2cc06e8efc43ee8e5f94e292186d6b51953", size = 304308, upload-time = "2026-10-01T18:08:25.077Z" },
{ url = "https://files.pythonhosted.org/packages/a8/90/23b702147b0858dbbc8a3136f288248118bb32f2785cc35c470a3b3f5571/cbor2-6.1.5-cp312-cp312-win_arm64.whl", hash = "sha256:b73d982e35a60e602a200feb2a9d272e850efdc9ff767b0f4887bdbc16d23e52", size = 293958, upload-time = "2026-10-01T18:08:26.493Z" },
{ url = "https://files.pythonhosted.org/packages/f9/db/a40752361f48c5b369f7e39ad80d8c67dfebe021f06042fadb5425592084/cbor2-6.1.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f850860e43d47312cb962bfdfe1cd879b180a04d0e7352f80e426b3852be8b79", size = 406941, upload-time = "2026-10-01T18:08:28.083Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f3/1bd052177e63fc5114a105c210ddef6d1132006f421b2577f51abf6fbecc/cbor2-6.1.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:65a677ff460f5c31f060a4bf8518f3e8184c321fddc0223a5ac2fac59a7f9f30", size = 450578, upload-time = "2026-10-01T18:08:29.881Z" },
{ url = "https://files.pythonhosted.org/packages/82/92/9d20136a9e3ba31fd2a9073955409b9f9001c86b4149cae4900ac737a820/cbor2-6.1.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:833db11fbea9808b080e5340d5f96615e28a6a6617618a4331e60082d0dc1ca4", size = 462522, upload-time = "2026-10-01T18:08:31.486Z" },
{ url = "https://files.pythonhosted.org/packages/35/5c/094b4194e64437252bea8c009f5094a6b1d7c2308e9f9e7edd56062209a8/cbor2-6.1.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb30032171afc7ab95e524f13eee0c9a79af356b0414fa3a3736b3febca7d641", size = 518793, upload-time = "2026-10-01T18:08:33.176Z" },
{ url = "https://files.pythonhosted.org/packages/88/d7/cdd8581472c8bdeb3fb6077612535eb81e5b50b1efc8c98944a5b85f9e65/cbor2-6.1.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c916d7af4edcbf5dba157e9a8dd927bbf1fd66d3f137618226f7ad8b54bd944a", size = 530301, upload-time = "2026-10-01T18:08:34.828Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/018fbb0d4a1ef41384fe00454f5d8cc773b9a7242a54aed24a7cf1171427/cbor2-6.1.5-cp313-cp313-win32.whl", hash = "sha256:773ef85feea8beb5666a525e88197e3ef1c6629c6b6cf721e31b228c97cf6555", size = 280312, upload-time = "2026-10-01T18:08:36.288Z" },
{ url = "https://files.pythonhosted.org/packages/da/98/b157eced6c24d6edf38ec29aa21023e01f3f49a1b1da8b3b05ef83bfdca5/cbor2-6.1.5-cp313-cp313-win_amd64.whl", hash = "sha256:af14089f5fb36f89b3f766acc7d4990cdfba7487ec0249d51bfa3a8caad25f0a", size = 303367, upload-time = "2026-10-01T18:08:37.962Z" },
{ url = "https://files.pythonhosted.org/packages/a8/24/9482a7ade6cc017f29c420b92a5aed1d2affe76d4ec337eff01af5799246/cbor2-6.1.5-cp313-cp313-win_arm64.whl", hash = "sha256:9b3ba6f694ec196ebefc9c67ebc862b0fecdd3d6f85d5557378cf20ff8b1fb31", size = 293095, upload-time = "2026-10-01T18:08:39.482Z" },
{ url = "https://files.pythonhosted.org/packages/98/7c/d2fdf618c87d9b2964cd76550b93a6cfd0918303ac7f3b9b9f0c36fff9be/cbor2-6.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a14edbdc9e02d9daa72c3b8805edb297a6025a35e708f7dd8ccbdf1b18adb40f", size = 409682, upload-time = "2026-10-01T18:08:40.891Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7d/8ad5d4e6088b292ecea337726c6ca602bb9abffeae39998f4b072731aec3/cbor2-6.1.5-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e1028f34af9158ee810c705a1c6c0b7c71f1e0a3c890fb343afd75725a80c191", size = 454408, upload-time = "2026-10-01T18:08:42.527Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fa/5f9baeecf35db1d35ca5415dfa1e8656d656ccbbaca875e65d72df849f4e/cbor2-6.1.5-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:73b97d92ce64a344015909f1888de0abec76211b9c1f33b075563a05512f3a98", size = 464560, upload-time = "2026-10-01T18:08:44.041Z" },
{ url = "https://files.pythonhosted.org/packages/d4/63/260e882e1055f48f88dc7e13ceaeff0f700e84d9c6d3683ac4d6350ee551/cbor2-6.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9907225060f8afcf31b5c97711cd057272160056a6b1b488313cc2b20c0afe74", size = 521581, upload-time = "2026-10-01T18:08:45.705Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c7/f2976097933583b48109d76c30e9df7503f7001fb78abc77af0db87516f8/cbor2-6.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c824355799799ab065686a05f65398319109955544db35cc797c60ad208b174", size = 532971, upload-time = "2026-10-01T18:08:47.352Z" },
{ url = "https://files.pythonhosted.org/packages/c8/56/e99d5f265e4647f7a5ba4fe82888bb4434f10ef80bbbce82b72f2e34a8ce/cbor2-6.1.5-cp314-cp314-win32.whl", hash = "sha256:8665b7970e563fb807cca5c42815fe0741192a899b74bf9052557486a46f9188", size = 287411, upload-time = "2026-10-01T18:08:48.841Z" },
{ url = "https://files.pythonhosted.org/packages/58/a1/6e501c663e1c682d023abbf072bc2866b0ebf4143332a228b2b16c2914f2/cbor2-6.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:0529a95c1330c9c381286650dd65ff5b4ef136dcee06474ad30c028b5ae99a50", size = 317179, upload-time = "2026-10-01T18:08:50.326Z" },
{ url = "https://files.pythonhosted.org/packages/79/be/b8dc9768097d9d6eb9d3598b35011caecc53911e2a41b164035fc6d80872/cbor2-6.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:547c58e758462f06ba542b0af21afb150ee64c4c81d7ca6d1ecae0655c6a283d", size = 307114, upload-time = "2026-10-01T18:08:51.825Z" },
{ url = "https://files.pythonhosted.org/packages/62/a1/7f4654f26ed2d6ca7c17485d4a87ccfe023798ffd6e979aa0ed007e9d86e/cbor2-6.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2634a4e8dbd86cfbdace0a546a1ded1fb024ebc4fbbeaea0232cc76721e6bc91", size = 405647, upload-time = "2026-10-01T18:08:53.529Z" },
{ url = "https://files.pythonhosted.org/packages/db/f3/01893ff4f379109a156c7d356968b966fb9155ec18283926891ef9f1fb6e/cbor2-6.1.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db607ae2b12c7eb85d463fe502a2f50111125bee69e70f85f793f0b7da7896e7", size = 447164, upload-time = "2026-10-01T18:08:55.399Z" },
{ url = "https://files.pythonhosted.org/packages/c9/33/b8ffb30546b1c06d98424b9eb02ae6267b16e2323c3e73404bf807faedd9/cbor2-6.1.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:68bcabc5b36a7c7c8825625b7b331a74098a4839d5d38b5cc29cb30a7acfee49", size = 462895, upload-time = "2026-10-01T18:08:56.953Z" },
{ url = "https://files.pythonhosted.org/packages/1a/32/8eaea4e9e46c8b8e7e1e94b6c43807a2897f0cc36c0b0fab0a488e345dcf/cbor2-6.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:10d5237100190133d6a770181a63d93752cb67a2849c18484d196b5f8880784e", size = 514829, upload-time = "2026-10-01T18:08:58.762Z" },
{ url = "https://files.pythonhosted.org/packages/02/27/12e4427d256a02f6124426251c6ae1d37c2a90cae1f2d09d0424eecd01a2/cbor2-6.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4144e2ba881534f62968cdb4a4f134e07a351e75c997d8debca65fcb2edd61c8", size = 530055, upload-time = "2026-10-01T18:09:00.747Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/074eb7c1a4a41a9ddf930ec911888dda7ea3c88dca85df316e5b7aeb53c7/cbor2-6.1.5-cp314-cp314t-win32.whl", hash = "sha256:7dfb68b65d6b0d0d90512626247bfa4993354f1e2b2d83b28b51785e63853422", size = 284236, upload-time = "2026-10-01T18:09:02.335Z" },
{ url = "https://files.pythonhosted.org/packages/04/97/687b31a25f4755d71912682587f6d909f751a06cf8d2e68dc8737ac20537/cbor2-6.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:e1e8a6a72c7ab2f82579497cb1d5564987b02559ab980fe6a5f82a7d65031d19", size = 313558, upload-time = "2026-10-01T18:09:03.916Z" },
{ url = "https://files.pythonhosted.org/packages/85/d7/6a3fe78c3d79385bedb1a40b8d1554bbcb03b8762ed5847e77ec9b86b777/cbor2-6.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:edc4a4dfa313b2cd78d7562cb99b51615e06c89832b78c0c02e2b5c2e27906ae", size = 301775, upload-time = "2026-10-01T18:09:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/b6/97/98c7c04aa255a9f6b2d1d3c35d210d0363fc7fa7c67963d6886086238748/cbor2-6.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f340682e2481ab729c399f8b81147476c5a179cfef65d02402702aeb9429088", size = 402161, upload-time = "2026-10-01T18:09:07.143Z" },
{ url = "https://files.pythonhosted.org/packages/19/69/8c209c49a7a1cefe7d6aa35211523ca5c25b3cf35e1b281cfdea2a42ec81/cbor2-6.1.5-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:30f88d1aff6c8c58ffec56591468f820d5ce6aee0bd64ae7443c0d7ef653eaf8", size = 446558, upload-time = "2026-10-01T18:09:08.964Z" },
{ url = "https://files.pythonhosted.org/packages/eb/65/c6836f9bb9f14a01696c5d90fee07585ae595b6b466ae1c7885405f7317d/cbor2-6.1.5-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:f294e65db28424fe89985faf74648622e04da7977ca5401ac65c7d1b6538d08a", size = 460016, upload-time = "2026-10-01T18:09:10.694Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a5/f58879254c9e5478f05bc9d5aaad9310b190d8a942f992980c877ba8795b/cbor2-6.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b586912cdb086dbad12052250acd5922fbe66a341ebee7031039eedf90fe84b1", size = 513758, upload-time = "2026-10-01T18:09:12.374Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ec/7ad474e9f79f8f7047754d4be6cc55b58f774ad3990631420dcd2f429197/cbor2-6.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e6d54e11887e649345b2ecb491a8e2866f4abdb6d83abc2a1a52d5ee23785ff8", size = 527606, upload-time = "2026-10-01T18:09:13.957Z" },
{ url = "https://files.pythonhosted.org/packages/01/90/df3e21b7d71ab6bf61f8fd8a0c87ad1de129dbbc5bc5dc2b01b1a1437e2d/cbor2-6.1.5-cp315-cp315-win32.whl", hash = "sha256:4e298c8a88488ebbf5475e51273b8d80da08f7b47aebfa79eb904fc82da49474", size = 281140, upload-time = "2026-10-01T18:09:15.542Z" },
{ url = "https://files.pythonhosted.org/packages/57/58/d31f4eb982a87a71b469b16d1579ec703ba0fcd7f748907b89e84b6c1120/cbor2-6.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9a154e010044662ce2e433f7c49e9c0f89ad7b86cb20e5d2e5afe6fd1753162", size = 308898, upload-time = "2026-10-01T18:09:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/e9/55/016955040b4193a50440116c4ccc827df15860c9a192476cd178671270c9/cbor2-6.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:cf89dd755e9781bea60bb67c1569d32ca10c38412126ab58bbc0235c697d98fc", size = 299711, upload-time = "2026-10-01T18:09:18.996Z" },
{ url = "https://files.pythonhosted.org/packages/7a/09/e7895f5388f243e6224581c77133d0404e9c8d302e72ec9179cdd8bdc007/cbor2-6.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:42217c9de0ead6c5a6c1a6ca6b836204ac46b5bf4f57c758f522f308d7784bf0", size = 397947, upload-time = "2026-10-01T18:09:20.702Z" },
{ url = "https://files.pythonhosted.org/packages/e2/6e/983bbf4850acb3ec3e99b039331e568fca0fd10bcd2c55746374d24e5875/cbor2-6.1.5-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:40754de6aef3f3d37f2ab36bb431da145359d0e28fce739683f8717ad2e97280", size = 441234, upload-time = "2026-10-01T18:09:22.584Z" },
{ url = "https://files.pythonhosted.org/packages/f5/0c/a19e7b8627dfc291c1004e67e0594ce687a5ccfc32321748b27cefca76a1/cbor2-6.1.5-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:9140388e9a732f3748641abb91d257d30cc466a7ed13c2c5a3d1aaa6af37bd66", size = 457317, upload-time = "2026-10-01T18:09:24.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/4e/2fa0a755436323155b574ded8d6fa840bec8f153ba7a47c2363d316e0df9/cbor2-6.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:040cf628af473fe18cb6f56bdac556d2398102e56852aab5206fbeb3dbde6b52", size = 507155, upload-time = "2026-10-01T18:09:25.61Z" },
{ url = "https://files.pythonhosted.org/packages/0f/b8/6fbe00ebaa935ab0683f5d9eb7b6f67097e0398a1e8e4120eb1298968f07/cbor2-6.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:151f624186a6b607d14074dfffe7b601f403445ab430554e3d920390c3068b05", size = 524789, upload-time = "2026-10-01T18:09:27.451Z" },
{ url = "https://files.pythonhosted.org/packages/ba/55/f10f5a273a680ef9beb36e6c22f92461d1d9c19bea6cb1bd876a1eb26d3b/cbor2-6.1.5-cp315-cp315t-win32.whl", hash = "sha256:1538e87b4b32764bc4940a37b6aa72e3bc6855033aac18d392d70daa89113a2b", size = 277303, upload-time = "2026-10-01T18:09:29.102Z" },
{ url = "https://files.pythonhosted.org/packages/78/33/c8c958ee8bb1a0931d1f863fa2b8ab9526e29c841c86f7a428feb7cb9a76/cbor2-6.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0b1fa210f23b1f822ee0c9157c99b0e851fce93c6da1dc8441aa7fb3c4089d70", size = 305311, upload-time = "2026-10-01T18:09:30.645Z" },
{ url = "https://files.pythonhosted.org/packages/d4/c0/e27a1e516a89af7194fc497f4b96d9601771ca41bb66fd5738113df80282/cbor2-6.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:fd34b35b0a2b366f5b4bd53489ccd10d7576b0d4dd68db38ef64b4e617ea8f76", size = 294495, upload-time = "2026-10-01T18:09:32.192Z" },
] ]
[[package]] [[package]]
@@ -948,6 +904,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "kaya-core" name = "kaya-core"
version = "0.0.8" version = "0.0.8"
@@ -1061,7 +1026,6 @@ source = { editable = "packages/kca-app" }
dependencies = [ dependencies = [
{ name = "aerich" }, { name = "aerich" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "cbor2" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "granian" }, { name = "granian" },
{ name = "httpx" }, { name = "httpx" },
@@ -1088,6 +1052,9 @@ otel = [
{ name = "opentelemetry-instrumentation-redis" }, { name = "opentelemetry-instrumentation-redis" },
{ name = "opentelemetry-instrumentation-system-metrics" }, { name = "opentelemetry-instrumentation-system-metrics" },
] ]
s3 = [
{ name = "boto3" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
@@ -1099,7 +1066,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aerich" }, { name = "aerich" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "cbor2" }, { name = "boto3", marker = "extra == 's3'", specifier = ">=1.34" },
{ name = "cryptography", specifier = ">=44" }, { name = "cryptography", specifier = ">=44" },
{ name = "granian", specifier = ">=2.0" }, { name = "granian", specifier = ">=2.0" },
{ name = "httpx" }, { name = "httpx" },
@@ -1122,7 +1089,7 @@ requires-dist = [
{ name = "redis" }, { name = "redis" },
{ name = "tortoise-orm", specifier = ">=0.25" }, { name = "tortoise-orm", specifier = ">=0.25" },
] ]
provides-extras = ["otel"] provides-extras = ["s3", "otel"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@@ -1711,6 +1678,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" }, { url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -1802,6 +1781,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.19.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.5.0" version = "2.5.0"
+22 -35
View File
@@ -45,7 +45,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let info = create_signal(None::<String>); let info = create_signal(None::<String>);
let openssl_config = create_signal(String::new()); let openssl_config = create_signal(String::new());
let actions_open = create_signal(false); let actions_open = create_signal(false);
let transform_open = create_signal(false);
let export_open = create_signal(false); let export_open = create_signal(false);
let revoke_open = create_signal(false); let revoke_open = create_signal(false);
let can_revoke = create_signal(false); let can_revoke = create_signal(false);
@@ -353,16 +352,12 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" }
button(class="btn btn-outline-light", r#type="button", on:click=move |_| { button(class="btn btn-outline-light", r#type="button", on:click=move |_| {
actions_open.set(false); actions_open.set(false);
transform_open.set(false);
export_open.set(true); export_open.set(true);
}) { "Export" } }) { "Export" }
div(class="dropdown") { div(class="dropdown") {
button(class="btn btn-primary dropdown-toggle", r#type="button", aria-haspopup="true", button(class="btn btn-primary dropdown-toggle", r#type="button", aria-haspopup="true",
aria-expanded=if actions_open.get() { "true" } else { "false" }, aria-expanded=if actions_open.get() { "true" } else { "false" },
on:click=move |_| { on:click=move |_| actions_open.set(!actions_open.get())) { "Actions" }
transform_open.set(false);
actions_open.set(!actions_open.get());
}) { "Actions" }
(if actions_open.get() { (if actions_open.get() {
view! { view! {
div(class="dropdown-menu dropdown-menu-end show", role="menu") { div(class="dropdown-menu dropdown-menu-end show", role="menu") {
@@ -375,34 +370,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} else { view! {} }) } else { view! {} })
} }
div(class="dropdown") {
button(class="btn btn-outline-light dropdown-toggle", r#type="button", aria-haspopup="true",
aria-expanded=if transform_open.get() { "true" } else { "false" },
on:click=move |_| {
actions_open.set(false);
transform_open.set(!transform_open.get());
}) { "Transform" }
(if transform_open.get() {
view! {
div(class="dropdown-menu dropdown-menu-end show", role="menu") {
button(class="dropdown-item", r#type="button", role="menuitem", on:click=move |_| {
transform_open.set(false);
transform("open-key");
}) { "Extract public key" }
button(class="dropdown-item", r#type="button", role="menuitem", on:click=move |_| {
transform_open.set(false);
transform("request");
}) { "Create request" }
button(class="dropdown-item", r#type="button", role="menuitem", on:click=move |_| {
transform_open.set(false);
transform("template");
}) { "Create template" }
a(class="dropdown-item", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar"), role="menuitem",
on:click=move |_| transform_open.set(false)) { "Similar certificate" }
}
}
} else { view! {} })
}
}, },
)) ))
div(class="card mb-3") { div(class="card mb-3") {
@@ -424,6 +391,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
(tab("extensions", "Extensions")) (tab("extensions", "Extensions"))
(tab("validation", "Validation")) (tab("validation", "Validation"))
(tab("metadata", "Metadata")) (tab("metadata", "Metadata"))
(tab("transform", "Transform"))
(if is_ca { tab("ca", "CA") } else { view! {} }) (if is_ca { tab("ca", "CA") } else { view! {} })
} }
(if active_tab.get_clone() == "overview" { (if active_tab.get_clone() == "overview" {
@@ -504,6 +472,23 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} }
} else { view! {} }) } else { view! {} })
(if active_tab.get_clone() == "transform" {
view! {
div(id="panel-transform", role="tabpanel", aria-labelledby="tab-transform", class="row g-3") {
div(class="col-12 col-lg-6") {
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" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" }
}
}
}
}
}
} else { view! {} })
(if is_ca && active_tab.get_clone() == "ca" { (if is_ca && active_tab.get_clone() == "ca" {
view! { view! {
div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", class="card mt-3") { div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", class="card mt-3") {
@@ -688,7 +673,9 @@ fn initial_tab() -> String {
.unwrap_or_default(); .unwrap_or_default();
let hash = hash.trim_start_matches('#'); let hash = hash.trim_start_matches('#');
match hash { match hash {
"overview" | "extensions" | "validation" | "metadata" | "ca" => hash.to_string(), "overview" | "extensions" | "validation" | "metadata" | "transform" | "ca" => {
hash.to_string()
}
_ => "overview".to_string(), _ => "overview".to_string(),
} }
} }
+1 -58
View File
@@ -31,10 +31,6 @@ struct ExtraSubjectRow {
value: Signal<String>, value: Signal<String>,
} }
fn key_is_selectable(key: &KeySummary, include_used: bool) -> bool {
key.has_private && (include_used || key.use_count == 0)
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct CertFormSignals { struct CertFormSignals {
explicit_dn: Signal<Vec<String>>, explicit_dn: Signal<Vec<String>>,
@@ -98,7 +94,6 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let comment = create_signal(String::new()); let comment = create_signal(String::new());
let signer_id = create_signal("self".to_string()); let signer_id = create_signal("self".to_string());
let key_id = create_signal(String::new()); let key_id = create_signal(String::new());
let include_used_keys = create_signal(false);
let new_key = create_signal(false); let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string()); let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string()); let key_size = create_signal("2048".to_string());
@@ -303,9 +298,6 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
name.set(format!("{} copy", value.summary.name)); name.set(format!("{} copy", value.summary.name));
comment.set(value.summary.comment.clone()); comment.set(value.summary.comment.clone());
if let Some(key) = value.summary.key_id.clone() { if let Some(key) = value.summary.key_id.clone() {
// XCA includes used keys when copying a certificate so
// the original key remains available and selected.
include_used_keys.set(true);
key_id.set(key); key_id.set(key);
} }
if let Some(issuer) = value.summary.issuer_id.clone() { if let Some(issuer) = value.summary.issuer_id.clone() {
@@ -602,10 +594,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
.collect::<Vec<View>>() .collect::<Vec<View>>()
}; };
let key_options = move || { let key_options = move || {
let include_used = include_used_keys.get();
keys.get_clone() keys.get_clone()
.into_iter() .into_iter()
.filter(|key| key_is_selectable(key, include_used)) .filter(|key| key.has_private)
.map(|key| { .map(|key| {
view! { view! {
option(value=key.id.clone()) { (key.name.clone()) } option(value=key.id.clone()) { (key.name.clone()) }
@@ -613,22 +604,6 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
}) })
.collect::<Vec<View>>() .collect::<Vec<View>>()
}; };
create_effect(move || {
if include_used_keys.get() {
return;
}
let selected = key_id.get_clone();
if selected.is_empty() {
return;
}
let selected_is_used = keys
.get_clone()
.into_iter()
.any(|key| key.id == selected && key.use_count > 0);
if selected_is_used {
key_id.set(String::new());
}
});
let template_options = move || { let template_options = move || {
templates.get_clone().into_iter().map(|template| view! { 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() }) } option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) }
@@ -753,10 +728,6 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
option(value="") { "— select a key —" } option(value="") { "— select a key —" }
(key_options()) (key_options())
} }
div(class="form-check mt-2") {
input(class="form-check-input", r#type="checkbox", id="include-used-keys", bind:checked=include_used_keys)
label(class="form-check-label", r#for="include-used-keys") { "Include used keys" }
}
} }
}) })
} }
@@ -1531,31 +1502,3 @@ fn query_param(name: &str) -> Option<String> {
} }
None None
} }
#[cfg(test)]
mod tests {
use super::*;
fn key(has_private: bool, use_count: i64) -> KeySummary {
KeySummary {
has_private,
use_count,
..KeySummary::default()
}
}
#[test]
fn key_selection_hides_used_and_public_keys_by_default() {
assert!(key_is_selectable(&key(true, 0), false));
assert!(!key_is_selectable(&key(true, 1), false));
assert!(!key_is_selectable(&key(false, 0), false));
}
#[test]
fn key_selection_includes_used_private_keys_when_requested() {
assert!(key_is_selectable(&key(true, 0), true));
assert!(key_is_selectable(&key(true, 2), true));
assert!(!key_is_selectable(&key(false, 0), true));
assert!(!key_is_selectable(&key(false, 1), true));
}
}