Add full XCA curve and digest parity
CI / Build and push docker image (push) Successful in 2m16s
CI / Build and push OCSP image (push) Successful in 34s

This commit is contained in:
KCA
2026-10-26 03:45:25 +00:00
parent b910fa706c
commit d5f060cbf0
36 changed files with 4326 additions and 331 deletions
+10 -3
View File
@@ -57,12 +57,19 @@ OIDC_0_REDIRECT_URI=https://kca.example.com/auth/default/callback
Every user owns a private set of vaults. A vault contains keys, certificates, Every user owns a private set of vaults. A vault contains keys, certificates,
requests, CRLs and templates; non-owners get `404`. Vaults can be renamed, requests, CRLs and templates; non-owners get `404`. Vaults can be renamed,
deleted and configured from **Settings** (default hash, serial length, deleted and configured from **Settings** (default hash, serial length,
explicit/mandatory DN entries, default key type/size, default validity). explicit/mandatory DN entries, default key type/size/curve, default validity).
### Keys ### Keys
- Generate RSA (1024–8192), DSA (1024–3072), EC (P-192/P-224/P-256/P-384/P-521, - Generate RSA (1024–8192), DSA (1024–3072), Ed25519 and EC keys across the
secp256k1, brainpool) and Ed25519 keys. complete set of 79 named curves exposed by XCA (NIST/SECG, X9.62, binary,
WTLS and Brainpool variants). Legacy curves use an in-memory OpenSSL 3
compatibility backend; existing native curves continue through `cryptography`.
- Sign certificates, requests and CRLs with XCA's key-specific hash matrix:
RSA supports MD5, RIPEMD-160, SHA-1, SHA-2 and SHA-3; EC supports SHA-1,
SHA-2 and SHA-3; DSA supports SHA-1/SHA-224/SHA-256; Ed25519 uses its
built-in signature algorithm. Insecure compatibility choices are marked in
the UI and SHA-256 remains the default.
- Import PEM/DER public or private keys, PKCS#8, OpenSSH, PKCS#12 and PVK. - Import PEM/DER public or private keys, PKCS#8, OpenSSH, PKCS#12 and PVK.
- Export PEM public/private, traditional encrypted PEM, PKCS#8 (plain or - Export PEM public/private, traditional encrypted PEM, PKCS#8 (plain or
encrypted), DER public/private, SSH2 public/private, JWK public/private and encrypted), DER public/private, SSH2 public/private, JWK public/private and
+68
View File
@@ -0,0 +1,68 @@
// XCA curve and signature-hash options through the real SPA.
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();
}
test("all curve options, per-key hashes and default curve persistence", async ({ page }) => {
const user = unique("crypto");
const vaultName = unique("Crypto Vault");
const keyName = unique("compat-key");
const requestName = unique("compat-request");
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();
await page.goto(`${vaultUrl}/keys/new`);
await (await field(page, "Key type")).selectOption("ec");
const curve = await field(page, "Curve");
await expect(curve.locator("option")).toHaveCount(79);
await curve.selectOption("secp112r1");
await expect(page.getByText(/secp112r1 is marked as insecure/)).toBeVisible();
await (await field(page, "Internal name")).fill(keyName);
await page.getByRole("button", { name: "Generate key" }).click();
await expect(page.getByRole("heading", { name: keyName })).toBeVisible();
await expect(page.getByText("secp112r1", { exact: true })).toBeVisible();
await page.goto(`${vaultUrl}/requests/new`);
await (await field(page, "Internal name")).fill(requestName);
await (await field(page, "Common name (CN)")).fill("compat.example.com");
await page.locator("label").filter({ hasText: "Existing key" }).locator("xpath=following-sibling::select[1]").selectOption({ label: `${keyName} (EC)` });
const hash = await field(page, "Signature hash");
await expect(hash.locator("option")).toHaveCount(9);
await expect(hash.locator('option[value="md5"]')).toHaveCount(0);
await expect(hash.locator('option[value="ripemd160"]')).toHaveCount(0);
await hash.selectOption("sha3-256");
await page.getByRole("button", { name: "Create request" }).click();
await expect(page.getByRole("heading", { name: requestName })).toBeVisible();
await expect(page.getByText("ecdsa_with_SHA3-256", { exact: true })).toBeVisible();
await page.goto(`${vaultUrl}/settings`);
await (await field(page, "Default key type")).selectOption("ec");
const defaultCurve = await field(page, "Curve");
await expect(defaultCurve.locator("option")).toHaveCount(79);
await defaultCurve.selectOption("prime192v2");
await (await field(page, "Default signature hash")).selectOption("ripemd160");
await page.getByRole("button", { name: "Save settings" }).click();
await expect(page.getByText("Settings saved.")).toBeVisible();
await page.reload();
await expect(await field(page, "Curve")).toHaveValue("prime192v2");
await expect(await field(page, "Default signature hash")).toHaveValue("ripemd160");
});
+4
View File
@@ -29,6 +29,10 @@ REDIS_URL=redis://localhost:6379/0
# When unset, a built-in development key is used (the UI warns about it). # When unset, a built-in development key is used (the UI warns about it).
#KCA_MASTER_KEY= #KCA_MASTER_KEY=
# Optional absolute path to an OpenSSL 3 libcrypto used for XCA legacy curves
# and hashes. Normally discovered automatically; no OpenSSL CLI is required.
#KCA_LIBCRYPTO=/usr/lib/x86_64-linux-gnu/libcrypto.so.3
# --- Application ----------------------------------------------------------- # --- Application -----------------------------------------------------------
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+15 -8
View File
@@ -29,12 +29,15 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
trunk build --release --public-url /static/ trunk build --release --public-url /static/
# --- Backend --------------------------------------------------------------- # --- Backend ---------------------------------------------------------------
FROM alpine:3.24 AS builder FROM python:3.12-slim-trixie AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
RUN --mount=type=cache,target=/var/cache/apk \ RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
apk add python3 build-base python3-dev git /etc/apt/sources.list.d/debian.sources 2>/dev/null || true \
&& apt-get update \
&& apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
RUN git config --global --add safe.directory /repo RUN git config --global --add safe.directory /repo
ENV UV_PYTHON_DOWNLOADS=0 \ ENV UV_PYTHON_DOWNLOADS=0 \
@@ -56,11 +59,15 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY server/migrations ./migrations COPY server/migrations ./migrations
# --- Runtime --------------------------------------------------------------- # --- Runtime ---------------------------------------------------------------
FROM alpine:3.24 FROM python:3.12-slim-trixie
RUN --mount=type=cache,target=/var/cache/apk \ RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
apk add python3 ca-certificates tzdata \ /etc/apt/sources.list.d/debian.sources 2>/dev/null || true \
&& addgroup -S app && adduser -S -G app app && apt-get update \
&& 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
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /repo/server/migrations /app/migrations COPY --from=builder /repo/server/migrations /app/migrations
@@ -82,6 +89,6 @@ USER app
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/health', timeout=2).read()" || exit 1
CMD ["granian", "kca.app:app"] CMD ["granian", "kca.app:app"]
@@ -34,6 +34,7 @@ from ..service import (
parse_datetime, parse_datetime,
parse_uuid, parse_uuid,
renew_certificate, renew_certificate,
resolve_signature_hash,
revoke_certificate, revoke_certificate,
save_key, save_key,
search_text, search_text,
@@ -593,7 +594,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
parsed = cert_core.parse_certificate(bytes(cert.cert_der)) parsed = cert_core.parse_certificate(bytes(cert.cert_der))
key = await save_key( key = await save_key(
vault, vault,
parsed.public_key(), cert_core.certificate_public_key(parsed),
name=f"{cert.name} public key", name=f"{cert.name} public key",
comment=f"Extracted from certificate {cert.name}", comment=f"Extracted from certificate {cert.name}",
source="transformed", source="transformed",
@@ -617,11 +618,17 @@ def register(app: KayaApp, kca: "Kca") -> None:
return return
parsed = cert_core.parse_certificate(bytes(cert.cert_der)) parsed = cert_core.parse_certificate(bytes(cert.cert_der))
try: try:
private_key = await load_private_key(key)
signature_hash = resolve_signature_hash(
private_key,
None,
cert_core.signature_hash_name(parsed) or "sha256",
)
csr = req_core.build_csr( csr = req_core.build_csr(
subject=parsed.subject, subject=parsed.subject,
private_key=await load_private_key(key), private_key=private_key,
extension_spec=cert_core.certificate_extension_specs(parsed), extension_spec=cert_core.certificate_extension_specs(parsed),
signature_hash=cert_core.signature_hash_name(parsed), signature_hash=signature_hash,
) )
except ValueError as exc: except ValueError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
@@ -22,8 +22,10 @@ from ..service import (
ValidationError, ValidationError,
generate_key, generate_key,
parse_extensions, parse_extensions,
parse_key_generation,
parse_subject, parse_subject,
parse_uuid, parse_uuid,
resolve_signature_hash,
save_key, save_key,
search_text, search_text,
serialize_request_detail, serialize_request_detail,
@@ -31,6 +33,7 @@ from ..service import (
serialize_template_summary, serialize_template_summary,
load_private_key, load_private_key,
vault_for_owner, vault_for_owner,
vault_settings,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -96,26 +99,40 @@ def register(app: KayaApp, kca: "Kca") -> None:
key_obj = await load_private_key(key_model) key_obj = await load_private_key(key_model)
elif isinstance(body.get("new_key"), dict): elif isinstance(body.get("new_key"), dict):
new_key = body["new_key"] new_key = body["new_key"]
generated = await generate_key( params = parse_key_generation({
str(new_key.get("key_type") or "rsa"), "key_type": new_key.get("key_type") or "rsa",
int(new_key.get("size") or key_core.DEFAULT_KEY_SIZE), "size": new_key.get("size", key_core.DEFAULT_KEY_SIZE),
new_key.get("curve"), "curve": new_key.get("curve"),
"name": new_key.get("name") or name,
})
generated = await generate_key(params["key_type"], params["size"], params["curve"])
# Reject an invalid explicit/default digest before leaving a
# newly generated key persisted without a corresponding CSR.
resolve_signature_hash(
generated,
body.get("signature_hash"),
vault_settings(vault).get("default_hash", "sha256"),
) )
key_model = await save_key( key_model = await save_key(
vault, vault,
generated, generated,
name=str(new_key.get("name") or name), name=params["name"],
comment="Generated while creating a request", comment="Generated while creating a request",
source="generated", source="generated",
) )
key_obj = generated key_obj = generated
else: else:
raise ValidationError("select an existing key or generate a new one") raise ValidationError("select an existing key or generate a new one")
signature_hash = resolve_signature_hash(
key_obj,
body.get("signature_hash"),
vault_settings(vault).get("default_hash", "sha256"),
)
csr = req_core.build_csr( csr = req_core.build_csr(
subject=subject, subject=subject,
private_key=key_obj, private_key=key_obj,
extension_spec=extensions, extension_spec=extensions,
signature_hash=body.get("signature_hash"), signature_hash=signature_hash,
attributes=body.get("attributes") or {}, attributes=body.get("attributes") or {},
) )
except (ValidationError, ValueError) as exc: except (ValidationError, ValueError) as exc:
@@ -319,8 +319,10 @@ def register(app: KayaApp, kca: "Kca") -> None:
return return
await send_json(ctx, 200, { await send_json(ctx, 200, {
"key_types": key_core.key_type_options(), "key_types": key_core.key_type_options(),
"hash_algorithms": list(key_core.HASH_ALGORITHMS), "hash_algorithms": [item.name for item in key_core.HASHES],
"curves": list(key_core.EC_CURVES), "hash_options": key_core.hash_options(),
"curves": [curve.name for curve in key_core.CURVES],
"curve_options": key_core.curve_options(),
"dn_attributes": name_core.name_attribute_options(), "dn_attributes": name_core.name_attribute_options(),
"eku": name_core.eku_options(), "eku": name_core.eku_options(),
"key_usages": ext_core.KEY_USAGES, "key_usages": ext_core.KEY_USAGES,
+86 -19
View File
@@ -11,6 +11,12 @@ from uuid import UUID
from cryptography import x509 from cryptography import x509
from tortoise.expressions import Q from tortoise.expressions import Q
from kca.core.algorithms import (
HASH_BY_NAME,
canonical_curve_name,
default_signature_hash,
validate_signature_hash,
)
from kca.core import certificates as cert_core from kca.core import certificates as cert_core
from kca.core import crls as crl_core from kca.core import crls as crl_core
from kca.core import extensions as ext_core from kca.core import extensions as ext_core
@@ -66,8 +72,24 @@ def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict
raise ValidationError(f"unknown setting {key!r}") raise ValidationError(f"unknown setting {key!r}")
merged[key] = value merged[key] = value
# Validate the values that affect cryptographic operations. # Validate the values that affect cryptographic operations.
if merged.get("default_hash") not in key_core.HASH_ALGORITHMS: if merged.get("default_hash") not in HASH_BY_NAME:
raise ValidationError("default_hash must be sha1, sha224, sha256, sha384 or sha512") raise ValidationError("default_hash must be one of: " + ", ".join(HASH_BY_NAME))
default_key = merged.get("default_key") or {}
if not isinstance(default_key, Mapping):
raise ValidationError("default_key must be an object")
try:
parsed_default_key = parse_key_generation({
"key_type": default_key.get("type") or "rsa",
"size": default_key.get("size", key_core.DEFAULT_KEY_SIZE),
"curve": default_key.get("curve"),
})
except ValueError as exc:
raise ValidationError(str(exc)) from exc
merged["default_key"] = {
"type": parsed_default_key["key_type"],
"size": parsed_default_key["size"],
"curve": parsed_default_key["curve"],
}
try: try:
bits = int(merged.get("serial_len_bits", 64)) bits = int(merged.get("serial_len_bits", 64))
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
@@ -498,8 +520,13 @@ def parse_key_generation(payload: Mapping[str, Any]) -> Dict[str, Any]:
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise ValidationError("key size must be an integer") from exc raise ValidationError("key size must be an integer") from exc
curve = payload.get("curve") curve = payload.get("curve")
if key_type == "ec" and curve not in key_core.EC_CURVES: if key_type == "ec":
raise ValidationError("unsupported EC curve") try:
curve = canonical_curve_name(str(curve or "secp256r1"))
except ValueError as exc:
raise ValidationError(str(exc)) from exc
else:
curve = None
name = str(payload.get("name") or "").strip() name = str(payload.get("name") or "").strip()
if not name: if not name:
name = f"{key_type.upper()} {size}" if key_type != "ec" else f"EC {curve}" name = f"{key_type.upper()} {size}" if key_type != "ec" else f"EC {curve}"
@@ -567,9 +594,19 @@ async def key_public_object(key: Key) -> Any:
def _public_part(key_obj: Any) -> Any: def _public_part(key_obj: Any) -> Any:
"""Return a public key object from either a private or public key.""" """Return a public key object from either a private or public key."""
if hasattr(key_obj, "public_key"): return key_core.public_part(key_obj)
return key_obj.public_key()
return key_obj
def resolve_signature_hash(
key_obj: Any,
requested: Any = None,
default: Optional[str] = "sha256",
) -> Optional[str]:
"""Resolve an omitted default or validate an explicit hash for this key."""
kind = key_core.key_type_name(key_obj)
if requested not in (None, ""):
return validate_signature_hash(kind, str(requested))
return default_signature_hash(kind, default)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -691,6 +728,7 @@ async def issue_certificate(
user: User, user: User,
) -> Certificate: ) -> Certificate:
"""Create a certificate from the wizard payload.""" """Create a certificate from the wizard payload."""
settings_obj = vault_settings(vault)
name = str(payload.get("name") or "").strip() name = str(payload.get("name") or "").strip()
if not name: if not name:
raise ValidationError("name is required") raise ValidationError("name is required")
@@ -722,18 +760,33 @@ async def issue_certificate(
if key_model.has_private and (signer is None or payload.get("use_key_private", True)): if key_model.has_private and (signer is None or payload.get("use_key_private", True)):
key_obj = await load_private_key(key_model) key_obj = await load_private_key(key_model)
elif new_key_payload: elif new_key_payload:
new_key_params = parse_key_generation({
"key_type": new_key_payload.get("key_type") or "rsa",
"size": new_key_payload.get("size", key_core.DEFAULT_KEY_SIZE),
"curve": new_key_payload.get("curve"),
"name": new_key_payload.get("name") or name,
})
try: try:
generated = await generate_key( generated = await generate_key(
str(new_key_payload.get("key_type") or "rsa"), new_key_params["key_type"],
int(new_key_payload.get("size") or key_core.DEFAULT_KEY_SIZE), new_key_params["size"],
new_key_payload.get("curve"), new_key_params["curve"],
) )
if signer is None:
validation_key_type = key_core.key_type_name(generated)
else:
assert signer_key is not None # validated above for CA-signed issuance
validation_key_type = signer_key.key_type
if payload.get("signature_hash") not in (None, ""):
validate_signature_hash(validation_key_type, str(payload.get("signature_hash")))
else:
default_signature_hash(validation_key_type, settings_obj.get("default_hash", "sha256"))
except ValueError as exc: except ValueError as exc:
raise ValidationError(str(exc)) from exc raise ValidationError(str(exc)) from exc
key_model = await save_key( key_model = await save_key(
vault, vault,
generated, generated,
name=str(new_key_payload.get("name") or name), name=new_key_params["name"],
comment="Generated while creating a certificate", comment="Generated while creating a certificate",
source="generated", source="generated",
) )
@@ -760,7 +813,6 @@ async def issue_certificate(
extensions = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions) extensions = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions)
not_before, not_after, no_expiration = validate_validity(payload) not_before, not_after, no_expiration = validate_validity(payload)
settings_obj = vault_settings(vault)
check_mandatory_dn(subject, settings_obj) check_mandatory_dn(subject, settings_obj)
bits = int(settings_obj.get("serial_len_bits", 64)) bits = int(settings_obj.get("serial_len_bits", 64))
raw_serial = payload.get("serial") or payload.get("serial_hex") or 0 raw_serial = payload.get("serial") or payload.get("serial_hex") or 0
@@ -772,12 +824,13 @@ async def issue_certificate(
serial = await unique_serial(vault, signer, bits) serial = await unique_serial(vault, signer, bits)
elif await _serial_in_use(vault, signer, serial): elif await _serial_in_use(vault, signer, serial):
raise ValidationError("serial number is already in use or revoked by the issuer") raise ValidationError("serial number is already in use or revoked by the issuer")
signature_hash = payload.get("signature_hash") or settings_obj.get("default_hash", "sha256")
if signer is None: if signer is None:
if key_model is None or not key_model.has_private: if key_model is None or not key_model.has_private:
raise ValidationError("a self-signed certificate requires the private key") raise ValidationError("a self-signed certificate requires the private key")
private_key = await load_private_key(key_model) private_key = await load_private_key(key_model)
signature_hash = resolve_signature_hash(
private_key, payload.get("signature_hash"), settings_obj.get("default_hash", "sha256")
)
cert = cert_core.build_self_signed( cert = cert_core.build_self_signed(
subject=subject, subject=subject,
private_key=private_key, private_key=private_key,
@@ -790,6 +843,9 @@ async def issue_certificate(
else: else:
signer_private = await load_private_key(signer_key) signer_private = await load_private_key(signer_key)
signer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) signer_cert = cert_core.parse_certificate(bytes(signer.cert_der))
signature_hash = resolve_signature_hash(
signer_private, payload.get("signature_hash"), settings_obj.get("default_hash", "sha256")
)
cert = cert_core.build_signed( cert = cert_core.build_signed(
subject=subject, subject=subject,
public_key=_public_part(key_obj), public_key=_public_part(key_obj),
@@ -895,7 +951,7 @@ async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[
) )
else: else:
generated = await generate_key("ec", curve="secp256r1") generated = await generate_key("ec", curve="secp256r1")
public_key = generated.public_key() public_key = key_core.public_part(generated)
extensions_spec = parse_extensions(payload.get("extensions")) extensions_spec = parse_extensions(payload.get("extensions"))
if request_obj is not None and payload.get("copy_request_extensions"): if request_obj is not None and payload.get("copy_request_extensions"):
@@ -951,16 +1007,21 @@ async def renew_certificate(
check_mandatory_dn(parsed.subject, vault_settings(vault)) check_mandatory_dn(parsed.subject, vault_settings(vault))
signer_private = await load_private_key(signer_key) signer_private = await load_private_key(signer_key)
signer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) signer_cert = cert_core.parse_certificate(bytes(signer.cert_der))
signature_hash = resolve_signature_hash(
signer_private,
payload.get("signature_hash"),
cert_core.signature_hash_name(parsed) or "sha256",
)
new_cert = cert_core.build_certificate( new_cert = cert_core.build_certificate(
subject=parsed.subject, subject=parsed.subject,
public_key=parsed.public_key(), public_key=cert_core.certificate_public_key(parsed),
issuer_cert=None if signer.id == cert.id else signer_cert, issuer_cert=None if signer.id == cert.id else signer_cert,
issuer_private_key=signer_private, issuer_private_key=signer_private,
serial=serial, serial=serial,
not_before=not_before, not_before=not_before,
not_after=not_after, not_after=not_after,
extensions=list(parsed.extensions), extensions=list(parsed.extensions),
signature_hash=payload.get("signature_hash") or cert_core._signature_hash_name(parsed), signature_hash=signature_hash,
) )
info = cert_core.certificate_info(new_cert) info = cert_core.certificate_info(new_cert)
row = await Certificate.create( row = await Certificate.create(
@@ -1111,9 +1172,15 @@ async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]
next_update = last_update + timedelta(days=days) next_update = last_update + timedelta(days=days)
crl_number = int(payload.get("crl_number") or (authority.crl_number + 1)) crl_number = int(payload.get("crl_number") or (authority.crl_number + 1))
revocations = await CertificateRevocation.filter(ca_id=ca.id) revocations = await CertificateRevocation.filter(ca_id=ca.id)
issuer_private = await load_private_key(key)
signature_hash = resolve_signature_hash(
issuer_private,
payload.get("signature_hash"),
vault_settings(vault).get("default_hash", "sha256"),
)
crl = crl_core.build_crl( crl = crl_core.build_crl(
issuer_cert=cert_core.parse_certificate(bytes(ca.cert_der)), issuer_cert=cert_core.parse_certificate(bytes(ca.cert_der)),
issuer_private_key=await load_private_key(key), issuer_private_key=issuer_private,
revocations=[ revocations=[
{ {
"serial_hex": row.serial_hex, "serial_hex": row.serial_hex,
@@ -1126,7 +1193,7 @@ async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]
last_update=last_update, last_update=last_update,
next_update=next_update, next_update=next_update,
crl_number=crl_number, crl_number=crl_number,
signature_hash=payload.get("signature_hash") or vault_settings(vault).get("default_hash", "sha256"), signature_hash=signature_hash,
with_reason=bool(payload.get("with_reason", True)), with_reason=bool(payload.get("with_reason", True)),
include_aki=bool(payload.get("include_aki", True)), include_aki=bool(payload.get("include_aki", True)),
include_issuer_alt_name=bool(payload.get("include_issuer_alt_name", True)), include_issuer_alt_name=bool(payload.get("include_issuer_alt_name", True)),
+7 -5
View File
@@ -1,7 +1,9 @@
# kca-core # kca-core
Pure, I/O-free PKI domain logic for KCA. It knows how to generate and parse Pure PKI domain logic for KCA. It knows how to generate and parse keys, build
keys, build distinguished names, encode X.509 extensions, issue certificates, distinguished names, encode X.509 extensions, issue certificates, create CSRs
create CSRs and CRLs, apply templates, and convert every supported import and and CRLs, apply templates, and convert every supported import and export
export format. It has no kaya, database or filesystem dependencies and is format. It has no kaya or database dependencies and is unit-tested in
unit-tested in isolation. isolation. Native operations use `cryptography`; an in-memory CFFI/OpenSSL 3
backend supports XCA's legacy named curves and signature hashes without
private-key temporary files or command-line tools.
+1
View File
@@ -9,6 +9,7 @@ description = "Pure PKI domain logic for KCA (keys, names, extensions, certifica
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"cffi>=2",
"cryptography>=44", "cryptography>=44",
"paramiko>=3.5", "paramiko>=3.5",
] ]
@@ -0,0 +1,260 @@
"""XCA-compatible elliptic-curve and signature-digest catalogues.
The registry deliberately contains metadata only. Native ``cryptography``
objects stay in :mod:`kca.core.keys`; curves which that library cannot
represent are handled by :mod:`kca.core.openssl_compat`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional, Tuple
@dataclass(frozen=True)
class CurveInfo:
"""A named curve exposed by XCA.
``name`` is KCA's stable API/storage name. ``openssl_name`` is the short
name accepted by OpenSSL. They differ for the two aliases which KCA has
historically called secp192r1/secp256r1 and XCA calls prime192v1/prime256v1.
"""
name: str
openssl_name: str
group: str
key_size: int
field: str
@property
def label(self) -> str:
return self.name
@property
def comment(self) -> str:
return f"{self.group} {self.field}-field curve ({self.key_size} bits)"
@property
def insecure(self) -> bool:
# Small curves are offered for compatibility, not for new PKIs.
return self.key_size < 224
@property
def aliases(self) -> Tuple[str, ...]:
return (self.openssl_name,) if self.openssl_name != self.name else ()
def as_dict(self) -> Dict[str, object]:
return {
"name": self.name,
"label": self.label,
"group": self.group,
"comment": self.comment,
"key_size": self.key_size,
"aliases": list(self.aliases),
"insecure": self.insecure,
}
def _curve(name: str, group: str, size: int, field: str = "prime", openssl_name: Optional[str] = None) -> CurveInfo:
return CurveInfo(name, openssl_name or name, group, size, field)
# This is the de-duplicated union of XCA's RFC5480, X9.62 and other curve
# allowlists in builtin_curves.cpp. Keep the two historical KCA aliases stable.
CURVES: Tuple[CurveInfo, ...] = (
# RFC 5480 / NIST prime curves.
_curve("secp192r1", "RFC 5480", 192, openssl_name="prime192v1"),
_curve("secp224r1", "RFC 5480", 224),
_curve("secp256r1", "RFC 5480", 256, openssl_name="prime256v1"),
_curve("secp384r1", "RFC 5480", 384),
_curve("secp521r1", "RFC 5480", 521),
# X9.62 prime curves.
_curve("prime192v2", "X9.62", 192),
_curve("prime192v3", "X9.62", 192),
_curve("prime239v1", "X9.62", 239),
_curve("prime239v2", "X9.62", 239),
_curve("prime239v3", "X9.62", 239),
# X9.62 binary curves.
_curve("c2pnb163v1", "X9.62 binary", 163, "binary"),
_curve("c2pnb163v2", "X9.62 binary", 163, "binary"),
_curve("c2pnb163v3", "X9.62 binary", 163, "binary"),
_curve("c2pnb176v1", "X9.62 binary", 176, "binary"),
_curve("c2tnb191v1", "X9.62 binary", 191, "binary"),
_curve("c2tnb191v2", "X9.62 binary", 191, "binary"),
_curve("c2tnb191v3", "X9.62 binary", 191, "binary"),
_curve("c2pnb208w1", "X9.62 binary", 208, "binary"),
_curve("c2tnb239v1", "X9.62 binary", 239, "binary"),
_curve("c2tnb239v2", "X9.62 binary", 239, "binary"),
_curve("c2tnb239v3", "X9.62 binary", 239, "binary"),
_curve("c2pnb272w1", "X9.62 binary", 272, "binary"),
_curve("c2pnb304w1", "X9.62 binary", 304, "binary"),
_curve("c2tnb359v1", "X9.62 binary", 359, "binary"),
_curve("c2pnb368w1", "X9.62 binary", 368, "binary"),
_curve("c2tnb431r1", "X9.62 binary", 431, "binary"),
# SECG binary curves.
_curve("sect113r1", "SECG binary", 113, "binary"),
_curve("sect113r2", "SECG binary", 113, "binary"),
_curve("sect131r1", "SECG binary", 131, "binary"),
_curve("sect131r2", "SECG binary", 131, "binary"),
_curve("sect163k1", "SECG binary", 163, "binary"),
_curve("sect163r1", "SECG binary", 163, "binary"),
_curve("sect163r2", "SECG binary", 163, "binary"),
_curve("sect193r1", "SECG binary", 193, "binary"),
_curve("sect193r2", "SECG binary", 193, "binary"),
_curve("sect233k1", "SECG binary", 233, "binary"),
_curve("sect233r1", "SECG binary", 233, "binary"),
_curve("sect239k1", "SECG binary", 239, "binary"),
_curve("sect283k1", "SECG binary", 283, "binary"),
_curve("sect283r1", "SECG binary", 283, "binary"),
_curve("sect409k1", "SECG binary", 409, "binary"),
_curve("sect409r1", "SECG binary", 409, "binary"),
_curve("sect571k1", "SECG binary", 571, "binary"),
_curve("sect571r1", "SECG binary", 571, "binary"),
# Additional SECG prime curves.
_curve("secp112r1", "SECG", 112),
_curve("secp112r2", "SECG", 112),
_curve("secp128r1", "SECG", 128),
_curve("secp128r2", "SECG", 128),
_curve("secp160k1", "SECG", 160),
_curve("secp160r1", "SECG", 160),
_curve("secp160r2", "SECG", 160),
_curve("secp192k1", "SECG", 192),
_curve("secp224k1", "SECG", 224),
_curve("secp256k1", "SECG", 256),
# WAP / WTLS named curves.
_curve("wap-wsg-idm-ecid-wtls1", "WTLS", 113, "binary"),
_curve("wap-wsg-idm-ecid-wtls3", "WTLS", 163, "binary"),
_curve("wap-wsg-idm-ecid-wtls4", "WTLS", 113, "binary"),
_curve("wap-wsg-idm-ecid-wtls5", "WTLS", 163, "binary"),
_curve("wap-wsg-idm-ecid-wtls6", "WTLS", 112),
_curve("wap-wsg-idm-ecid-wtls7", "WTLS", 160),
_curve("wap-wsg-idm-ecid-wtls8", "WTLS", 112),
_curve("wap-wsg-idm-ecid-wtls9", "WTLS", 160),
_curve("wap-wsg-idm-ecid-wtls10", "WTLS", 233, "binary"),
_curve("wap-wsg-idm-ecid-wtls11", "WTLS", 233, "binary"),
_curve("wap-wsg-idm-ecid-wtls12", "WTLS", 224),
# Brainpool random (r1) and twisted (t1) variants.
_curve("brainpoolP160r1", "Brainpool", 160),
_curve("brainpoolP160t1", "Brainpool", 160),
_curve("brainpoolP192r1", "Brainpool", 192),
_curve("brainpoolP192t1", "Brainpool", 192),
_curve("brainpoolP224r1", "Brainpool", 224),
_curve("brainpoolP224t1", "Brainpool", 224),
_curve("brainpoolP256r1", "Brainpool", 256),
_curve("brainpoolP256t1", "Brainpool", 256),
_curve("brainpoolP320r1", "Brainpool", 320),
_curve("brainpoolP320t1", "Brainpool", 320),
_curve("brainpoolP384r1", "Brainpool", 384),
_curve("brainpoolP384t1", "Brainpool", 384),
_curve("brainpoolP512r1", "Brainpool", 512),
_curve("brainpoolP512t1", "Brainpool", 512),
)
CURVE_BY_NAME: Dict[str, CurveInfo] = {curve.name: curve for curve in CURVES}
_CURVE_LOOKUP: Dict[str, CurveInfo] = {}
for _info in CURVES:
for _name in (_info.name, _info.openssl_name, *_info.aliases):
_CURVE_LOOKUP[_name.lower()] = _info
def curve_info(name: str) -> CurveInfo:
"""Resolve a KCA or OpenSSL curve name, raising a public-facing error."""
try:
return _CURVE_LOOKUP[str(name).lower()]
except (KeyError, AttributeError) as exc:
raise ValueError(f"unsupported EC curve {name!r}") from exc
def canonical_curve_name(name: str) -> str:
return curve_info(name).name
@dataclass(frozen=True)
class HashInfo:
name: str
label: str
insecure: bool = False
def as_dict(self) -> Dict[str, object]:
return {"name": self.name, "label": self.label, "insecure": self.insecure}
HASHES: Tuple[HashInfo, ...] = (
HashInfo("md5", "MD5", True),
HashInfo("ripemd160", "RIPEMD-160", True),
HashInfo("sha1", "SHA-1", True),
HashInfo("sha224", "SHA-224"),
HashInfo("sha256", "SHA-256"),
HashInfo("sha384", "SHA-384"),
HashInfo("sha512", "SHA-512"),
HashInfo("sha3-224", "SHA3-224"),
HashInfo("sha3-256", "SHA3-256"),
HashInfo("sha3-384", "SHA3-384"),
HashInfo("sha3-512", "SHA3-512"),
)
HASH_BY_NAME: Dict[str, HashInfo] = {item.name: item for item in HASHES}
_SHA2 = ("sha224", "sha256", "sha384", "sha512")
_SHA3 = ("sha3-224", "sha3-256", "sha3-384", "sha3-512")
KEY_HASHES: Dict[str, Tuple[str, ...]] = {
"rsa": ("md5", "ripemd160", "sha1", *_SHA2, *_SHA3),
"dsa": ("sha1", "sha224", "sha256"),
"ec": ("sha1", *_SHA2, *_SHA3),
"ed25519": (),
}
KEY_TYPES = ("rsa", "dsa", "ec", "ed25519")
def normalize_key_type(key_type: str) -> str:
return str(key_type or "").lower()
def possible_hashes(key_type: str) -> Tuple[str, ...]:
try:
return KEY_HASHES[normalize_key_type(key_type)]
except KeyError as exc:
raise ValueError(f"unsupported key type {key_type!r}") from exc
def validate_signature_hash(key_type: str, name: Optional[str]) -> Optional[str]:
"""Validate and normalize an explicitly requested signature hash."""
kind = normalize_key_type(key_type)
allowed = possible_hashes(kind)
if not allowed:
if name not in (None, ""):
raise ValueError(f"{kind.upper()} signatures do not use a separate hash algorithm")
return None
normalized = str(name or "sha256").lower()
if normalized not in allowed:
raise ValueError(f"hash algorithm {normalized!r} is not supported with {kind.upper()} keys")
return normalized
def default_signature_hash(key_type: str, preferred: Optional[str] = None) -> Optional[str]:
"""Choose a deterministic allowed hash for an omitted UI/API default."""
allowed = possible_hashes(key_type)
if not allowed:
return None
candidate = str(preferred or "sha256").lower()
if candidate in allowed:
return candidate
if "sha256" in allowed:
return "sha256"
return allowed[0]
__all__ = [
"CURVES",
"CURVE_BY_NAME",
"CurveInfo",
"HASHES",
"HASH_BY_NAME",
"HashInfo",
"KEY_HASHES",
"KEY_TYPES",
"canonical_curve_name",
"curve_info",
"default_signature_hash",
"normalize_key_type",
"possible_hashes",
"validate_signature_hash",
]
@@ -8,9 +8,11 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtensionOID, NameOID from cryptography.x509.oid import ExtensionOID, NameOID
from . import openssl_compat
from .algorithms import validate_signature_hash
from .extensions import ( from .extensions import (
build_extensions, build_extensions,
eku_name, eku_name,
@@ -18,8 +20,22 @@ from .extensions import (
extension_rows, extension_rows,
extension_specs, extension_specs,
) )
from .keys import PrivateKey, PublicKey, hash_algorithm, public_der from .keys import (
PrivateKey,
PublicKey,
hash_algorithm,
is_compat_key,
key_type_name,
private_der,
public_der,
public_key_from_der,
public_part,
)
from .names import entries_from_name, name_from_entries, name_hash, one_line from .names import entries_from_name, name_from_entries, name_hash, one_line
from .signatures import (
signature_algorithm_name as oid_signature_algorithm_name,
signature_hash_name as oid_signature_hash_name,
)
#: XCA's "no well-defined expiration" sentinel. #: XCA's "no well-defined expiration" sentinel.
FAR_FUTURE = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc) FAR_FUTURE = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
@@ -83,7 +99,7 @@ def build_certificate(
not_before: datetime, not_before: datetime,
not_after: datetime, not_after: datetime,
extensions: Iterable[x509.Extension], extensions: Iterable[x509.Extension],
signature_hash: Optional[str] = "sha256", signature_hash: Optional[str] = None,
) -> x509.Certificate: ) -> x509.Certificate:
"""Issue a certificate (self-signed when ``issuer_cert`` is None).""" """Issue a certificate (self-signed when ``issuer_cert`` is None)."""
if serial <= 0: if serial <= 0:
@@ -95,19 +111,40 @@ def build_certificate(
if not_after <= not_before: if not_after <= not_before:
raise ValueError("notAfter must be later than notBefore") raise ValueError("notAfter must be later than notBefore")
issuer_name = issuer_cert.subject if issuer_cert is not None else subject issuer_name = issuer_cert.subject if issuer_cert is not None else subject
signer_type = key_type_name(issuer_private_key)
normalized_hash = validate_signature_hash(signer_type, signature_hash)
compat_signing = is_compat_key(public_key) or is_compat_key(issuer_private_key) or normalized_hash in {
"md5",
"ripemd160",
"sha1",
}
# A native throwaway key lets cryptography encode the complete certificate
# even when the real subject or issuer uses an OpenSSL-only curve. OpenSSL
# then replaces the SPKI and signs the identical TBS fields with the real key.
template_key = ec.generate_private_key(ec.SECP256R1()) if compat_signing else None
builder = ( builder = (
x509.CertificateBuilder() x509.CertificateBuilder()
.subject_name(subject) .subject_name(subject)
.issuer_name(issuer_name) .issuer_name(issuer_name)
.public_key(public_key) .public_key(template_key.public_key() if template_key is not None else public_key)
.serial_number(serial) .serial_number(serial)
.not_valid_before(not_before) .not_valid_before(not_before)
.not_valid_after(not_after) .not_valid_after(not_after)
) )
for extension in extensions: for extension in extensions:
builder = builder.add_extension(extension.value, extension.critical) builder = builder.add_extension(extension.value, extension.critical)
algorithm = hash_algorithm(signature_hash, issuer_private_key) if template_key is None:
return builder.sign(private_key=issuer_private_key, algorithm=algorithm) algorithm = hash_algorithm(normalized_hash, issuer_private_key)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm)
template = builder.sign(private_key=template_key, algorithm=hashes.SHA256())
result_der = openssl_compat.resign_certificate(
certificate_der(template),
public_der(public_key),
private_der(issuer_private_key),
normalized_hash,
)
return parse_certificate(result_der)
def parse_certificate(data: bytes) -> x509.Certificate: def parse_certificate(data: bytes) -> x509.Certificate:
@@ -122,6 +159,14 @@ def certificate_pem(cert: x509.Certificate) -> str:
return cert.public_bytes(serialization.Encoding.PEM).decode("ascii") return cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
def certificate_public_der(cert: x509.Certificate) -> bytes:
"""Return the exact SPKI, including for OpenSSL-only named curves."""
try:
return public_der(cert.public_key())
except Exception: # noqa: BLE001 - cryptography lacks this curve
return openssl_compat.certificate_public_der(certificate_der(cert))
def certificate_info(cert: x509.Certificate) -> CertificateInfo: def certificate_info(cert: x509.Certificate) -> CertificateInfo:
try: try:
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value
@@ -139,9 +184,7 @@ def certificate_info(cert: x509.Certificate) -> CertificateInfo:
is_ca=is_ca, is_ca=is_ca,
path_length=path_length, path_length=path_length,
signature_algorithm=signature_algorithm_name(cert), signature_algorithm=signature_algorithm_name(cert),
public_key_der=cert.public_key().public_bytes( public_key_der=certificate_public_der(cert),
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
),
fingerprint_md5=fingerprint(cert, "md5"), fingerprint_md5=fingerprint(cert, "md5"),
fingerprint_sha1=fingerprint(cert, "sha1"), fingerprint_sha1=fingerprint(cert, "sha1"),
fingerprint_sha256=fingerprint(cert, "sha256"), fingerprint_sha256=fingerprint(cert, "sha256"),
@@ -150,29 +193,8 @@ def certificate_info(cert: x509.Certificate) -> CertificateInfo:
) )
_SIGNATURE_OIDS = {
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.1.14": "sha224WithRSAEncryption",
"1.2.840.10045.4.1": "ecdsa-with-SHA1",
"1.2.840.10045.4.3.2": "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3": "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4": "ecdsa-with-SHA512",
"1.3.101.112": "Ed25519",
}
def signature_algorithm_name(cert: x509.Certificate) -> str: def signature_algorithm_name(cert: x509.Certificate) -> str:
algorithm = cert.signature_algorithm_oid return oid_signature_algorithm_name(cert.signature_algorithm_oid)
dotted = algorithm.dotted_string
if dotted in _SIGNATURE_OIDS:
return _SIGNATURE_OIDS[dotted]
name = getattr(algorithm, "name", None)
if name and name != "Undefined":
return name
return dotted
def fingerprint(cert: x509.Certificate, algorithm: str = "sha256") -> str: def fingerprint(cert: x509.Certificate, algorithm: str = "sha256") -> str:
@@ -186,21 +208,20 @@ def fingerprint(cert: x509.Certificate, algorithm: str = "sha256") -> str:
def is_self_signed(cert: x509.Certificate) -> bool: def is_self_signed(cert: x509.Certificate) -> bool:
if cert.subject != cert.issuer: return cert.subject == cert.issuer and verify_directly_issued_by(cert, cert)
return False
try:
cert.verify_directly_issued_by(cert)
return True
except Exception: # noqa: BLE001 - invalid signature
return False
def verify_directly_issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool: def verify_directly_issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool:
if cert.issuer != issuer.subject:
return False
try: try:
cert.verify_directly_issued_by(issuer) cert.verify_directly_issued_by(issuer)
return True return True
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001 - try OpenSSL for unsupported curves/hashes
return False try:
return openssl_compat.verify_certificate(certificate_der(cert), certificate_der(issuer))
except Exception: # noqa: BLE001 - malformed input or invalid signature
return False
def find_issuer(cert: x509.Certificate, candidates: Iterable[x509.Certificate]) -> Optional[x509.Certificate]: def find_issuer(cert: x509.Certificate, candidates: Iterable[x509.Certificate]) -> Optional[x509.Certificate]:
@@ -323,13 +344,14 @@ def certificate_spec(cert: x509.Certificate) -> Dict[str, Any]:
def signature_hash_name(cert: x509.Certificate) -> Optional[str]: def signature_hash_name(cert: x509.Certificate) -> Optional[str]:
from_oid = oid_signature_hash_name(cert.signature_algorithm_oid)
if from_oid is not None:
return from_oid
try: try:
algorithm = cert.signature_hash_algorithm algorithm = cert.signature_hash_algorithm
except Exception: # noqa: BLE001 - Ed25519 except Exception: # noqa: BLE001 - Ed25519 or an unknown legacy OID
return None return None
if algorithm is None: return algorithm.name if algorithm is not None else None
return None
return algorithm.name
# Backwards-compatible private alias used by the app layer. # Backwards-compatible private alias used by the app layer.
@@ -409,7 +431,10 @@ def ics_calendar(events: Sequence[str]) -> str:
def certificate_public_key(cert: x509.Certificate) -> PublicKey: def certificate_public_key(cert: x509.Certificate) -> PublicKey:
return cert.public_key() try:
return cert.public_key()
except Exception: # noqa: BLE001 - use the OpenSSL-backed key facade
return public_key_from_der(certificate_public_der(cert))
def subject_entries(cert: x509.Certificate) -> List[Dict[str, str]]: def subject_entries(cert: x509.Certificate) -> List[Dict[str, str]]:
@@ -436,9 +461,9 @@ def build_self_signed(
not_before: datetime, not_before: datetime,
not_after: datetime, not_after: datetime,
extension_spec: Optional[Dict[str, Any]] = None, extension_spec: Optional[Dict[str, Any]] = None,
signature_hash: Optional[str] = "sha256", signature_hash: Optional[str] = None,
) -> x509.Certificate: ) -> x509.Certificate:
public_key = private_key.public_key() public_key = public_part(private_key)
extensions = build_extensions( extensions = build_extensions(
extension_spec, extension_spec,
public_key=public_key, public_key=public_key,
@@ -468,7 +493,7 @@ def build_signed(
not_before: datetime, not_before: datetime,
not_after: datetime, not_after: datetime,
extension_spec: Optional[Dict[str, Any]] = None, extension_spec: Optional[Dict[str, Any]] = None,
signature_hash: Optional[str] = "sha256", signature_hash: Optional[str] = None,
) -> x509.Certificate: ) -> x509.Certificate:
extensions = build_extensions( extensions = build_extensions(
extension_spec, extension_spec,
@@ -501,6 +526,7 @@ __all__ = [
"certificate_extensions", "certificate_extensions",
"certificate_info", "certificate_info",
"certificate_pem", "certificate_pem",
"certificate_public_der",
"certificate_public_key", "certificate_public_key",
"certificate_spec", "certificate_spec",
"chain_for", "chain_for",
+29 -13
View File
@@ -7,12 +7,15 @@ from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional from typing import Any, Dict, Iterable, List, Mapping, Optional
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtensionOID from cryptography.x509.oid import ExtensionOID
from . import openssl_compat
from .algorithms import validate_signature_hash
from .certificates import normalize_datetime, signature_algorithm_name from .certificates import normalize_datetime, signature_algorithm_name
from .extensions import extension_rows, extension_specs from .extensions import authority_key_identifier, extension_rows, extension_specs
from .keys import PrivateKey, hash_algorithm from .keys import PrivateKey, hash_algorithm, is_compat_key, key_type_name, private_der
#: XCA's revocation reason names, in OpenSSL order. #: XCA's revocation reason names, in OpenSSL order.
CRL_REASONS: List[Dict[str, Any]] = [ CRL_REASONS: List[Dict[str, Any]] = [
@@ -59,7 +62,7 @@ def build_crl(
last_update: datetime, last_update: datetime,
next_update: datetime, next_update: datetime,
crl_number: Optional[int] = None, crl_number: Optional[int] = None,
signature_hash: Optional[str] = "sha256", signature_hash: Optional[str] = None,
with_reason: bool = True, with_reason: bool = True,
include_aki: bool = True, include_aki: bool = True,
include_issuer_alt_name: bool = True, include_issuer_alt_name: bool = True,
@@ -98,12 +101,7 @@ def build_crl(
builder = builder.add_extension(x509.CRLNumber(int(crl_number)), critical=False) builder = builder.add_extension(x509.CRLNumber(int(crl_number)), critical=False)
if include_aki: if include_aki:
try: builder = builder.add_extension(authority_key_identifier(issuer_cert), critical=False)
ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski)
except x509.ExtensionNotFound:
aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_cert.public_key())
builder = builder.add_extension(aki, critical=False)
if include_issuer_alt_name: if include_issuer_alt_name:
try: try:
@@ -112,8 +110,19 @@ def build_crl(
except x509.ExtensionNotFound: except x509.ExtensionNotFound:
pass pass
algorithm = hash_algorithm(signature_hash, issuer_private_key) normalized_hash = validate_signature_hash(key_type_name(issuer_private_key), signature_hash)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm) compat_signing = is_compat_key(issuer_private_key) or normalized_hash in {"md5", "ripemd160", "sha1"}
if not compat_signing:
algorithm = hash_algorithm(normalized_hash, issuer_private_key)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm)
template_key = ec.generate_private_key(ec.SECP256R1())
template = builder.sign(private_key=template_key, algorithm=hashes.SHA256())
result_der = openssl_compat.resign_crl(
crl_der(template),
private_der(issuer_private_key),
normalized_hash,
)
return parse_crl(result_der)
def parse_crl(data: bytes) -> x509.CertificateRevocationList: def parse_crl(data: bytes) -> x509.CertificateRevocationList:
@@ -179,7 +188,14 @@ def verify_crl(crl: x509.CertificateRevocationList, issuer_cert: x509.Certificat
if crl.issuer != issuer_cert.subject: if crl.issuer != issuer_cert.subject:
return False return False
try: try:
return bool(crl.is_signature_valid(issuer_cert.public_key())) if crl.is_signature_valid(issuer_cert.public_key()):
return True
except Exception: # noqa: BLE001 - use OpenSSL for unsupported curves/hashes
pass
try:
return openssl_compat.verify_crl(
crl_der(crl), issuer_cert.public_bytes(serialization.Encoding.DER)
)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return False return False
@@ -17,12 +17,15 @@ from cryptography.hazmat.primitives.serialization import (
pkcs12, pkcs12,
) )
from .certificates import certificate_der, certificate_pem, index_entry, ics_calendar, ics_event, openssl_config from . import openssl_compat
from .certificates import certificate_der, certificate_pem, certificate_public_key, index_entry, ics_calendar, ics_event, openssl_config
from .crls import crl_der, crl_pem, ics_event as crl_ics_event from .crls import crl_der, crl_pem, ics_event as crl_ics_event
from .keys import ( from .keys import (
PrivateKey, PrivateKey,
is_compat_key,
jwk_private, jwk_private,
jwk_public, jwk_public,
private_der,
private_der_bytes, private_der_bytes,
private_pem, private_pem,
public_pem, public_pem,
@@ -109,13 +112,30 @@ def export_certificate(
if private_key is None: if private_key is None:
raise ValueError("PKCS#12 export requires the private key") raise ValueError("PKCS#12 export requires the private key")
cas = [item for item in chain if item != cert] if fmt == "pkcs12-chain" else [] cas = [item for item in chain if item != cert] if fmt == "pkcs12-chain" else []
data = pkcs12.serialize_key_and_certificates( compat_container = is_compat_key(private_key)
name=stem.encode(), if not compat_container:
key=private_key, for item in [cert, *cas]:
cert=cert, try:
cas=cas or None, item.public_key()
encryption_algorithm=_password_encryption(password), except Exception: # noqa: BLE001 - cryptography lacks this curve
) compat_container = True
break
if compat_container:
data = openssl_compat.serialize_pkcs12(
stem,
private_der(private_key),
certificate_der(cert),
[certificate_der(item) for item in cas],
password,
)
else:
data = pkcs12.serialize_key_and_certificates(
name=stem.encode(),
key=private_key,
cert=cert,
cas=cas or None,
encryption_algorithm=_password_encryption(password),
)
return ExportResult(f"{stem}.pfx", "application/x-pkcs12", data) return ExportResult(f"{stem}.pfx", "application/x-pkcs12", data)
if fmt == "openvpn": if fmt == "openvpn":
if private_key is None: if private_key is None:
@@ -136,7 +156,7 @@ def export_certificate(
if fmt == "vcalendar": if fmt == "vcalendar":
return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([ics_event(cert, name)]).encode("utf-8")) return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([ics_event(cert, name)]).encode("utf-8"))
if fmt in ("jwk", "jwk-public", "jwk-chain"): if fmt in ("jwk", "jwk-public", "jwk-chain"):
jwk = jwk_public(cert.public_key(), kid=name or None) jwk = jwk_public(certificate_public_key(cert), kid=name or None)
der = certificate_der(cert) der = certificate_der(cert)
jwk["x5t"] = _b64u(hashlib.sha1(der).digest()) jwk["x5t"] = _b64u(hashlib.sha1(der).digest())
jwk["x5t#256"] = _b64u(hashlib.sha256(der).digest()) jwk["x5t#256"] = _b64u(hashlib.sha256(der).digest())
@@ -12,6 +12,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ( from cryptography.x509.oid import (
AuthorityInformationAccessOID, AuthorityInformationAccessOID,
ExtendedKeyUsageOID, ExtendedKeyUsageOID,
@@ -19,6 +20,8 @@ from cryptography.x509.oid import (
ObjectIdentifier, ObjectIdentifier,
) )
from . import openssl_compat
from .keys import public_key_identifier
from .names import ( from .names import (
DN_ATTRIBUTES, DN_ATTRIBUTES,
EKU_OIDS, EKU_OIDS,
@@ -443,6 +446,34 @@ def _policies_from_spec(spec: Mapping[str, Any]) -> x509.CertificatePolicies:
return x509.CertificatePolicies(policies) return x509.CertificatePolicies(policies)
def authority_key_identifier(
issuer_cert: Optional[x509.Certificate],
public_key: Any = None,
) -> x509.AuthorityKeyIdentifier:
"""Build AKI without requiring native support for the issuer's curve."""
if issuer_cert is not None:
try:
ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
return x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski)
except x509.ExtensionNotFound:
try:
return x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_cert.public_key())
except Exception: # noqa: BLE001 - cryptography lacks this curve
spki = openssl_compat.certificate_public_der(
issuer_cert.public_bytes(serialization.Encoding.DER)
)
digest = openssl_compat.spki_key_identifier(spki)
else:
if public_key is None:
raise ValueError("a public key is required for authority key identifier")
digest = public_key_identifier(public_key)
return x509.AuthorityKeyIdentifier(
key_identifier=digest,
authority_cert_issuer=None,
authority_cert_serial_number=None,
)
def build_extensions( def build_extensions(
spec: Optional[Mapping[str, Any]], spec: Optional[Mapping[str, Any]],
*, *,
@@ -528,27 +559,17 @@ def build_extensions(
x509.Extension( x509.Extension(
oid=x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER, oid=x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER,
critical=_critical(ski_spec), critical=_critical(ski_spec),
value=x509.SubjectKeyIdentifier.from_public_key(public_key), value=x509.SubjectKeyIdentifier(public_key_identifier(public_key)),
) )
) )
if include_aki and spec.get("authority_key_identifier"): if include_aki and spec.get("authority_key_identifier"):
aki_spec = spec.get("authority_key_identifier") aki_spec = spec.get("authority_key_identifier")
try:
if issuer_cert is not None:
ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski)
else:
aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key)
except Exception: # noqa: BLE001 - fall back to the public key
aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(
issuer_cert.public_key() if issuer_cert is not None else public_key
)
result.append( result.append(
x509.Extension( x509.Extension(
oid=x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER, oid=x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
critical=_critical(aki_spec), critical=_critical(aki_spec),
value=aki, value=authority_key_identifier(issuer_cert, public_key),
) )
) )
@@ -10,9 +10,10 @@ from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Sequence
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
from . import openssl_compat
from .certificates import certificate_info from .certificates import certificate_info
from .crls import crl_info from .crls import crl_info
from .keys import PrivateKey, PublicKey, is_private_key, key_info, load_key_bytes from .keys import PrivateKey, PublicKey, is_private_key, key_info, load_key_bytes, private_key_from_der
from .names import entries_from_name from .names import entries_from_name
from .requests import request_info from .requests import request_info
from .templates import template_from_xca_data from .templates import template_from_xca_data
@@ -131,8 +132,14 @@ def _template_item(template: Dict[str, Any], filename: str) -> ParsedItem:
def parse_pkcs12(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]: def parse_pkcs12(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
try: try:
key, cert, cas = pkcs12.load_key_and_certificates(data, password.encode() if password else None) key, cert, cas = pkcs12.load_key_and_certificates(data, password.encode() if password else None)
except Exception as exc: # noqa: BLE001 except Exception: # noqa: BLE001 - try OpenSSL-only curves
raise ValueError("unable to read the PKCS#12 file (wrong password or unsupported encryption)") from exc try:
private_der, cert_der, ca_ders = openssl_compat.load_pkcs12(data, password)
key = private_key_from_der(private_der) if private_der is not None else None
cert = x509.load_der_x509_certificate(cert_der) if cert_der is not None else None
cas = [x509.load_der_x509_certificate(item) for item in ca_ders]
except Exception as compat_error: # noqa: BLE001
raise ValueError("unable to read the PKCS#12 file (wrong password or unsupported encryption)") from compat_error
items: List[ParsedItem] = [] items: List[ParsedItem] = []
name = _guess_name(cert, filename) if cert is not None else _guess_name(None, filename) name = _guess_name(cert, filename) if cert is not None else _guess_name(None, filename)
if cert is not None: if cert is not None:
+146 -38
View File
@@ -9,8 +9,8 @@ import base64
import hashlib import hashlib
import io import io
import struct import struct
from dataclasses import dataclass from dataclasses import dataclass, replace
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple, TypeGuard, cast
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa
@@ -21,21 +21,34 @@ from cryptography.hazmat.primitives.serialization import (
PublicFormat, PublicFormat,
) )
PrivateKey = ( from . import openssl_compat
from .algorithms import (
CURVES,
HASHES,
KEY_TYPES,
canonical_curve_name,
curve_info,
possible_hashes as hashes_for_key_type,
validate_signature_hash,
)
NativePrivateKey = (
rsa.RSAPrivateKey rsa.RSAPrivateKey
| dsa.DSAPrivateKey | dsa.DSAPrivateKey
| ec.EllipticCurvePrivateKey | ec.EllipticCurvePrivateKey
| ed25519.Ed25519PrivateKey | ed25519.Ed25519PrivateKey
) )
PublicKey = ( NativePublicKey = (
rsa.RSAPublicKey rsa.RSAPublicKey
| dsa.DSAPublicKey | dsa.DSAPublicKey
| ec.EllipticCurvePublicKey | ec.EllipticCurvePublicKey
| ed25519.Ed25519PublicKey | ed25519.Ed25519PublicKey
) )
PrivateKey = NativePrivateKey | openssl_compat.CompatKey
PublicKey = NativePublicKey | openssl_compat.CompatKey
#: Curves offered by the UI. ``cryptography`` supports all of them on every #: Curves supported directly by ``cryptography``. The complete XCA catalogue
#: platform we target. #: lives in ``algorithms.CURVES``; other entries use ``openssl_compat``.
EC_CURVES: Dict[str, ec.EllipticCurve] = { EC_CURVES: Dict[str, ec.EllipticCurve] = {
"secp192r1": ec.SECP192R1(), "secp192r1": ec.SECP192R1(),
"secp224r1": ec.SECP224R1(), "secp224r1": ec.SECP224R1(),
@@ -50,19 +63,23 @@ EC_CURVES: Dict[str, ec.EllipticCurve] = {
_CURVE_NAMES = {curve.name: name for name, curve in EC_CURVES.items()} _CURVE_NAMES = {curve.name: name for name, curve in EC_CURVES.items()}
KEY_TYPES = ("rsa", "dsa", "ec", "ed25519")
DEFAULT_KEY_SIZE = 2048 DEFAULT_KEY_SIZE = 2048
MAX_KEY_SIZE = 8192 MAX_KEY_SIZE = 8192
MIN_KEY_SIZE = 512 MIN_KEY_SIZE = 512
#: Hash algorithms selectable for signatures. Ed25519 has no separate hash. #: Native hash objects. RIPEMD-160 intentionally has no entry: OpenSSL's
#: compatibility signer handles it without inventing a cryptography hash type.
HASH_ALGORITHMS: Dict[str, Optional[hashes.HashAlgorithm]] = { HASH_ALGORITHMS: Dict[str, Optional[hashes.HashAlgorithm]] = {
"md5": hashes.MD5(),
"sha1": hashes.SHA1(), "sha1": hashes.SHA1(),
"sha224": hashes.SHA224(), "sha224": hashes.SHA224(),
"sha256": hashes.SHA256(), "sha256": hashes.SHA256(),
"sha384": hashes.SHA384(), "sha384": hashes.SHA384(),
"sha512": hashes.SHA512(), "sha512": hashes.SHA512(),
"sha3-224": hashes.SHA3_224(),
"sha3-256": hashes.SHA3_256(),
"sha3-384": hashes.SHA3_384(),
"sha3-512": hashes.SHA3_512(),
} }
@@ -84,7 +101,27 @@ class KeyInfo:
return self.public_sha256 return self.public_sha256
def is_compat_key(key: Any) -> TypeGuard[openssl_compat.CompatKey]:
return isinstance(key, openssl_compat.CompatKey)
def _canonicalize_compat(key: openssl_compat.CompatKey) -> PrivateKey | PublicKey:
if key.curve is None:
# OpenSSL also handles legacy RSA/DSA/Ed25519 encodings that native
# cryptography may reject. Return their canonical DER to the native
# type system rather than treating every compatibility key as EC.
if key.private_der is not None:
return serialization.load_der_private_key(key.private_der, password=None)
return serialization.load_der_public_key(key.public_der)
canonical = canonical_curve_name(key.curve)
return key if key.curve == canonical else replace(key, curve=canonical)
def _require_private(key: Any) -> PrivateKey: def _require_private(key: Any) -> PrivateKey:
if is_compat_key(key):
if key.private_der is None:
raise ValueError("a private key is required")
return key
if not isinstance( if not isinstance(
key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey) key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)
): ):
@@ -93,11 +130,27 @@ def _require_private(key: Any) -> PrivateKey:
def _public_key(key: PrivateKey | PublicKey) -> PublicKey: def _public_key(key: PrivateKey | PublicKey) -> PublicKey:
if is_compat_key(key):
return key.public_key()
if isinstance(key, (rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)): if isinstance(key, (rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)):
return key return key
return key.public_key() return key.public_key()
def public_part(key: PrivateKey | PublicKey) -> PublicKey:
"""Return the public half through either the native or compat backend."""
return _public_key(key)
def public_key_identifier(key: PrivateKey | PublicKey) -> bytes:
"""Return the RFC 5280 SHA-1 subject key identifier for any key."""
if is_compat_key(key):
return openssl_compat.spki_key_identifier(key.public_der)
from cryptography import x509
return x509.SubjectKeyIdentifier.from_public_key(_public_key(key)).digest
def generate_key(key_type: str, size: int = DEFAULT_KEY_SIZE, curve: Optional[str] = None) -> PrivateKey: def generate_key(key_type: str, size: int = DEFAULT_KEY_SIZE, curve: Optional[str] = None) -> PrivateKey:
"""Generate a private key of the requested type.""" """Generate a private key of the requested type."""
key_type = (key_type or "").lower() key_type = (key_type or "").lower()
@@ -110,16 +163,18 @@ def generate_key(key_type: str, size: int = DEFAULT_KEY_SIZE, curve: Optional[st
raise ValueError("DSA key size must be between 512 and 3072 bits") raise ValueError("DSA key size must be between 512 and 3072 bits")
return dsa.generate_private_key(key_size=int(size)) return dsa.generate_private_key(key_size=int(size))
if key_type == "ec": if key_type == "ec":
curve_name = curve or "secp256r1" selected = curve_info(curve or "secp256r1")
if curve_name not in EC_CURVES: if selected.name in EC_CURVES:
raise ValueError(f"unsupported EC curve {curve_name!r}") return ec.generate_private_key(EC_CURVES[selected.name])
return ec.generate_private_key(EC_CURVES[curve_name]) return _canonicalize_compat(openssl_compat.generate_ec(selected.openssl_name))
if key_type == "ed25519": if key_type == "ed25519":
return ed25519.Ed25519PrivateKey.generate() return ed25519.Ed25519PrivateKey.generate()
raise ValueError(f"unsupported key type {key_type!r}") raise ValueError(f"unsupported key type {key_type!r}")
def key_type_name(key: PrivateKey | PublicKey) -> str: def key_type_name(key: PrivateKey | PublicKey) -> str:
if is_compat_key(key):
return "EC"
if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)): if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)):
return "RSA" return "RSA"
if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)): if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)):
@@ -132,6 +187,8 @@ def key_type_name(key: PrivateKey | PublicKey) -> str:
def key_size(key: PrivateKey | PublicKey) -> int: def key_size(key: PrivateKey | PublicKey) -> int:
if is_compat_key(key):
return key.key_size
if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey, dsa.DSAPrivateKey, dsa.DSAPublicKey)): if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey, dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return key.key_size return key.key_size
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
@@ -142,23 +199,33 @@ def key_size(key: PrivateKey | PublicKey) -> int:
def curve_name(key: PrivateKey | PublicKey) -> Optional[str]: def curve_name(key: PrivateKey | PublicKey) -> Optional[str]:
if is_compat_key(key):
return canonical_curve_name(key.curve)
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return _CURVE_NAMES.get(key.curve.name, key.curve.name) return _CURVE_NAMES.get(key.curve.name, key.curve.name)
return None return None
def is_private_key(key: Any) -> bool: def is_private_key(key: Any) -> bool:
if is_compat_key(key):
return key.private_der is not None
return isinstance( return isinstance(
key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey) key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)
) )
def public_der(key: PrivateKey | PublicKey) -> bytes: def public_der(key: PrivateKey | PublicKey) -> bytes:
return _public_key(key).public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) if is_compat_key(key):
return key.public_der
return cast(NativePublicKey, _public_key(key)).public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
def private_der(key: PrivateKey) -> bytes: def private_der(key: PrivateKey) -> bytes:
return _require_private(key).private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption()) if is_compat_key(key):
_require_private(key)
assert key.private_der is not None
return key.private_der
return cast(NativePrivateKey, _require_private(key)).private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
def key_info(key: PrivateKey | PublicKey) -> KeyInfo: def key_info(key: PrivateKey | PublicKey) -> KeyInfo:
@@ -174,24 +241,17 @@ def key_info(key: PrivateKey | PublicKey) -> KeyInfo:
def possible_hashes(key: PrivateKey | PublicKey) -> List[str]: def possible_hashes(key: PrivateKey | PublicKey) -> List[str]:
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): return list(hashes_for_key_type(key_type_name(key)))
return []
if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return ["sha1", "sha224", "sha256"]
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
# Every supported curve is large enough for SHA-512 in practice.
return ["sha1", "sha224", "sha256", "sha384", "sha512"]
return ["sha1", "sha224", "sha256", "sha384", "sha512"]
def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional[hashes.HashAlgorithm]: def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional[hashes.HashAlgorithm]:
"""The signature hash for ``key``; ``None`` means the key's built-in hash.""" """Return a native hash after enforcing XCA's key/digest policy."""
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): normalized = validate_signature_hash(key_type_name(key), name)
if normalized is None:
return None return None
name = (name or "sha256").lower() if normalized not in HASH_ALGORITHMS:
if name not in HASH_ALGORITHMS: raise ValueError(f"hash algorithm {normalized!r} requires OpenSSL compatibility signing")
raise ValueError(f"unsupported hash algorithm {name!r}") return HASH_ALGORITHMS[normalized]
return HASH_ALGORITHMS[name]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -199,8 +259,14 @@ def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _compat_bytes(value: bytes | str) -> bytes:
return value.encode("ascii") if isinstance(value, str) else value
def public_pem(key: PrivateKey | PublicKey) -> str: def public_pem(key: PrivateKey | PublicKey) -> str:
return _public_key(key).public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii") if is_compat_key(key):
return _compat_bytes(openssl_compat.public_pem(key)).decode("ascii")
return cast(NativePublicKey, _public_key(key)).public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii")
def public_der_bytes(key: PrivateKey | PublicKey) -> bytes: def public_der_bytes(key: PrivateKey | PublicKey) -> bytes:
@@ -209,32 +275,40 @@ def public_der_bytes(key: PrivateKey | PublicKey) -> bytes:
def private_pem(key: PrivateKey, password: Optional[str] = None, traditional: bool = False) -> bytes: def private_pem(key: PrivateKey, password: Optional[str] = None, traditional: bool = False) -> bytes:
key = _require_private(key) key = _require_private(key)
if is_compat_key(key):
return _compat_bytes(openssl_compat.private_pem(key, password=password, traditional=traditional))
encryption: serialization.KeySerializationEncryption encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode()) encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
fmt = PrivateFormat.TraditionalOpenSSL if traditional else PrivateFormat.PKCS8 fmt = PrivateFormat.TraditionalOpenSSL if traditional else PrivateFormat.PKCS8
if traditional and isinstance(key, ed25519.Ed25519PrivateKey): if traditional and isinstance(key, ed25519.Ed25519PrivateKey):
raise ValueError("Ed25519 keys cannot be exported in traditional PEM format") raise ValueError("Ed25519 keys cannot be exported in traditional PEM format")
return key.private_bytes(Encoding.PEM, fmt, encryption) return cast(NativePrivateKey, key).private_bytes(Encoding.PEM, fmt, encryption)
def private_der_bytes(key: PrivateKey, password: Optional[str] = None) -> bytes: def private_der_bytes(key: PrivateKey, password: Optional[str] = None) -> bytes:
key = _require_private(key) key = _require_private(key)
if is_compat_key(key):
return openssl_compat.private_der_bytes(key, password=password)
encryption: serialization.KeySerializationEncryption encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode()) encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
return key.private_bytes(Encoding.DER, PrivateFormat.PKCS8, encryption) return cast(NativePrivateKey, key).private_bytes(Encoding.DER, PrivateFormat.PKCS8, encryption)
def ssh_public(key: PrivateKey | PublicKey, comment: str = "") -> str: def ssh_public(key: PrivateKey | PublicKey, comment: str = "") -> str:
encoded = _public_key(key).public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode("ascii") if is_compat_key(key):
raise ValueError("OpenSSH export does not support this EC curve")
encoded = cast(NativePublicKey, _public_key(key)).public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode("ascii")
return f"{encoded} {comment}".strip() return f"{encoded} {comment}".strip()
def ssh_private(key: PrivateKey, password: Optional[str] = None) -> bytes: def ssh_private(key: PrivateKey, password: Optional[str] = None) -> bytes:
"""Serialize a private key in OpenSSH format.""" """Serialize a private key in OpenSSH format."""
key = _require_private(key) key = _require_private(key)
if is_compat_key(key):
raise ValueError("OpenSSH export does not support this EC curve")
encryption: serialization.KeySerializationEncryption encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode()) encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
return key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, encryption) return cast(NativePrivateKey, key).private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, encryption)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -421,6 +495,17 @@ def load_key_bytes(data: bytes, password: Optional[str] = None) -> Tuple[Private
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
errors.append(str(exc)) errors.append(str(exc))
# OpenSSL-only named curves, including encrypted PKCS#8/traditional keys.
try:
loaded = openssl_compat.load_key_bytes(data, password)
if isinstance(loaded, tuple):
key, private = loaded
else:
key, private = loaded, is_private_key(loaded)
return _canonicalize_compat(key), private
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# OpenSSH private / public # OpenSSH private / public
try: try:
key = serialization.load_ssh_private_key(data, password=password_bytes) key = serialization.load_ssh_private_key(data, password=password_bytes)
@@ -490,11 +575,17 @@ def _load_ssh_private_with_paramiko(data: bytes, password: Optional[str]) -> Pri
def public_key_from_der(data: bytes) -> PublicKey: def public_key_from_der(data: bytes) -> PublicKey:
return serialization.load_der_public_key(data) try:
return serialization.load_der_public_key(data)
except Exception: # noqa: BLE001 - try OpenSSL-only named curves
return _canonicalize_compat(openssl_compat.public_key_from_der(data))
def private_key_from_der(data: bytes, password: Optional[str] = None) -> PrivateKey: def private_key_from_der(data: bytes, password: Optional[str] = None) -> PrivateKey:
return serialization.load_der_private_key(data, password=password.encode() if password else None) try:
return serialization.load_der_private_key(data, password=password.encode() if password else None)
except Exception: # noqa: BLE001 - try OpenSSL-only named curves
return _canonicalize_compat(openssl_compat.private_key_from_der(data, password=password))
def public_key_sha256(key: PrivateKey | PublicKey) -> str: def public_key_sha256(key: PrivateKey | PublicKey) -> str:
@@ -502,15 +593,27 @@ def public_key_sha256(key: PrivateKey | PublicKey) -> str:
def key_type_options() -> List[Dict[str, Any]]: def key_type_options() -> List[Dict[str, Any]]:
return [ options: List[Dict[str, Any]] = [
{"type": "rsa", "label": "RSA", "sizes": [1024, 2048, 3072, 4096, 8192]}, {"type": "rsa", "label": "RSA", "sizes": [1024, 2048, 3072, 4096, 8192]},
{"type": "dsa", "label": "DSA", "sizes": [1024, 2048, 3072]}, {"type": "dsa", "label": "DSA", "sizes": [1024, 2048, 3072]},
{"type": "ec", "label": "EC", "curves": list(EC_CURVES)}, {"type": "ec", "label": "EC", "curves": [curve.name for curve in CURVES]},
{"type": "ed25519", "label": "ED25519", "sizes": []}, {"type": "ed25519", "label": "ED25519", "sizes": []},
] ]
for option in options:
option["hashes"] = list(hashes_for_key_type(option["type"]))
return options
def curve_options() -> List[Dict[str, Any]]:
return [curve.as_dict() for curve in CURVES]
def hash_options() -> List[Dict[str, Any]]:
return [item.as_dict() for item in HASHES]
__all__ = [ __all__ = [
"CURVES",
"DEFAULT_KEY_SIZE", "DEFAULT_KEY_SIZE",
"EC_CURVES", "EC_CURVES",
"HASH_ALGORITHMS", "HASH_ALGORITHMS",
@@ -521,8 +624,11 @@ __all__ = [
"PrivateKey", "PrivateKey",
"PublicKey", "PublicKey",
"curve_name", "curve_name",
"curve_options",
"generate_key", "generate_key",
"hash_options",
"hash_algorithm", "hash_algorithm",
"is_compat_key",
"is_private_key", "is_private_key",
"jwk_private", "jwk_private",
"jwk_public", "jwk_public",
@@ -539,7 +645,9 @@ __all__ = [
"public_der", "public_der",
"public_der_bytes", "public_der_bytes",
"public_key_from_der", "public_key_from_der",
"public_key_identifier",
"public_key_sha256", "public_key_sha256",
"public_part",
"public_pem", "public_pem",
"pvk_decode_private", "pvk_decode_private",
"pvk_encode_private", "pvk_encode_private",
@@ -0,0 +1,476 @@
"""Minimal OpenSSL 3 ``libcrypto`` ABI bindings used by the compatibility layer.
This module deliberately uses cffi's ABI mode: no compiler, OpenSSL headers, or
CLI executable is required at runtime. It only exposes process-wide library
loading and scoped memory/DER helpers. All cryptographic objects are owned by
callers for the duration of a single operation and are never shared with
``cryptography`` or pyOpenSSL.
Set ``KCA_LIBCRYPTO`` to an absolute path (or another loader-compatible name) to
select a particular OpenSSL 3 libcrypto. Otherwise the normal system loader
and a small list of conventional OpenSSL 3 library names are tried.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import os
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Iterator, Optional
try:
import cffi
except ImportError: # pragma: no cover - depends on the installation
cffi = None # type: ignore[assignment]
_CDEF = r"""
typedef KCA_SIZE_T size_t;
typedef KCA_UINTPTR_T uintptr_t;
typedef struct bio_st BIO;
typedef struct bio_method_st BIO_METHOD;
typedef struct evp_pkey_st EVP_PKEY;
typedef struct evp_md_st EVP_MD;
typedef struct evp_cipher_st EVP_CIPHER;
typedef struct ec_key_st EC_KEY;
typedef struct ec_group_st EC_GROUP;
typedef struct rsa_st RSA;
typedef struct x509_st X509;
typedef struct X509_req_st X509_REQ;
typedef struct X509_crl_st X509_CRL;
typedef struct X509_pubkey_st X509_PUBKEY;
typedef struct asn1_object_st ASN1_OBJECT;
typedef struct X509_algor_st X509_ALGOR;
typedef struct PKCS12_st PKCS12;
typedef struct openssl_stack_st OPENSSL_STACK;
typedef int pem_password_cb(char *buf, int size, int rwflag, void *userdata);
typedef struct {
const unsigned char *data;
size_t length;
} KCA_PASSWORD;
typedef struct {
int nid;
const char *comment;
} EC_builtin_curve;
const char *OpenSSL_version(int type);
unsigned long OpenSSL_version_num(void);
unsigned long ERR_get_error(void);
void ERR_error_string_n(unsigned long e, char *buf, size_t len);
void ERR_clear_error(void);
const BIO_METHOD *BIO_s_mem(void);
BIO *BIO_new(const BIO_METHOD *type);
BIO *BIO_new_mem_buf(const void *buf, int len);
int BIO_free(BIO *b);
int BIO_read(BIO *b, void *data, int dlen);
size_t BIO_ctrl_pending(BIO *b);
EVP_PKEY *EVP_PKEY_new(void);
void EVP_PKEY_free(EVP_PKEY *pkey);
int EVP_PKEY_get_bits(const EVP_PKEY *pkey);
int EVP_PKEY_is_a(const EVP_PKEY *pkey, const char *name);
int EVP_PKEY_get_utf8_string_param(const EVP_PKEY *pkey, const char *key_name,
char *str, size_t max_buf_sz,
size_t *out_len);
int EVP_PKEY_set1_EC_KEY(EVP_PKEY *pkey, EC_KEY *key);
EC_KEY *EVP_PKEY_get1_EC_KEY(EVP_PKEY *pkey);
int EVP_PKEY_set1_RSA(EVP_PKEY *pkey, RSA *key);
const EVP_CIPHER *EVP_aes_256_cbc(void);
const EVP_MD *EVP_get_digestbyname(const char *name);
int OBJ_txt2nid(const char *s);
int OBJ_sn2nid(const char *s);
const char *OBJ_nid2sn(int n);
size_t EC_get_builtin_curves(EC_builtin_curve *r, size_t nitems);
EC_KEY *EC_KEY_new_by_curve_name(int nid);
void EC_KEY_free(EC_KEY *key);
int EC_KEY_generate_key(EC_KEY *key);
int EC_KEY_check_key(const EC_KEY *key);
void EC_KEY_set_asn1_flag(EC_KEY *key, int asn1_flag);
const EC_GROUP *EC_KEY_get0_group(const EC_KEY *key);
int EC_GROUP_get_curve_name(const EC_GROUP *group);
int EC_GROUP_get_degree(const EC_GROUP *group);
RSA *RSA_new(void);
void RSA_free(RSA *rsa);
EVP_PKEY *PEM_read_bio_PrivateKey(BIO *bp, EVP_PKEY **x,
pem_password_cb *cb, void *u);
EVP_PKEY *PEM_read_bio_PUBKEY(BIO *bp, EVP_PKEY **x,
pem_password_cb *cb, void *u);
RSA *PEM_read_bio_RSAPublicKey(BIO *bp, RSA **x,
pem_password_cb *cb, void *u);
int PEM_write_bio_PUBKEY(BIO *bp, const EVP_PKEY *x);
int PEM_write_bio_PrivateKey_traditional(BIO *bp, const EVP_PKEY *x,
const EVP_CIPHER *enc,
const unsigned char *kstr, int klen,
pem_password_cb *cb, void *u);
int PEM_write_bio_PKCS8PrivateKey(BIO *bp, const EVP_PKEY *x,
const EVP_CIPHER *enc, char *kstr, int klen,
pem_password_cb *cb, void *u);
EVP_PKEY *d2i_PKCS8PrivateKey_bio(BIO *bp, EVP_PKEY **x,
pem_password_cb *cb, void *u);
int i2d_PKCS8PrivateKey_bio(BIO *bp, const EVP_PKEY *x,
const EVP_CIPHER *enc, char *kstr, int klen,
pem_password_cb *cb, void *u);
EVP_PKEY *d2i_AutoPrivateKey(EVP_PKEY **a, const unsigned char **pp,
long length);
EVP_PKEY *d2i_PUBKEY(EVP_PKEY **a, const unsigned char **pp, long length);
RSA *d2i_RSAPublicKey(RSA **a, const unsigned char **pp, long length);
int i2d_PUBKEY_bio(BIO *bp, const EVP_PKEY *pkey);
X509 *d2i_X509(X509 **a, const unsigned char **pp, long length);
void X509_free(X509 *a);
X509_REQ *d2i_X509_REQ(X509_REQ **a, const unsigned char **pp, long length);
void X509_REQ_free(X509_REQ *a);
X509_CRL *d2i_X509_CRL(X509_CRL **a, const unsigned char **pp, long length);
void X509_CRL_free(X509_CRL *a);
EVP_PKEY *X509_get_pubkey(X509 *x);
EVP_PKEY *X509_REQ_get_pubkey(X509_REQ *req);
int X509_set_pubkey(X509 *x, EVP_PKEY *pkey);
int X509_REQ_set_pubkey(X509_REQ *x, EVP_PKEY *pkey);
int X509_sign(X509 *x, EVP_PKEY *pkey, const EVP_MD *md);
int X509_REQ_sign(X509_REQ *x, EVP_PKEY *pkey, const EVP_MD *md);
int X509_CRL_sign(X509_CRL *x, EVP_PKEY *pkey, const EVP_MD *md);
int X509_verify(X509 *a, EVP_PKEY *r);
int X509_REQ_verify(X509_REQ *a, EVP_PKEY *r);
int X509_CRL_verify(X509_CRL *a, EVP_PKEY *r);
int i2d_X509_bio(BIO *bp, X509 *x);
int i2d_X509_REQ_bio(BIO *bp, X509_REQ *req);
int i2d_X509_CRL_bio(BIO *bp, X509_CRL *crl);
X509_PUBKEY *d2i_X509_PUBKEY(X509_PUBKEY **a, const unsigned char **pp,
long length);
void X509_PUBKEY_free(X509_PUBKEY *a);
int X509_PUBKEY_get0_param(ASN1_OBJECT **ppkalg, const unsigned char **pk,
int *ppklen, X509_ALGOR **pa, X509_PUBKEY *pub);
PKCS12 *d2i_PKCS12(PKCS12 **a, const unsigned char **pp, long length);
void PKCS12_free(PKCS12 *p12);
int PKCS12_parse(PKCS12 *p12, const char *pass, EVP_PKEY **pkey,
X509 **cert, OPENSSL_STACK **ca);
PKCS12 *PKCS12_create(const char *pass, const char *name, EVP_PKEY *pkey,
X509 *cert, OPENSSL_STACK *ca, int nid_key,
int nid_cert, int iter, int mac_iter, int keytype);
int PKCS12_set_mac(PKCS12 *p12, const char *pass, int passlen,
unsigned char *salt, int saltlen, int iter,
const EVP_MD *md_type);
int i2d_PKCS12_bio(BIO *bp, PKCS12 *p12);
OPENSSL_STACK *OPENSSL_sk_new_null(void);
int OPENSSL_sk_num(const OPENSSL_STACK *st);
void *OPENSSL_sk_value(const OPENSSL_STACK *st, int i);
int OPENSSL_sk_push(OPENSSL_STACK *st, const void *data);
void OPENSSL_sk_free(OPENSSL_STACK *st);
"""
class OpenSSLUnavailableError(RuntimeError):
"""The requested OpenSSL 3 backend cannot be loaded."""
def _unsigned_c_type(size: int) -> str:
"""Select an ABI-compatible unsigned C type for size_t/uintptr_t."""
candidates = (
("unsigned char", ctypes.c_ubyte),
("unsigned short", ctypes.c_ushort),
("unsigned int", ctypes.c_uint),
("unsigned long", ctypes.c_ulong),
("unsigned long long", ctypes.c_ulonglong),
)
for name, ctype in candidates:
if ctypes.sizeof(ctype) == size:
return name
raise OpenSSLUnavailableError(f"unsupported C integer width: {size}")
_CDEF = _CDEF.replace(
"typedef KCA_SIZE_T size_t;",
f"typedef {_unsigned_c_type(ctypes.sizeof(ctypes.c_size_t))} size_t;",
).replace(
"typedef KCA_UINTPTR_T uintptr_t;",
f"typedef {_unsigned_c_type(ctypes.sizeof(ctypes.c_void_p))} uintptr_t;",
)
def _library_candidates() -> list[str]:
override = os.environ.get("KCA_LIBCRYPTO")
if override is not None:
if not override.strip():
raise OpenSSLUnavailableError("KCA_LIBCRYPTO is empty")
return [override]
candidates: list[str] = []
discovered = ctypes.util.find_library("crypto")
if discovered:
candidates.append(discovered)
candidates.extend(
[
"libcrypto.so.3",
"libcrypto.so",
"libcrypto.3.dylib",
"libcrypto.dylib",
"libcrypto-3-x64.dll",
"libcrypto-3.dll",
]
)
# Preserve loader order while avoiding repeated dlopen attempts.
return list(dict.fromkeys(candidates))
@dataclass(eq=False)
class OpenSSLBackend:
"""A loaded libcrypto and its cffi namespace.
Instances are initialized by :func:`get_backend`; applications should not
construct them directly. The cffi library handle is safe to reuse, while
every object pointer created through it remains operation-local.
"""
ffi: Any
lib: Any
path: str
version: str
@classmethod
def load(cls) -> "OpenSSLBackend":
if cffi is None:
raise OpenSSLUnavailableError("cffi is required for OpenSSL compatibility")
candidates = _library_candidates()
errors: list[str] = []
override = os.environ.get("KCA_LIBCRYPTO") is not None
for path in candidates:
ffi = cffi.FFI()
ffi.cdef(_CDEF)
try:
lib = ffi.dlopen(path)
version_num = int(lib.OpenSSL_version_num())
if version_num >> 28 != 3:
raise OpenSSLUnavailableError(
f"{path} is not OpenSSL 3.x"
)
version_ptr = lib.OpenSSL_version(0)
if version_ptr == ffi.NULL:
raise OpenSSLUnavailableError(
f"cannot determine the OpenSSL version in {path}"
)
version = ffi.string(version_ptr).decode("utf-8", "replace")
backend = cls(ffi=ffi, lib=lib, path=path, version=version)
backend._install_password_callback()
return backend
except (OSError, OpenSSLUnavailableError) as exc:
errors.append(f"{path}: {exc}")
if override:
break
detail = "; ".join(errors) if errors else "no libcrypto candidates found"
raise OpenSSLUnavailableError(f"unable to load OpenSSL 3 libcrypto ({detail})")
def _install_password_callback(self) -> None:
ffi = self.ffi
@ffi.callback("int(char *, int, int, void *)")
def password_callback(buf: Any, size: int, _rwflag: int, userdata: Any) -> int:
# No interpreter input or prompting is permitted on an import path.
if userdata == ffi.NULL or size <= 0:
return 0
state = ffi.cast("KCA_PASSWORD *", userdata)
length = int(state.length)
if length <= 0 or length > size:
return 0
ffi.memmove(buf, state.data, length)
return length
# cffi only guarantees callback lifetime while the cdata is referenced.
self._password_callback = password_callback
@contextmanager
def operation(self) -> Iterator["OpenSSLBackend"]:
"""Isolate OpenSSL's thread-local error queue for one public call."""
self.lib.ERR_clear_error()
try:
yield self
finally:
self.lib.ERR_clear_error()
def error_messages(self) -> list[str]:
messages: list[str] = []
while True:
code = int(self.lib.ERR_get_error())
if not code:
break
buf = self.ffi.new("char[256]")
self.lib.ERR_error_string_n(code, buf, len(buf))
messages.append(self.ffi.string(buf).decode("utf-8", "replace"))
return messages
def raise_value_error(self, action: str) -> None:
"""Raise a password-free error, optionally with OpenSSL's diagnostics."""
messages = self.error_messages()
detail = f": {'; '.join(messages)}" if messages else ""
raise ValueError(f"{action}{detail}")
@contextmanager
def input_bio(self, data: bytes) -> Iterator[Any]:
if not data:
raise ValueError("input is empty")
if len(data) > 0x7FFFFFFF:
raise ValueError("input is too large")
view = self.ffi.from_buffer(data)
bio = self.lib.BIO_new_mem_buf(self.ffi.cast("const void *", view), len(data))
if bio == self.ffi.NULL:
self.raise_value_error("cannot allocate an input BIO")
try:
yield bio
finally:
self.lib.BIO_free(bio)
@contextmanager
def output_bio(self) -> Iterator[Any]:
bio = self.lib.BIO_new(self.lib.BIO_s_mem())
if bio == self.ffi.NULL:
self.raise_value_error("cannot allocate an output BIO")
try:
yield bio
finally:
self.lib.BIO_free(bio)
def output_bytes(self, bio: Any) -> bytes:
size = int(self.lib.BIO_ctrl_pending(bio))
if size > 0x7FFFFFFF:
raise ValueError("OpenSSL output is too large")
if size == 0:
return b""
buf = self.ffi.new("unsigned char[]", size)
read = int(self.lib.BIO_read(bio, buf, size))
if read != size:
self.raise_value_error("cannot read OpenSSL output")
return self.ffi.buffer(buf, size)[:]
def decode_der(
self,
data: bytes,
decoder: Any,
free_function: Any,
description: str,
) -> Any:
"""Decode exactly one DER object and return its owning pointer.
The caller must release the returned pointer with ``free_function``.
Most callers should prefer :meth:`decoded_der`, which scopes ownership
automatically.
"""
if not isinstance(data, bytes):
try:
data = bytes(data)
except Exception as exc: # noqa: BLE001 - normalize input errors
raise ValueError(f"{description} must be DER bytes") from exc
if not data:
raise ValueError(f"{description} is empty")
if len(data) > 0x7FFFFFFF:
raise ValueError(f"{description} is too large")
view = self.ffi.from_buffer(data)
start = self.ffi.cast("const unsigned char *", view)
cursor = self.ffi.new("const unsigned char **", start)
obj = decoder(self.ffi.NULL, cursor, len(data))
if obj == self.ffi.NULL:
self.raise_value_error(f"cannot parse {description}")
start_address = int(self.ffi.cast("uintptr_t", start))
end_address = int(self.ffi.cast("uintptr_t", cursor[0]))
if end_address - start_address != len(data):
free_function(obj)
raise ValueError(f"trailing data after {description}")
return obj
@contextmanager
def decoded_der(
self,
data: bytes,
decoder: Any,
free_function: Any,
description: str,
) -> Iterator[Any]:
"""Decode exactly one DER object and scope its owning pointer."""
obj = self.decode_der(data, decoder, free_function, description)
try:
yield obj
finally:
free_function(obj)
@contextmanager
def password(self, password: Optional[str | bytes]) -> Iterator[Any]:
"""Create operation-local callback state, wiping its C buffer after use."""
if password is None:
raw = None
elif isinstance(password, str):
raw = password.encode("utf-8")
elif isinstance(password, (bytes, bytearray, memoryview)):
raw = bytes(password)
else:
raise TypeError("password must be str, bytes, or None")
if raw is None:
buf = self.ffi.NULL
else:
# A trailing NUL is not included in state.length and is not passed
# to PKCS#8 as part of the password.
buf = self.ffi.new("unsigned char[]", raw + b"\0")
state = self.ffi.new("KCA_PASSWORD *")
state.data = self.ffi.cast("const unsigned char *", buf)
state.length = 0 if raw is None else len(raw)
try:
yield state
finally:
if raw:
self.ffi.memmove(buf, b"\0" * (len(raw) + 1), len(raw) + 1)
_backend_lock = threading.Lock()
_backend_instance: Optional[OpenSSLBackend] = None
def get_backend() -> OpenSSLBackend:
"""Return the process-wide loaded library, initializing it on first use."""
global _backend_instance
if _backend_instance is None:
with _backend_lock:
if _backend_instance is None:
_backend_instance = OpenSSLBackend.load()
return _backend_instance
def backend_available() -> bool:
"""Return whether cffi and a usable system OpenSSL 3 libcrypto are present."""
try:
get_backend()
except (OpenSSLUnavailableError, OSError):
return False
return True
def library_path() -> str:
"""Return the loader name/path selected for libcrypto."""
return get_backend().path
def openssl_version() -> str:
"""Return the selected libcrypto's version string."""
return get_backend().version
__all__ = [
"OpenSSLBackend",
"OpenSSLUnavailableError",
"backend_available",
"get_backend",
"library_path",
"openssl_version",
]
@@ -0,0 +1,941 @@
"""OpenSSL 3 compatibility operations for keys and signed PKIX objects.
The native KCA paths use :mod:`cryptography`. This module is the isolated
fallback for OpenSSL EC curves that cryptography does not expose and for
signature digests that cryptography intentionally no longer supports. Every
operation reparses canonical DER into a fresh ``EVP_PKEY`` and releases it
before returning. No OpenSSL pointer is stored in a Python object or exchanged
with cryptography/pyOpenSSL.
All inputs and outputs are immutable in-memory DER/PEM bytes. In particular,
this module never invokes the OpenSSL CLI and never creates temporary files.
"""
from __future__ import annotations
import hashlib
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Iterable, Iterator, Optional
from .openssl_abi import (
OpenSSLBackend,
OpenSSLUnavailableError,
backend_available,
get_backend,
library_path,
openssl_version,
)
# OpenSSL's EC_KEY ASN.1 flag value for a named-curve AlgorithmIdentifier.
_OPENSSL_EC_NAMED_CURVE = 1
_PKCS12_ITERATIONS = 10000
@dataclass(frozen=True)
class CompatKey:
"""Serializable key material returned by the compatibility backend.
``public_der`` is a SubjectPublicKeyInfo. A private key's ``private_der``
is always canonical, unencrypted PKCS#8 DER; encryption is applied only by
:func:`private_pem` or :func:`private_der_bytes`. ``curve`` is the OpenSSL
short name for EC keys and ``None`` for other key types. EC ``key_size``
is the curve field degree, matching XCA/cryptography rather than the size
of the curve's group order.
The private DER is excluded from ``repr`` to avoid accidental key disclosure.
"""
public_der: bytes
private_der: Optional[bytes] = field(repr=False)
curve: Optional[str]
key_size: int
is_private: bool
def __post_init__(self) -> None:
if self.is_private != (self.private_der is not None):
raise ValueError("is_private must agree with the presence of private_der")
if not self.public_der:
raise ValueError("public_der cannot be empty")
if self.key_size <= 0:
raise ValueError("key_size must be positive")
def public_key(self) -> "CompatKey":
"""Return a public-only copy, discarding the private component."""
if not self.is_private:
return self
return CompatKey(
public_der=self.public_der,
private_der=None,
curve=self.curve,
key_size=self.key_size,
is_private=False,
)
def _bytes(data: Any, description: str) -> bytes:
if isinstance(data, bytes):
return data
try:
return bytes(data)
except Exception as exc: # noqa: BLE001 - normalize buffer input errors
raise ValueError(f"{description} must be bytes") from exc
def _ascii(value: str, description: str) -> bytes:
if not isinstance(value, str) or not value:
raise ValueError(f"{description} must be a non-empty string")
try:
raw = value.encode("ascii")
except UnicodeEncodeError as exc:
raise ValueError(f"{description} must be an ASCII OpenSSL name") from exc
if b"\0" in raw:
raise ValueError(f"{description} contains a NUL byte")
return raw
@contextmanager
def _owned_evp(backend: OpenSSLBackend, pkey: Any) -> Iterator[Any]:
try:
yield pkey
finally:
backend.lib.EVP_PKEY_free(pkey)
@contextmanager
def _owned(backend: OpenSSLBackend, ptr: Any, free_function: Any) -> Iterator[Any]:
try:
yield ptr
finally:
free_function(ptr)
def _evp_from_rsa(backend: OpenSSLBackend, rsa: Any) -> Any:
"""Copy an RSA reference into a new EVP_PKEY, then release the RSA pointer."""
ffi = backend.ffi
lib = backend.lib
try:
pkey = lib.EVP_PKEY_new()
if pkey == ffi.NULL:
backend.raise_value_error("cannot allocate a public key")
try:
if int(lib.EVP_PKEY_set1_RSA(pkey, rsa)) != 1:
backend.raise_value_error("cannot convert the RSA public key")
except Exception:
lib.EVP_PKEY_free(pkey)
raise
return pkey
finally:
lib.RSA_free(rsa)
def _read_der_private_evp(
backend: OpenSSLBackend, data: bytes, password: Optional[str | bytes]
) -> Any:
"""Return an owning EVP_PKEY decoded from encrypted/unencrypted PKCS#8 or traditional DER."""
ffi = backend.ffi
lib = backend.lib
data = _bytes(data, "private key")
# This BIO decoder handles both encrypted and unencrypted PKCS#8 and never
# prompts: our callback returns failure when no password was supplied.
with backend.password(password) as state:
with backend.input_bio(data) as bio:
pkey = lib.d2i_PKCS8PrivateKey_bio(
bio, ffi.NULL, backend._password_callback, state
)
trailing = int(lib.BIO_ctrl_pending(bio)) if pkey != ffi.NULL else 0
if pkey != ffi.NULL:
if trailing == 0:
return pkey
lib.EVP_PKEY_free(pkey)
lib.ERR_clear_error()
# d2i_AutoPrivateKey covers unencrypted traditional RSA/DSA/EC DER. DER
# has no standard encrypted traditional representation.
try:
return backend.decode_der(
data, lib.d2i_AutoPrivateKey, lib.EVP_PKEY_free, "DER private key"
)
except ValueError:
lib.ERR_clear_error()
raise ValueError(
"cannot parse DER private key (wrong password or unsupported format)"
) from None
def _read_der_public_evp(backend: OpenSSLBackend, data: bytes) -> Any:
lib = backend.lib
data = _bytes(data, "public key")
try:
return backend.decode_der(
data, lib.d2i_PUBKEY, lib.EVP_PKEY_free, "DER SubjectPublicKeyInfo"
)
except ValueError:
lib.ERR_clear_error()
# PKCS#1 is the traditional standalone RSA public-key representation.
try:
rsa = backend.decode_der(
data, lib.d2i_RSAPublicKey, lib.RSA_free, "DER RSA public key"
)
except ValueError:
lib.ERR_clear_error()
raise ValueError("cannot parse DER public key") from None
return _evp_from_rsa(backend, rsa)
def _read_pem_private_evp(
backend: OpenSSLBackend, data: bytes, password: Optional[str | bytes]
) -> Any:
ffi = backend.ffi
with backend.password(password) as state:
with backend.input_bio(data) as bio:
pkey = backend.lib.PEM_read_bio_PrivateKey(
bio, ffi.NULL, backend._password_callback, state
)
if pkey == ffi.NULL:
backend.lib.ERR_clear_error()
raise ValueError(
"cannot parse PEM private key (wrong password or unsupported format)"
)
return pkey
def _read_pem_public_evp(backend: OpenSSLBackend, data: bytes) -> Any:
ffi = backend.ffi
lib = backend.lib
with backend.input_bio(data) as bio:
pkey = lib.PEM_read_bio_PUBKEY(bio, ffi.NULL, ffi.NULL, ffi.NULL)
if pkey != ffi.NULL:
return pkey
lib.ERR_clear_error()
with backend.input_bio(data) as bio:
rsa = lib.PEM_read_bio_RSAPublicKey(bio, ffi.NULL, ffi.NULL, ffi.NULL)
if rsa == ffi.NULL:
lib.ERR_clear_error()
raise ValueError("cannot parse PEM public key")
return _evp_from_rsa(backend, rsa)
def _pkey_public_der(backend: OpenSSLBackend, pkey: Any) -> bytes:
with backend.output_bio() as bio:
if int(backend.lib.i2d_PUBKEY_bio(bio, pkey)) != 1:
backend.raise_value_error("cannot encode SubjectPublicKeyInfo")
return backend.output_bytes(bio)
def _pkey_private_der(
backend: OpenSSLBackend,
pkey: Any,
password_state: Any = None,
*,
encrypt: bool = False,
) -> bytes:
ffi = backend.ffi
lib = backend.lib
cipher = lib.EVP_aes_256_cbc() if encrypt else ffi.NULL
if cipher == ffi.NULL and encrypt:
backend.raise_value_error("AES-256-CBC is unavailable")
if encrypt:
password = ffi.cast("char *", password_state.data)
password_length = int(password_state.length)
else:
password = ffi.NULL
password_length = 0
with backend.output_bio() as bio:
result = lib.i2d_PKCS8PrivateKey_bio(
bio,
pkey,
cipher,
password,
password_length,
ffi.NULL,
ffi.NULL,
)
if int(result) != 1:
backend.raise_value_error("cannot encode the PKCS#8 private key")
return backend.output_bytes(bio)
def _pkey_curve_name(backend: OpenSSLBackend, pkey: Any) -> Optional[str]:
ffi = backend.ffi
lib = backend.lib
if int(lib.EVP_PKEY_is_a(pkey, b"EC")) != 1:
return None
ec_key = lib.EVP_PKEY_get1_EC_KEY(pkey)
if ec_key != ffi.NULL:
try:
group = lib.EC_KEY_get0_group(ec_key)
nid = int(lib.EC_GROUP_get_curve_name(group)) if group != ffi.NULL else 0
name = lib.OBJ_nid2sn(nid) if nid else ffi.NULL
if name != ffi.NULL:
return ffi.string(name).decode("ascii")
finally:
lib.EC_KEY_free(ec_key)
lib.ERR_clear_error()
# Provider-only EVP_PKEYs do not necessarily expose a legacy EC_KEY. The
# standard "group" UTF-8 parameter is the equivalent named-curve metadata.
out_len = ffi.new("size_t *")
buf = ffi.new("char[256]")
if int(lib.EVP_PKEY_get_utf8_string_param(pkey, b"group", buf, len(buf), out_len)) == 1:
length = min(int(out_len[0]), len(buf) - 1)
return ffi.buffer(buf, length)[:].split(b"\0", 1)[0].decode("utf-8", "replace")
lib.ERR_clear_error()
return None
def _pkey_key_size(backend: OpenSSLBackend, pkey: Any) -> int:
# EVP_PKEY_get_bits() reports the group-order size for some EC curves.
# XCA and cryptography instead expose the field degree (for example, 176
# for c2pnb176v1 and 160 for secp160k1), so prefer EC_GROUP_get_degree().
if int(backend.lib.EVP_PKEY_is_a(pkey, b"EC")) == 1:
ec_key = backend.lib.EVP_PKEY_get1_EC_KEY(pkey)
if ec_key != backend.ffi.NULL:
try:
group = backend.lib.EC_KEY_get0_group(ec_key)
degree = int(backend.lib.EC_GROUP_get_degree(group)) if group != backend.ffi.NULL else 0
if degree > 0:
return degree
finally:
backend.lib.EC_KEY_free(ec_key)
backend.lib.ERR_clear_error()
return int(backend.lib.EVP_PKEY_get_bits(pkey))
def _key_from_evp(backend: OpenSSLBackend, pkey: Any, private: bool) -> CompatKey:
key_size = _pkey_key_size(backend, pkey)
if key_size <= 0:
backend.raise_value_error("cannot determine the key size")
public_der = _pkey_public_der(backend, pkey)
private_der = _pkey_private_der(backend, pkey) if private else None
return CompatKey(
public_der=public_der,
private_der=private_der,
curve=_pkey_curve_name(backend, pkey),
key_size=key_size,
is_private=private,
)
def available_ec_curves() -> list[str]:
"""Return OpenSSL's compiled-in named EC curves, by canonical short name."""
backend = get_backend()
with backend.operation():
ffi = backend.ffi
lib = backend.lib
count = int(lib.EC_get_builtin_curves(ffi.NULL, 0))
if count <= 0:
backend.raise_value_error("cannot enumerate OpenSSL EC curves")
curves = ffi.new("EC_builtin_curve[]", count)
actual = int(lib.EC_get_builtin_curves(curves, count))
names: list[str] = []
for index in range(min(count, actual)):
short_name = lib.OBJ_nid2sn(curves[index].nid)
if short_name != ffi.NULL:
names.append(ffi.string(short_name).decode("ascii"))
return names
def generate_ec(name: str) -> CompatKey:
"""Generate a private EC key for any named curve provided by libcrypto."""
backend = get_backend()
curve_name = _ascii(name, "curve name")
with backend.operation():
ffi = backend.ffi
lib = backend.lib
nid = int(lib.OBJ_txt2nid(curve_name))
if nid == 0:
raise ValueError(f"unknown OpenSSL EC curve {name!r}")
ec_key = lib.EC_KEY_new_by_curve_name(nid)
if ec_key == ffi.NULL:
backend.raise_value_error(f"OpenSSL cannot initialize EC curve {name!r}")
try:
lib.EC_KEY_set_asn1_flag(ec_key, _OPENSSL_EC_NAMED_CURVE)
if int(lib.EC_KEY_generate_key(ec_key)) != 1:
backend.raise_value_error(f"OpenSSL cannot generate an EC key for {name!r}")
if int(lib.EC_KEY_check_key(ec_key)) != 1:
backend.raise_value_error("OpenSSL generated an invalid EC key")
pkey = lib.EVP_PKEY_new()
if pkey == ffi.NULL:
backend.raise_value_error("cannot allocate an EVP_PKEY")
try:
if int(lib.EVP_PKEY_set1_EC_KEY(pkey, ec_key)) != 1:
backend.raise_value_error("cannot wrap the generated EC key")
return _key_from_evp(backend, pkey, private=True)
finally:
lib.EVP_PKEY_free(pkey)
finally:
lib.EC_KEY_free(ec_key)
def private_key_from_der(
data: bytes, password: Optional[str | bytes] = None
) -> CompatKey:
"""Load a DER private key (traditional or PKCS#8, including encrypted PKCS#8)."""
backend = get_backend()
with backend.operation():
pkey = _read_der_private_evp(backend, data, password)
with _owned_evp(backend, pkey):
return _key_from_evp(backend, pkey, private=True)
def public_key_from_der(data: bytes) -> CompatKey:
"""Load a DER SubjectPublicKeyInfo or traditional PKCS#1 RSA public key."""
backend = get_backend()
with backend.operation():
pkey = _read_der_public_evp(backend, data)
with _owned_evp(backend, pkey):
return _key_from_evp(backend, pkey, private=False)
def load_key_bytes(data: bytes, password: Optional[str | bytes] = None) -> CompatKey:
"""Load a private or public key from PEM or DER.
Private parsing is attempted first so encrypted private keys never get
mistaken for public data. The returned :class:`CompatKey` records whether
a private component was present.
"""
backend = get_backend()
raw = _bytes(data, "key")
is_pem = raw.lstrip().startswith(b"-----BEGIN")
with backend.operation():
errors: list[ValueError] = []
readers: tuple[tuple[Any, bool], ...] = (
(_read_pem_private_evp, True),
(_read_pem_public_evp, False),
) if is_pem else (
(_read_der_private_evp, True),
(_read_der_public_evp, False),
)
for reader, private in readers:
try:
if private:
pkey = reader(backend, raw, password)
else:
pkey = reader(backend, raw)
except ValueError as exc:
errors.append(exc)
backend.lib.ERR_clear_error()
continue
with _owned_evp(backend, pkey):
return _key_from_evp(backend, pkey, private=private)
raise ValueError("unable to parse key as PEM/DER private or public key") from errors[-1]
def _require_private(key: CompatKey) -> bytes:
if not isinstance(key, CompatKey):
raise TypeError("key must be a CompatKey")
if not key.is_private or key.private_der is None:
raise ValueError("a private CompatKey is required")
return key.private_der
def _encryption_requested(password: Optional[str | bytes]) -> bool:
if password is None:
return False
if isinstance(password, (str, bytes, bytearray, memoryview)):
# Match the native KCA serialization API: an empty password selects
# NoEncryption rather than producing an unreadable empty-passphrase PEM.
return len(password) > 0
raise TypeError("password must be str, bytes, or None")
def public_pem(key: CompatKey) -> str:
"""Serialize the public component as a SubjectPublicKeyInfo PEM string."""
if not isinstance(key, CompatKey):
raise TypeError("key must be a CompatKey")
backend = get_backend()
with backend.operation():
pkey = _read_der_public_evp(backend, key.public_der)
with _owned_evp(backend, pkey):
with backend.output_bio() as bio:
if int(backend.lib.PEM_write_bio_PUBKEY(bio, pkey)) != 1:
backend.raise_value_error("cannot encode public PEM")
return backend.output_bytes(bio).decode("ascii")
def private_pem(
key: CompatKey,
password: Optional[str | bytes] = None,
traditional: bool = False,
) -> bytes:
"""Serialize a private key as PKCS#8 PEM, or traditional OpenSSL PEM.
A non-empty ``password`` selects AES-256-CBC encryption. Traditional
encryption is the legacy PEM ``DEK-Info`` form; PKCS#8 uses PBES2/PBKDF2 as
provided by OpenSSL. ``None`` and an empty password produce unencrypted PEM.
"""
private_der = _require_private(key)
backend = get_backend()
with backend.operation():
pkey = _read_der_private_evp(backend, private_der, None)
with _owned_evp(backend, pkey):
with backend.password(password) as state:
encrypt = _encryption_requested(password)
cipher = backend.lib.EVP_aes_256_cbc() if encrypt else backend.ffi.NULL
if encrypt and cipher == backend.ffi.NULL:
backend.raise_value_error("AES-256-CBC is unavailable")
with backend.output_bio() as bio:
if traditional:
result = backend.lib.PEM_write_bio_PrivateKey_traditional(
bio,
pkey,
cipher,
state.data if encrypt else backend.ffi.NULL,
int(state.length) if encrypt else 0,
backend.ffi.NULL,
backend.ffi.NULL,
)
else:
result = backend.lib.PEM_write_bio_PKCS8PrivateKey(
bio,
pkey,
backend.ffi.cast("const EVP_CIPHER *", cipher),
backend.ffi.cast("char *", state.data) if encrypt else backend.ffi.NULL,
int(state.length) if encrypt else 0,
backend.ffi.NULL,
backend.ffi.NULL,
)
if int(result) != 1:
backend.raise_value_error("cannot encode private PEM")
return backend.output_bytes(bio)
def private_der_bytes(
key: CompatKey, password: Optional[str | bytes] = None
) -> bytes:
"""Serialize private PKCS#8 DER, encrypting with AES-256-CBC when requested."""
private_der = _require_private(key)
backend = get_backend()
with backend.operation():
pkey = _read_der_private_evp(backend, private_der, None)
with _owned_evp(backend, pkey):
with backend.password(password) as state:
return _pkey_private_der(
backend, pkey, state, encrypt=_encryption_requested(password)
)
def certificate_public_der(cert_der: bytes) -> bytes:
"""Extract a certificate's SubjectPublicKeyInfo without decoding its key in Python."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
cert_der, backend.lib.d2i_X509, backend.lib.X509_free, "DER certificate"
) as cert:
pkey = backend.lib.X509_get_pubkey(cert)
if pkey == backend.ffi.NULL:
backend.raise_value_error("certificate has no supported public key")
with _owned_evp(backend, pkey):
return _pkey_public_der(backend, pkey)
def csr_public_der(csr_der: bytes) -> bytes:
"""Extract a PKCS#10 request's SubjectPublicKeyInfo."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
csr_der, backend.lib.d2i_X509_REQ, backend.lib.X509_REQ_free, "DER CSR"
) as req:
pkey = backend.lib.X509_REQ_get_pubkey(req)
if pkey == backend.ffi.NULL:
backend.raise_value_error("CSR has no supported public key")
with _owned_evp(backend, pkey):
return _pkey_public_der(backend, pkey)
def spki_key_identifier(spki_der: bytes) -> bytes:
"""Return RFC 5280 method 1 SKI: SHA-1 of the subjectPublicKey BIT STRING payload.
The tag, length, and unused-bits octet are not hashed, and the enclosing
SubjectPublicKeyInfo sequence is not hashed either.
"""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
spki_der,
backend.lib.d2i_X509_PUBKEY,
backend.lib.X509_PUBKEY_free,
"DER SubjectPublicKeyInfo",
) as spki:
public_bytes = backend.ffi.new("const unsigned char **")
public_length = backend.ffi.new("int *")
result = backend.lib.X509_PUBKEY_get0_param(
backend.ffi.NULL,
public_bytes,
public_length,
backend.ffi.NULL,
spki,
)
if int(result) != 1 or public_bytes[0] == backend.ffi.NULL:
backend.raise_value_error("cannot extract the subjectPublicKey BIT STRING")
length = int(public_length[0])
if length < 0:
raise ValueError("invalid subjectPublicKey length")
payload = backend.ffi.buffer(public_bytes[0], length)[:]
return hashlib.sha1(payload, usedforsecurity=False).digest()
def _digest(backend: OpenSSLBackend, digest: Optional[str]) -> Any:
if digest is None:
# Ed25519 and other one-shot signature algorithms require a null EVP_MD.
return backend.ffi.NULL
name = _ascii(digest, "digest")
md = backend.lib.EVP_get_digestbyname(name)
if md == backend.ffi.NULL:
backend.lib.ERR_clear_error()
raise ValueError(f"unsupported OpenSSL digest {digest!r}")
return md
def resign_certificate(
template_der: bytes,
subject_public_der: bytes,
issuer_private_der: bytes,
digest: Optional[str],
) -> bytes:
"""Replace a certificate subject key, re-sign in place, and return new DER."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
template_der, backend.lib.d2i_X509, backend.lib.X509_free, "DER certificate template"
) as cert:
subject = _read_der_public_evp(backend, subject_public_der)
with _owned_evp(backend, subject):
if int(backend.lib.X509_set_pubkey(cert, subject)) != 1:
backend.raise_value_error("cannot replace the certificate public key")
issuer = _read_der_private_evp(backend, issuer_private_der, None)
with _owned_evp(backend, issuer):
if int(backend.lib.X509_sign(cert, issuer, _digest(backend, digest))) <= 0:
backend.raise_value_error("cannot sign the certificate")
with backend.output_bio() as bio:
if int(backend.lib.i2d_X509_bio(bio, cert)) != 1:
backend.raise_value_error("cannot encode the signed certificate")
return backend.output_bytes(bio)
def resign_csr(
template_der: bytes,
subject_public_der: bytes,
private_der: bytes,
digest: Optional[str],
) -> bytes:
"""Replace a CSR subject key, re-sign with the private key, and return DER."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
template_der, backend.lib.d2i_X509_REQ, backend.lib.X509_REQ_free, "DER CSR template"
) as req:
subject = _read_der_public_evp(backend, subject_public_der)
with _owned_evp(backend, subject):
if int(backend.lib.X509_REQ_set_pubkey(req, subject)) != 1:
backend.raise_value_error("cannot replace the CSR public key")
private = _read_der_private_evp(backend, private_der, None)
with _owned_evp(backend, private):
if int(backend.lib.X509_REQ_sign(req, private, _digest(backend, digest))) <= 0:
backend.raise_value_error("cannot sign the CSR")
with backend.output_bio() as bio:
if int(backend.lib.i2d_X509_REQ_bio(bio, req)) != 1:
backend.raise_value_error("cannot encode the signed CSR")
return backend.output_bytes(bio)
def resign_crl(template_der: bytes, issuer_private_der: bytes, digest: Optional[str]) -> bytes:
"""Re-sign a DER CRL template with the supplied issuer private key."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
template_der, backend.lib.d2i_X509_CRL, backend.lib.X509_CRL_free, "DER CRL template"
) as crl:
issuer = _read_der_private_evp(backend, issuer_private_der, None)
with _owned_evp(backend, issuer):
if int(backend.lib.X509_CRL_sign(crl, issuer, _digest(backend, digest))) <= 0:
backend.raise_value_error("cannot sign the CRL")
with backend.output_bio() as bio:
if int(backend.lib.i2d_X509_CRL_bio(bio, crl)) != 1:
backend.raise_value_error("cannot encode the signed CRL")
return backend.output_bytes(bio)
def _verification_result(backend: OpenSSLBackend, result: int, description: str) -> bool:
if result == 1:
return True
if result == 0:
# A normal bad signature is not exceptional and must not become a
# misleading "unsupported algorithm" error.
backend.lib.ERR_clear_error()
return False
messages = backend.error_messages()
# X509_verify() uses -1 rather than 0 when an otherwise valid issuer key
# has a different type from the signature algorithm (for example, an EC
# key supplied for an RSA-signed certificate). That is still simply a
# failed verification with the supplied key, not malformed input.
mismatch_markers = ("wrong public key type", "key type mismatch")
if any(
marker in message.lower()
for message in messages
for marker in mismatch_markers
):
return False
detail = f": {'; '.join(messages)}" if messages else ""
raise ValueError(f"cannot verify {description}{detail}")
def verify_certificate(cert_der: bytes, issuer_cert_der: bytes) -> bool:
"""Verify only a certificate signature with the issuer certificate's key."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
cert_der, backend.lib.d2i_X509, backend.lib.X509_free, "DER certificate"
) as cert:
with backend.decoded_der(
issuer_cert_der,
backend.lib.d2i_X509,
backend.lib.X509_free,
"DER issuer certificate",
) as issuer_cert:
issuer = backend.lib.X509_get_pubkey(issuer_cert)
if issuer == backend.ffi.NULL:
backend.raise_value_error("issuer certificate has no supported public key")
with _owned_evp(backend, issuer):
result = int(backend.lib.X509_verify(cert, issuer))
return _verification_result(backend, result, "certificate signature")
def verify_csr(csr_der: bytes) -> bool:
"""Verify a CSR signature with its contained subject public key."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
csr_der, backend.lib.d2i_X509_REQ, backend.lib.X509_REQ_free, "DER CSR"
) as req:
public = backend.lib.X509_REQ_get_pubkey(req)
if public == backend.ffi.NULL:
backend.raise_value_error("CSR has no supported public key")
with _owned_evp(backend, public):
result = int(backend.lib.X509_REQ_verify(req, public))
return _verification_result(backend, result, "CSR signature")
def verify_crl(crl_der: bytes, issuer_cert_der: bytes) -> bool:
"""Verify only a CRL signature with the issuer certificate's key."""
backend = get_backend()
with backend.operation():
with backend.decoded_der(
crl_der, backend.lib.d2i_X509_CRL, backend.lib.X509_CRL_free, "DER CRL"
) as crl:
with backend.decoded_der(
issuer_cert_der,
backend.lib.d2i_X509,
backend.lib.X509_free,
"DER issuer certificate",
) as issuer_cert:
issuer = backend.lib.X509_get_pubkey(issuer_cert)
if issuer == backend.ffi.NULL:
backend.raise_value_error("issuer certificate has no supported public key")
with _owned_evp(backend, issuer):
result = int(backend.lib.X509_CRL_verify(crl, issuer))
return _verification_result(backend, result, "CRL signature")
def _pkcs12_cstring(value: Optional[str | bytes], description: str) -> tuple[Optional[bytes], bool]:
if value is None:
return None, False
if isinstance(value, str):
raw = value.encode("utf-8")
elif isinstance(value, (bytes, bytearray, memoryview)):
raw = bytes(value)
else:
raise TypeError(f"{description} must be str, bytes, or None")
if b"\0" in raw:
raise ValueError(f"PKCS#12 {description} cannot contain a NUL byte")
return raw, True
def load_pkcs12(
data: bytes, password: Optional[str | bytes]
) -> tuple[Optional[bytes], Optional[bytes], list[bytes]]:
"""Parse PKCS#12 DER into ``(private_pkcs8, main_certificate, ca_certificates)``.
OpenSSL's PKCS#12 API uses C strings for passphrases, so embedded NUL bytes
are rejected. Wrong passwords and malformed containers raise ``ValueError``
without including the supplied password in the error.
"""
backend = get_backend()
raw_password, supplied = _pkcs12_cstring(password, "password")
with backend.operation():
with backend.decoded_der(
data, backend.lib.d2i_PKCS12, backend.lib.PKCS12_free, "DER PKCS#12 container"
) as p12:
ffi = backend.ffi
lib = backend.lib
password_buf = ffi.new("char[]", (raw_password or b"") + b"\0") if supplied else ffi.NULL
pkey_out = ffi.new("EVP_PKEY **")
cert_out = ffi.new("X509 **")
ca_out = ffi.new("OPENSSL_STACK **")
try:
if int(lib.PKCS12_parse(p12, password_buf, pkey_out, cert_out, ca_out)) != 1:
lib.ERR_clear_error()
raise ValueError("cannot parse PKCS#12 (wrong password or unsupported content)")
private_der: Optional[bytes] = None
cert_der: Optional[bytes] = None
ca_ders: list[bytes] = []
if pkey_out[0] != ffi.NULL:
private_der = _pkey_private_der(backend, pkey_out[0])
if cert_out[0] != ffi.NULL:
with backend.output_bio() as bio:
if int(lib.i2d_X509_bio(bio, cert_out[0])) != 1:
backend.raise_value_error("cannot encode the PKCS#12 certificate")
cert_der = backend.output_bytes(bio)
if ca_out[0] != ffi.NULL:
count = int(lib.OPENSSL_sk_num(ca_out[0]))
for index in range(count):
cert_ptr = lib.OPENSSL_sk_value(ca_out[0], index)
if cert_ptr == ffi.NULL:
continue
with backend.output_bio() as bio:
if int(lib.i2d_X509_bio(bio, ffi.cast("X509 *", cert_ptr))) != 1:
backend.raise_value_error("cannot encode a PKCS#12 CA certificate")
ca_ders.append(backend.output_bytes(bio))
return private_der, cert_der, ca_ders
finally:
if pkey_out[0] != ffi.NULL:
lib.EVP_PKEY_free(pkey_out[0])
if cert_out[0] != ffi.NULL:
lib.X509_free(cert_out[0])
if ca_out[0] != ffi.NULL:
count = int(lib.OPENSSL_sk_num(ca_out[0]))
for index in range(count):
cert_ptr = lib.OPENSSL_sk_value(ca_out[0], index)
if cert_ptr != ffi.NULL:
lib.X509_free(ffi.cast("X509 *", cert_ptr))
lib.OPENSSL_sk_free(ca_out[0])
if supplied and raw_password:
ffi.memmove(password_buf, b"\0" * (len(raw_password) + 1), len(raw_password) + 1)
def serialize_pkcs12(
name: str,
private_pkcs8_der: bytes,
cert_der: bytes,
ca_cert_ders: Iterable[bytes],
password: Optional[str | bytes],
) -> bytes:
"""Create an in-memory PKCS#12 container with AES-256 and SHA-256.
A non-empty password selects PBES2/PBKDF2 with AES-256-CBC for both key and
certificate encryption, plus an HMAC-SHA-256 integrity MAC. ``None`` and
an empty password preserve cryptography's NoEncryption behavior. ``name``
is the OpenSSL friendlyName attached to the key and main certificate.
"""
backend = get_backend()
raw_name, _ = _pkcs12_cstring(name, "friendly name")
raw_password, _ = _pkcs12_cstring(password, "password")
encrypt = bool(raw_password)
if raw_name is None:
raise TypeError("PKCS#12 friendly name cannot be None")
with backend.operation():
ffi = backend.ffi
lib = backend.lib
name_buf = ffi.new("char[]", raw_name + b"\0")
password_buf = ffi.new("char[]", (raw_password or b"") + b"\0") if encrypt else ffi.NULL
ca_stack = ffi.NULL
ca_owned: list[Any] = []
try:
pkey = _read_der_private_evp(backend, private_pkcs8_der, None)
with _owned_evp(backend, pkey):
with backend.decoded_der(
cert_der, lib.d2i_X509, lib.X509_free, "DER PKCS#12 certificate"
) as cert:
ca_list = list(ca_cert_ders)
if ca_list:
ca_stack = lib.OPENSSL_sk_new_null()
if ca_stack == ffi.NULL:
backend.raise_value_error("cannot allocate the PKCS#12 CA stack")
for ca_der in ca_list:
# Keep owning pointers until PKCS12_create has consumed
# the stack; the stack itself does not own these entries.
ca_cert = backend.decode_der(
ca_der, lib.d2i_X509, lib.X509_free, "DER PKCS#12 CA certificate"
)
ca_owned.append(ca_cert)
if int(lib.OPENSSL_sk_push(ca_stack, ca_cert)) <= 0:
backend.raise_value_error("cannot build the PKCS#12 CA stack")
aes_nid = int(lib.OBJ_txt2nid(b"AES-256-CBC"))
if aes_nid == 0:
raise ValueError("OpenSSL does not provide AES-256-CBC for PKCS#12")
p12 = lib.PKCS12_create(
password_buf,
name_buf,
pkey,
cert,
ca_stack,
aes_nid if encrypt else -1,
aes_nid if encrypt else -1,
_PKCS12_ITERATIONS,
_PKCS12_ITERATIONS,
0,
)
if p12 == ffi.NULL:
backend.raise_value_error("cannot create PKCS#12")
with _owned(backend, p12, lib.PKCS12_free):
# Explicitly set SHA-256 for OpenSSL versions whose
# PKCS12_create default MAC is still SHA-1.
sha256 = lib.EVP_get_digestbyname(b"sha256")
if sha256 == ffi.NULL:
raise ValueError("OpenSSL does not provide SHA-256 for PKCS#12")
if int(
lib.PKCS12_set_mac(
p12,
password_buf,
len(raw_password) if encrypt and raw_password is not None else -1,
ffi.NULL,
0,
_PKCS12_ITERATIONS,
sha256,
)
) != 1:
backend.raise_value_error("cannot protect the PKCS#12 MAC")
with backend.output_bio() as bio:
if int(lib.i2d_PKCS12_bio(bio, p12)) != 1:
backend.raise_value_error("cannot encode PKCS#12")
return backend.output_bytes(bio)
finally:
for ca_cert in ca_owned:
lib.X509_free(ca_cert)
if ca_stack != ffi.NULL:
lib.OPENSSL_sk_free(ca_stack)
if encrypt and raw_password:
ffi.memmove(password_buf, b"\0" * (len(raw_password) + 1), len(raw_password) + 1)
__all__ = [
"CompatKey",
"OpenSSLUnavailableError",
"available_ec_curves",
"backend_available",
"certificate_public_der",
"csr_public_der",
"generate_ec",
"library_path",
"load_key_bytes",
"load_pkcs12",
"openssl_version",
"private_der_bytes",
"private_key_from_der",
"private_pem",
"public_key_from_der",
"public_pem",
"resign_certificate",
"resign_crl",
"resign_csr",
"serialize_pkcs12",
"spki_key_identifier",
"verify_certificate",
"verify_crl",
"verify_csr",
]
@@ -6,13 +6,30 @@ from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional from typing import Any, Dict, Iterable, List, Mapping, Optional
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.name import _ASN1Type from cryptography.x509.name import _ASN1Type
from cryptography.x509.oid import ObjectIdentifier from cryptography.x509.oid import ObjectIdentifier
from . import openssl_compat
from .algorithms import validate_signature_hash
from .extensions import build_extensions, extension_config_lines, extension_rows, extension_specs from .extensions import build_extensions, extension_config_lines, extension_rows, extension_specs
from .keys import PrivateKey, PublicKey, hash_algorithm, public_der from .keys import (
PrivateKey,
PublicKey,
hash_algorithm,
is_compat_key,
key_type_name,
private_der,
public_der,
public_key_from_der,
public_part,
)
from .names import entries_from_name, name_from_entries from .names import entries_from_name, name_from_entries
from .signatures import (
signature_algorithm_name as oid_signature_algorithm_name,
signature_hash_name as oid_signature_hash_name,
)
CHALLENGE_PASSWORD_OID = "1.2.840.113549.1.9.7" CHALLENGE_PASSWORD_OID = "1.2.840.113549.1.9.7"
UNSTRUCTURED_NAME_OID = "1.2.840.113549.1.9.2" UNSTRUCTURED_NAME_OID = "1.2.840.113549.1.9.2"
@@ -37,10 +54,10 @@ def build_csr(
subject: x509.Name, subject: x509.Name,
private_key: PrivateKey, private_key: PrivateKey,
extension_spec: Optional[Mapping[str, Any]] = None, extension_spec: Optional[Mapping[str, Any]] = None,
signature_hash: Optional[str] = "sha256", signature_hash: Optional[str] = None,
attributes: Optional[Mapping[str, str]] = None, attributes: Optional[Mapping[str, str]] = None,
) -> x509.CertificateSigningRequest: ) -> x509.CertificateSigningRequest:
public_key = private_key.public_key() public_key = public_part(private_key)
extensions = build_extensions( extensions = build_extensions(
extension_spec, extension_spec,
public_key=public_key, public_key=public_key,
@@ -58,6 +75,9 @@ def build_csr(
x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME, x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME,
) )
] ]
normalized_hash = validate_signature_hash(key_type_name(private_key), signature_hash)
compat_signing = is_compat_key(private_key) or normalized_hash in {"md5", "ripemd160", "sha1"}
template_key = ec.generate_private_key(ec.SECP256R1()) if compat_signing else None
builder = x509.CertificateSigningRequestBuilder().subject_name(subject) builder = x509.CertificateSigningRequestBuilder().subject_name(subject)
for extension in extensions: for extension in extensions:
builder = builder.add_extension(extension.value, extension.critical) builder = builder.add_extension(extension.value, extension.critical)
@@ -68,8 +88,17 @@ def build_csr(
str(value).encode("utf-8"), str(value).encode("utf-8"),
_tag=_ASN1Type.UTF8String, _tag=_ASN1Type.UTF8String,
) )
algorithm = hash_algorithm(signature_hash, private_key) if template_key is None:
return builder.sign(private_key, algorithm) algorithm = hash_algorithm(normalized_hash, private_key)
return builder.sign(private_key, algorithm)
template = builder.sign(template_key, hashes.SHA256())
result_der = openssl_compat.resign_csr(
csr_der(template),
public_der(public_key),
private_der(private_key),
normalized_hash,
)
return parse_csr(result_der)
def parse_csr(data: bytes) -> x509.CertificateSigningRequest: def parse_csr(data: bytes) -> x509.CertificateSigningRequest:
@@ -85,37 +114,36 @@ def csr_pem(csr: x509.CertificateSigningRequest) -> str:
def verify_csr(csr: x509.CertificateSigningRequest) -> bool: def verify_csr(csr: x509.CertificateSigningRequest) -> bool:
return bool(csr.is_signature_valid) try:
if csr.is_signature_valid:
return True
except Exception: # noqa: BLE001 - use OpenSSL for unsupported curves/hashes
pass
try:
return openssl_compat.verify_csr(csr_der(csr))
except Exception: # noqa: BLE001
return False
def csr_public_der(csr: x509.CertificateSigningRequest) -> bytes:
try:
return public_der(csr.public_key())
except Exception: # noqa: BLE001 - cryptography lacks this curve
return openssl_compat.csr_public_der(csr_der(csr))
def request_info(csr: x509.CertificateSigningRequest) -> RequestInfo: def request_info(csr: x509.CertificateSigningRequest) -> RequestInfo:
public_key = csr.public_key()
return RequestInfo( return RequestInfo(
subject=csr.subject, subject=csr.subject,
signature_algorithm=_signature_name(csr), signature_algorithm=_signature_name(csr),
public_key_der=public_der(public_key), public_key_der=csr_public_der(csr),
fingerprint_sha256=hashlib.sha256(csr_der(csr)).hexdigest(), fingerprint_sha256=hashlib.sha256(csr_der(csr)).hexdigest(),
attributes=attribute_rows(csr), attributes=attribute_rows(csr),
) )
_SIGNATURE_OIDS = {
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.1.14": "sha224WithRSAEncryption",
"1.2.840.10045.4.1": "ecdsa-with-SHA1",
"1.2.840.10045.4.3.2": "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3": "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4": "ecdsa-with-SHA512",
"1.3.101.112": "Ed25519",
}
def _signature_name(csr: x509.CertificateSigningRequest) -> str: def _signature_name(csr: x509.CertificateSigningRequest) -> str:
oid = csr.signature_algorithm_oid return oid_signature_algorithm_name(csr.signature_algorithm_oid)
return _SIGNATURE_OIDS.get(oid.dotted_string, getattr(oid, "name", None) or oid.dotted_string)
def _decode_der_string(value: bytes) -> Optional[str]: def _decode_der_string(value: bytes) -> Optional[str]:
@@ -176,14 +204,17 @@ def csr_spec(csr: x509.CertificateSigningRequest) -> Dict[str, Any]:
"signature_hash": _hash_name(csr), "signature_hash": _hash_name(csr),
"extensions": extension_specs(csr.extensions), "extensions": extension_specs(csr.extensions),
"attributes": attribute_values(csr), "attributes": attribute_values(csr),
"public_key_der": public_der(csr.public_key()).hex(), "public_key_der": csr_public_der(csr).hex(),
} }
def _hash_name(csr: x509.CertificateSigningRequest) -> Optional[str]: def _hash_name(csr: x509.CertificateSigningRequest) -> Optional[str]:
from_oid = oid_signature_hash_name(csr.signature_algorithm_oid)
if from_oid is not None:
return from_oid
try: try:
algorithm = csr.signature_hash_algorithm algorithm = csr.signature_hash_algorithm
except Exception: # noqa: BLE001 - Ed25519 except Exception: # noqa: BLE001 - Ed25519 or an unknown legacy OID
return None return None
return algorithm.name if algorithm is not None else None return algorithm.name if algorithm is not None else None
@@ -197,7 +228,10 @@ def csr_extension_specs(csr: x509.CertificateSigningRequest) -> Dict[str, Any]:
def csr_public_key(csr: x509.CertificateSigningRequest) -> PublicKey: def csr_public_key(csr: x509.CertificateSigningRequest) -> PublicKey:
return csr.public_key() try:
return csr.public_key()
except Exception: # noqa: BLE001 - use the OpenSSL-backed key facade
return public_key_from_der(csr_public_der(csr))
def openssl_config(csr: x509.CertificateSigningRequest) -> str: def openssl_config(csr: x509.CertificateSigningRequest) -> str:
@@ -229,6 +263,7 @@ __all__ = [
"csr_extension_specs", "csr_extension_specs",
"csr_extensions", "csr_extensions",
"csr_pem", "csr_pem",
"csr_public_der",
"csr_public_key", "csr_public_key",
"csr_spec", "csr_spec",
"openssl_config", "openssl_config",
@@ -0,0 +1,80 @@
"""Signature AlgorithmIdentifier names and digest lookup.
``cryptography`` intentionally does not expose hash objects for every legacy
signature OID. KCA still needs to display, renew and transform those objects,
so this small OID table is independent of its signing implementation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class SignatureInfo:
name: str
key_type: str
hash_name: Optional[str]
def _signature(name: str, key_type: str, hash_name: Optional[str]) -> SignatureInfo:
return SignatureInfo(name, key_type, hash_name)
SIGNATURE_OIDS: Dict[str, SignatureInfo] = {
# RSA PKCS#1 v1.5.
"1.2.840.113549.1.1.4": _signature("md5WithRSAEncryption", "rsa", "md5"),
"1.2.840.113549.1.1.5": _signature("sha1WithRSAEncryption", "rsa", "sha1"),
"1.2.840.113549.1.1.14": _signature("sha224WithRSAEncryption", "rsa", "sha224"),
"1.2.840.113549.1.1.11": _signature("sha256WithRSAEncryption", "rsa", "sha256"),
"1.2.840.113549.1.1.12": _signature("sha384WithRSAEncryption", "rsa", "sha384"),
"1.2.840.113549.1.1.13": _signature("sha512WithRSAEncryption", "rsa", "sha512"),
"1.3.36.3.3.1.2": _signature("ripemd160WithRSA", "rsa", "ripemd160"),
# NIST RSA/SHA-3 signature algorithms.
"2.16.840.1.101.3.4.3.13": _signature("RSA-SHA3-224", "rsa", "sha3-224"),
"2.16.840.1.101.3.4.3.14": _signature("RSA-SHA3-256", "rsa", "sha3-256"),
"2.16.840.1.101.3.4.3.15": _signature("RSA-SHA3-384", "rsa", "sha3-384"),
"2.16.840.1.101.3.4.3.16": _signature("RSA-SHA3-512", "rsa", "sha3-512"),
# ECDSA.
"1.2.840.10045.4.1": _signature("ecdsa-with-SHA1", "ec", "sha1"),
"1.2.840.10045.4.3.1": _signature("ecdsa-with-SHA224", "ec", "sha224"),
"1.2.840.10045.4.3.2": _signature("ecdsa-with-SHA256", "ec", "sha256"),
"1.2.840.10045.4.3.3": _signature("ecdsa-with-SHA384", "ec", "sha384"),
"1.2.840.10045.4.3.4": _signature("ecdsa-with-SHA512", "ec", "sha512"),
"2.16.840.1.101.3.4.3.9": _signature("ecdsa_with_SHA3-224", "ec", "sha3-224"),
"2.16.840.1.101.3.4.3.10": _signature("ecdsa_with_SHA3-256", "ec", "sha3-256"),
"2.16.840.1.101.3.4.3.11": _signature("ecdsa_with_SHA3-384", "ec", "sha3-384"),
"2.16.840.1.101.3.4.3.12": _signature("ecdsa_with_SHA3-512", "ec", "sha3-512"),
# DSA.
"1.2.840.10040.4.3": _signature("dsa_with_SHA1", "dsa", "sha1"),
"2.16.840.1.101.3.4.3.1": _signature("dsa_with_SHA224", "dsa", "sha224"),
"2.16.840.1.101.3.4.3.2": _signature("dsa_with_SHA256", "dsa", "sha256"),
# Ed25519 has no separately encoded digest.
"1.3.101.112": _signature("Ed25519", "ed25519", None),
}
def signature_info(oid: object) -> Optional[SignatureInfo]:
dotted = getattr(oid, "dotted_string", str(oid))
return SIGNATURE_OIDS.get(dotted)
def signature_algorithm_name(oid: object) -> str:
dotted = getattr(oid, "dotted_string", str(oid))
info = SIGNATURE_OIDS.get(dotted)
if info is not None:
return info.name
name = getattr(oid, "_name", None) or getattr(oid, "name", None)
if name and name not in ("Unknown OID", "Undefined"):
return str(name)
return dotted
def signature_hash_name(oid: object) -> Optional[str]:
info = signature_info(oid)
if info is not None:
return info.hash_name
return None
__all__ = ["SIGNATURE_OIDS", "SignatureInfo", "signature_algorithm_name", "signature_hash_name", "signature_info"]
@@ -17,14 +17,14 @@ from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, modes from cryptography.hazmat.primitives.ciphers import Cipher, modes
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
from .certificates import parse_certificate from .certificates import parse_certificate
from .crls import parse_crl from .crls import parse_crl
from .imports import ParsedItem from .imports import ParsedItem
from .keys import is_private_key, load_key_bytes from .keys import is_private_key, load_key_bytes, public_key_from_der
from .requests import parse_csr from .requests import parse_csr
from .templates import template_from_xca_payload from .templates import template_from_xca_payload
@@ -91,16 +91,18 @@ def _decrypt_private(encoded: str, password: Optional[str]) -> Optional[Any]:
return None return None
# Modern XCA: PKCS#8 EncryptedPrivateKeyInfo. # Modern XCA: PKCS#8 EncryptedPrivateKeyInfo.
try: try:
return serialization.load_der_private_key( key, private = load_key_bytes(data, password)
data, password=password.encode() if password else None if private:
) return key
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
# Pre-2.0 legacy: 8-byte IV + 3DES-CBC(traditional DER private key). # Pre-2.0 legacy: 8-byte IV + 3DES-CBC(traditional DER private key).
legacy = _legacy_decrypt(data, password) legacy = _legacy_decrypt(data, password)
if legacy: if legacy:
try: try:
return serialization.load_der_private_key(legacy, password=None) key, private = load_key_bytes(legacy)
if private:
return key
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
return None return None
@@ -183,7 +185,7 @@ def _read_database(
continue continue
public_row = public_rows[item_id] public_row = public_rows[item_id]
try: try:
public_key = serialization.load_der_public_key(base64.b64decode(public_row["public"])) public_key = public_key_from_der(base64.b64decode(public_row["public"]))
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
continue continue
key = public_key key = public_key
@@ -0,0 +1,339 @@
"""End-to-end core workflows for XCA curve and signature-hash parity."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from kca.core import algorithms, certificates, crls, exports, imports, keys, requests
from kca.core.extensions import default_extension_spec
from kca.core.names import entries_from_json, name_from_entries
def _name(cn: str = "Algorithm parity CA") -> x509.Name:
return name_from_entries(entries_from_json([{"name": "CN", "value": cn}]))
def _ca_spec() -> dict:
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
spec["key_usage"] = {"critical": True, "usages": ["digital_signature", "key_cert_sign", "crl_sign"]}
spec["subject_key_identifier"] = {"critical": False}
spec["authority_key_identifier"] = {"critical": False}
return spec
class AlgorithmWorkflowTest(unittest.TestCase):
maxDiff = None
def assert_workflow(self, key: object, signature_hash: str | None, curve: str | None = None) -> None:
now = datetime.now(timezone.utc)
public = keys.public_part(key)
public_der = keys.public_der(public)
csr = requests.build_csr(
subject=_name("Algorithm parity CSR"),
private_key=key,
extension_spec=_ca_spec(),
signature_hash=signature_hash,
attributes={requests.CHALLENGE_PASSWORD_OID: "challenge"},
)
self.assertTrue(requests.verify_csr(csr))
request_info = requests.request_info(csr)
self.assertEqual(request_info.public_key_der, public_der)
self.assertEqual(requests.csr_spec(csr)["signature_hash"], signature_hash)
self.assertEqual(requests.attribute_values(csr)[requests.CHALLENGE_PASSWORD_OID], "challenge")
cert = certificates.build_self_signed(
subject=_name(),
private_key=key,
serial=1,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
signature_hash=signature_hash,
)
self.assertTrue(certificates.is_self_signed(cert))
self.assertTrue(certificates.verify_directly_issued_by(cert, cert))
cert_info = certificates.certificate_info(cert)
self.assertEqual(cert_info.public_key_der, public_der)
self.assertEqual(certificates.signature_hash_name(cert), signature_hash)
ski = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
self.assertEqual(ski.digest, keys.public_key_identifier(public))
aki = cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
self.assertEqual(aki.key_identifier, ski.digest)
crl = crls.build_crl(
issuer_cert=cert,
issuer_private_key=key,
revocations=[],
last_update=now,
next_update=now + timedelta(days=7),
crl_number=1,
signature_hash=signature_hash,
)
self.assertTrue(crls.verify_crl(crl, cert))
self.assertEqual(crl.signature_algorithm_oid, cert.signature_algorithm_oid)
expected_curve = curve if curve is not None else keys.curve_name(key)
reloaded_private = keys.private_key_from_der(keys.private_der(key))
self.assertEqual(keys.public_der(reloaded_private), public_der)
self.assertEqual(keys.curve_name(reloaded_private), expected_curve)
reloaded_public = keys.public_key_from_der(public_der)
self.assertEqual(keys.public_der(reloaded_public), public_der)
self.assertEqual(keys.curve_name(reloaded_public), expected_curve)
def test_every_xca_curve(self) -> None:
self.assertEqual(len(algorithms.CURVES), 79)
for curve in algorithms.CURVES:
with self.subTest(curve=curve.name):
key = keys.generate_key("ec", curve=curve.name)
info = keys.key_info(key)
self.assertEqual(info.key_type, "EC")
self.assertEqual(info.curve, curve.name)
self.assertTrue(info.is_private)
self.assert_workflow(key, "sha256", curve.name)
def test_mixed_native_and_compat_issuers(self) -> None:
now = datetime.now(timezone.utc)
for ca_curve, leaf_curve in (("secp112r1", "secp256r1"), ("secp256r1", "sect163k1")):
with self.subTest(ca_curve=ca_curve, leaf_curve=leaf_curve):
ca_key = keys.generate_key("ec", curve=ca_curve)
leaf_key = keys.generate_key("ec", curve=leaf_curve)
ca = certificates.build_self_signed(
subject=_name("Mixed CA"),
private_key=ca_key,
serial=10,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
leaf_spec = default_extension_spec()
leaf_spec["subject_key_identifier"] = {"critical": False}
leaf_spec["authority_key_identifier"] = {"critical": False}
leaf = certificates.build_signed(
subject=_name("Mixed leaf"),
public_key=keys.public_part(leaf_key),
issuer_cert=ca,
issuer_private_key=ca_key,
serial=11,
not_before=now,
not_after=now + timedelta(days=10),
extension_spec=leaf_spec,
)
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
self.assertFalse(certificates.verify_directly_issued_by(leaf, leaf))
self.assertEqual(certificates.certificate_info(leaf).public_key_der, keys.public_der(leaf_key))
ca_ski = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
leaf_aki = leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
self.assertEqual(leaf_aki.key_identifier, ca_ski.digest)
def test_ed25519_issuer_with_compat_subject(self) -> None:
now = datetime.now(timezone.utc)
ca_key = keys.generate_key("ed25519")
leaf_key = keys.generate_key("ec", curve="sect163k1")
ca = certificates.build_self_signed(
subject=_name("Ed25519 CA"),
private_key=ca_key,
serial=20,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
signature_hash=None,
)
leaf = certificates.build_signed(
subject=_name("Ed25519 signed compat leaf"),
public_key=keys.public_part(leaf_key),
issuer_cert=ca,
issuer_private_key=ca_key,
serial=21,
not_before=now,
not_after=now + timedelta(days=10),
extension_spec=default_extension_spec(),
signature_hash=None,
)
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
self.assertEqual(certificates.certificate_info(leaf).public_key_der, keys.public_der(leaf_key))
self.assertIsNone(certificates.signature_hash_name(leaf))
def test_issuer_name_is_checked_in_openssl_fallback(self) -> None:
now = datetime.now(timezone.utc)
ca_key = keys.generate_key("ec", curve="secp112r1")
first = certificates.build_self_signed(
subject=_name("First issuer"),
private_key=ca_key,
serial=30,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
other_with_same_key = certificates.build_self_signed(
subject=_name("Other issuer with the same key"),
private_key=ca_key,
serial=31,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
leaf = certificates.build_signed(
subject=_name("Leaf"),
public_key=keys.public_part(ca_key),
issuer_cert=first,
issuer_private_key=ca_key,
serial=32,
not_before=now,
not_after=now + timedelta(days=10),
extension_spec=default_extension_spec(),
)
self.assertTrue(certificates.verify_directly_issued_by(leaf, first))
self.assertFalse(certificates.verify_directly_issued_by(leaf, other_with_same_key))
def test_xca_signature_hash_matrix(self) -> None:
cases = [
("rsa", {"size": 1024}, algorithms.possible_hashes("rsa")),
("ec", {"curve": "secp256r1"}, algorithms.possible_hashes("ec")),
("dsa", {"size": 1024}, algorithms.possible_hashes("dsa")),
]
for key_type, kwargs, hashes in cases:
key = keys.generate_key(key_type, **kwargs)
for signature_hash in hashes:
with self.subTest(key_type=key_type, signature_hash=signature_hash):
self.assert_workflow(key, signature_hash)
def test_ed25519_has_no_separate_hash(self) -> None:
key = keys.generate_key("ed25519")
self.assert_workflow(key, None)
with self.assertRaises(ValueError):
requests.build_csr(subject=_name(), private_key=key, signature_hash="sha256")
def test_invalid_key_hash_combinations(self) -> None:
ec_key = keys.generate_key("ec", curve="secp256r1")
dsa_key = keys.generate_key("dsa", size=1024)
with self.assertRaises(ValueError):
requests.build_csr(subject=_name(), private_key=ec_key, signature_hash="md5")
with self.assertRaises(ValueError):
requests.build_csr(subject=_name(), private_key=dsa_key, signature_hash="sha3-256")
def test_representative_encrypted_compat_roundtrips(self) -> None:
for curve in ("secp112r1", "prime192v2", "sect163k1", "brainpoolP160t1", "wap-wsg-idm-ecid-wtls6"):
with self.subTest(curve=curve):
key = keys.generate_key("ec", curve=curve)
for traditional in (False, True):
for password in (None, "secret"):
encoded = keys.private_pem(key, password=password, traditional=traditional)
loaded, private = keys.load_key_bytes(encoded, password=password)
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
encrypted_der = keys.private_der_bytes(key, password="secret")
loaded = keys.private_key_from_der(encrypted_der, password="secret")
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
public, private = keys.load_key_bytes(keys.public_pem(key).encode("ascii"))
self.assertFalse(private)
self.assertEqual(keys.public_der(public), keys.public_der(key))
def test_pkcs12_compat_export_import(self) -> None:
now = datetime.now(timezone.utc)
key = keys.generate_key("ec", curve="secp112r1")
cert = certificates.build_self_signed(
subject=_name("PKCS12 compat"),
private_key=key,
serial=40,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
encoded = exports.export_certificate(
cert,
"pkcs12",
name="compat",
private_key=key,
password="p12-secret",
).data
items = imports.parse_pkcs12(encoded, password="p12-secret")
by_kind = {}
for item in items:
by_kind.setdefault(item.kind, []).append(item)
self.assertEqual(len(by_kind["certificate"]), 1)
self.assertEqual(len(by_kind["key"]), 1)
self.assertEqual(
certificates.certificate_der(by_kind["certificate"][0].obj),
certificates.certificate_der(cert),
)
self.assertTrue(by_kind["key"][0].is_private)
self.assertEqual(keys.public_der(by_kind["key"][0].obj), keys.public_der(key))
def test_non_ec_keys_survive_openssl_fallback_import(self) -> None:
key = keys.generate_key("rsa", size=1024)
der = keys.private_der(key)
original = serialization.load_der_private_key
calls = 0
def legacy_only(data: bytes, password: object = None) -> object:
nonlocal calls
calls += 1
if calls == 1:
raise ValueError("simulated legacy-only encoding")
return original(data, password=password)
with patch("kca.core.keys.serialization.load_der_private_key", new=legacy_only):
loaded = keys.private_key_from_der(der)
self.assertIsInstance(loaded, rsa.RSAPrivateKey)
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
self.assertEqual(calls, 2)
def test_blank_pkcs12_passwords_remain_unencrypted(self) -> None:
now = datetime.now(timezone.utc)
compat_key = keys.generate_key("ec", curve="secp112r1")
compat_ca = certificates.build_self_signed(
subject=_name("Unencrypted compat CA"),
private_key=compat_key,
serial=50,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
leaf_key = keys.generate_key("ec", curve="secp256r1")
leaf = certificates.build_signed(
subject=_name("Mixed-chain leaf"),
public_key=keys.public_part(leaf_key),
issuer_cert=compat_ca,
issuer_private_key=compat_key,
serial=51,
not_before=now,
not_after=now + timedelta(days=10),
extension_spec=default_extension_spec(),
)
pbes2_oid = bytes.fromhex("06092a864886f70d01050d")
for password in (None, ""):
with self.subTest(password=password):
encoded = exports.export_certificate(
leaf,
"pkcs12-chain",
name="unencrypted",
chain=[leaf, compat_ca],
private_key=leaf_key,
password=password,
).data
self.assertNotIn(pbes2_oid, encoded)
items = imports.parse_pkcs12(encoded, password=password)
imported_keys = [item for item in items if item.kind == "key"]
self.assertEqual(len(imported_keys), 1)
self.assertEqual(keys.public_der(imported_keys[0].obj), keys.public_der(leaf_key))
def test_option_metadata_is_complete(self) -> None:
options = keys.key_type_options()
self.assertEqual(len(options), 4)
by_type = {item["type"]: item for item in options}
self.assertEqual(len(by_type["ec"]["curves"]), 79)
for key_type, option in by_type.items():
self.assertEqual(option["hashes"], list(algorithms.possible_hashes(key_type)))
self.assertEqual(len(keys.curve_options()), 79)
self.assertEqual(len(keys.hash_options()), 11)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,92 @@
"""XCA algorithm catalogue and signature metadata tests."""
from __future__ import annotations
import unittest
from kca.core import algorithms, signatures
EXPECTED_CURVES = {
"secp192r1", "secp224r1", "secp256r1", "secp384r1", "secp521r1",
"prime192v2", "prime192v3", "prime239v1", "prime239v2", "prime239v3",
"c2pnb163v1", "c2pnb163v2", "c2pnb163v3", "c2pnb176v1",
"c2tnb191v1", "c2tnb191v2", "c2tnb191v3", "c2pnb208w1",
"c2tnb239v1", "c2tnb239v2", "c2tnb239v3", "c2pnb272w1",
"c2pnb304w1", "c2tnb359v1", "c2pnb368w1", "c2tnb431r1",
"sect113r1", "sect113r2", "sect131r1", "sect131r2", "sect163k1",
"sect163r1", "sect163r2", "sect193r1", "sect193r2", "sect233k1",
"sect233r1", "sect239k1", "sect283k1", "sect283r1", "sect409k1",
"sect409r1", "sect571k1", "sect571r1",
"secp112r1", "secp112r2", "secp128r1", "secp128r2", "secp160k1",
"secp160r1", "secp160r2", "secp192k1", "secp224k1", "secp256k1",
"wap-wsg-idm-ecid-wtls1", "wap-wsg-idm-ecid-wtls3",
"wap-wsg-idm-ecid-wtls4", "wap-wsg-idm-ecid-wtls5",
"wap-wsg-idm-ecid-wtls6", "wap-wsg-idm-ecid-wtls7",
"wap-wsg-idm-ecid-wtls8", "wap-wsg-idm-ecid-wtls9",
"wap-wsg-idm-ecid-wtls10", "wap-wsg-idm-ecid-wtls11",
"wap-wsg-idm-ecid-wtls12",
"brainpoolP160r1", "brainpoolP160t1", "brainpoolP192r1",
"brainpoolP192t1", "brainpoolP224r1", "brainpoolP224t1",
"brainpoolP256r1", "brainpoolP256t1", "brainpoolP320r1",
"brainpoolP320t1", "brainpoolP384r1", "brainpoolP384t1",
"brainpoolP512r1", "brainpoolP512t1",
}
class AlgorithmCatalogueTest(unittest.TestCase):
def test_exact_xca_curve_set(self) -> None:
self.assertEqual({curve.name for curve in algorithms.CURVES}, EXPECTED_CURVES)
self.assertEqual(len(algorithms.CURVES), 79)
self.assertEqual(len({curve.openssl_name for curve in algorithms.CURVES}), 79)
def test_aliases_preserve_existing_kca_names(self) -> None:
self.assertEqual(algorithms.canonical_curve_name("prime192v1"), "secp192r1")
self.assertEqual(algorithms.canonical_curve_name("prime256v1"), "secp256r1")
self.assertEqual(algorithms.curve_info("BRAINPOOLP256R1").name, "brainpoolP256r1")
with self.assertRaises(ValueError):
algorithms.curve_info("SM2")
def test_xca_hash_matrix(self) -> None:
self.assertEqual(
algorithms.possible_hashes("RSA"),
("md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384", "sha3-512"),
)
self.assertEqual(
algorithms.possible_hashes("ec"),
("sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384", "sha3-512"),
)
self.assertEqual(algorithms.possible_hashes("dsa"), ("sha1", "sha224", "sha256"))
self.assertEqual(algorithms.possible_hashes("ed25519"), ())
self.assertEqual(algorithms.default_signature_hash("ec", "md5"), "sha256")
self.assertIsNone(algorithms.default_signature_hash("ed25519", "md5"))
with self.assertRaises(ValueError):
algorithms.validate_signature_hash("ec", "md5")
with self.assertRaises(ValueError):
algorithms.validate_signature_hash("dsa", "sha3-256")
with self.assertRaises(ValueError):
algorithms.validate_signature_hash("ed25519", "sha256")
def test_signature_oid_names_and_hashes(self) -> None:
class OID:
def __init__(self, dotted: str) -> None:
self.dotted_string = dotted
self._name = "Unknown OID"
cases = {
"1.2.840.113549.1.1.4": ("md5WithRSAEncryption", "md5"),
"1.3.36.3.3.1.2": ("ripemd160WithRSA", "ripemd160"),
"2.16.840.1.101.3.4.3.14": ("RSA-SHA3-256", "sha3-256"),
"2.16.840.1.101.3.4.3.10": ("ecdsa_with_SHA3-256", "sha3-256"),
"1.2.840.10040.4.3": ("dsa_with_SHA1", "sha1"),
}
for dotted, (name, hash_name) in cases.items():
with self.subTest(oid=dotted):
oid = OID(dotted)
self.assertEqual(signatures.signature_algorithm_name(oid), name)
self.assertEqual(signatures.signature_hash_name(oid), hash_name)
self.assertEqual(signatures.signature_algorithm_name(OID("1.3.101.112")), "Ed25519")
self.assertIsNone(signatures.signature_hash_name(OID("1.3.101.112")))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,449 @@
from __future__ import annotations
import hashlib
import unittest
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import NameOID
from kca.core import openssl_compat as compat
UNSUPPORTED_CURVES = (
"secp112r1",
"prime192v2",
"sect163k1",
"brainpoolP160t1",
"wap-wsg-idm-ecid-wtls6",
)
def _require_curve(name: str) -> None:
available = compat.available_ec_curves()
canonical = "prime256v1" if name == "secp256r1" else name
if canonical not in available:
raise unittest.SkipTest(f"system OpenSSL does not provide EC curve {name}")
def _self_signed_certificate(
private_key: rsa.RSAPrivateKey,
common_name: str,
serial: int,
) -> x509.Certificate:
now = datetime(2024, 1, 1, tzinfo=timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
return (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(private_key.public_key())
.serial_number(serial)
.not_valid_before(now)
.not_valid_after(now + timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
critical=False,
)
.sign(private_key, hashes.SHA256())
)
def _build_native_templates() -> dict[str, object]:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
certificate = _self_signed_certificate(private_key, "OpenSSL compatibility", 1000)
extra_ca = _self_signed_certificate(private_key, "OpenSSL compatibility extra CA", 1001)
now = datetime(2024, 2, 1, tzinfo=timezone.utc)
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(certificate.subject)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(private_key, hashes.SHA256())
)
revoked = (
x509.RevokedCertificateBuilder()
.serial_number(42)
.revocation_date(now)
.build()
)
crl = (
x509.CertificateRevocationListBuilder()
.issuer_name(certificate.subject)
.last_update(now)
.next_update(now + timedelta(days=30))
.add_revoked_certificate(revoked)
.add_extension(x509.CRLNumber(7), critical=False)
.sign(private_key, hashes.SHA256())
)
return {
"key_object": private_key,
"private_der": private_key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
),
"public_der": private_key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
),
"cert_object": certificate,
"cert_der": certificate.public_bytes(serialization.Encoding.DER),
"csr_der": csr.public_bytes(serialization.Encoding.DER),
"crl_der": crl.public_bytes(serialization.Encoding.DER),
"extra_ca_object": extra_ca,
"extra_ca_der": extra_ca.public_bytes(serialization.Encoding.DER),
}
def _resign_all(
templates: dict[str, object], key: compat.CompatKey, digest: str
) -> tuple[bytes, bytes, bytes]:
cert = compat.resign_certificate(
templates["cert_der"], key.public_der, key.private_der, digest
)
csr = compat.resign_csr(
templates["csr_der"], key.public_der, key.private_der, digest
)
crl = compat.resign_crl(templates["crl_der"], key.private_der, digest)
return cert, csr, crl
class OpenSSLCompatTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.native_templates = _build_native_templates()
def test_backend_and_curve_discovery(self) -> None:
self.assertTrue(compat.backend_available())
self.assertTrue(compat.openssl_version().startswith("OpenSSL 3."))
self.assertTrue(compat.library_path())
curves = compat.available_ec_curves()
self.assertTrue(curves)
self.assertEqual(len(curves), len(set(curves)))
self.assertIn("prime256v1", curves)
def test_ec_key_size_is_curve_field_degree(self) -> None:
for curve, key_size in (("c2pnb176v1", 176), ("secp160k1", 160)):
with self.subTest(curve=curve):
_require_curve(curve)
self.assertEqual(compat.generate_ec(curve).key_size, key_size)
def test_native_ec_interoperates_with_cryptography(self) -> None:
_require_curve("prime256v1")
key = compat.generate_ec("prime256v1")
self.assertTrue(key.is_private)
self.assertEqual(key.curve, "prime256v1")
self.assertEqual(key.key_size, 256)
# Native keys still cross the API boundary only as DER, never as pointers.
native_private = serialization.load_der_private_key(key.private_der, password=None)
native_public = serialization.load_der_public_key(key.public_der)
self.assertEqual(
native_private.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
),
key.private_der,
)
self.assertEqual(
native_public.public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
),
key.public_der,
)
def test_arbitrary_curve_and_key_round_trips(self) -> None:
for curve in UNSUPPORTED_CURVES:
with self.subTest(curve=curve):
_require_curve(curve)
key = compat.generate_ec(curve)
self.assertEqual(key.curve, curve)
self.assertTrue(key.is_private)
self.assertGreater(key.key_size, 0)
self.assertIsNotNone(key.private_der)
public = key.public_key()
self.assertFalse(public.is_private)
self.assertIsNone(public.private_der)
self.assertIs(public.public_key(), public)
self.assertEqual(public.public_der, key.public_der)
self.assertEqual(compat.public_key_from_der(key.public_der).public_der, key.public_der)
self.assertEqual(compat.load_key_bytes(key.public_der), public)
self.assertEqual(compat.private_key_from_der(key.private_der), key)
self.assertEqual(compat.load_key_bytes(key.private_der), key)
public_pem = compat.public_pem(key)
self.assertIsInstance(public_pem, str)
self.assertTrue(public_pem.startswith("-----BEGIN PUBLIC KEY-----"))
self.assertEqual(compat.load_key_bytes(public_pem.encode("ascii")), public)
pkcs8_pem = compat.private_pem(key)
self.assertTrue(pkcs8_pem.startswith(b"-----BEGIN PRIVATE KEY-----"))
self.assertEqual(compat.load_key_bytes(pkcs8_pem), key)
traditional_pem = compat.private_pem(key, traditional=True)
self.assertTrue(traditional_pem.startswith(b"-----BEGIN EC PRIVATE KEY-----"))
self.assertEqual(compat.load_key_bytes(traditional_pem), key)
encrypted_pem = compat.private_pem(key, password="correct horse")
self.assertTrue(encrypted_pem.startswith(b"-----BEGIN ENCRYPTED PRIVATE KEY-----"))
self.assertEqual(compat.load_key_bytes(encrypted_pem, password="correct horse"), key)
encrypted_traditional = compat.private_pem(
key, password="correct horse", traditional=True
)
self.assertTrue(encrypted_traditional.startswith(b"-----BEGIN EC PRIVATE KEY-----"))
self.assertIn(b"DEK-Info:", encrypted_traditional)
self.assertEqual(
compat.load_key_bytes(encrypted_traditional, password="correct horse"), key
)
self.assertEqual(compat.private_der_bytes(key), key.private_der)
encrypted_der = compat.private_der_bytes(key, password="correct horse")
self.assertNotEqual(encrypted_der, key.private_der)
self.assertEqual(
compat.private_key_from_der(encrypted_der, password="correct horse"), key
)
self.assertEqual(compat.load_key_bytes(encrypted_der, password="correct horse"), key)
def test_wrong_password_errors_do_not_disclose_password(self) -> None:
_require_curve("secp112r1")
key = compat.generate_ec("secp112r1")
password = "not-for-error-output"
encrypted = compat.private_der_bytes(key, password=password)
attempted_password = "definitely-wrong-secret"
with self.assertRaises(ValueError) as caught:
compat.private_key_from_der(encrypted, password=attempted_password)
self.assertNotIn(password, str(caught.exception))
self.assertNotIn(attempted_password, str(caught.exception))
with self.assertRaises(ValueError):
compat.private_pem(key.public_key())
self.assertEqual(compat.private_pem(key, password=""), compat.private_pem(key))
self.assertEqual(compat.private_der_bytes(key, password=""), key.private_der)
def test_native_rsa_traditional_and_pkcs1_der(self) -> None:
templates = self.native_templates
private_key = templates["key_object"]
traditional_der = private_key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
pkcs1_public_der = private_key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.PKCS1,
)
for encoded in (templates["private_der"], traditional_der):
loaded = compat.load_key_bytes(encoded)
self.assertTrue(loaded.is_private)
self.assertIsNone(loaded.curve)
self.assertEqual(loaded.key_size, 2048)
self.assertEqual(loaded.public_der, templates["public_der"])
public = compat.public_key_from_der(pkcs1_public_der)
self.assertEqual(public.public_der, templates["public_der"])
pkcs1_public_pem = private_key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.PKCS1,
)
self.assertEqual(compat.load_key_bytes(pkcs1_public_pem).public_der, templates["public_der"])
encrypted_by_cryptography = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.BestAvailableEncryption(b"import-password"),
)
self.assertEqual(
compat.load_key_bytes(encrypted_by_cryptography, "import-password").public_der,
templates["public_der"],
)
def test_spki_key_identifier_known_answer(self) -> None:
# RFC 8032 test 1 Ed25519 public key in SubjectPublicKeyInfo. The expected
# value is SHA-1 of only the 32 bytes in the BIT STRING, independently of
# cryptography's SubjectKeyIdentifier implementation.
spki = bytes.fromhex(
"302a300506032b6570032100"
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
)
self.assertEqual(
compat.spki_key_identifier(spki).hex(),
"5b27aa5589179770e47575b162a1ded97b8bfc6d",
)
self.assertNotEqual(compat.spki_key_identifier(spki), hashlib.sha1(spki).digest())
def test_unsupported_curve_cert_csr_crl_sha256(self) -> None:
for curve in UNSUPPORTED_CURVES:
with self.subTest(curve=curve):
_require_curve(curve)
key = compat.generate_ec(curve)
cert, csr, crl = _resign_all(self.native_templates, key, "sha256")
self.assertEqual(compat.certificate_public_der(cert), key.public_der)
self.assertEqual(compat.csr_public_der(csr), key.public_der)
self.assertTrue(compat.verify_certificate(cert, cert))
self.assertTrue(compat.verify_csr(csr))
self.assertTrue(compat.verify_crl(crl, cert))
# No call to cryptography.Certificate.public_key() or CSR.public_key()
# is needed (or valid) for these unsupported curves.
def test_unsupported_subject_with_native_rsa_issuer(self) -> None:
_require_curve("sect163k1")
subject_key = compat.generate_ec("sect163k1")
cert = compat.resign_certificate(
self.native_templates["cert_der"],
subject_key.public_der,
self.native_templates["private_der"],
"sha256",
)
self.assertEqual(compat.certificate_public_der(cert), subject_key.public_der)
self.assertTrue(compat.verify_certificate(cert, self.native_templates["cert_der"]))
self.assertFalse(compat.verify_certificate(cert, cert))
def test_rsa_legacy_digest_cert_csr_crl(self) -> None:
for digest in ("sha256", "sha1", "md5", "ripemd160"):
with self.subTest(digest=digest):
key = compat.private_key_from_der(self.native_templates["private_der"])
cert, csr, crl = _resign_all(self.native_templates, key, digest)
self.assertTrue(compat.verify_certificate(cert, cert))
self.assertTrue(compat.verify_csr(csr))
self.assertTrue(compat.verify_crl(crl, cert))
# The signature algorithm must actually change, not merely verify
# under a cached template signature.
parsed_cert = x509.load_der_x509_certificate(cert)
parsed_csr = x509.load_der_x509_csr(csr)
parsed_crl = x509.load_der_x509_crl(crl)
expected_oid = {
"sha256": "1.2.840.113549.1.1.11",
"sha1": "1.2.840.113549.1.1.5",
"md5": "1.2.840.113549.1.1.4",
"ripemd160": "1.3.36.3.3.1.2",
}[digest]
self.assertEqual(parsed_cert.signature_algorithm_oid.dotted_string, expected_oid)
self.assertEqual(parsed_csr.signature_algorithm_oid.dotted_string, expected_oid)
self.assertEqual(parsed_crl.signature_algorithm_oid.dotted_string, expected_oid)
def test_legacy_and_sha3_ec_cert_csr_crl(self) -> None:
for digest in ("sha1", "sha3-256"):
with self.subTest(digest=digest):
_require_curve("prime256v1")
key = compat.generate_ec("prime256v1")
cert, csr, crl = _resign_all(self.native_templates, key, digest)
self.assertTrue(compat.verify_certificate(cert, cert))
self.assertTrue(compat.verify_csr(csr))
self.assertTrue(compat.verify_crl(crl, cert))
self.assertEqual(
x509.load_der_x509_certificate(cert).signature_hash_algorithm.name, digest
)
self.assertEqual(x509.load_der_x509_csr(csr).signature_hash_algorithm.name, digest)
self.assertEqual(x509.load_der_x509_crl(crl).signature_hash_algorithm.name, digest)
def test_bad_signatures_return_false(self) -> None:
key = compat.private_key_from_der(self.native_templates["private_der"])
cert, csr, crl = _resign_all(self.native_templates, key, "sha256")
other_native = rsa.generate_private_key(public_exponent=65537, key_size=2048)
other_cert = _self_signed_certificate(other_native, "Wrong issuer", 2000)
other_der = other_cert.public_bytes(serialization.Encoding.DER)
self.assertFalse(compat.verify_certificate(cert, other_der))
self.assertFalse(compat.verify_crl(crl, other_der))
damaged_csr = bytearray(csr)
damaged_csr[-1] ^= 1
self.assertFalse(compat.verify_csr(bytes(damaged_csr)))
def test_malformed_and_unsupported_inputs_raise_value_error(self) -> None:
with self.assertRaises(ValueError):
compat.public_key_from_der(b"not DER")
with self.assertRaises(ValueError):
compat.private_key_from_der(b"not DER")
with self.assertRaises(ValueError):
compat.certificate_public_der(b"not DER")
with self.assertRaises(ValueError):
compat.csr_public_der(b"not DER")
with self.assertRaises(ValueError):
compat.spki_key_identifier(b"not DER")
with self.assertRaises(ValueError):
compat.verify_certificate(b"not DER", self.native_templates["cert_der"])
with self.assertRaises(ValueError):
compat.verify_csr(b"not DER")
with self.assertRaises(ValueError):
compat.verify_crl(b"not DER", self.native_templates["cert_der"])
with self.assertRaisesRegex(ValueError, "digest"):
compat.resign_crl(
self.native_templates["crl_der"],
self.native_templates["private_der"],
"not-a-digest",
)
def test_pkcs12_modern_serialize_and_load(self) -> None:
encoded = compat.serialize_pkcs12(
"compat friendly name",
self.native_templates["private_der"],
self.native_templates["cert_der"],
[self.native_templates["extra_ca_der"]],
"p12-password",
)
# id-pbes2, aes256-CBC, and id-sha256: modern PBKDF2 encryption and MAC,
# not legacy RC2/3DES PBE with SHA-1.
self.assertIn(bytes.fromhex("06092a864886f70d01050d"), encoded)
self.assertIn(bytes.fromhex("060960864801650304012a"), encoded)
self.assertIn(bytes.fromhex("0609608648016503040201"), encoded)
private_der, cert_der, ca_ders = compat.load_pkcs12(encoded, "p12-password")
self.assertIsNotNone(private_der)
self.assertEqual(
compat.private_key_from_der(private_der).public_der,
self.native_templates["public_der"],
)
self.assertEqual(cert_der, self.native_templates["cert_der"])
self.assertEqual(ca_ders, [self.native_templates["extra_ca_der"]])
native_key, native_cert, native_cas = pkcs12.load_key_and_certificates(
encoded, b"p12-password"
)
self.assertIsNotNone(native_key)
self.assertIsNotNone(native_cert)
self.assertIsNotNone(native_cas)
self.assertEqual(len(native_cas), 1)
with self.assertRaises(ValueError) as caught:
compat.load_pkcs12(encoded, "wrong-password")
self.assertNotIn("wrong-password", str(caught.exception))
def test_pkcs12_loads_cryptography_container(self) -> None:
encoded = pkcs12.serialize_key_and_certificates(
b"native friendly name",
self.native_templates["key_object"],
self.native_templates["cert_object"],
[self.native_templates["extra_ca_object"]],
serialization.BestAvailableEncryption(b"native-p12-password"),
)
private_der, cert_der, ca_ders = compat.load_pkcs12(encoded, "native-p12-password")
self.assertIsNotNone(private_der)
self.assertEqual(
compat.private_key_from_der(private_der).public_der,
self.native_templates["public_der"],
)
self.assertEqual(cert_der, self.native_templates["cert_der"])
self.assertEqual(ca_ders, [self.native_templates["extra_ca_der"]])
def test_pkcs12_supports_unsupported_ec_curve(self) -> None:
_require_curve("secp112r1")
key = compat.generate_ec("secp112r1")
cert = compat.resign_certificate(
self.native_templates["cert_der"], key.public_der, key.private_der, "sha256"
)
encoded = compat.serialize_pkcs12("ec compat", key.private_der, cert, [], "ec-p12")
private_der, loaded_cert, ca_ders = compat.load_pkcs12(encoded, "ec-p12")
self.assertIsNotNone(private_der)
self.assertEqual(compat.private_key_from_der(private_der).public_der, key.public_der)
self.assertEqual(loaded_cert, cert)
self.assertEqual(ca_ders, [])
self.assertTrue(compat.verify_certificate(loaded_cert, loaded_cert))
if __name__ == "__main__":
unittest.main()
@@ -130,6 +130,91 @@ class XcaDbTest(unittest.TestCase):
self.assertEqual(items[3].name, "Synthetic CRL") self.assertEqual(items[3].name, "Synthetic CRL")
self.assertEqual(items[4].obj["name"], "Synthetic template") self.assertEqual(items[4].obj["name"], "Synthetic template")
def test_import_openssl_only_curve_database(self) -> None:
key = keys.generate_key("ec", curve="secp112r1")
now = datetime.now(timezone.utc)
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
cert = certificates.build_self_signed(
subject=_name("Compat XCA CA"),
private_key=key,
serial=43,
not_before=now,
not_after=now + timedelta(days=365),
extension_spec=spec,
)
csr = requests.build_csr(subject=_name("Compat XCA CSR"), private_key=key, extension_spec=spec)
crl = crls.build_crl(
issuer_cert=cert,
issuer_private_key=key,
revocations=[],
last_update=now,
next_update=now + timedelta(days=30),
crl_number=4,
)
encrypted = keys.private_der_bytes(key, password="dbpass")
date = now.strftime("%Y%m%d%H%M%SZ")
with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle:
path = handle.name
try:
db = sqlite3.connect(path)
db.executescript(
"""
CREATE TABLE settings (key_ CHAR(20) UNIQUE, value TEXT);
CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(128), type INTEGER, source INTEGER, date CHAR(15), comment VARCHAR(2048), stamp INTEGER DEFAULT 0, del SMALLINT DEFAULT 0);
CREATE TABLE public_keys (item INTEGER, type CHAR(4), hash INTEGER, len INTEGER, "public" TEXT);
CREATE TABLE private_keys (item INTEGER, ownPass INTEGER, private TEXT);
CREATE TABLE x509super (item INTEGER, subj_hash INTEGER, pkey INTEGER, key_hash INTEGER);
CREATE TABLE requests (item INTEGER, hash INTEGER, signed INTEGER, request TEXT);
CREATE TABLE certs (item INTEGER, hash INTEGER, iss_hash INTEGER, serial VARCHAR(64), issuer INTEGER, ca INTEGER, cert TEXT);
CREATE TABLE authority (item INTEGER, template INTEGER, crlExpire CHAR(15), crlNo INTEGER, crlDays INTEGER);
CREATE TABLE crls (item INTEGER, hash INTEGER, num INTEGER, iss_hash INTEGER, issuer INTEGER, crl TEXT);
CREATE TABLE revocations (caId INTEGER, serial VARCHAR(64), date CHAR(15), invaldate CHAR(15), crlNo INTEGER, reasonBit INTEGER);
CREATE TABLE templates (item INTEGER, version INTEGER, template TEXT);
CREATE TABLE takeys (item INTEGER UNIQUE, value TEXT);
"""
)
db.execute("INSERT INTO settings VALUES ('schema', '8')")
for item_id, name, item_type in ((1, "Compat key", 1), (2, "Compat CA", 3), (3, "Compat CSR", 2), (4, "Compat CRL", 4)):
db.execute("INSERT INTO items VALUES (?, ?, ?, 2, ?, '', 0, 0)", (item_id, name, item_type, date))
db.execute(
"INSERT INTO public_keys VALUES (1, 'EC', 0, 112, ?)",
(base64.b64encode(keys.public_der(key)).decode(),),
)
db.execute("INSERT INTO private_keys VALUES (1, 0, ?)", (base64.b64encode(encrypted).decode(),))
db.execute("INSERT INTO x509super VALUES (2, 0, 1, 0)")
db.execute("INSERT INTO x509super VALUES (3, 0, 1, 0)")
db.execute(
"INSERT INTO certs VALUES (2, 0, 0, '2b', NULL, 1, ?)",
(base64.b64encode(certificates.certificate_der(cert)).decode(),),
)
db.execute("INSERT INTO authority VALUES (2, NULL, NULL, 0, 30)")
db.execute(
"INSERT INTO requests VALUES (3, 0, 0, ?)",
(base64.b64encode(requests.csr_der(csr)).decode(),),
)
db.execute(
"INSERT INTO crls VALUES (4, 0, 1, 0, 2, ?)",
(base64.b64encode(crls.crl_der(crl)).decode(),),
)
db.commit()
db.close()
with open(path, "rb") as handle:
items = import_xca_database(handle.read(), "compat.xdb", "dbpass")
finally:
import os
os.unlink(path)
self.assertEqual([item.kind for item in items], ["key", "certificate", "request", "crl"])
self.assertTrue(items[0].is_private)
self.assertEqual(keys.curve_name(items[0].obj), "secp112r1")
self.assertEqual(keys.public_der(items[0].obj), keys.public_der(key))
self.assertEqual(certificates.certificate_info(items[1].obj).public_key_der, keys.public_der(key))
self.assertTrue(requests.verify_csr(items[2].obj))
self.assertTrue(crls.verify_crl(items[3].obj, items[1].obj))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+204
View File
@@ -0,0 +1,204 @@
"""Route-level coverage for XCA curve and signature-hash parity."""
from __future__ import annotations
import base64
import unittest
from datetime import datetime, timedelta, timezone
from tests.helpers.api import api_client
from tests.helpers.asynctest import async_test
from tests.helpers.oidc import oidc_user
def _subject(cn: str) -> list[dict]:
return [{"name": "CN", "value": cn}]
def _extensions(ca: bool = True) -> dict:
return {
"basic_constraints": {"critical": True, "ca": ca, "path_length": None},
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"] if ca else ["digital_signature"]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
async def _vault(client) -> str:
response = await client.post("/api/vaults", json={"name": "Algorithm parity"})
assert response.status_code == 201, response.text
return response.json()["id"]
class AlgorithmRouteTests(unittest.TestCase):
@async_test
async def test_options_and_default_curve_settings(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
response = await client.get(f"/api/vaults/{vault_id}/options")
assert response.status_code == 200, response.text
options = response.json()
self.assertEqual(len(options["curves"]), 79)
self.assertEqual(len(options["curve_options"]), 79)
self.assertEqual(len(options["hash_algorithms"]), 11)
self.assertEqual(len(options["hash_options"]), 11)
by_type = {item["type"]: item for item in options["key_types"]}
self.assertIn("ripemd160", by_type["rsa"]["hashes"])
self.assertNotIn("ripemd160", by_type["ec"]["hashes"])
self.assertEqual(by_type["dsa"]["hashes"], ["sha1", "sha224", "sha256"])
self.assertEqual(by_type["ed25519"]["hashes"], [])
response = await client.get(f"/api/vaults/{vault_id}/settings")
version = response.json()["version"]
response = await client.patch(
f"/api/vaults/{vault_id}/settings",
json={
"settings": {
"default_hash": "ripemd160",
"default_key": {"type": "ec", "size": 2048, "curve": "prime192v2"},
},
"version": version,
},
)
assert response.status_code == 200, response.text
settings = response.json()["settings"]
self.assertEqual(settings["default_hash"], "ripemd160")
self.assertEqual(settings["default_key"]["curve"], "prime192v2")
@async_test
async def test_compat_curve_complete_api_workflow(self) -> None:
now = datetime.now(timezone.utc)
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
response = await client.post(
f"/api/vaults/{vault_id}/keys",
json={"name": "Binary CA key", "key_type": "ec", "curve": "sect163k1"},
)
assert response.status_code == 201, response.text
key = response.json()
self.assertEqual(key["curve"], "sect163k1")
self.assertIn("sha3-512", key["possible_hashes"])
self.assertNotIn("md5", key["possible_hashes"])
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "Binary request",
"key_id": key["id"],
"subject": _subject("binary.example.com"),
"extensions": _extensions(False),
"signature_hash": "sha3-256",
},
)
assert response.status_code == 201, response.text
request = response.json()
self.assertTrue(request["signature_valid"])
self.assertEqual(request["signature_algorithm"], "ecdsa_with_SHA3-256")
response = await client.post(
f"/api/vaults/{vault_id}/certificates",
json={
"name": "Binary CA",
"key_id": key["id"],
"subject": _subject("Binary CA"),
"not_before": now.isoformat(),
"not_after": (now + timedelta(days=30)).isoformat(),
"extensions": _extensions(True),
"signature_hash": "sha1",
},
)
assert response.status_code == 201, response.text
cert = response.json()
self.assertEqual(cert["signature_algorithm"], "ecdsa-with-SHA1")
self.assertEqual(cert["key_type"], "EC")
self.assertEqual(cert["cert_spec"]["signature_hash"], "sha1")
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/crl",
json={"days": 7, "signature_hash": "sha3-512"},
)
assert response.status_code == 201, response.text
self.assertEqual(response.json()["signature_algorithm"], "ecdsa_with_SHA3-512")
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/renew",
json={"validity": {"amount": 10, "unit": "days"}},
)
assert response.status_code == 201, response.text
self.assertEqual(response.json()["signature_algorithm"], "ecdsa-with-SHA1")
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/export",
json={"format": "pkcs12", "password": "route-p12-secret"},
)
assert response.status_code == 200, response.text
self.assertGreater(len(base64.b64decode(response.json()["content_base64"])), 100)
@async_test
async def test_ed25519_and_invalid_nested_hashes(self) -> None:
from kca.models import Key
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
response = await client.post(
f"/api/vaults/{vault_id}/keys",
json={"name": "Ed key", "key_type": "ed25519"},
)
assert response.status_code == 201, response.text
key = response.json()
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "Ed request",
"key_id": key["id"],
"subject": _subject("ed.example.com"),
"extensions": _extensions(False),
},
)
assert response.status_code == 201, response.text
self.assertEqual(response.json()["signature_algorithm"], "Ed25519")
self.assertIsNone(response.json()["csr_spec"]["signature_hash"])
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "Invalid Ed request",
"key_id": key["id"],
"subject": _subject("invalid-ed.example.com"),
"extensions": _extensions(False),
"signature_hash": "sha256",
},
)
assert response.status_code == 400, response.text
before = await Key.filter().count()
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "Invalid nested EC request",
"new_key": {"key_type": "ec", "curve": "secp112r1"},
"subject": _subject("invalid-ec.example.com"),
"extensions": _extensions(False),
"signature_hash": "md5",
},
)
assert response.status_code == 400, response.text
self.assertEqual(await Key.filter().count(), before)
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "Valid nested EC request",
"new_key": {"key_type": "ec", "curve": "secp112r1"},
"subject": _subject("valid-ec.example.com"),
"extensions": _extensions(False),
},
)
assert response.status_code == 201, response.text
self.assertEqual(response.json()["signature_algorithm"], "ecdsa-with-SHA256")
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -1102,12 +1102,14 @@ name = "kca-core"
version = "0.1.0" version = "0.1.0"
source = { editable = "packages/kca-core" } source = { editable = "packages/kca-core" }
dependencies = [ dependencies = [
{ name = "cffi" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "paramiko" }, { name = "paramiko" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cffi", specifier = ">=2" },
{ name = "cryptography", specifier = ">=44" }, { name = "cryptography", specifier = ">=44" },
{ name = "paramiko", specifier = ">=3.5" }, { name = "paramiko", specifier = ">=3.5" },
] ]
+552
View File
@@ -0,0 +1,552 @@
//! Shared cryptographic option metadata, selection rules, and form controls.
use serde_json::{json, Value};
use sycamore::prelude::*;
use crate::model::{CurveOption, HashOption, KeyTypeOption, OptionsPage};
const DEFAULT_HASH: &str = "sha256";
const DEFAULT_CURVE: &str = "secp256r1";
const DEFAULT_SIZE: &str = "2048";
pub fn is_ed25519(key_type: Option<&str>) -> bool {
key_type
.map(|kind| kind.eq_ignore_ascii_case("ed25519"))
.unwrap_or(false)
}
fn key_type_rule<'a>(options: &'a OptionsPage, key_type: &str) -> Option<&'a KeyTypeOption> {
options
.key_types
.iter()
.find(|rule| rule.key_type.eq_ignore_ascii_case(key_type))
}
/// The rich options are authoritative; the legacy arrays keep the controls
/// usable while an older vault options response is being upgraded.
pub fn available_hashes(options: &OptionsPage) -> Vec<HashOption> {
if !options.hash_options.is_empty() {
return options.hash_options.clone();
}
options
.hash_algorithms
.iter()
.map(|name| HashOption {
name: name.clone(),
label: name.clone(),
insecure: false,
})
.collect()
}
/// Return hashes accepted by the actual signing key. `None` means that no key
/// has been selected yet, so the unrestricted list is shown temporarily.
pub fn allowed_hashes(options: &OptionsPage, key_type: Option<&str>) -> Vec<HashOption> {
let available = available_hashes(options);
if is_ed25519(key_type) {
return Vec::new();
}
let Some(key_type) = key_type else {
return available;
};
let Some(rule) = key_type_rule(options, key_type) else {
return Vec::new();
};
let Some(allowed) = rule.hashes.as_ref() else {
return available;
};
available
.into_iter()
.filter(|hash| allowed.iter().any(|name| name == &hash.name))
.collect()
}
/// Preserve a valid explicit choice, prefer SHA-256 for stale choices, and
/// finally use the first algorithm accepted by the key.
pub fn valid_hash(selected: &str, allowed: &[HashOption]) -> String {
let names = allowed
.iter()
.map(|option| option.name.clone())
.collect::<Vec<_>>();
valid_choice(selected, &names, DEFAULT_HASH)
}
fn valid_choice(selected: &str, choices: &[String], preferred: &str) -> String {
if choices.iter().any(|choice| choice == selected) {
return selected.to_string();
}
if choices.iter().any(|choice| choice == preferred) {
return preferred.to_string();
}
choices.first().cloned().unwrap_or_default()
}
pub fn available_curves(options: &OptionsPage, key_type: &str) -> Vec<CurveOption> {
let mut available = if !options.curve_options.is_empty() {
options.curve_options.clone()
} else {
options
.curves
.iter()
.map(|name| CurveOption {
name: name.clone(),
label: name.clone(),
..CurveOption::default()
})
.collect::<Vec<_>>()
};
if let Some(allowed) = key_type_rule(options, key_type).and_then(|rule| rule.curves.as_ref()) {
available.retain(|curve| allowed.iter().any(|name| name == &curve.name));
}
available
}
pub fn key_sizes(options: &OptionsPage, key_type: &str) -> Vec<String> {
key_type_rule(options, key_type)
.and_then(|rule| rule.sizes.as_ref())
.cloned()
.unwrap_or_default()
.into_iter()
.map(|size| size.to_string())
.collect()
}
/// Add only parameters understood by the selected key algorithm.
pub fn add_key_parameters(payload: &mut Value, key_type: &str, size: &str, curve: &str) {
match key_type {
"ec" => {
payload["curve"] = json!(curve);
if let Some(object) = payload.as_object_mut() {
object.remove("size");
}
}
"rsa" | "dsa" => {
payload["size"] = json!(size.parse::<i64>().unwrap_or(2048));
if let Some(object) = payload.as_object_mut() {
object.remove("curve");
}
}
_ => {
if let Some(object) = payload.as_object_mut() {
object.remove("size");
object.remove("curve");
}
}
}
}
/// Ed25519 has its own signature algorithm and must not carry a digest field.
pub fn apply_signature_hash(payload: &mut Value, key_type: Option<&str>, hash: &str) {
if is_ed25519(key_type) {
if let Some(object) = payload.as_object_mut() {
object.remove("signature_hash");
}
} else {
payload["signature_hash"] = json!(hash);
}
}
fn option_label(name: &str, label: &str, insecure: bool) -> String {
let label = if label.trim().is_empty() { name } else { label };
if insecure {
format!("{label} (insecure)")
} else {
label.to_string()
}
}
fn key_type_option_views(options: &[KeyTypeOption]) -> Vec<View> {
options
.iter()
.map(|option| {
let value = option.key_type.clone();
let label = option_label(&option.key_type, &option.label, false);
view! { option(value=value) { (label) } }
})
.collect()
}
fn hash_option_views(options: &[HashOption]) -> Vec<View> {
options
.iter()
.map(|option| {
let value = option.name.clone();
let label = option_label(&option.name, &option.label, option.insecure);
view! { option(value=value) { (label) } }
})
.collect()
}
fn curve_option_views(options: &[CurveOption]) -> Vec<View> {
let mut groups: Vec<(String, Vec<CurveOption>)> = Vec::new();
for option in options.iter().cloned() {
let group = if option.group.trim().is_empty() {
"Curves".to_string()
} else {
option.group.clone()
};
if let Some((_, items)) = groups.iter_mut().find(|(name, _)| name == &group) {
items.push(option);
} else {
groups.push((group, vec![option]));
}
}
groups
.into_iter()
.map(|(group, items)| {
let items = items
.into_iter()
.map(|option| {
let value = option.name.clone();
let label = option_label(&option.name, &option.label, option.insecure);
let comment = option.comment.clone();
view! { option(value=value, title=comment) { (label) } }
})
.collect::<Vec<View>>();
view! { optgroup(label=group) { (items) } }
})
.collect()
}
fn insecure_warning(label: String, insecure: bool, comment: &str) -> View {
if !insecure {
return view! {};
}
let comment = comment.trim();
let message = if comment.is_empty() {
format!("Warning: {label} is marked as insecure; use it only for compatibility.")
} else {
format!("Warning: {label} is marked as insecure; use it only for compatibility. {comment}")
};
view! {
div(class="alert alert-warning py-2 mb-0 mt-2", role="alert") {
(message)
}
}
}
fn hash_warning(options: &[HashOption], selected: &str) -> View {
let Some(option) = options.iter().find(|option| option.name == selected) else {
return view! {};
};
insecure_warning(
option_label(&option.name, &option.label, false),
option.insecure,
"",
)
}
fn curve_warning(options: &[CurveOption], selected: &str) -> View {
let Some(option) = options.iter().find(|option| option.name == selected) else {
return view! {};
};
insecure_warning(
option_label(&option.name, &option.label, false),
option.insecure,
&option.comment,
)
}
/// Key type selector backed by the vault options instead of a local subset.
pub fn key_type_select(options: Signal<Option<OptionsPage>>, selected: Signal<String>) -> View {
create_effect(move || {
let Some(options) = options.get_clone() else {
return;
};
let names = options
.key_types
.iter()
.map(|option| option.key_type.clone())
.collect::<Vec<_>>();
if names.is_empty() {
return;
}
let current = selected.get_clone();
let next = valid_choice(&current, &names, "rsa");
if next != current {
selected.set(next);
}
});
view! {
select(class="form-select", bind:value=selected) {
(move || options
.get_clone()
.map(|options| key_type_option_views(&options.key_types))
.unwrap_or_default())
}
}
}
/// Type-aware size/curve control shared by key, CSR, certificate and settings
/// forms. Invalid persisted values are normalized as soon as options arrive.
pub fn key_parameter_input(
options: Signal<Option<OptionsPage>>,
key_type: Signal<String>,
size: Signal<String>,
curve: Signal<String>,
) -> View {
create_effect(move || {
let Some(options) = options.get_clone() else {
return;
};
let kind = key_type.get_clone();
if kind == "ec" {
let curves = available_curves(&options, &kind)
.into_iter()
.map(|option| option.name)
.collect::<Vec<_>>();
if !curves.is_empty() {
let current = curve.get_clone();
let next = valid_choice(&current, &curves, DEFAULT_CURVE);
if next != current {
curve.set(next);
}
}
} else if !is_ed25519(Some(&kind)) {
let sizes = key_sizes(&options, &kind);
if !sizes.is_empty() {
let current = size.get_clone();
let next = valid_choice(&current, &sizes, DEFAULT_SIZE);
if next != current {
size.set(next);
}
}
}
});
view! {
(move || {
let kind = key_type.get_clone();
let Some(options) = options.get_clone() else {
return view! {
label(class="form-label") { "Key parameters" }
select(class="form-select", disabled=true) {
option(value="") { "Loading options…" }
}
};
};
if kind == "ec" {
let curves = available_curves(&options, &kind);
let warning = curve_warning(&curves, &curve.get_clone());
let mut items = curve_option_views(&curves);
if items.is_empty() {
items.push(view! { option(value="") { "No curves available" } });
}
view! {
label(class="form-label") { "Curve" }
select(class="form-select", disabled=curves.is_empty(), bind:value=curve) {
(items)
}
(warning)
}
} else if is_ed25519(Some(&kind)) {
view! {
div(class="alert alert-info py-2 mb-0") {
"Ed25519 keys have a fixed 256-bit size."
}
}
} else {
let sizes = key_sizes(&options, &kind);
let no_sizes = sizes.is_empty();
let items = if no_sizes {
vec![view! { option(value="") { "No key sizes available" } }]
} else {
sizes
.into_iter()
.map(|value| {
let option_value = value.clone();
view! { option(value=option_value) { (value) } }
})
.collect()
};
view! {
label(class="form-label") { "Key size (bits)" }
select(class="form-select", disabled=no_sizes, bind:value=size) {
(items)
}
}
}
})
}
}
/// Signature hash selector filtered by the key that will actually sign.
pub fn signature_hash_input(
label: &'static str,
options: Signal<Option<OptionsPage>>,
signing_key_type: impl Fn() -> Option<String> + Copy + 'static,
selected: Signal<String>,
) -> View {
create_effect(move || {
let Some(options) = options.get_clone() else {
return;
};
let kind = signing_key_type();
let next = if is_ed25519(kind.as_deref()) {
String::new()
} else {
valid_hash(
&selected.get_clone(),
&allowed_hashes(&options, kind.as_deref()),
)
};
if next != selected.get_clone() {
selected.set(next);
}
});
view! {
(move || {
let kind = signing_key_type();
let Some(options) = options.get_clone() else {
return view! {
label(class="form-label") { (label) }
select(class="form-select", disabled=true) {
option(value="") { "Loading hash algorithms…" }
}
};
};
if is_ed25519(kind.as_deref()) {
view! {
label(class="form-label") { (label) }
select(class="form-select", disabled=true, bind:value=selected) {
option(value="") { "Not used for Ed25519" }
}
div(class="form-text") { "Ed25519 signs without a configurable hash." }
}
} else {
let hashes = allowed_hashes(&options, kind.as_deref());
let warning = hash_warning(&hashes, &selected.get_clone());
let mut items = hash_option_views(&hashes);
if items.is_empty() {
items.push(view! { option(value="") { "No compatible hashes" } });
}
view! {
label(class="form-label") { (label) }
select(class="form-select", disabled=hashes.is_empty(), bind:value=selected) {
(items)
}
(warning)
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn options() -> OptionsPage {
serde_json::from_value(json!({
"key_types": [
{"type": "rsa", "label": "RSA", "sizes": [1024, 2048, 4096], "hashes": ["sha256", "sha1"]},
{"type": "dsa", "label": "DSA", "sizes": [1024, 3072], "hashes": ["sha256", "sha384"]},
{"type": "ec", "label": "EC", "curves": ["secp256r1", "weakCurve"], "hashes": ["sha384", "sha512"]},
{"type": "ed25519", "label": "ED25519", "hashes": []}
],
"hash_options": [
{"name": "sha256", "label": "SHA-256", "insecure": false},
{"name": "sha384", "label": "SHA-384", "insecure": false},
{"name": "sha512", "label": "SHA-512", "insecure": false},
{"name": "sha1", "label": "SHA-1", "insecure": true}
],
"curve_options": [
{"name": "secp256r1", "label": "NIST P-256", "group": "NIST", "insecure": false},
{"name": "weakCurve", "label": "Legacy curve", "group": "Legacy", "insecure": true},
{"name": "otherCurve", "label": "Other curve", "group": "Other", "insecure": false}
]
}))
.unwrap()
}
#[test]
fn hashes_follow_signing_key_rules() {
let options = options();
let rsa = allowed_hashes(&options, Some("rsa"));
assert_eq!(
rsa.iter()
.map(|hash| hash.name.as_str())
.collect::<Vec<_>>(),
["sha256", "sha1"]
);
assert_eq!(allowed_hashes(&options, Some("RSA")), rsa);
let ec = allowed_hashes(&options, Some("ec"));
assert_eq!(
ec.iter().map(|hash| hash.name.as_str()).collect::<Vec<_>>(),
["sha384", "sha512"]
);
assert!(allowed_hashes(&options, Some("ed25519")).is_empty());
assert_eq!(allowed_hashes(&options, None).len(), 4);
}
#[test]
fn legacy_options_remain_usable_without_rich_metadata() {
let options: OptionsPage = serde_json::from_value(json!({
"key_types": [{"type": "rsa", "label": "RSA", "sizes": [2048]}],
"hash_algorithms": ["sha256", "sha512"],
"curves": ["secp256r1"]
}))
.unwrap();
assert_eq!(allowed_hashes(&options, Some("rsa")).len(), 2);
assert_eq!(available_curves(&options, "ec").len(), 1);
}
#[test]
fn stale_hash_falls_back_without_overriding_valid_choice() {
let allowed = allowed_hashes(&options(), Some("ec"));
assert_eq!(valid_hash("sha512", &allowed), "sha512");
assert_eq!(valid_hash("sha1", &allowed), "sha384");
let allowed = allowed_hashes(&options(), Some("rsa"));
assert_eq!(valid_hash("sha512", &allowed), "sha256");
assert_eq!(valid_hash("sha1", &allowed), "sha1");
assert_eq!(valid_hash("sha256", &[]), "");
}
#[test]
fn curves_and_sizes_use_key_type_metadata() {
let options = options();
let curves = available_curves(&options, "ec");
assert_eq!(curves.len(), 2);
assert_eq!(
valid_choice(
"removed",
&curves
.iter()
.map(|curve| curve.name.clone())
.collect::<Vec<_>>(),
DEFAULT_CURVE
),
"secp256r1"
);
assert_eq!(key_sizes(&options, "dsa"), ["1024", "3072"]);
assert_eq!(
valid_choice("2048", &key_sizes(&options, "dsa"), DEFAULT_SIZE),
"1024"
);
}
#[test]
fn payload_rules_cover_curves_dsa_and_ed25519() {
let mut ec = json!({"key_type": "ec", "size": 2048});
add_key_parameters(&mut ec, "ec", "2048", "secp256r1");
assert_eq!(ec["curve"], "secp256r1");
assert!(ec.get("size").is_none());
let mut dsa = json!({"key_type": "dsa"});
add_key_parameters(&mut dsa, "dsa", "3072", "secp256r1");
assert_eq!(dsa["size"], 3072);
assert!(dsa.get("curve").is_none());
let mut ed = json!({"key_type": "ed25519", "size": 2048, "signature_hash": "sha256"});
add_key_parameters(&mut ed, "ed25519", "2048", "secp256r1");
apply_signature_hash(&mut ed, Some("ed25519"), "sha256");
assert!(ed.get("size").is_none());
assert!(ed.get("signature_hash").is_none());
}
#[test]
fn insecure_labels_are_explicit() {
assert_eq!(option_label("sha1", "SHA-1", true), "SHA-1 (insecure)");
assert_eq!(option_label("sha256", "SHA-256", false), "SHA-256");
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod crypto;
pub mod extension_form; pub mod extension_form;
pub mod list_editor; pub mod list_editor;
pub mod nav; pub mod nav;
+43 -1
View File
@@ -581,12 +581,16 @@ pub struct SummaryPage {
#[derive(Debug, Clone, PartialEq, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct OptionsPage { pub struct OptionsPage {
#[serde(default)] #[serde(default)]
pub key_types: Vec<Value>, pub key_types: Vec<KeyTypeOption>,
#[serde(default)] #[serde(default)]
pub hash_algorithms: Vec<String>, pub hash_algorithms: Vec<String>,
#[serde(default)] #[serde(default)]
pub curves: Vec<String>, pub curves: Vec<String>,
#[serde(default)] #[serde(default)]
pub curve_options: Vec<CurveOption>,
#[serde(default)]
pub hash_options: Vec<HashOption>,
#[serde(default)]
pub dn_attributes: Vec<DnAttribute>, pub dn_attributes: Vec<DnAttribute>,
#[serde(default)] #[serde(default)]
pub eku: Vec<DnAttribute>, pub eku: Vec<DnAttribute>,
@@ -602,6 +606,44 @@ pub struct OptionsPage {
pub templates: Vec<TemplateSummary>, pub templates: Vec<TemplateSummary>,
} }
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeyTypeOption {
#[serde(rename = "type", default)]
pub key_type: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub sizes: Option<Vec<i64>>,
#[serde(default)]
pub curves: Option<Vec<String>>,
#[serde(default)]
pub hashes: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CurveOption {
#[serde(default)]
pub name: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub group: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub insecure: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct HashOption {
#[serde(default)]
pub name: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub insecure: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct DnAttribute { pub struct DnAttribute {
#[serde(default)] #[serde(default)]
+21 -9
View File
@@ -4,15 +4,19 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::crypto::{apply_signature_hash, signature_hash_input};
use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{copy_button, empty_state, page_header, toast_error}; use crate::components::ui::{copy_button, empty_state, page_header, toast_error};
use crate::dom; use crate::dom;
use crate::model::{fmt_date, CertificateDetail, Me, Revocation, TemplateSummary}; use crate::model::{
fmt_date, CertificateDetail, Me, OptionsPage, Revocation, TemplateSummary,
};
pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str()); let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str());
let me = create_signal(None::<Me>); let me = create_signal(None::<Me>);
let options = create_signal(None::<OptionsPage>);
let cert = create_signal(None::<CertificateDetail>); let cert = create_signal(None::<CertificateDetail>);
let error = create_signal(None::<String>); let error = create_signal(None::<String>);
let loading = create_signal(true); let loading = create_signal(true);
@@ -85,6 +89,9 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
if let Ok(user) = api::me().await { if let Ok(user) = api::me().await {
me.set(Some(user)); me.set(Some(user));
} }
if let Ok(page) = api::options(id).await {
options.set(Some(page));
}
loading.set(false); loading.set(false);
}); });
load(); load();
@@ -188,13 +195,18 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
}); });
}; };
let ca_key_type = move || {
cert.get_clone()
.and_then(|certificate| certificate.key_type.clone())
};
let generate_crl = move |_| { let generate_crl = move |_| {
let payload = json!({ let mut payload = json!({
"days": crl_days.get_clone().parse::<i64>().unwrap_or(30), "days": crl_days.get_clone().parse::<i64>().unwrap_or(30),
"signature_hash": crl_hash.get_clone(),
"with_reason": true, "with_reason": true,
"include_aki": true, "include_aki": true,
}); });
apply_signature_hash(&mut payload, ca_key_type().as_deref(), &crl_hash.get_clone());
spawn_local(async move { spawn_local(async move {
match api::generate_crl(id, cert_id_value, &payload).await { match api::generate_crl(id, cert_id_value, &payload).await {
Ok(crl) => { Ok(crl) => {
@@ -518,12 +530,12 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
input(class="form-control", bind:value=crl_days) input(class="form-control", bind:value=crl_days)
} }
div(class="col-md-3") { div(class="col-md-3") {
label(class="form-label") { "Hash" } (signature_hash_input(
select(class="form-select", bind:value=crl_hash) { "Hash",
option(value="sha256") { "SHA-256" } options,
option(value="sha384") { "SHA-384" } ca_key_type,
option(value="sha512") { "SHA-512" } crl_hash,
} ))
} }
div(class="col-md-3") { div(class="col-md-3") {
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" } button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
+70 -23
View File
@@ -4,6 +4,10 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::crypto::{
add_key_parameters, apply_signature_hash, key_parameter_input, key_type_select,
signature_hash_input,
};
use crate::components::extension_form::{ use crate::components::extension_form::{
build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals, build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals,
}; };
@@ -14,7 +18,7 @@ use crate::components::list_editor::{
use crate::components::nav::{app_nav, whoami}; use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error}; use crate::components::ui::{page_header, toast_error};
use crate::model::{ use crate::model::{
CertificateDetail, CertificateSummary, DnAttribute, KeySummary, Me, TemplateSummary, CertificateDetail, CertificateSummary, DnAttribute, KeySummary, Me, OptionsPage, TemplateSummary,
}; };
const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"]; const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"];
@@ -73,6 +77,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let reactive_root = use_global_scope(); let reactive_root = use_global_scope();
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>); let me = create_signal(None::<Me>);
let options = create_signal(None::<OptionsPage>);
let keys = create_signal(Vec::<KeySummary>::new()); let keys = create_signal(Vec::<KeySummary>::new());
let signers = create_signal(Vec::<CertificateSummary>::new()); let signers = create_signal(Vec::<CertificateSummary>::new());
let templates = create_signal(Vec::<TemplateSummary>::new()); let templates = create_signal(Vec::<TemplateSummary>::new());
@@ -82,6 +87,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let request_id = query_param("request").unwrap_or_default(); let request_id = query_param("request").unwrap_or_default();
let request_mode = !request_id.is_empty(); let request_mode = !request_id.is_empty();
let request_id_for_load = request_id.clone(); let request_id_for_load = request_id.clone();
let request_key_id = create_signal(String::new());
let name = create_signal(String::new()); let name = create_signal(String::new());
let comment = create_signal(String::new()); let comment = create_signal(String::new());
@@ -90,6 +96,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let new_key = create_signal(false); let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string()); let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string()); let key_size = create_signal("2048".to_string());
let curve = create_signal("secp256r1".to_string());
let template_id = create_signal(String::new()); let template_id = create_signal(String::new());
let template_apply_mode = create_signal("all".to_string()); let template_apply_mode = create_signal("all".to_string());
let ca_default_template = create_signal(None::<String>); let ca_default_template = create_signal(None::<String>);
@@ -256,6 +263,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
if let Some(size) = key.get("size").and_then(|v| v.as_i64()) { if let Some(size) = key.get("size").and_then(|v| v.as_i64()) {
key_size.set(size.to_string()); key_size.set(size.to_string());
} }
if let Some(value) = key.get("curve").and_then(|v| v.as_str()) {
curve.set(value.to_string());
}
} }
if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) { if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) {
let names = items let names = items
@@ -277,8 +287,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
} }
} }
} }
if let Ok(options) = api::options(&id).await { if let Ok(page) = api::options(id).await {
dn_attributes.set(options.dn_attributes); dn_attributes.set(page.dn_attributes.clone());
options.set(Some(page));
} }
if !request_mode { if !request_mode {
if let Some(cert_id) = from_cert.clone() { if let Some(cert_id) = from_cert.clone() {
@@ -311,6 +322,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
if request_mode { if request_mode {
if let Ok(value) = api::request(&id, &request_id_for_load).await { if let Ok(value) = api::request(&id, &request_id_for_load).await {
name.set(format!("{} certificate", value.summary.name)); name.set(format!("{} certificate", value.summary.name));
request_key_id.set(value.summary.key_id.clone().unwrap_or_default());
let subject_json = let subject_json =
serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); serde_json::to_value(&value.subject_entries).unwrap_or(json!([]));
reactive_root.run_in(|| apply_subject_json(&form, &subject_json)); reactive_root.run_in(|| apply_subject_json(&form, &subject_json));
@@ -318,6 +330,34 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
} }
}); });
let signing_key_type = move || {
let keys = keys.get_clone();
let signer = signer_id.get_clone();
if signer != "self" {
let issuer_key_id = signers
.get_clone()
.into_iter()
.find(|cert| cert.id == signer)
.and_then(|cert| cert.key_id);
return issuer_key_id.and_then(|key_id| {
keys.into_iter()
.find(|key| key.id == key_id)
.map(|key| key.key_type)
});
}
if !request_mode && new_key.get() {
return Some(key_type.get_clone());
}
let subject_key_id = if request_mode {
request_key_id.get_clone()
} else {
key_id.get_clone()
};
keys.into_iter()
.find(|key| key.id == subject_key_id)
.map(|key| key.key_type)
};
let apply_template_id = move |template: String, mode: String| { let apply_template_id = move |template: String, mode: String| {
if template.is_empty() { if template.is_empty() {
return; return;
@@ -430,10 +470,14 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
"comment": comment.get_clone(), "comment": comment.get_clone(),
"subject": subject, "subject": subject,
"extensions": extensions, "extensions": extensions,
"signature_hash": signature_hash.get_clone(),
"signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) },
"validity": validity_value(&form), "validity": validity_value(&form),
}); });
apply_signature_hash(
&mut payload,
signing_key_type().as_deref(),
&signature_hash.get_clone(),
);
if validity_mode.get_clone() == "absolute" && !no_expiration.get() { if validity_mode.get_clone() == "absolute" && !no_expiration.get() {
payload["not_before"] = json!(not_before_text.get_clone()); payload["not_before"] = json!(not_before_text.get_clone());
payload["not_after"] = json!(not_after_text.get_clone()); payload["not_after"] = json!(not_after_text.get_clone());
@@ -443,11 +487,17 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
payload["modify_subject"] = json!(true); payload["modify_subject"] = json!(true);
payload["copy_request_extensions"] = json!(false); payload["copy_request_extensions"] = json!(false);
} else if new_key.get() { } else if new_key.get() {
payload["new_key"] = json!({ let mut generated_key = json!({
"key_type": key_type.get_clone(), "key_type": key_type.get_clone(),
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
"name": format!("{} key", name.get_clone()), "name": format!("{} key", name.get_clone()),
}); });
add_key_parameters(
&mut generated_key,
&key_type.get_clone(),
&key_size.get_clone(),
&curve.get_clone(),
);
payload["new_key"] = generated_key;
} else if !key_id.get_clone().is_empty() { } else if !key_id.get_clone().is_empty() {
payload["key_id"] = json!(key_id.get_clone()); payload["key_id"] = json!(key_id.get_clone());
} else { } else {
@@ -494,15 +544,18 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
} }
}; };
let signer = signer_id.get_clone(); let signer = signer_id.get_clone();
let payload = json!({ let mut payload = json!({
"name": if name.get_clone().is_empty() { "preview".to_string() } else { name.get_clone() }, "name": if name.get_clone().is_empty() { "preview".to_string() } else { name.get_clone() },
"subject": subject, "subject": subject,
"extensions": extensions, "extensions": extensions,
"signature_hash": signature_hash.get_clone(),
"signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) },
"validity": validity_value(&form), "validity": validity_value(&form),
}); });
let mut payload = payload; apply_signature_hash(
&mut payload,
signing_key_type().as_deref(),
&signature_hash.get_clone(),
);
if validity_mode.get_clone() == "absolute" && !no_expiration.get() { if validity_mode.get_clone() == "absolute" && !no_expiration.get() {
payload["not_before"] = json!(not_before_text.get_clone()); payload["not_before"] = json!(not_before_text.get_clone());
payload["not_after"] = json!(not_after_text.get_clone()); payload["not_after"] = json!(not_after_text.get_clone());
@@ -602,13 +655,12 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
} }
} }
div(class="col-md-6") { div(class="col-md-6") {
label(class="form-label") { "Signature hash" } (signature_hash_input(
select(class="form-select", bind:value=signature_hash) { "Signature hash",
option(value="sha256") { "SHA-256" } options,
option(value="sha384") { "SHA-384" } signing_key_type,
option(value="sha512") { "SHA-512" } signature_hash,
option(value="sha1") { "SHA-1" } ))
}
} }
div(class="col-md-6") { div(class="col-md-6") {
label(class="form-label") { "Template" } label(class="form-label") { "Template" }
@@ -661,15 +713,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
div(class="row g-3") { div(class="row g-3") {
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Key type" } label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) { (key_type_select(options, key_type))
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
} }
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Key size" } (key_parameter_input(options, key_type, key_size, curve))
input(class="form-control", bind:value=key_size)
} }
} }
} }
+18 -59
View File
@@ -4,25 +4,15 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::crypto::{add_key_parameters, key_parameter_input, key_type_select};
use crate::components::nav::{app_nav, whoami}; use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error}; use crate::components::ui::{page_header, toast_error};
use crate::model::Me; use crate::model::{Me, OptionsPage};
const RSA_SIZES: &[&str] = &["1024", "2048", "3072", "4096", "8192"];
const DSA_SIZES: &[&str] = &["1024", "2048", "3072"];
const CURVES: &[&str] = &[
"secp256r1",
"secp384r1",
"secp521r1",
"secp256k1",
"brainpoolP256r1",
"brainpoolP384r1",
"brainpoolP512r1",
];
pub fn KeyNewPage(vault_id: String) -> View { pub fn KeyNewPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>); let me = create_signal(None::<Me>);
let options = create_signal(None::<OptionsPage>);
let error = create_signal(None::<String>); let error = create_signal(None::<String>);
let name = create_signal(String::new()); let name = create_signal(String::new());
let comment = create_signal(String::new()); let comment = create_signal(String::new());
@@ -37,6 +27,11 @@ pub fn KeyNewPage(vault_id: String) -> View {
me.set(Some(user)); me.set(Some(user));
} }
} }
if let Ok(page) = api::options(id).await {
if options.is_alive() {
options.set(Some(page));
}
}
}); });
let submit = move |_| { let submit = move |_| {
@@ -51,11 +46,12 @@ pub fn KeyNewPage(vault_id: String) -> View {
"comment": comment.get_clone(), "comment": comment.get_clone(),
"key_type": key_type_value, "key_type": key_type_value,
}); });
if key_type_value == "ec" { add_key_parameters(
payload["curve"] = json!(curve.get_clone()); &mut payload,
} else if key_type_value == "rsa" || key_type_value == "dsa" { &key_type_value,
payload["size"] = json!(size.get_clone().parse::<i64>().unwrap_or(2048)); &size.get_clone(),
} &curve.get_clone(),
);
saving.set(true); saving.set(true);
let id = id.clone(); let id = id.clone();
spawn_local(async move { spawn_local(async move {
@@ -74,45 +70,6 @@ pub fn KeyNewPage(vault_id: String) -> View {
}); });
}; };
let type_options = move || {
["rsa", "dsa", "ec", "ed25519"]
.iter()
.map(|value| {
view! {
option(value=*value) {
(if *value == "ed25519" { "ED25519".to_string() } else { value.to_uppercase() })
}
}
})
.collect::<Vec<View>>()
};
let parameter_input = move || {
let kind = key_type.get_clone();
if kind == "ec" {
view! {
label(class="form-label") { "Curve" }
select(class="form-select", bind:value=curve) {
(CURVES.iter().map(|value| view! {
option(value=*value) { (*value) }
}).collect::<Vec<View>>())
}
}
} else if kind == "ed25519" {
view! { div(class="alert alert-info py-2 mb-0") { "Ed25519 keys have a fixed 256-bit size." } }
} else {
let sizes = if kind == "dsa" { DSA_SIZES } else { RSA_SIZES };
view! {
label(class="form-label") { "Key size (bits)" }
select(class="form-select", bind:value=size) {
(sizes.iter().map(|value| view! {
option(value=*value) { (*value) }
}).collect::<Vec<View>>())
}
}
}
};
let nav = move || match me.get_clone() { let nav = move || match me.get_clone() {
Some(user) => whoami(&user), Some(user) => whoami(&user),
None => view! {}, None => view! {},
@@ -134,9 +91,11 @@ pub fn KeyNewPage(vault_id: String) -> View {
div(class="row g-3 mb-3") { div(class="row g-3 mb-3") {
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Key type" } label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) { (type_options()) } (key_type_select(options, key_type))
}
div(class="col-md-4") {
(key_parameter_input(options, key_type, size, curve))
} }
div(class="col-md-4") { (move || parameter_input()) }
} }
div(class="mb-3") { div(class="mb-3") {
label(class="form-label") { "Comment" } label(class="form-label") { "Comment" }
+44 -18
View File
@@ -4,13 +4,18 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::crypto::{
add_key_parameters, apply_signature_hash, key_parameter_input, key_type_select,
signature_hash_input,
};
use crate::components::nav::{app_nav, whoami}; use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error}; use crate::components::ui::{page_header, toast_error};
use crate::model::{KeySummary, Me}; use crate::model::{KeySummary, Me, OptionsPage};
pub fn RequestNewPage(vault_id: String) -> View { pub fn RequestNewPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>); let me = create_signal(None::<Me>);
let options = create_signal(None::<OptionsPage>);
let keys = create_signal(Vec::<KeySummary>::new()); let keys = create_signal(Vec::<KeySummary>::new());
let error = create_signal(None::<String>); let error = create_signal(None::<String>);
let saving = create_signal(false); let saving = create_signal(false);
@@ -21,6 +26,7 @@ pub fn RequestNewPage(vault_id: String) -> View {
let new_key = create_signal(false); let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string()); let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string()); let key_size = create_signal("2048".to_string());
let curve = create_signal("secp256r1".to_string());
let signature_hash = create_signal("sha256".to_string()); let signature_hash = create_signal("sha256".to_string());
let cn = create_signal(String::new()); let cn = create_signal(String::new());
@@ -54,8 +60,24 @@ pub fn RequestNewPage(vault_id: String) -> View {
keys.set(page.results.into_iter().filter(|key| key.has_private).collect()); keys.set(page.results.into_iter().filter(|key| key.has_private).collect());
} }
} }
if let Ok(page) = api::options(id).await {
if options.is_alive() {
options.set(Some(page));
}
}
}); });
let signing_key_type = move || {
if new_key.get() {
return Some(key_type.get_clone());
}
let selected = key_id.get_clone();
keys.get_clone()
.into_iter()
.find(|key| key.id == selected)
.map(|key| key.key_type)
};
let submit = move |_| { let submit = move |_| {
let request_name = name.get_clone(); let request_name = name.get_clone();
if request_name.trim().is_empty() { if request_name.trim().is_empty() {
@@ -113,14 +135,24 @@ pub fn RequestNewPage(vault_id: String) -> View {
"comment": comment.get_clone(), "comment": comment.get_clone(),
"subject": subject, "subject": subject,
"extensions": extensions, "extensions": extensions,
"signature_hash": signature_hash.get_clone(),
}); });
apply_signature_hash(
&mut payload,
signing_key_type().as_deref(),
&signature_hash.get_clone(),
);
if new_key.get() { if new_key.get() {
payload["new_key"] = json!({ let mut generated_key = json!({
"key_type": key_type.get_clone(), "key_type": key_type.get_clone(),
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
"name": format!("{} key", name.get_clone()), "name": format!("{} key", name.get_clone()),
}); });
add_key_parameters(
&mut generated_key,
&key_type.get_clone(),
&key_size.get_clone(),
&curve.get_clone(),
);
payload["new_key"] = generated_key;
} else if !key_id.get_clone().is_empty() { } else if !key_id.get_clone().is_empty() {
payload["key_id"] = json!(key_id.get_clone()); payload["key_id"] = json!(key_id.get_clone());
} else { } else {
@@ -180,13 +212,12 @@ pub fn RequestNewPage(vault_id: String) -> View {
input(class="form-control", bind:value=name) input(class="form-control", bind:value=name)
} }
div(class="col-md-6") { div(class="col-md-6") {
label(class="form-label") { "Signature hash" } (signature_hash_input(
select(class="form-select", bind:value=signature_hash) { "Signature hash",
option(value="sha256") { "SHA-256" } options,
option(value="sha384") { "SHA-384" } signing_key_type,
option(value="sha512") { "SHA-512" } signature_hash,
option(value="sha1") { "SHA-1" } ))
}
} }
div(class="col-12") { div(class="col-12") {
label(class="form-label") { "Comment" } label(class="form-label") { "Comment" }
@@ -217,15 +248,10 @@ pub fn RequestNewPage(vault_id: String) -> View {
div(class="row g-3") { div(class="row g-3") {
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Key type" } label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) { (key_type_select(options, key_type))
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
} }
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Key size" } (key_parameter_input(options, key_type, key_size, curve))
input(class="form-control", bind:value=key_size)
} }
} }
} }
+28 -17
View File
@@ -4,13 +4,15 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::crypto::{key_parameter_input, key_type_select, signature_hash_input};
use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error}; use crate::components::ui::{page_header, toast_error};
use crate::model::Me; use crate::model::{Me, OptionsPage};
pub fn SettingsPage(vault_id: String) -> View { pub fn SettingsPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>); let me = create_signal(None::<Me>);
let options = create_signal(None::<OptionsPage>);
let error = create_signal(None::<String>); let error = create_signal(None::<String>);
let info = create_signal(None::<String>); let info = create_signal(None::<String>);
let loading = create_signal(true); let loading = create_signal(true);
@@ -25,6 +27,7 @@ pub fn SettingsPage(vault_id: String) -> View {
let hide_unusable = create_signal(false); let hide_unusable = create_signal(false);
let default_key_type = create_signal("rsa".to_string()); let default_key_type = create_signal("rsa".to_string());
let default_key_size = create_signal("2048".to_string()); let default_key_size = create_signal("2048".to_string());
let default_key_curve = create_signal("secp256r1".to_string());
let default_validity = create_signal("365".to_string()); let default_validity = create_signal("365".to_string());
let default_validity_unit = create_signal("days".to_string()); let default_validity_unit = create_signal("days".to_string());
@@ -32,6 +35,9 @@ pub fn SettingsPage(vault_id: String) -> View {
if let Ok(user) = api::me().await { if let Ok(user) = api::me().await {
me.set(Some(user)); me.set(Some(user));
} }
if let Ok(page) = api::options(id).await {
options.set(Some(page));
}
match api::vault_settings(&id).await { match api::vault_settings(&id).await {
Ok(value) => { Ok(value) => {
version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(0)); version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(0));
@@ -57,6 +63,9 @@ pub fn SettingsPage(vault_id: String) -> View {
if let Some(v) = key.get("size").and_then(|v| v.as_i64()) { if let Some(v) = key.get("size").and_then(|v| v.as_i64()) {
default_key_size.set(v.to_string()); default_key_size.set(v.to_string());
} }
if let Some(v) = key.get("curve").and_then(|v| v.as_str()) {
default_key_curve.set(v.to_string());
}
} }
if let Some(validity) = settings.get("default_validity") { if let Some(validity) = settings.get("default_validity") {
if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) { if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) {
@@ -87,7 +96,11 @@ pub fn SettingsPage(vault_id: String) -> View {
"default_key": { "default_key": {
"type": default_key_type.get_clone(), "type": default_key_type.get_clone(),
"size": default_key_size.get_clone().parse::<i64>().unwrap_or(2048), "size": default_key_size.get_clone().parse::<i64>().unwrap_or(2048),
"curve": null, "curve": if default_key_type.get_clone() == "ec" {
json!(default_key_curve.get_clone())
} else {
serde_json::Value::Null
},
}, },
"default_validity": { "default_validity": {
"amount": default_validity.get_clone().parse::<i64>().unwrap_or(365), "amount": default_validity.get_clone().parse::<i64>().unwrap_or(365),
@@ -130,14 +143,12 @@ pub fn SettingsPage(vault_id: String) -> View {
div(class="form-section-title") { "Cryptography" } div(class="form-section-title") { "Cryptography" }
div(class="row g-3") { div(class="row g-3") {
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Default signature hash" } (signature_hash_input(
select(class="form-select", bind:value=default_hash) { "Default signature hash",
option(value="sha256") { "SHA-256" } options,
option(value="sha384") { "SHA-384" } || None,
option(value="sha512") { "SHA-512" } default_hash,
option(value="sha224") { "SHA-224" } ))
option(value="sha1") { "SHA-1" }
}
} }
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Serial number length (bits)" } label(class="form-label") { "Serial number length (bits)" }
@@ -175,15 +186,15 @@ pub fn SettingsPage(vault_id: String) -> View {
div(class="row g-3") { div(class="row g-3") {
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Default key type" } label(class="form-label") { "Default key type" }
select(class="form-select", bind:value=default_key_type) { (key_type_select(options, default_key_type))
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
} }
div(class="col-md-4") { div(class="col-md-4") {
label(class="form-label") { "Default key size" } (key_parameter_input(
input(class="form-control", bind:value=default_key_size) options,
default_key_type,
default_key_size,
default_key_curve,
))
} }
div(class="col-md-4 d-flex align-items-end") { div(class="col-md-4 d-flex align-items-end") {
button(class="btn btn-primary", disabled=saving.get(), on:click=save) { button(class="btn btn-primary", disabled=saving.get(), on:click=save) {