Add full XCA curve and digest parity
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
Generated
+2
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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(¤t, &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(¤t, &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(¤t, &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,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
@@ -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)]
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
@@ -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" }
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user