Compare commits
7
Commits
0301cbff6f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a101182fdd | ||
|
|
ef64c4a532 | ||
|
|
df98ada177 | ||
|
|
a7cfda4167 | ||
|
|
9da68a9625 | ||
|
|
3918a2ac71 | ||
|
|
356e3beccd |
@@ -7,10 +7,10 @@ organised in private **vaults** (each vault is the web equivalent of one XCA
|
||||
database).
|
||||
|
||||
- **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend:
|
||||
OIDC login, Redis sessions, personal API tokens, Postgres (Tortoise ORM +
|
||||
aerich migrations), AES-256-GCM private-key encryption, and a complete REST
|
||||
API. The pure PKI domain logic lives in `packages/kca-core`; the deployable
|
||||
app is `packages/kca-app`.
|
||||
OIDC login, Redis sessions, personal API tokens, PostgreSQL or SQLite
|
||||
(Tortoise ORM + aerich migrations), AES-256-GCM private-key encryption, and
|
||||
a complete REST API. The pure PKI domain logic lives in `packages/kca-core`;
|
||||
the deployable app is `packages/kca-app`.
|
||||
- **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly
|
||||
by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under
|
||||
`web/vendor/`) and a dark PKI-console theme.
|
||||
@@ -50,6 +50,45 @@ OIDC_0_REDIRECT_URI=https://kca.example.com/auth/default/callback
|
||||
|
||||
`server/.env.example` documents every setting.
|
||||
|
||||
## Database backends
|
||||
|
||||
PostgreSQL is the default production backend. KCA also supports persistent,
|
||||
file-backed SQLite for single-writer deployments. Configure it from `server/`
|
||||
with either the component settings:
|
||||
|
||||
```sh
|
||||
export DATABASE_ENGINE=sqlite
|
||||
export DATABASE_NAME=/data/kca.db
|
||||
export DATABASE_OPTIONS=busy_timeout=5000
|
||||
```
|
||||
|
||||
or one URL:
|
||||
|
||||
```sh
|
||||
export DATABASE_URL='sqlite:///data/kca.db?busy_timeout=5000'
|
||||
```
|
||||
|
||||
Run migrations before starting or upgrading the app:
|
||||
|
||||
```sh
|
||||
uv run aerich upgrade
|
||||
```
|
||||
|
||||
Tortoise enables WAL journaling and foreign-key enforcement by default; the
|
||||
busy timeout allows concurrent readers and a writer to wait briefly for locks.
|
||||
Only in-memory SQLite test databases create their schemas implicitly.
|
||||
|
||||
For containers, mount a persistent volume at `/data` for both the migration
|
||||
and application containers and use the same SQLite URL for each. The image's
|
||||
`/data` directory is writable by its non-root application user. When invoking
|
||||
`aerich upgrade` directly in the image, set its working directory to `/app`
|
||||
(for example, `docker run -w /app ...`). Keep SQLite to one application
|
||||
writer/replica, and use SQLite-aware backups rather than independently copying
|
||||
live database, WAL and shared-memory files.
|
||||
|
||||
When changing models, migration SQL must support both PostgreSQL and SQLite.
|
||||
The PostgreSQL migration commands below remain the default development flow.
|
||||
|
||||
## What it does
|
||||
|
||||
### Vaults
|
||||
@@ -154,36 +193,42 @@ only as SHA-256 digests, and cannot mint/revoke tokens.
|
||||
|
||||
## OCSP responder
|
||||
|
||||
KCA can publish full per-CA certificate-status snapshots to any S3-compatible
|
||||
bucket and ships a standalone Rust/Axum responder on port `2560`.
|
||||
KCA publishes full per-CA certificate-status snapshots to Redis and ships a
|
||||
standalone Rust/Axum responder on port `2560`. By default, snapshots use the
|
||||
same Redis instance and logical database as sessions; setting
|
||||
`KCA_OCSP_REDIS_URL` selects a dedicated OCSP instance instead.
|
||||
|
||||
- KCA publishes `ocsp/<KCA CA UUID>.json` whenever certificates are issued,
|
||||
renewed, revoked, deleted or imported. Revocation reasons are
|
||||
included; this is a full status snapshot, not a revoked-only list.
|
||||
- The responder has no Postgres/Redis/KCA master-key access. It only reads the
|
||||
configured S3 prefix and uses a delegated OCSP signing certificate/key per
|
||||
CA.
|
||||
- KCA publishes `ocsp:<KCA CA UUID>` whenever certificates are issued, renewed,
|
||||
revoked, deleted or imported. Revocation reasons are included; this is a full
|
||||
CBOR-encoded status snapshot, not a revoked-only list. OCSP keys are separate
|
||||
from session keys through their prefix.
|
||||
- The responder has no Postgres or KCA master-key access. It reads only OCSP
|
||||
snapshots from Redis and uses a delegated OCSP signing certificate/key per
|
||||
CA. Where Redis ACLs are available, restrict the responder to `ocsp:*` so it
|
||||
cannot read session entries.
|
||||
- Each CA snapshot is cached for one second with a single-flight refresh, so
|
||||
concurrent requests cause at most one S3 `GetObject` per CA per second.
|
||||
- The publisher refreshes unchanged snapshots every 30 seconds by default. The
|
||||
responder rejects snapshots older than its configured grace period even when
|
||||
S3 is readable, and returns `tryLater` when no fresh snapshot is available.
|
||||
- OCSP `thisUpdate` reflects the snapshot generation time and `nextUpdate` never
|
||||
extends beyond the snapshot's hard expiry.
|
||||
concurrent requests share one atomic Redis `GET` + `PTTL` lookup per CA.
|
||||
- The publisher refreshes unchanged snapshots every 30 seconds by default and
|
||||
sets each key's expiry to 60 seconds. A missing or expired snapshot produces
|
||||
`unknown`; Redis failures produce `tryLater` when no unexpired cached
|
||||
snapshot remains.
|
||||
- OCSP `thisUpdate` is the response time and `nextUpdate` never extends beyond
|
||||
the snapshot's Redis expiry.
|
||||
- Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same
|
||||
`release/*` tags as the server image. The responder image is a statically
|
||||
linked musl executable running `FROM scratch` with only the CA bundle.
|
||||
|
||||
Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other
|
||||
Configure `REDIS_URL`, optional `KCA_OCSP_REDIS_URL`, and the other
|
||||
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
|
||||
configuration derived from `ocsp-responder/responder.example.yaml`, setting
|
||||
each issuer's `ca_id` to its CA certificate UUID in KCA, or with Compose:
|
||||
each issuer's `ca_id` to its CA certificate UUID in KCA and using the OCSP
|
||||
Redis URL, or with Compose:
|
||||
|
||||
```sh
|
||||
docker compose --profile ocsp up --build
|
||||
```
|
||||
|
||||
See `ocsp-responder/README.md` for the JSON layout, signer requirements and
|
||||
See `ocsp-responder/README.md` for the CBOR layout, signer requirements and
|
||||
`openssl ocsp` verification.
|
||||
|
||||
## REST API
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
# KCA — Kubernetes deployment sketch (namespace: kca).
|
||||
#
|
||||
# Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis
|
||||
# is included because sessions are disposable. KCA_MASTER_KEY is the AES key
|
||||
# stores disposable sessions and expiring OCSP snapshots. Set
|
||||
# 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.
|
||||
#
|
||||
# kubectl apply -f deploy/k8s/kca.yaml
|
||||
|
||||
+13
-17
@@ -105,24 +105,20 @@ services:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
|
||||
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health}
|
||||
# OCSP status publisher (optional). The bucket must be writable by
|
||||
# KCA; the responder later reads the same prefix with read-only access.
|
||||
KCA_OCSP_S3_BUCKET: ${KCA_OCSP_S3_BUCKET:-}
|
||||
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}
|
||||
# OCSP snapshots default to the session Redis. Set KCA_OCSP_REDIS_URL to
|
||||
# use a dedicated instance instead.
|
||||
KCA_OCSP_REDIS_URL: ${KCA_OCSP_REDIS_URL:-}
|
||||
KCA_OCSP_REDIS_PREFIX: ${KCA_OCSP_REDIS_PREFIX:-ocsp}
|
||||
KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1}
|
||||
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30}
|
||||
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:-}
|
||||
KCA_OCSP_REDIS_EXPIRY_SECONDS: ${KCA_OCSP_REDIS_EXPIRY_SECONDS:-60}
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
|
||||
# Optional standalone OCSP responder. It deliberately has no database,
|
||||
# Redis or KCA master-key environment. Enable with:
|
||||
# Optional standalone OCSP responder. It has no database or KCA master-key
|
||||
# environment, but reads OCSP keys from the configured Redis (the session
|
||||
# Redis by default).
|
||||
# Enable with:
|
||||
# docker compose --profile ocsp up --build
|
||||
ocsp:
|
||||
profiles: ["ocsp"]
|
||||
@@ -130,12 +126,12 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ocsp-responder/Dockerfile
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
OCSP_CONFIG: /etc/ocsp/responder.yaml
|
||||
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:-}
|
||||
OCSP_REDIS_URL: ${OCSP_REDIS_URL:-redis://redis:6379/0}
|
||||
volumes:
|
||||
- ${OCSP_CONFIG_FILE:-./ocsp-responder/responder.example.yaml}:/etc/ocsp/responder.yaml:ro
|
||||
- ${OCSP_CA_DIR:-./ocsp-responder/ca}:/etc/ocsp/ca:ro
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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);
|
||||
});
|
||||
Generated
+159
-1032
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ tower-http = { version = "0.6", features = ["limit", "trace", "timeout"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
cbor2 = "1"
|
||||
serde_yaml = "0.9"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
@@ -30,14 +30,4 @@ ecdsa = { version = "0.16", features = ["der"] }
|
||||
pkcs8 = { version = "0.10", features = ["pem"] }
|
||||
x509-ocsp = { version = "0.2.1", features = ["builder", "std"] }
|
||||
x509-cert = { version = "0.2", features = ["std"] }
|
||||
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",
|
||||
] }
|
||||
redis = { version = "1", features = ["tokio-comp", "connection-manager", "script"] }
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# Standalone Rust/Axum OCSP responder.
|
||||
#
|
||||
# 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
|
||||
# outbound HTTPS to S3. Configuration, CA certificates and delegated signer
|
||||
# material are mounted at runtime.
|
||||
# executable and the only file besides it is the CA bundle needed for TLS
|
||||
# connections. Configuration, CA certificates and delegated signer material
|
||||
# are mounted at runtime.
|
||||
|
||||
FROM rust:1-alpine AS builder
|
||||
|
||||
|
||||
+48
-45
@@ -1,50 +1,51 @@
|
||||
# KCA OCSP responder
|
||||
|
||||
Standalone Rust/Axum responder. It reads one full JSON status snapshot per CA
|
||||
from S3-compatible object storage, caches it for one second with a single-flight
|
||||
refresh, and signs OCSP responses with a delegated responder certificate.
|
||||
Standalone Rust/Axum responder. It reads one full CBOR status snapshot per CA
|
||||
from Redis, caches it for one second with a single-flight refresh, and signs
|
||||
OCSP responses with a delegated responder certificate.
|
||||
|
||||
It has **no KCA database access**, no Redis access, and no KCA master key. It
|
||||
only needs:
|
||||
It has **no KCA database access** and no KCA master key. It only needs:
|
||||
|
||||
- `s3:GetObject` on the configured bucket/prefix;
|
||||
- read access to OCSP keys in the Redis instance selected by KCA (the session
|
||||
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;
|
||||
- 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,
|
||||
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
|
||||
|
||||
KCA publishes objects as:
|
||||
KCA publishes keys as:
|
||||
|
||||
```
|
||||
<prefix>/<KCA CA UUID>.json
|
||||
<prefix>:<KCA CA UUID>
|
||||
```
|
||||
|
||||
Set each issuer's required `ca_id` to the UUID of its CA certificate row in KCA.
|
||||
This isolates snapshots when the same public CA certificate exists in multiple
|
||||
vaults. The responder validates both this ID and the CA certificate fingerprint
|
||||
against every downloaded snapshot.
|
||||
The default prefix is `ocsp`. Set each issuer's required `ca_id` to the UUID of
|
||||
its CA certificate row in KCA. This isolates snapshots when the same public CA
|
||||
certificate exists in multiple vaults. The responder validates both this ID and
|
||||
the CA certificate fingerprint against every downloaded snapshot.
|
||||
|
||||
KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON
|
||||
contains all certificates issued by that CA, both valid and revoked, and a
|
||||
required `generated_at` timestamp. The responder never infers status from a
|
||||
partial revoked-only list.
|
||||
|
||||
## Upgrading from fingerprint-named snapshots
|
||||
|
||||
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.
|
||||
KCA publishes these keys when either `REDIS_URL` or `KCA_OCSP_REDIS_URL` is
|
||||
configured, preferring the dedicated OCSP URL when both are present. The
|
||||
`kca-ocsp-status/v2` payload is encoded with CBOR and contains all certificates
|
||||
issued by that CA, both valid and revoked, without a generation timestamp.
|
||||
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
|
||||
coordinated because legacy JSON payloads are rejected until republished.
|
||||
|
||||
## Run locally
|
||||
|
||||
1. Create a config based on `responder.example.yaml`, setting each issuer's
|
||||
`ca_id` to its CA certificate UUID in KCA.
|
||||
2. Put your CA certificates and delegated signer files where the config points.
|
||||
3. Export standard AWS credentials / endpoint variables as needed.
|
||||
2. Set `redis.url` or `OCSP_REDIS_URL` to the OCSP Redis URL used by KCA (the
|
||||
session `REDIS_URL` unless `KCA_OCSP_REDIS_URL` overrides it).
|
||||
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
|
||||
published scratch image), or mount it via a Docker secret.
|
||||
5. Run:
|
||||
@@ -62,12 +63,12 @@ docker run --rm -p 2560:2560 \
|
||||
-v "$PWD/responder.yaml:/etc/ocsp/responder.yaml:ro" \
|
||||
-v "$PWD/ca:/etc/ocsp/ca:ro" \
|
||||
-v "$PWD/signers:/etc/ocsp/signers:ro" \
|
||||
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
|
||||
-e OCSP_REDIS_URL \
|
||||
gitea.woggioni.net/woggioni/kca-ocsp:latest
|
||||
```
|
||||
|
||||
It runs `FROM scratch` and contains only a statically linked musl executable
|
||||
and a CA bundle for outbound S3 HTTPS. There is no shell or package manager in
|
||||
and a CA bundle for TLS connections. There is no shell or package manager in
|
||||
the image.
|
||||
|
||||
## Verify with OpenSSL
|
||||
@@ -96,21 +97,23 @@ build a trust store containing the CA or validate with your normal PKI tooling.
|
||||
|
||||
## Cache and availability
|
||||
|
||||
- Per-CA cache TTL: `s3.cache_ttl_ms` (default 1000 ms).
|
||||
- Concurrent requests for the same CA share one S3 `GetObject`.
|
||||
- `s3.stale_grace_seconds` is also the hard maximum snapshot age, measured from
|
||||
`generated_at`. Expired or future-dated snapshots are rejected even when S3
|
||||
remains readable; if no valid cached snapshot remains, OCSP returns
|
||||
`tryLater`.
|
||||
- If a refresh fails, a cached snapshot is served only while both its time since
|
||||
the last successful fetch and its `generated_at` age remain within
|
||||
`s3.stale_grace_seconds`.
|
||||
- Per-CA cache TTL: `redis.cache_ttl_ms` (default 1000 ms).
|
||||
- Concurrent requests for the same CA share one refresh. A Redis Lua script
|
||||
performs `GET` and `PTTL` atomically, keeping the snapshot and its expiry
|
||||
consistent.
|
||||
- A missing or expired key produces `unknown`, as does a serial absent from an
|
||||
existing snapshot. A key that exists without a TTL is rejected as a data
|
||||
error. Redis connection and command failures produce `tryLater` when no
|
||||
unexpired cached snapshot remains.
|
||||
- If a refresh fails, a cached snapshot is served only until its Redis expiry.
|
||||
A missing key clears cached status so it cannot be revived by a later Redis
|
||||
failure.
|
||||
- KCA republishes unchanged snapshots every
|
||||
`KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds). Keep that interval
|
||||
comfortably below the responder's `s3.stale_grace_seconds` (default 60). For
|
||||
a rolling upgrade, enable publisher heartbeats before the freshness-enforcing
|
||||
responder so existing snapshots remain within the age limit.
|
||||
- OCSP `thisUpdate` is the snapshot's `generated_at`. `nextUpdate` is capped at
|
||||
the snapshot's hard expiry, while `producedAt` is the response signing time.
|
||||
- With multiple CAs, the TTL is per CA. If you need a global one-request-per-
|
||||
second limit across all CAs, that requires a shared rate limiter.
|
||||
`KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds) and sets each
|
||||
key's expiry using `KCA_OCSP_REDIS_EXPIRY_SECONDS` (default 60 seconds).
|
||||
Keep the heartbeat comfortably below the key expiry.
|
||||
- OCSP `thisUpdate` and `producedAt` use the response time. `nextUpdate` is
|
||||
capped at the snapshot's Redis expiry when a snapshot is present.
|
||||
- With multiple CAs, the cache TTL is per CA. If you need a global
|
||||
one-request-per-second limit across all CAs, that requires a shared rate
|
||||
limiter.
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
listen: 0.0.0.0:2560
|
||||
next_update_seconds: 10
|
||||
|
||||
s3:
|
||||
bucket: kca-ocsp
|
||||
redis:
|
||||
# OCSP Redis used by KCA (the session Redis unless KCA_OCSP_REDIS_URL is set).
|
||||
# OCSP_REDIS_URL overrides this value at runtime.
|
||||
url: redis://redis:6379/0
|
||||
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
|
||||
# Hard maximum snapshot age. Keep comfortably above KCA's heartbeat interval.
|
||||
stale_grace_seconds: 60
|
||||
|
||||
issuers:
|
||||
- name: Example Root CA
|
||||
|
||||
+112
-121
@@ -1,9 +1,8 @@
|
||||
//! One-second, single-flight cache of per-CA status snapshots.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::snapshot::Snapshot;
|
||||
@@ -11,7 +10,8 @@ use crate::store::ObjectStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CachedSnapshot {
|
||||
snapshot: Arc<Snapshot>,
|
||||
pub snapshot: Arc<Snapshot>,
|
||||
pub expires_at: SystemTime,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ impl SnapshotCache {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return a snapshot, refreshing from S3 at most once per `ttl`.
|
||||
/// Return a snapshot, refreshing from Redis at most once per `ttl`.
|
||||
///
|
||||
/// Snapshots older than `stale_grace` are never used. On refresh failure,
|
||||
/// cached data is also limited to `stale_grace` since its last fetch.
|
||||
/// A cached snapshot is never used after its Redis key expires,
|
||||
/// including when a refresh fails.
|
||||
pub async fn get<S: ObjectStore + ?Sized>(
|
||||
&self,
|
||||
store: &S,
|
||||
@@ -37,12 +37,10 @@ impl SnapshotCache {
|
||||
expected_sha256: &str,
|
||||
expected_ca_id: &str,
|
||||
ttl: Duration,
|
||||
stale_grace: Duration,
|
||||
) -> Result<Arc<Snapshot>, CacheError> {
|
||||
) -> Result<CachedSnapshot, CacheError> {
|
||||
if let Some(value) = self.cached.read().await.as_ref() {
|
||||
if value.fetched_at.elapsed() < ttl && snapshot_age_valid(&value.snapshot, stale_grace)
|
||||
{
|
||||
return Ok(value.snapshot.clone());
|
||||
if fresh_cache_entry_valid(value, ttl) {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +50,8 @@ impl SnapshotCache {
|
||||
let stale = {
|
||||
let cache = self.cached.read().await;
|
||||
if let Some(value) = cache.as_ref() {
|
||||
if value.fetched_at.elapsed() < ttl
|
||||
&& snapshot_age_valid(&value.snapshot, stale_grace)
|
||||
{
|
||||
return Ok(value.snapshot.clone());
|
||||
if fresh_cache_entry_valid(value, ttl) {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
Some(value.clone())
|
||||
} else {
|
||||
@@ -64,21 +60,31 @@ impl SnapshotCache {
|
||||
};
|
||||
|
||||
match store.get(key).await {
|
||||
Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) {
|
||||
Ok(snapshot) => match validate_snapshot_age(&snapshot, stale_grace) {
|
||||
Ok(()) => {
|
||||
let snapshot = Arc::new(snapshot);
|
||||
*self.cached.write().await = Some(CachedSnapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
Ok(crate::store::StoredSnapshot::Missing) => {
|
||||
// A key that expired or was deleted must not be replaced by
|
||||
// an older cached status on a later Redis failure.
|
||||
*self.cached.write().await = None;
|
||||
Err(CacheError::Missing)
|
||||
}
|
||||
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(),
|
||||
});
|
||||
Ok(snapshot)
|
||||
};
|
||||
*self.cached.write().await = Some(cached.clone());
|
||||
Ok(cached)
|
||||
}
|
||||
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(), stale_grace),
|
||||
Err(error) => self.fallback(stale, error),
|
||||
}
|
||||
}
|
||||
Err(error) => self.fallback(stale, error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,45 +92,37 @@ impl SnapshotCache {
|
||||
&self,
|
||||
stale: Option<CachedSnapshot>,
|
||||
error: String,
|
||||
stale_grace: Duration,
|
||||
) -> Result<Arc<Snapshot>, CacheError> {
|
||||
) -> Result<CachedSnapshot, CacheError> {
|
||||
if let Some(value) = stale {
|
||||
if value.fetched_at.elapsed() <= stale_grace
|
||||
&& snapshot_age_valid(&value.snapshot, stale_grace)
|
||||
{
|
||||
tracing::warn!(error = %error, "serving stale OCSP snapshot");
|
||||
return Ok(value.snapshot);
|
||||
if expiry_valid(value.expires_at) {
|
||||
tracing::warn!(error = %error, "serving unexpired cached OCSP snapshot after refresh failure");
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(CacheError::Unavailable(error))
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_age_valid(snapshot: &Snapshot, stale_grace: Duration) -> bool {
|
||||
validate_snapshot_age(snapshot, stale_grace).is_ok()
|
||||
fn fresh_cache_entry_valid(value: &CachedSnapshot, ttl: Duration) -> bool {
|
||||
value.fetched_at.elapsed() < ttl && expiry_valid(value.expires_at)
|
||||
}
|
||||
|
||||
fn validate_snapshot_age(snapshot: &Snapshot, stale_grace: Duration) -> Result<(), String> {
|
||||
let age = Utc::now()
|
||||
.signed_duration_since(snapshot.generated_at)
|
||||
.to_std()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"snapshot generation time {} is in the future",
|
||||
snapshot.generated_at
|
||||
)
|
||||
})?;
|
||||
if age > stale_grace {
|
||||
return Err(format!(
|
||||
"snapshot generated at {} exceeds maximum age {stale_grace:?}",
|
||||
snapshot.generated_at
|
||||
));
|
||||
fn expiry_valid(expires_at: SystemTime) -> bool {
|
||||
expires_at > SystemTime::now()
|
||||
}
|
||||
|
||||
fn validate_expiry(expires_at: SystemTime) -> Result<(), String> {
|
||||
if expiry_valid(expires_at) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("snapshot Redis key expired at {expires_at:?}"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CacheError {
|
||||
#[error("OCSP snapshot is missing")]
|
||||
Missing,
|
||||
#[error("OCSP snapshot unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
}
|
||||
@@ -137,119 +135,97 @@ mod tests {
|
||||
use bytes::Bytes;
|
||||
|
||||
use super::*;
|
||||
use crate::snapshot::sha256_hex;
|
||||
use crate::store::{ObjectStore, StoreError};
|
||||
use crate::snapshot::{sha256_hex, test_body as body};
|
||||
use crate::store::{ObjectStore, StoreError, StoredSnapshot};
|
||||
|
||||
struct FakeStore {
|
||||
body: String,
|
||||
body: Vec<u8>,
|
||||
expires_at: SystemTime,
|
||||
calls: AtomicUsize,
|
||||
fail: AtomicBool,
|
||||
missing: AtomicBool,
|
||||
}
|
||||
|
||||
impl FakeStore {
|
||||
fn new(body: String) -> Self {
|
||||
fn new(body: Vec<u8>, lifetime: Duration) -> Self {
|
||||
Self {
|
||||
body,
|
||||
expires_at: SystemTime::now() + lifetime,
|
||||
calls: AtomicUsize::new(0),
|
||||
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]
|
||||
impl ObjectStore for FakeStore {
|
||||
async fn get(&self, _key: &str) -> Result<Bytes, StoreError> {
|
||||
async fn get(&self, _key: &str) -> Result<StoredSnapshot, StoreError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
return Err(StoreError::S3("offline".to_string()));
|
||||
return Err(StoreError::Redis("offline".to_string()));
|
||||
}
|
||||
Ok(Bytes::from(self.body.clone()))
|
||||
if self.missing.load(Ordering::SeqCst) {
|
||||
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]
|
||||
async fn single_flight_refreshes_once() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
let store = FakeStore::new(body(&sha, Utc::now()));
|
||||
let store = FakeStore::new(body(&sha), Duration::from_secs(1));
|
||||
let cache = SnapshotCache::new();
|
||||
let (a, b) = tokio::join!(
|
||||
cache.get(
|
||||
&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)
|
||||
)
|
||||
cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),),
|
||||
cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),)
|
||||
);
|
||||
assert!(a.is_ok());
|
||||
assert!(b.is_ok());
|
||||
assert_eq!(store.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(a.unwrap().expires_at, store.expires_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_after_ttl_and_serves_stale_on_error() {
|
||||
async fn refreshes_after_ttl_and_serves_unexpired_cache_on_error() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
let store = FakeStore::new(body(&sha, Utc::now()));
|
||||
let store = FakeStore::new(body(&sha), Duration::from_secs(1));
|
||||
let cache = SnapshotCache::new();
|
||||
cache
|
||||
.get(
|
||||
&store,
|
||||
"key",
|
||||
&sha,
|
||||
"ca",
|
||||
Duration::from_millis(20),
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.get(&store, "key", &sha, "ca", Duration::from_millis(20))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let stale = cache
|
||||
.get(
|
||||
&store,
|
||||
"key",
|
||||
&sha,
|
||||
"ca",
|
||||
Duration::from_millis(20),
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.get(&store, "key", &sha, "ca", Duration::from_millis(20))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stale.entries.len(), 1);
|
||||
assert_eq!(stale.snapshot.entries.len(), 1);
|
||||
assert_eq!(store.calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_expired_snapshot_from_healthy_store() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
let generated_at = Utc::now() - chrono::Duration::seconds(30);
|
||||
let store = FakeStore::new(body(&sha, generated_at));
|
||||
let store = FakeStore::expired(body(&sha));
|
||||
let cache = SnapshotCache::new();
|
||||
let result = cache
|
||||
.get(
|
||||
&store,
|
||||
"key",
|
||||
&sha,
|
||||
"ca",
|
||||
Duration::from_millis(1),
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.get(&store, "key", &sha, "ca", Duration::from_millis(1))
|
||||
.await;
|
||||
assert!(matches!(result, Err(CacheError::Unavailable(_))));
|
||||
assert_eq!(store.calls.load(Ordering::SeqCst), 1);
|
||||
@@ -258,22 +234,37 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn successful_fetches_do_not_extend_snapshot_lifetime() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
let store = FakeStore::new(body(&sha, Utc::now()));
|
||||
let store = FakeStore::new(body(&sha), Duration::from_millis(100));
|
||||
let cache = SnapshotCache::new();
|
||||
let ttl = Duration::from_secs(1);
|
||||
let stale_grace = Duration::from_millis(100);
|
||||
cache
|
||||
.get(&store, "key", &sha, "ca", ttl, stale_grace)
|
||||
.await
|
||||
.unwrap();
|
||||
cache.get(&store, "key", &sha, "ca", ttl).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
let expired = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await;
|
||||
let expired = cache.get(&store, "key", &sha, "ca", ttl).await;
|
||||
assert!(matches!(expired, Err(CacheError::Unavailable(_))));
|
||||
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let unavailable = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await;
|
||||
let unavailable = cache.get(&store, "key", &sha, "ca", ttl).await;
|
||||
assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct Config {
|
||||
#[serde(default = "default_listen")]
|
||||
pub listen: SocketAddr,
|
||||
#[serde(default)]
|
||||
pub s3: S3Config,
|
||||
pub redis: RedisConfig,
|
||||
#[serde(default)]
|
||||
pub issuers: Vec<IssuerConfig>,
|
||||
#[serde(default = "default_ocsp_next_update_seconds")]
|
||||
@@ -27,42 +27,32 @@ pub struct IssuerConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct S3Config {
|
||||
pub bucket: String,
|
||||
pub struct RedisConfig {
|
||||
pub url: String,
|
||||
#[serde(default = "default_prefix")]
|
||||
pub prefix: String,
|
||||
pub region: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force_path_style: bool,
|
||||
#[serde(default = "default_cache_ttl_ms")]
|
||||
pub cache_ttl_ms: u64,
|
||||
#[serde(default = "default_stale_grace_seconds")]
|
||||
pub stale_grace_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for S3Config {
|
||||
impl Default for RedisConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bucket: String::new(),
|
||||
url: String::new(),
|
||||
prefix: default_prefix(),
|
||||
region: None,
|
||||
endpoint: None,
|
||||
force_path_style: false,
|
||||
cache_ttl_ms: default_cache_ttl_ms(),
|
||||
stale_grace_seconds: default_stale_grace_seconds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl S3Config {
|
||||
pub fn object_key(&self, ca_id: &str) -> String {
|
||||
let prefix = self.prefix.trim_matches('/');
|
||||
let name = format!("{}.json", ca_id.to_lowercase());
|
||||
impl RedisConfig {
|
||||
pub fn key(&self, ca_id: &str) -> String {
|
||||
let prefix = self.prefix.trim_matches(['/', ':']);
|
||||
let name = ca_id.to_lowercase();
|
||||
if prefix.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{prefix}/{name}")
|
||||
format!("{prefix}:{name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,10 +71,6 @@ fn default_cache_ttl_ms() -> u64 {
|
||||
1000
|
||||
}
|
||||
|
||||
fn default_stale_grace_seconds() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_ocsp_next_update_seconds() -> u64 {
|
||||
10
|
||||
}
|
||||
@@ -97,8 +83,11 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>>
|
||||
if config.issuers.is_empty() {
|
||||
return Err("configuration must contain at least one issuer".into());
|
||||
}
|
||||
if config.s3.bucket.trim().is_empty() {
|
||||
return Err("s3.bucket must be configured".into());
|
||||
if let Ok(url) = std::env::var("OCSP_REDIS_URL") {
|
||||
config.redis.url = url;
|
||||
}
|
||||
if config.redis.url.trim().is_empty() {
|
||||
return Err("redis.url must be configured".into());
|
||||
}
|
||||
if let Ok(listen) = std::env::var("OCSP_LISTEN") {
|
||||
config.listen = listen.parse()?;
|
||||
@@ -111,14 +100,14 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_key_uses_ca_id() {
|
||||
let config = S3Config {
|
||||
fn redis_key_uses_ca_id() {
|
||||
let config = RedisConfig {
|
||||
prefix: "ocsp".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
config.object_key("123E4567-E89B-12D3-A456-426614174000"),
|
||||
"ocsp/123e4567-e89b-12d3-a456-426614174000.json"
|
||||
config.key("123E4567-E89B-12D3-A456-426614174000"),
|
||||
"ocsp:123e4567-e89b-12d3-a456-426614174000"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +111,9 @@ impl Issuer {
|
||||
pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>(
|
||||
&self,
|
||||
store: &S,
|
||||
config: &crate::config::S3Config,
|
||||
) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> {
|
||||
let key = config.object_key(&self.ca_id);
|
||||
config: &crate::config::RedisConfig,
|
||||
) -> Result<crate::cache::CachedSnapshot, crate::cache::CacheError> {
|
||||
let key = config.key(&self.ca_id);
|
||||
self.cache
|
||||
.get(
|
||||
store,
|
||||
@@ -121,7 +121,6 @@ impl Issuer {
|
||||
&self.fingerprint,
|
||||
&self.ca_id,
|
||||
Duration::from_millis(config.cache_ttl_ms),
|
||||
Duration::from_secs(config.stale_grace_seconds),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
.init();
|
||||
|
||||
let config = config::load_config()?;
|
||||
let store = StatusStore::new(&config.s3).await;
|
||||
let store = StatusStore::new(&config.redis).await?;
|
||||
let mut issuers = Vec::with_capacity(config.issuers.len());
|
||||
for issuer_config in &config.issuers {
|
||||
let issuer = Issuer::load(issuer_config)?;
|
||||
|
||||
+25
-29
@@ -8,7 +8,6 @@ use axum::extract::{Path, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use der::{Decode, Encode};
|
||||
use x509_cert::ext::pkix::CrlReason;
|
||||
use x509_ocsp::builder::OcspResponseBuilder;
|
||||
@@ -67,8 +66,11 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
|
||||
return ocsp_response(OcspResponse::unauthorized());
|
||||
}
|
||||
|
||||
let snapshot = match issuer.snapshot(&state.store, &state.config.s3).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
let (snapshot, expires_at) = match issuer.snapshot(&state.store, &state.config.redis).await {
|
||||
Ok(cached) => (cached.snapshot, Some(cached.expires_at)),
|
||||
Err(crate::cache::CacheError::Missing) => {
|
||||
(Arc::new(crate::snapshot::Snapshot::default()), None)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable");
|
||||
return ocsp_response(OcspResponse::try_later());
|
||||
@@ -83,12 +85,8 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
|
||||
return ocsp_response(OcspResponse::internal_error());
|
||||
}
|
||||
};
|
||||
let (this_update, next_update) = response_update_times(
|
||||
snapshot.generated_at,
|
||||
state.config.next_update_seconds,
|
||||
state.config.s3.stale_grace_seconds,
|
||||
now,
|
||||
);
|
||||
let (this_update, next_update) =
|
||||
response_update_times(expires_at, state.config.next_update_seconds, now);
|
||||
let this_update = match OcspGeneralizedTime::try_from(this_update) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
@@ -143,17 +141,17 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
|
||||
}
|
||||
|
||||
fn response_update_times(
|
||||
generated_at: DateTime<Utc>,
|
||||
expires_at: Option<SystemTime>,
|
||||
next_update_seconds: u64,
|
||||
stale_grace_seconds: u64,
|
||||
now: 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
|
||||
.checked_add(Duration::from_secs(next_update_seconds))
|
||||
.and_then(|next| expires_at.map(|expires| next.min(expires)));
|
||||
(this_update, next_update)
|
||||
.map(|next| match expires_at {
|
||||
Some(expires_at) => next.min(expires_at),
|
||||
None => next,
|
||||
});
|
||||
(now, next_update)
|
||||
}
|
||||
|
||||
fn ocsp_response(response: OcspResponse) -> Response {
|
||||
@@ -179,22 +177,20 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn update_times_use_snapshot_age_and_bound_next_update() {
|
||||
let generated_at = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let generated_at_system = system_time(generated_at);
|
||||
fn update_times_use_response_time_and_bound_next_update_to_expiry() {
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_225_600);
|
||||
let expires_at = base + Duration::from_secs(60);
|
||||
|
||||
let now = generated_at_system + Duration::from_secs(5);
|
||||
let (this_update, next_update) = response_update_times(generated_at, 10, 60, now);
|
||||
assert_eq!(this_update, generated_at_system);
|
||||
let now = base + Duration::from_secs(5);
|
||||
let (this_update, next_update) = response_update_times(Some(expires_at), 10, now);
|
||||
assert_eq!(this_update, now);
|
||||
assert_eq!(next_update, Some(now + Duration::from_secs(10)));
|
||||
|
||||
let now = generated_at_system + Duration::from_secs(55);
|
||||
let (_, next_update) = response_update_times(generated_at, 10, 60, now);
|
||||
assert_eq!(
|
||||
next_update,
|
||||
Some(generated_at_system + Duration::from_secs(60))
|
||||
);
|
||||
let now = base + Duration::from_secs(55);
|
||||
let (_, next_update) = response_update_times(Some(expires_at), 10, now);
|
||||
assert_eq!(next_update, Some(expires_at));
|
||||
|
||||
let (_, next_update) = response_update_times(None, 10, now);
|
||||
assert_eq!(next_update, Some(now + Duration::from_secs(10)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::{Digest as _, Sha1};
|
||||
use sha2::Sha256;
|
||||
use x509_cert::ext::pkix::CrlReason;
|
||||
|
||||
pub const SCHEMA: &str = "kca-ocsp-status/v1";
|
||||
pub const SCHEMA: &str = "kca-ocsp-status/v2";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
@@ -23,22 +23,20 @@ pub struct StatusEntry {
|
||||
pub reason: Option<CrlReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Snapshot {
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub entries: HashMap<Vec<u8>, StatusEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct RawSnapshot {
|
||||
schema: String,
|
||||
issuer: RawIssuer,
|
||||
generated_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
entries: Vec<RawEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct RawIssuer {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
@@ -46,7 +44,7 @@ struct RawIssuer {
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct RawEntry {
|
||||
serial_hex: String,
|
||||
status: String,
|
||||
@@ -63,13 +61,12 @@ impl Snapshot {
|
||||
expected_sha256: &str,
|
||||
expected_ca_id: &str,
|
||||
) -> Result<Self, SnapshotError> {
|
||||
let raw: RawSnapshot = serde_json::from_slice(bytes)?;
|
||||
cbor2::validate_slice(bytes).map_err(|error| SnapshotError::Cbor(error.to_string()))?;
|
||||
let raw: RawSnapshot =
|
||||
cbor2::from_slice(bytes).map_err(|error| SnapshotError::Cbor(error.to_string()))?;
|
||||
if raw.schema != 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) {
|
||||
return Err(SnapshotError::IssuerIdMismatch {
|
||||
expected: expected_ca_id.to_string(),
|
||||
@@ -115,10 +112,7 @@ impl Snapshot {
|
||||
}
|
||||
}
|
||||
let _ = raw.issuer.sha256;
|
||||
Ok(Self {
|
||||
generated_at: raw.generated_at,
|
||||
entries,
|
||||
})
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> {
|
||||
@@ -174,12 +168,10 @@ pub fn hash_sha256(bytes: &[u8]) -> Vec<u8> {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SnapshotError {
|
||||
#[error("invalid JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("invalid CBOR: {0}")]
|
||||
Cbor(String),
|
||||
#[error("unsupported snapshot schema {0:?}")]
|
||||
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}")]
|
||||
IssuerIdMismatch { expected: String, actual: String },
|
||||
#[error("snapshot issuer {actual} does not match configured CA {expected}")]
|
||||
@@ -194,28 +186,64 @@ pub enum SnapshotError {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn body(sha: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"schema": "kca-ocsp-status/v1",
|
||||
"issuer": {{"id": "ca", "sha256": "{sha}", "subject": "CN=CA"}},
|
||||
"generated_at": "2026-01-01T00:00:00Z",
|
||||
"entries": [
|
||||
{{"serial_hex": "01ab", "status": "valid", "not_after": "2027-01-01T00:00:00Z"}},
|
||||
{{"serial_hex": "00ff", "status": "revoked", "revoked_at": "2026-02-03T04:05:06Z", "reason": "keyCompromise"}}
|
||||
]
|
||||
}}"#
|
||||
)
|
||||
fn raw_body(sha: &str, schema: &str) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
schema: schema.to_string(),
|
||||
issuer: RawIssuer {
|
||||
id: "ca".to_string(),
|
||||
sha256: sha.to_string(),
|
||||
},
|
||||
entries: vec![
|
||||
RawEntry {
|
||||
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]
|
||||
fn parses_statuses_and_reasons() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha, "ca").unwrap();
|
||||
let snapshot = Snapshot::parse(&body(&sha), &sha, "ca").unwrap();
|
||||
let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap();
|
||||
assert_eq!(good.status, Status::Good);
|
||||
let revoked = snapshot.status_for_serial(&[0xff]).unwrap();
|
||||
@@ -227,32 +255,27 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_wrong_issuer_schema_and_duplicates() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong", "ca").is_err());
|
||||
assert!(Snapshot::parse(&body(&sha), "wrong", "ca").is_err());
|
||||
assert!(matches!(
|
||||
Snapshot::parse(body(&sha).as_bytes(), &sha, "other"),
|
||||
Snapshot::parse(&body(&sha), &sha, "other"),
|
||||
Err(SnapshotError::IssuerIdMismatch { .. })
|
||||
));
|
||||
let wrong_schema = body(&sha).replace(SCHEMA, "other");
|
||||
assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha, "ca").is_err());
|
||||
let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\"");
|
||||
assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err());
|
||||
let wrong_schema = cbor2::to_vec(&raw_body(&sha, "other")).unwrap();
|
||||
assert!(Snapshot::parse(&wrong_schema, &sha, "ca").is_err());
|
||||
let mut duplicate = raw_body(&sha, SCHEMA);
|
||||
duplicate.entries[1].serial_hex = duplicate.entries[0].serial_hex.clone();
|
||||
let duplicate = cbor2::to_vec(&duplicate).unwrap();
|
||||
assert!(Snapshot::parse(&duplicate, &sha, "ca").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_future_generation_time() {
|
||||
fn rejects_legacy_json() {
|
||||
let sha = sha256_hex(b"ca");
|
||||
let missing = body(&sha)
|
||||
.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);
|
||||
let legacy =
|
||||
br#"{"schema":"kca-ocsp-status/v2","issuer":{"id":"ca","sha256":"sha"},"entries":[]}"#;
|
||||
assert!(matches!(
|
||||
Snapshot::parse(future.as_bytes(), &sha, "ca"),
|
||||
Err(SnapshotError::FutureGeneration(_))
|
||||
Snapshot::parse(legacy, &sha, "ca"),
|
||||
Err(SnapshotError::Cbor(_))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+143
-37
@@ -1,64 +1,170 @@
|
||||
//! S3-compatible object store access.
|
||||
//! Redis OCSP snapshot access.
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use redis::aio::ConnectionManager;
|
||||
|
||||
use crate::config::S3Config;
|
||||
use crate::config::RedisConfig;
|
||||
|
||||
const FETCH_SNAPSHOT: &str = r#"
|
||||
local body = redis.call('GET', KEYS[1])
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
return {body, ttl}
|
||||
"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StatusStore {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
connection: ConnectionManager,
|
||||
}
|
||||
|
||||
impl StatusStore {
|
||||
pub async fn new(config: &S3Config) -> Self {
|
||||
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
|
||||
if let Some(region) = config.region.as_ref() {
|
||||
loader = loader.region(aws_config::Region::new(region.clone()));
|
||||
}
|
||||
let shared = loader.load().await;
|
||||
let mut builder = aws_sdk_s3::config::Builder::from(&shared);
|
||||
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(),
|
||||
}
|
||||
pub async fn new(config: &RedisConfig) -> Result<Self, StoreError> {
|
||||
let client = redis::Client::open(config.url.as_str())
|
||||
.map_err(|error| StoreError::Redis(error.to_string()))?;
|
||||
let connection = client
|
||||
.get_connection_manager()
|
||||
.await
|
||||
.map_err(|error| StoreError::Redis(error.to_string()))?;
|
||||
Ok(Self { connection })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StoredSnapshot {
|
||||
Present { body: Bytes, expires_at: SystemTime },
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ObjectStore: Send + Sync {
|
||||
async fn get(&self, key: &str) -> Result<Bytes, StoreError>;
|
||||
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObjectStore for StatusStore {
|
||||
async fn get(&self, key: &str) -> Result<Bytes, StoreError> {
|
||||
let response = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError> {
|
||||
let mut connection = self.connection.clone();
|
||||
let (body, pttl): (Option<Vec<u8>>, i64) = redis::Script::new(FETCH_SNAPSHOT)
|
||||
.key(key)
|
||||
.send()
|
||||
.invoke_async(&mut connection)
|
||||
.await
|
||||
.map_err(|error| StoreError::S3(format!("{error:?}")))?;
|
||||
let data = response
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| StoreError::S3(format!("{error:?}")))?
|
||||
.into_bytes();
|
||||
Ok(data)
|
||||
.map_err(|error| StoreError::Redis(error.to_string()))?;
|
||||
stored_snapshot(body, pttl, SystemTime::now())
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_snapshot(
|
||||
body: Option<Vec<u8>>,
|
||||
pttl: i64,
|
||||
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)]
|
||||
pub enum StoreError {
|
||||
#[error("S3 GetObject failed: {0}")]
|
||||
S3(String),
|
||||
#[error("Redis request failed: {0}")]
|
||||
Redis(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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-11
@@ -1,6 +1,7 @@
|
||||
# Environment configuration for KCA. Copy to .env or export the variables.
|
||||
|
||||
# --- Database --------------------------------------------------------------
|
||||
# PostgreSQL (default). Run `aerich upgrade` before starting the app.
|
||||
DATABASE_ENGINE=postgres
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5432
|
||||
@@ -10,6 +11,15 @@ DATABASE_PASSWORD=password
|
||||
#DATABASE_OPTIONS=
|
||||
#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 ------------------------------------------------------------------
|
||||
# Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0.
|
||||
OIDC_0_PROVIDER=default
|
||||
@@ -56,19 +66,17 @@ OTEL_EXCLUDED_PATHS=/api/health
|
||||
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
|
||||
|
||||
# --- OCSP status publisher (optional) --------------------------------------
|
||||
# When a bucket is configured, KCA publishes one full JSON status snapshot
|
||||
# per CA. The standalone OCSP responder reads these objects. Uses the normal
|
||||
# AWS credential chain (AWS_ACCESS_KEY_ID /
|
||||
# AWS_SECRET_ACCESS_KEY, role, instance profile, ...).
|
||||
#KCA_OCSP_S3_BUCKET=kca-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 publishes one full CBOR status snapshot per CA. By default it uses the
|
||||
# same Redis instance and logical database as sessions. Set KCA_OCSP_REDIS_URL
|
||||
# to use a dedicated instance instead (also works when REDIS_URL is unset).
|
||||
# Keys use a separate prefix and expire unless refreshed by the heartbeat.
|
||||
#KCA_OCSP_REDIS_URL=redis://localhost:6380/0
|
||||
KCA_OCSP_REDIS_PREFIX=ocsp
|
||||
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
|
||||
# Republish unchanged snapshots as a liveness heartbeat. Keep this comfortably
|
||||
# below the responder's s3.stale_grace_seconds (default 60).
|
||||
# Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat
|
||||
# comfortably below the key expiry.
|
||||
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30
|
||||
KCA_OCSP_REDIS_EXPIRY_SECONDS=60
|
||||
|
||||
# --- CORS (optional) -------------------------------------------------------
|
||||
#CORS_ALLOW_ORIGINS=
|
||||
|
||||
+4
-3
@@ -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-app/pyproject.toml packages/kca-app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-workspace --package kca-app --extra otel --extra s3 --no-dev
|
||||
uv sync --frozen --no-install-workspace --package kca-app --extra otel --no-dev
|
||||
|
||||
COPY server/packages/ ./packages/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-editable --package kca-app --extra otel --extra s3 --no-dev \
|
||||
uv sync --locked --no-editable --package kca-app --extra otel --no-dev \
|
||||
--reinstall-package kca-core --reinstall-package kca-app
|
||||
|
||||
COPY server/migrations ./migrations
|
||||
@@ -67,7 +67,8 @@ RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& 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 /repo/server/migrations /app/migrations
|
||||
|
||||
@@ -3,7 +3,223 @@ from tortoise import BaseDBAsyncClient
|
||||
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:
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return SQLITE_UPGRADE
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "api_token" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
|
||||
@@ -4,11 +4,16 @@ RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
# Migration 0 bootstraps SQLite at the current schema.
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
ALTER TABLE "certificate" ADD "no_expiration" BOOL NOT NULL DEFAULT False;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
ALTER TABLE "certificate" DROP COLUMN "no_expiration";"""
|
||||
|
||||
|
||||
@@ -4,11 +4,16 @@ RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
# Migration 0 bootstraps SQLite at the current schema.
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
ALTER TABLE "authority" ADD "template_key" VARCHAR(64);"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
ALTER TABLE "authority" DROP COLUMN "template_key";"""
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
# Migration 0 bootstraps SQLite at the current schema.
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
|
||||
"ca_id" UUID NOT NULL PRIMARY KEY,
|
||||
@@ -17,6 +20,8 @@ COMMENT ON TABLE "ocsp_snapshot" IS 'Publication state for one CA''s OCSP status
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
DROP TABLE IF EXISTS "ocsp_snapshot";"""
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
# Migration 0 bootstraps SQLite at the current schema.
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_vault_owner_s_cdc1f8";
|
||||
ALTER TABLE "app_user" DROP CONSTRAINT IF EXISTS "app_user_sub_key";
|
||||
@@ -21,6 +24,8 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
if db.capabilities.dialect == "sqlite":
|
||||
return "SELECT 1;"
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_vault_owner_i_a035fb";
|
||||
DROP INDEX IF EXISTS "uid_app_user_issuer_d243fe";
|
||||
|
||||
@@ -26,14 +26,12 @@ dependencies = [
|
||||
"PyYAML",
|
||||
"redis",
|
||||
"cryptography>=44",
|
||||
"cbor2",
|
||||
"pyOpenSSL>=25",
|
||||
"paramiko>=3.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
s3 = [
|
||||
"boto3>=1.34",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
|
||||
@@ -142,13 +142,18 @@ def _database_url_from_parts(
|
||||
name: str,
|
||||
options: str,
|
||||
) -> str:
|
||||
netloc = quote(user, safe="")
|
||||
if password:
|
||||
netloc += ":" + quote(password, safe="")
|
||||
netloc += "@" + host
|
||||
if port:
|
||||
netloc += ":" + port
|
||||
url = f"{engine}://{netloc}/{name}"
|
||||
if engine == "sqlite":
|
||||
# DATABASE_NAME is a file path for SQLite (or :memory: for tests), so
|
||||
# network credentials and host/port settings do not apply.
|
||||
url = f"sqlite://{name}"
|
||||
else:
|
||||
netloc = quote(user, safe="")
|
||||
if password:
|
||||
netloc += ":" + quote(password, safe="")
|
||||
netloc += "@" + host
|
||||
if port:
|
||||
netloc += ":" + port
|
||||
url = f"{engine}://{netloc}/{name}"
|
||||
options = options.lstrip("?")
|
||||
if options:
|
||||
url += "?" + options
|
||||
@@ -162,6 +167,7 @@ class Settings:
|
||||
app_host: str
|
||||
app_port: int
|
||||
redis_url: Optional[str]
|
||||
ocsp_redis_url: Optional[str]
|
||||
static_dir: str
|
||||
logging_config: Optional[str]
|
||||
#: AES-256-GCM master key protecting private keys at rest.
|
||||
@@ -185,19 +191,32 @@ class Settings:
|
||||
otel_excluded_paths: Tuple[str, ...]
|
||||
otel_metric_export_interval_millis: int
|
||||
otel_metrics_poll_interval_seconds: int
|
||||
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_redis_prefix: str
|
||||
ocsp_publish_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
|
||||
def from_env() -> "Settings":
|
||||
raw_master = os.environ.get("KCA_MASTER_KEY")
|
||||
master_key_is_dev = not raw_master
|
||||
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY
|
||||
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(
|
||||
database_url=os.environ.get("DATABASE_URL")
|
||||
or _database_url_from_parts(
|
||||
@@ -213,6 +232,7 @@ class Settings:
|
||||
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||
app_port=int(_env("APP_PORT", "8080")),
|
||||
redis_url=os.environ.get("REDIS_URL") or None,
|
||||
ocsp_redis_url=os.environ.get("KCA_OCSP_REDIS_URL") or None,
|
||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||
logging_config=os.environ.get("LOGGING_CONFIG") or None,
|
||||
master_key=master_key,
|
||||
@@ -238,17 +258,12 @@ class Settings:
|
||||
otel_metrics_poll_interval_seconds=int(
|
||||
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
||||
),
|
||||
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_redis_prefix=_env("KCA_OCSP_REDIS_PREFIX", "ocsp").strip("/:"),
|
||||
ocsp_publish_interval_seconds=max(
|
||||
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
|
||||
),
|
||||
ocsp_heartbeat_interval_seconds=max(
|
||||
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
|
||||
),
|
||||
ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds,
|
||||
ocsp_redis_expiry_seconds=ocsp_redis_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Per-CA OCSP status snapshots and S3 publication.
|
||||
"""Per-CA OCSP status snapshots and Redis publication.
|
||||
|
||||
KCA is the source of truth for certificate status. This module builds a
|
||||
compact JSON document per CA and uploads it to an S3-compatible bucket so
|
||||
that the standalone OCSP responder never needs database access or a CA
|
||||
private key.
|
||||
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
|
||||
the standalone OCSP responder never needs database access or a CA private key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import cbor2
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from redis.asyncio import Redis
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from .config import Settings, settings
|
||||
@@ -22,7 +22,7 @@ from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = "kca-ocsp-status/v1"
|
||||
SCHEMA = "kca-ocsp-status/v2"
|
||||
|
||||
|
||||
def _iso(value: Optional[datetime]) -> Optional[str]:
|
||||
@@ -33,11 +33,11 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def object_key(ca_id: str) -> str:
|
||||
"""The S3 key for one CA database row; the responder uses the same CA ID."""
|
||||
prefix = settings.ocsp_s3_prefix.strip("/")
|
||||
name = f"{ca_id.lower()}.json"
|
||||
return f"{prefix}/{name}" if prefix else name
|
||||
def redis_key(ca_id: str) -> str:
|
||||
"""The Redis key for one CA database row; the responder uses the same key."""
|
||||
prefix = settings.ocsp_redis_prefix.strip("/:")
|
||||
name = ca_id.lower()
|
||||
return f"{prefix}:{name}" if prefix else name
|
||||
|
||||
|
||||
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
|
||||
@@ -73,7 +73,6 @@ async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
|
||||
"sha256": ca.fingerprint_sha256.lower(),
|
||||
"subject": cert_subject(ca),
|
||||
},
|
||||
"generated_at": _iso(datetime.now(timezone.utc)),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
@@ -84,64 +83,49 @@ def cert_subject(ca: Certificate) -> str:
|
||||
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
|
||||
|
||||
|
||||
def _s3_client() -> Any:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"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")
|
||||
async def publish_snapshot(
|
||||
ca: Certificate,
|
||||
snapshot: Optional[Dict[str, Any]] = None,
|
||||
client: Optional[Redis] = None,
|
||||
) -> str:
|
||||
"""Store one CA snapshot with an expiry and return its Redis key."""
|
||||
if snapshot is None:
|
||||
snapshot = await snapshot_for_ca(ca)
|
||||
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
||||
key = object_key(str(ca.id))
|
||||
body = cbor2.dumps(snapshot)
|
||||
key = redis_key(str(ca.id))
|
||||
|
||||
# boto3 is synchronous and credential resolution can block; do both the
|
||||
# client creation and the PUT outside the event loop.
|
||||
def upload() -> None:
|
||||
client = _s3_client()
|
||||
client.put_object(
|
||||
Bucket=settings.ocsp_s3_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType="application/json",
|
||||
CacheControl="no-store",
|
||||
)
|
||||
owned_client = None
|
||||
if client is None:
|
||||
if not settings.ocsp_effective_redis_url:
|
||||
raise RuntimeError("REDIS_URL is not configured")
|
||||
client = Redis.from_url(settings.ocsp_effective_redis_url)
|
||||
owned_client = client
|
||||
try:
|
||||
await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds)
|
||||
finally:
|
||||
if owned_client is not None:
|
||||
await owned_client.aclose()
|
||||
|
||||
await asyncio.to_thread(upload)
|
||||
return key
|
||||
|
||||
|
||||
async def mark_ocsp_dirty(ca_id: UUID) -> None:
|
||||
"""Mark one issuing CA's status object as needing a republish."""
|
||||
if not settings.ocsp_s3_bucket:
|
||||
"""Mark one issuing CA's status snapshot as needing a republish."""
|
||||
if not settings.ocsp_effective_redis_url:
|
||||
return
|
||||
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
|
||||
if row is None:
|
||||
await OcspSnapshot.create(ca_id=ca_id, dirty=True)
|
||||
return
|
||||
# Always save, even when already dirty: updated_at is the publisher's
|
||||
# optimistic-concurrency token for changes made during an upload.
|
||||
# optimistic-concurrency token for changes made during a write.
|
||||
row.dirty = True
|
||||
row.last_error = None # type: ignore[assignment]
|
||||
await row.save(update_fields=["dirty", "last_error", "updated_at"])
|
||||
|
||||
|
||||
async def mark_all_cas_dirty(vault: Vault) -> None:
|
||||
if not settings.ocsp_s3_bucket:
|
||||
if not settings.ocsp_effective_redis_url:
|
||||
return
|
||||
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
|
||||
await mark_ocsp_dirty(ca.id)
|
||||
@@ -155,10 +139,13 @@ class OcspPublisherMixin(KayaMixin):
|
||||
interval_seconds: int,
|
||||
ensure_context: Callable[[], Awaitable[None]],
|
||||
heartbeat_interval_seconds: int = 30,
|
||||
redis_client: Optional[Redis] = None,
|
||||
) -> None:
|
||||
self._interval = max(1, interval_seconds)
|
||||
self._heartbeat_interval = max(1, heartbeat_interval_seconds)
|
||||
self._ensure_context = ensure_context
|
||||
self._redis_client = redis_client
|
||||
self._owns_redis_client = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._bootstrapped = False
|
||||
|
||||
@@ -167,21 +154,19 @@ class OcspPublisherMixin(KayaMixin):
|
||||
return None
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
if not settings.ocsp_s3_bucket:
|
||||
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
|
||||
if not settings.ocsp_effective_redis_url:
|
||||
log.info(
|
||||
"OCSP Redis publication disabled "
|
||||
"(REDIS_URL and KCA_OCSP_REDIS_URL unset)"
|
||||
)
|
||||
return
|
||||
try:
|
||||
import boto3 # noqa: F401
|
||||
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
|
||||
if self._redis_client is None:
|
||||
self._redis_client = Redis.from_url(settings.ocsp_effective_redis_url)
|
||||
self._owns_redis_client = True
|
||||
self._task = loop.create_task(self._poll())
|
||||
log.info(
|
||||
"OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss",
|
||||
settings.ocsp_s3_bucket,
|
||||
settings.ocsp_s3_prefix or "(root)",
|
||||
"OCSP Redis publication enabled: prefix=%s interval=%ss",
|
||||
settings.ocsp_redis_prefix or "(root)",
|
||||
self._interval,
|
||||
)
|
||||
|
||||
@@ -189,6 +174,10 @@ class OcspPublisherMixin(KayaMixin):
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
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:
|
||||
# Let the Tortoise mixin establish its database context before the
|
||||
@@ -198,7 +187,7 @@ class OcspPublisherMixin(KayaMixin):
|
||||
try:
|
||||
if not self._bootstrapped:
|
||||
# Existing CAs may predate the publisher or have lost
|
||||
# their S3 objects; publish one snapshot for every CA on
|
||||
# their Redis keys; publish one snapshot for every CA on
|
||||
# the first successful tick.
|
||||
await self._ensure_context()
|
||||
for ca in await Certificate.filter(is_ca=True):
|
||||
@@ -232,7 +221,7 @@ class OcspPublisherMixin(KayaMixin):
|
||||
continue
|
||||
try:
|
||||
snapshot = await snapshot_for_ca(ca)
|
||||
key = await publish_snapshot(ca, snapshot)
|
||||
key = await publish_snapshot(ca, snapshot, client=self._redis_client)
|
||||
published_at = datetime.now(timezone.utc)
|
||||
cleared = await OcspSnapshot.filter(
|
||||
ca_id=row.ca_id,
|
||||
@@ -262,7 +251,7 @@ def publisher_mixin_from_settings(
|
||||
settings_obj: Settings,
|
||||
ensure_context: Callable[[], Awaitable[None]],
|
||||
) -> Optional[OcspPublisherMixin]:
|
||||
if not settings_obj.ocsp_s3_bucket:
|
||||
if not settings_obj.ocsp_effective_redis_url:
|
||||
return None
|
||||
return OcspPublisherMixin(
|
||||
settings_obj.ocsp_publish_interval_seconds,
|
||||
@@ -276,8 +265,8 @@ __all__ = [
|
||||
"SCHEMA",
|
||||
"mark_all_cas_dirty",
|
||||
"mark_ocsp_dirty",
|
||||
"object_key",
|
||||
"publish_snapshot",
|
||||
"publisher_mixin_from_settings",
|
||||
"redis_key",
|
||||
"snapshot_for_ca",
|
||||
]
|
||||
|
||||
@@ -155,10 +155,9 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
||||
row = await OcspSnapshot.get_or_none(ca_id=cert.id)
|
||||
snapshot = await snapshot_for_ca(cert)
|
||||
await send_json(ctx, 200, {
|
||||
"enabled": bool(settings.ocsp_s3_bucket),
|
||||
"bucket": settings.ocsp_s3_bucket,
|
||||
"prefix": settings.ocsp_s3_prefix,
|
||||
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
|
||||
"enabled": bool(settings.ocsp_effective_redis_url),
|
||||
"prefix": settings.ocsp_redis_prefix,
|
||||
"dirty": row.dirty if row else bool(settings.ocsp_effective_redis_url),
|
||||
"published_at": row.published_at.isoformat() if row and row.published_at else None,
|
||||
"last_error": row.last_error if row else None,
|
||||
"snapshot": snapshot,
|
||||
|
||||
@@ -23,10 +23,10 @@ It deliberately avoids the global-fallback singleton
|
||||
per process and would therefore break across event loops.
|
||||
|
||||
Schema management is split by backend: in-memory sqlite databases (the
|
||||
test suite) get ``generate_schemas`` on every fresh context; Postgres
|
||||
schemas are owned by aerich migrations and must be applied externally
|
||||
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
|
||||
the app serves requests.
|
||||
test suite) get ``generate_schemas`` on every fresh context; Postgres and
|
||||
file-backed SQLite schemas are owned by aerich migrations and must be
|
||||
applied externally (``aerich upgrade``, run by the ``db-migrate`` compose
|
||||
service) before the app serves requests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -104,12 +104,17 @@ class TortoiseMixin(KayaMixin):
|
||||
modules={"models": self._models_modules},
|
||||
)
|
||||
log.info("database context initialized (%s)", _safe_url(self._database_url))
|
||||
# Schema creation is only done for sqlite (in-memory test
|
||||
# databases). Postgres schemas are managed by aerich migrations
|
||||
# Only ephemeral test databases are created implicitly. Postgres
|
||||
# and persistent SQLite schemas are managed by aerich migrations
|
||||
# (applied by the db-migrate compose service / `aerich upgrade`).
|
||||
if self._database_url.startswith("sqlite"):
|
||||
connection = ctx.connections.get("default")
|
||||
is_memory_sqlite = (
|
||||
connection.capabilities.dialect == "sqlite"
|
||||
and getattr(connection, "filename", None) == ":memory:"
|
||||
)
|
||||
if is_memory_sqlite:
|
||||
await ctx.generate_schemas()
|
||||
log.info("sqlite schemas generated")
|
||||
log.info("in-memory sqlite schemas generated")
|
||||
return ctx
|
||||
|
||||
async def ensure_context(self) -> None:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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()
|
||||
@@ -941,7 +941,8 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
payload = response.json()
|
||||
assert payload["enabled"] is False
|
||||
snapshot = payload["snapshot"]
|
||||
assert snapshot["schema"] == "kca-ocsp-status/v1"
|
||||
assert snapshot["schema"] == "kca-ocsp-status/v2"
|
||||
assert "generated_at" not in snapshot
|
||||
assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
|
||||
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
|
||||
assert len(snapshot["entries"]) == 1
|
||||
@@ -1072,7 +1073,7 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
|
||||
leaf_key = await _key(client, vault_id, "dirty leaf")
|
||||
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"])
|
||||
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
|
||||
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
|
||||
with patch.object(ocsp, "settings", configured):
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
||||
@@ -1099,7 +1100,7 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
leaf = await _cert(
|
||||
client, vault_id, leaf_key["id"], "Existing Dirty Leaf", signer_id=ca["id"]
|
||||
)
|
||||
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
|
||||
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
|
||||
with patch.object(ocsp, "settings", configured):
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
||||
@@ -1128,7 +1129,7 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
vault_id = await _vault(client)
|
||||
ca_key = await _key(client, vault_id, "manual dirty ca")
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "Manual Dirty CA", ca=True)
|
||||
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
|
||||
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
|
||||
with patch.object(ocsp, "settings", configured):
|
||||
await OcspSnapshot.create(ca_id=ca["id"], dirty=False)
|
||||
response = await client.post(
|
||||
@@ -1176,7 +1177,7 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
)
|
||||
published = []
|
||||
|
||||
async def fake_publish(publish_ca, snapshot):
|
||||
async def fake_publish(publish_ca, snapshot, client=None):
|
||||
published.append(snapshot)
|
||||
if len(published) == 1:
|
||||
await CertificateRevocation.create(
|
||||
@@ -1186,9 +1187,9 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
reason="keyCompromise",
|
||||
)
|
||||
await ocsp.mark_ocsp_dirty(publish_ca.id)
|
||||
return ocsp.object_key(str(publish_ca.id))
|
||||
return ocsp.redis_key(str(publish_ca.id))
|
||||
|
||||
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
|
||||
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
|
||||
with patch.object(ocsp, "settings", configured), patch.object(
|
||||
ocsp, "publish_snapshot", fake_publish
|
||||
):
|
||||
@@ -1215,20 +1216,21 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
|
||||
@async_test
|
||||
async def test_publisher_marks_dirty_and_uploads(self) -> None:
|
||||
import cbor2
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca import ocsp
|
||||
from kca.models import OcspSnapshot
|
||||
|
||||
class FakeS3:
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
async def set(self, name, value, ex=None):
|
||||
self.calls.append({"name": name, "value": value, "ex": ex})
|
||||
|
||||
fake = FakeS3()
|
||||
fake = FakeRedis()
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice"):
|
||||
vault_id = await _vault(client)
|
||||
@@ -1236,25 +1238,25 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
|
||||
configured = replace(
|
||||
ocsp.settings,
|
||||
ocsp_s3_bucket="test-bucket",
|
||||
ocsp_s3_prefix="ocsp",
|
||||
redis_url="redis://localhost:6379/0",
|
||||
ocsp_redis_prefix="ocsp",
|
||||
)
|
||||
with patch.object(ocsp, "settings", configured), patch.object(
|
||||
ocsp, "_s3_client", lambda: fake
|
||||
):
|
||||
with patch.object(ocsp, "settings", configured):
|
||||
await ocsp.mark_ocsp_dirty(ca["id"])
|
||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||
assert row.dirty is True
|
||||
async def ensure_context():
|
||||
return None
|
||||
|
||||
mixin = ocsp.OcspPublisherMixin(1, ensure_context)
|
||||
mixin = ocsp.OcspPublisherMixin(1, ensure_context, redis_client=fake)
|
||||
await mixin.run_once()
|
||||
assert len(fake.calls) == 1
|
||||
call = fake.calls[0]
|
||||
assert call["Bucket"] == "test-bucket"
|
||||
assert call["Key"] == f"ocsp/{ca['id']}.json"
|
||||
assert b"kca-ocsp-status/v1" in call["Body"]
|
||||
assert call["name"] == f"ocsp:{ca['id']}"
|
||||
published = cbor2.loads(call["value"])
|
||||
assert published["schema"] == "kca-ocsp-status/v2"
|
||||
assert "generated_at" not in published
|
||||
assert call["ex"] == 60
|
||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||
assert row.dirty is False
|
||||
assert row.published_at is not None
|
||||
@@ -1279,7 +1281,7 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
)
|
||||
published = []
|
||||
|
||||
async def fake_publish(publish_ca, snapshot):
|
||||
async def fake_publish(publish_ca, snapshot, client=None):
|
||||
published.append(snapshot)
|
||||
return "key"
|
||||
|
||||
@@ -1317,7 +1319,7 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
)
|
||||
published = []
|
||||
|
||||
async def fake_publish(publish_ca, snapshot):
|
||||
async def fake_publish(publish_ca, snapshot, client=None):
|
||||
published.append(snapshot)
|
||||
return "key"
|
||||
|
||||
@@ -1334,21 +1336,23 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
|
||||
@async_test
|
||||
async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None:
|
||||
import json
|
||||
import cbor2
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca import ocsp
|
||||
from kca.models import Certificate, OcspSnapshot
|
||||
|
||||
class FakeS3:
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.objects = {}
|
||||
self.expiries = {}
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||
async def set(self, name, value, ex=None):
|
||||
self.objects[name] = value
|
||||
self.expiries[name] = ex
|
||||
|
||||
fake = FakeS3()
|
||||
fake = FakeRedis()
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice"):
|
||||
vault_id = await _vault(client)
|
||||
@@ -1372,14 +1376,12 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
|
||||
configured = replace(
|
||||
ocsp.settings,
|
||||
ocsp_s3_bucket="test-bucket",
|
||||
ocsp_s3_prefix="ocsp",
|
||||
redis_url="redis://localhost:6379/0",
|
||||
ocsp_redis_prefix="ocsp",
|
||||
)
|
||||
with patch.object(ocsp, "settings", configured), patch.object(
|
||||
ocsp, "_s3_client", lambda: fake
|
||||
):
|
||||
with patch.object(ocsp, "settings", configured):
|
||||
target = await Certificate.get(id=ca["id"])
|
||||
target_key = await ocsp.publish_snapshot(target)
|
||||
target_key = await ocsp.publish_snapshot(target, client=fake)
|
||||
|
||||
with oidc_user("bob"):
|
||||
attacker_vault_id = await _vault(client)
|
||||
@@ -1407,15 +1409,16 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
assert len(response.json()["skipped"]) == 1
|
||||
row = await OcspSnapshot.get(ca_id=imported.id)
|
||||
assert row.dirty is True
|
||||
imported_key = await ocsp.publish_snapshot(imported)
|
||||
imported_key = await ocsp.publish_snapshot(imported, client=fake)
|
||||
|
||||
assert target_key == f"ocsp/{ca['id']}.json"
|
||||
assert imported_key == f"ocsp/{imported.id}.json"
|
||||
assert target_key == f"ocsp:{ca['id']}"
|
||||
assert imported_key == f"ocsp:{imported.id}"
|
||||
assert imported_key != target_key
|
||||
assert set(fake.objects) == {target_key, imported_key}
|
||||
target_snapshot = json.loads(fake.objects[target_key])
|
||||
assert fake.expiries == {target_key: 60, imported_key: 60}
|
||||
target_snapshot = cbor2.loads(fake.objects[target_key])
|
||||
target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]}
|
||||
assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked"
|
||||
imported_snapshot = json.loads(fake.objects[imported_key])
|
||||
imported_snapshot = cbor2.loads(fake.objects[imported_key])
|
||||
imported_serials = {entry["serial_hex"] for entry in imported_snapshot["entries"]}
|
||||
assert leaf["serial_hex"].lower() not in imported_serials
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""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()
|
||||
Generated
+70
-70
@@ -310,31 +310,75 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.118"
|
||||
name = "cbor2"
|
||||
version = "6.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.118"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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/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]]
|
||||
@@ -904,15 +948,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "kaya-core"
|
||||
version = "0.0.8"
|
||||
@@ -1026,6 +1061,7 @@ source = { editable = "packages/kca-app" }
|
||||
dependencies = [
|
||||
{ name = "aerich" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "cbor2" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "granian" },
|
||||
{ name = "httpx" },
|
||||
@@ -1052,9 +1088,6 @@ otel = [
|
||||
{ name = "opentelemetry-instrumentation-redis" },
|
||||
{ name = "opentelemetry-instrumentation-system-metrics" },
|
||||
]
|
||||
s3 = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -1066,7 +1099,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aerich" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "boto3", marker = "extra == 's3'", specifier = ">=1.34" },
|
||||
{ name = "cbor2" },
|
||||
{ name = "cryptography", specifier = ">=44" },
|
||||
{ name = "granian", specifier = ">=2.0" },
|
||||
{ name = "httpx" },
|
||||
@@ -1089,7 +1122,7 @@ requires-dist = [
|
||||
{ name = "redis" },
|
||||
{ name = "tortoise-orm", specifier = ">=0.25" },
|
||||
]
|
||||
provides-extras = ["s3", "otel"]
|
||||
provides-extras = ["otel"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -1678,18 +1711,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1781,27 +1802,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tomli"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -31,6 +31,10 @@ struct ExtraSubjectRow {
|
||||
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)]
|
||||
struct CertFormSignals {
|
||||
explicit_dn: Signal<Vec<String>>,
|
||||
@@ -94,6 +98,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
let comment = create_signal(String::new());
|
||||
let signer_id = create_signal("self".to_string());
|
||||
let key_id = create_signal(String::new());
|
||||
let include_used_keys = create_signal(false);
|
||||
let new_key = create_signal(false);
|
||||
let key_type = create_signal("rsa".to_string());
|
||||
let key_size = create_signal("2048".to_string());
|
||||
@@ -298,6 +303,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
name.set(format!("{} copy", value.summary.name));
|
||||
comment.set(value.summary.comment.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);
|
||||
}
|
||||
if let Some(issuer) = value.summary.issuer_id.clone() {
|
||||
@@ -594,9 +602,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
.collect::<Vec<View>>()
|
||||
};
|
||||
let key_options = move || {
|
||||
let include_used = include_used_keys.get();
|
||||
keys.get_clone()
|
||||
.into_iter()
|
||||
.filter(|key| key.has_private)
|
||||
.filter(|key| key_is_selectable(key, include_used))
|
||||
.map(|key| {
|
||||
view! {
|
||||
option(value=key.id.clone()) { (key.name.clone()) }
|
||||
@@ -604,6 +613,22 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> 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 || {
|
||||
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() }) }
|
||||
@@ -728,6 +753,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
option(value="") { "— select a key —" }
|
||||
(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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1502,3 +1531,31 @@ fn query_param(name: &str) -> Option<String> {
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user