From d5f060cbf0f8363bc2125e56423836e09b4abc40 Mon Sep 17 00:00:00 2001 From: KCA Date: Mon, 26 Oct 2026 03:42:40 +0000 Subject: [PATCH] Add full XCA curve and digest parity --- README.md | 13 +- e2e/tests/crypto.spec.js | 68 ++ server/.env.example | 4 + server/Dockerfile | 23 +- .../kca-app/src/kca/routes/certificates.py | 13 +- .../kca-app/src/kca/routes/requests.py | 29 +- .../packages/kca-app/src/kca/routes/vaults.py | 6 +- server/packages/kca-app/src/kca/service.py | 105 +- server/packages/kca-core/README.md | 12 +- server/packages/kca-core/pyproject.toml | 1 + .../kca-core/src/kca/core/algorithms.py | 260 +++++ .../kca-core/src/kca/core/certificates.py | 122 ++- server/packages/kca-core/src/kca/core/crls.py | 42 +- .../packages/kca-core/src/kca/core/exports.py | 38 +- .../kca-core/src/kca/core/extensions.py | 45 +- .../packages/kca-core/src/kca/core/imports.py | 13 +- server/packages/kca-core/src/kca/core/keys.py | 184 +++- .../kca-core/src/kca/core/openssl_abi.py | 476 +++++++++ .../kca-core/src/kca/core/openssl_compat.py | 941 ++++++++++++++++++ .../kca-core/src/kca/core/requests.py | 91 +- .../kca-core/src/kca/core/signatures.py | 80 ++ .../packages/kca-core/src/kca/core/xca_db.py | 16 +- .../tests/test_algorithm_workflows.py | 339 +++++++ .../kca-core/tests/test_algorithms.py | 92 ++ .../kca-core/tests/test_openssl_compat.py | 449 +++++++++ server/packages/kca-core/tests/test_xca_db.py | 85 ++ server/tests/test_algorithm_routes.py | 204 ++++ server/uv.lock | 2 + web/src/components/crypto.rs | 552 ++++++++++ web/src/components/mod.rs | 1 + web/src/model.rs | 44 +- web/src/pages/certificate_detail.rs | 30 +- web/src/pages/certificate_new.rs | 93 +- web/src/pages/key_new.rs | 77 +- web/src/pages/request_new.rs | 62 +- web/src/pages/settings.rs | 45 +- 36 files changed, 4326 insertions(+), 331 deletions(-) create mode 100644 e2e/tests/crypto.spec.js create mode 100644 server/packages/kca-core/src/kca/core/algorithms.py create mode 100644 server/packages/kca-core/src/kca/core/openssl_abi.py create mode 100644 server/packages/kca-core/src/kca/core/openssl_compat.py create mode 100644 server/packages/kca-core/src/kca/core/signatures.py create mode 100644 server/packages/kca-core/tests/test_algorithm_workflows.py create mode 100644 server/packages/kca-core/tests/test_algorithms.py create mode 100644 server/packages/kca-core/tests/test_openssl_compat.py create mode 100644 server/tests/test_algorithm_routes.py create mode 100644 web/src/components/crypto.rs diff --git a/README.md b/README.md index 2154d82..9869d66 100644 --- a/README.md +++ b/README.md @@ -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, requests, CRLs and templates; non-owners get `404`. Vaults can be renamed, deleted and configured from **Settings** (default hash, serial length, -explicit/mandatory DN entries, default key type/size, default validity). +explicit/mandatory DN entries, default key type/size/curve, default validity). ### Keys -- Generate RSA (1024–8192), DSA (1024–3072), EC (P-192/P-224/P-256/P-384/P-521, - secp256k1, brainpool) and Ed25519 keys. +- Generate RSA (1024–8192), DSA (1024–3072), Ed25519 and EC keys across the + 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. - Export PEM public/private, traditional encrypted PEM, PKCS#8 (plain or encrypted), DER public/private, SSH2 public/private, JWK public/private and diff --git a/e2e/tests/crypto.spec.js b/e2e/tests/crypto.spec.js new file mode 100644 index 0000000..1e282de --- /dev/null +++ b/e2e/tests/crypto.spec.js @@ -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"); +}); diff --git a/server/.env.example b/server/.env.example index 31077a5..5f8c3f0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -29,6 +29,10 @@ REDIS_URL=redis://localhost:6379/0 # When unset, a built-in development key is used (the UI warns about it). #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 ----------------------------------------------------------- APP_HOST=0.0.0.0 APP_PORT=8080 diff --git a/server/Dockerfile b/server/Dockerfile index 492475c..07209ee 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -29,12 +29,15 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ trunk build --release --public-url /static/ # --- 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/ -RUN --mount=type=cache,target=/var/cache/apk \ - apk add python3 build-base python3-dev git +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \ + /etc/apt/sources.list.d/debian.sources 2>/dev/null || true \ + && apt-get update \ + && apt-get install -y --no-install-recommends build-essential git \ + && rm -rf /var/lib/apt/lists/* RUN git config --global --add safe.directory /repo ENV UV_PYTHON_DOWNLOADS=0 \ @@ -56,11 +59,15 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY server/migrations ./migrations # --- Runtime --------------------------------------------------------------- -FROM alpine:3.24 +FROM python:3.12-slim-trixie -RUN --mount=type=cache,target=/var/cache/apk \ - apk add python3 ca-certificates tzdata \ - && addgroup -S app && adduser -S -G app app +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \ + /etc/apt/sources.list.d/debian.sources 2>/dev/null || true \ + && apt-get update \ + && apt-get install -y --no-install-recommends 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 /repo/server/migrations /app/migrations @@ -82,6 +89,6 @@ USER app EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ - CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1 + CMD 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"] diff --git a/server/packages/kca-app/src/kca/routes/certificates.py b/server/packages/kca-app/src/kca/routes/certificates.py index f9b51d9..e3fcabc 100644 --- a/server/packages/kca-app/src/kca/routes/certificates.py +++ b/server/packages/kca-app/src/kca/routes/certificates.py @@ -34,6 +34,7 @@ from ..service import ( parse_datetime, parse_uuid, renew_certificate, + resolve_signature_hash, revoke_certificate, save_key, search_text, @@ -593,7 +594,7 @@ def register(app: KayaApp, kca: "Kca") -> None: parsed = cert_core.parse_certificate(bytes(cert.cert_der)) key = await save_key( vault, - parsed.public_key(), + cert_core.certificate_public_key(parsed), name=f"{cert.name} public key", comment=f"Extracted from certificate {cert.name}", source="transformed", @@ -617,11 +618,17 @@ def register(app: KayaApp, kca: "Kca") -> None: return parsed = cert_core.parse_certificate(bytes(cert.cert_der)) 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( subject=parsed.subject, - private_key=await load_private_key(key), + private_key=private_key, extension_spec=cert_core.certificate_extension_specs(parsed), - signature_hash=cert_core.signature_hash_name(parsed), + signature_hash=signature_hash, ) except ValueError as exc: await send_error(ctx, 400, str(exc)) diff --git a/server/packages/kca-app/src/kca/routes/requests.py b/server/packages/kca-app/src/kca/routes/requests.py index b17ec0a..4c599ba 100644 --- a/server/packages/kca-app/src/kca/routes/requests.py +++ b/server/packages/kca-app/src/kca/routes/requests.py @@ -22,8 +22,10 @@ from ..service import ( ValidationError, generate_key, parse_extensions, + parse_key_generation, parse_subject, parse_uuid, + resolve_signature_hash, save_key, search_text, serialize_request_detail, @@ -31,6 +33,7 @@ from ..service import ( serialize_template_summary, load_private_key, vault_for_owner, + vault_settings, ) if TYPE_CHECKING: @@ -96,26 +99,40 @@ def register(app: KayaApp, kca: "Kca") -> None: key_obj = await load_private_key(key_model) elif isinstance(body.get("new_key"), dict): new_key = body["new_key"] - generated = await generate_key( - str(new_key.get("key_type") or "rsa"), - int(new_key.get("size") or key_core.DEFAULT_KEY_SIZE), - new_key.get("curve"), + params = parse_key_generation({ + "key_type": new_key.get("key_type") or "rsa", + "size": new_key.get("size", key_core.DEFAULT_KEY_SIZE), + "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( vault, generated, - name=str(new_key.get("name") or name), + name=params["name"], comment="Generated while creating a request", source="generated", ) key_obj = generated else: 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( subject=subject, private_key=key_obj, extension_spec=extensions, - signature_hash=body.get("signature_hash"), + signature_hash=signature_hash, attributes=body.get("attributes") or {}, ) except (ValidationError, ValueError) as exc: diff --git a/server/packages/kca-app/src/kca/routes/vaults.py b/server/packages/kca-app/src/kca/routes/vaults.py index 6bf8e92..36e95fd 100644 --- a/server/packages/kca-app/src/kca/routes/vaults.py +++ b/server/packages/kca-app/src/kca/routes/vaults.py @@ -319,8 +319,10 @@ def register(app: KayaApp, kca: "Kca") -> None: return await send_json(ctx, 200, { "key_types": key_core.key_type_options(), - "hash_algorithms": list(key_core.HASH_ALGORITHMS), - "curves": list(key_core.EC_CURVES), + "hash_algorithms": [item.name for item in key_core.HASHES], + "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(), "eku": name_core.eku_options(), "key_usages": ext_core.KEY_USAGES, diff --git a/server/packages/kca-app/src/kca/service.py b/server/packages/kca-app/src/kca/service.py index a337126..b93ffb1 100644 --- a/server/packages/kca-app/src/kca/service.py +++ b/server/packages/kca-app/src/kca/service.py @@ -11,6 +11,12 @@ from uuid import UUID from cryptography import x509 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 crls as crl_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}") merged[key] = value # Validate the values that affect cryptographic operations. - if merged.get("default_hash") not in key_core.HASH_ALGORITHMS: - raise ValidationError("default_hash must be sha1, sha224, sha256, sha384 or sha512") + if merged.get("default_hash") not in HASH_BY_NAME: + 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: bits = int(merged.get("serial_len_bits", 64)) 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: raise ValidationError("key size must be an integer") from exc curve = payload.get("curve") - if key_type == "ec" and curve not in key_core.EC_CURVES: - raise ValidationError("unsupported EC curve") + if key_type == "ec": + 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() if not name: 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: """Return a public key object from either a private or public key.""" - if hasattr(key_obj, "public_key"): - return key_obj.public_key() - return key_obj + return key_core.public_part(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, ) -> Certificate: """Create a certificate from the wizard payload.""" + settings_obj = vault_settings(vault) name = str(payload.get("name") or "").strip() if not name: 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)): key_obj = await load_private_key(key_model) 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: generated = await generate_key( - str(new_key_payload.get("key_type") or "rsa"), - int(new_key_payload.get("size") or key_core.DEFAULT_KEY_SIZE), - new_key_payload.get("curve"), + new_key_params["key_type"], + new_key_params["size"], + 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: raise ValidationError(str(exc)) from exc key_model = await save_key( vault, generated, - name=str(new_key_payload.get("name") or name), + name=new_key_params["name"], comment="Generated while creating a certificate", 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) not_before, not_after, no_expiration = validate_validity(payload) - settings_obj = vault_settings(vault) check_mandatory_dn(subject, settings_obj) bits = int(settings_obj.get("serial_len_bits", 64)) 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) elif await _serial_in_use(vault, signer, serial): 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 key_model is None or not key_model.has_private: raise ValidationError("a self-signed certificate requires the private key") 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( subject=subject, private_key=private_key, @@ -790,6 +843,9 @@ async def issue_certificate( else: signer_private = await load_private_key(signer_key) 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( subject=subject, public_key=_public_part(key_obj), @@ -895,7 +951,7 @@ async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[ ) else: 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")) 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)) signer_private = await load_private_key(signer_key) 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( 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_private_key=signer_private, serial=serial, not_before=not_before, not_after=not_after, 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) 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) crl_number = int(payload.get("crl_number") or (authority.crl_number + 1)) 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( issuer_cert=cert_core.parse_certificate(bytes(ca.cert_der)), - issuer_private_key=await load_private_key(key), + issuer_private_key=issuer_private, revocations=[ { "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, next_update=next_update, 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)), include_aki=bool(payload.get("include_aki", True)), include_issuer_alt_name=bool(payload.get("include_issuer_alt_name", True)), diff --git a/server/packages/kca-core/README.md b/server/packages/kca-core/README.md index e519794..76c34cf 100644 --- a/server/packages/kca-core/README.md +++ b/server/packages/kca-core/README.md @@ -1,7 +1,9 @@ # kca-core -Pure, I/O-free PKI domain logic for KCA. It knows how to generate and parse -keys, build distinguished names, encode X.509 extensions, issue certificates, -create CSRs and CRLs, apply templates, and convert every supported import and -export format. It has no kaya, database or filesystem dependencies and is -unit-tested in isolation. +Pure PKI domain logic for KCA. It knows how to generate and parse keys, build +distinguished names, encode X.509 extensions, issue certificates, create CSRs +and CRLs, apply templates, and convert every supported import and export +format. It has no kaya or database dependencies and is unit-tested in +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. diff --git a/server/packages/kca-core/pyproject.toml b/server/packages/kca-core/pyproject.toml index 550257c..e8cbeb2 100644 --- a/server/packages/kca-core/pyproject.toml +++ b/server/packages/kca-core/pyproject.toml @@ -9,6 +9,7 @@ description = "Pure PKI domain logic for KCA (keys, names, extensions, certifica readme = "README.md" requires-python = ">=3.10" dependencies = [ + "cffi>=2", "cryptography>=44", "paramiko>=3.5", ] diff --git a/server/packages/kca-core/src/kca/core/algorithms.py b/server/packages/kca-core/src/kca/core/algorithms.py new file mode 100644 index 0000000..661f9c7 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/algorithms.py @@ -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", +] diff --git a/server/packages/kca-core/src/kca/core/certificates.py b/server/packages/kca-core/src/kca/core/certificates.py index 3a727b2..7791faa 100644 --- a/server/packages/kca-core/src/kca/core/certificates.py +++ b/server/packages/kca-core/src/kca/core/certificates.py @@ -8,9 +8,11 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa +from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import ExtensionOID, NameOID +from . import openssl_compat +from .algorithms import validate_signature_hash from .extensions import ( build_extensions, eku_name, @@ -18,8 +20,22 @@ from .extensions import ( 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, 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. FAR_FUTURE = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc) @@ -83,7 +99,7 @@ def build_certificate( not_before: datetime, not_after: datetime, extensions: Iterable[x509.Extension], - signature_hash: Optional[str] = "sha256", + signature_hash: Optional[str] = None, ) -> x509.Certificate: """Issue a certificate (self-signed when ``issuer_cert`` is None).""" if serial <= 0: @@ -95,19 +111,40 @@ def build_certificate( if not_after <= not_before: raise ValueError("notAfter must be later than notBefore") issuer_name = issuer_cert.subject if issuer_cert is not None else subject + 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 = ( x509.CertificateBuilder() .subject_name(subject) .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) .not_valid_before(not_before) .not_valid_after(not_after) ) for extension in extensions: builder = builder.add_extension(extension.value, extension.critical) - algorithm = hash_algorithm(signature_hash, issuer_private_key) - return builder.sign(private_key=issuer_private_key, algorithm=algorithm) + if template_key is None: + 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: @@ -122,6 +159,14 @@ def certificate_pem(cert: x509.Certificate) -> str: 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: try: 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, path_length=path_length, signature_algorithm=signature_algorithm_name(cert), - public_key_der=cert.public_key().public_bytes( - serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo - ), + public_key_der=certificate_public_der(cert), fingerprint_md5=fingerprint(cert, "md5"), fingerprint_sha1=fingerprint(cert, "sha1"), 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: - algorithm = cert.signature_algorithm_oid - dotted = algorithm.dotted_string - if dotted in _SIGNATURE_OIDS: - return _SIGNATURE_OIDS[dotted] - name = getattr(algorithm, "name", None) - if name and name != "Undefined": - return name - return dotted + return oid_signature_algorithm_name(cert.signature_algorithm_oid) 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: - if cert.subject != cert.issuer: - return False - try: - cert.verify_directly_issued_by(cert) - return True - except Exception: # noqa: BLE001 - invalid signature - return False + return cert.subject == cert.issuer and verify_directly_issued_by(cert, cert) def verify_directly_issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool: + if cert.issuer != issuer.subject: + return False try: cert.verify_directly_issued_by(issuer) return True - except Exception: # noqa: BLE001 - return False + except Exception: # noqa: BLE001 - try OpenSSL for unsupported curves/hashes + 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]: @@ -323,13 +344,14 @@ def certificate_spec(cert: x509.Certificate) -> Dict[str, Any]: 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: algorithm = cert.signature_hash_algorithm - except Exception: # noqa: BLE001 - Ed25519 + except Exception: # noqa: BLE001 - Ed25519 or an unknown legacy OID return None - if algorithm is None: - return None - return algorithm.name + return algorithm.name if algorithm is not None else None # 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: - 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]]: @@ -436,9 +461,9 @@ def build_self_signed( not_before: datetime, not_after: datetime, extension_spec: Optional[Dict[str, Any]] = None, - signature_hash: Optional[str] = "sha256", + signature_hash: Optional[str] = None, ) -> x509.Certificate: - public_key = private_key.public_key() + public_key = public_part(private_key) extensions = build_extensions( extension_spec, public_key=public_key, @@ -468,7 +493,7 @@ def build_signed( not_before: datetime, not_after: datetime, extension_spec: Optional[Dict[str, Any]] = None, - signature_hash: Optional[str] = "sha256", + signature_hash: Optional[str] = None, ) -> x509.Certificate: extensions = build_extensions( extension_spec, @@ -501,6 +526,7 @@ __all__ = [ "certificate_extensions", "certificate_info", "certificate_pem", + "certificate_public_der", "certificate_public_key", "certificate_spec", "chain_for", diff --git a/server/packages/kca-core/src/kca/core/crls.py b/server/packages/kca-core/src/kca/core/crls.py index 6c3839a..ec2f039 100644 --- a/server/packages/kca-core/src/kca/core/crls.py +++ b/server/packages/kca-core/src/kca/core/crls.py @@ -7,12 +7,15 @@ from datetime import datetime, timezone from typing import Any, Dict, Iterable, List, Mapping, Optional from cryptography import x509 -from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec 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 .extensions import extension_rows, extension_specs -from .keys import PrivateKey, hash_algorithm +from .extensions import authority_key_identifier, extension_rows, extension_specs +from .keys import PrivateKey, hash_algorithm, is_compat_key, key_type_name, private_der #: XCA's revocation reason names, in OpenSSL order. CRL_REASONS: List[Dict[str, Any]] = [ @@ -59,7 +62,7 @@ def build_crl( last_update: datetime, next_update: datetime, crl_number: Optional[int] = None, - signature_hash: Optional[str] = "sha256", + signature_hash: Optional[str] = None, with_reason: bool = True, include_aki: 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) if include_aki: - try: - ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value - aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski) - except x509.ExtensionNotFound: - aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_cert.public_key()) - builder = builder.add_extension(aki, critical=False) + builder = builder.add_extension(authority_key_identifier(issuer_cert), critical=False) if include_issuer_alt_name: try: @@ -112,8 +110,19 @@ def build_crl( except x509.ExtensionNotFound: pass - algorithm = hash_algorithm(signature_hash, issuer_private_key) - return builder.sign(private_key=issuer_private_key, algorithm=algorithm) + normalized_hash = validate_signature_hash(key_type_name(issuer_private_key), signature_hash) + 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: @@ -179,7 +188,14 @@ def verify_crl(crl: x509.CertificateRevocationList, issuer_cert: x509.Certificat if crl.issuer != issuer_cert.subject: return False 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 return False diff --git a/server/packages/kca-core/src/kca/core/exports.py b/server/packages/kca-core/src/kca/core/exports.py index 2b0d445..af590ae 100644 --- a/server/packages/kca-core/src/kca/core/exports.py +++ b/server/packages/kca-core/src/kca/core/exports.py @@ -17,12 +17,15 @@ from cryptography.hazmat.primitives.serialization import ( 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 .keys import ( PrivateKey, + is_compat_key, jwk_private, jwk_public, + private_der, private_der_bytes, private_pem, public_pem, @@ -109,13 +112,30 @@ def export_certificate( if private_key is None: raise ValueError("PKCS#12 export requires the private key") cas = [item for item in chain if item != cert] if fmt == "pkcs12-chain" else [] - data = pkcs12.serialize_key_and_certificates( - name=stem.encode(), - key=private_key, - cert=cert, - cas=cas or None, - encryption_algorithm=_password_encryption(password), - ) + compat_container = is_compat_key(private_key) + if not compat_container: + for item in [cert, *cas]: + try: + item.public_key() + 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) if fmt == "openvpn": if private_key is None: @@ -136,7 +156,7 @@ def export_certificate( if fmt == "vcalendar": return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([ics_event(cert, name)]).encode("utf-8")) if fmt in ("jwk", "jwk-public", "jwk-chain"): - jwk = jwk_public(cert.public_key(), kid=name or None) + jwk = jwk_public(certificate_public_key(cert), kid=name or None) der = certificate_der(cert) jwk["x5t"] = _b64u(hashlib.sha1(der).digest()) jwk["x5t#256"] = _b64u(hashlib.sha256(der).digest()) diff --git a/server/packages/kca-core/src/kca/core/extensions.py b/server/packages/kca-core/src/kca/core/extensions.py index ade5f7f..321b7e0 100644 --- a/server/packages/kca-core/src/kca/core/extensions.py +++ b/server/packages/kca-core/src/kca/core/extensions.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from cryptography import x509 +from cryptography.hazmat.primitives import serialization from cryptography.x509.oid import ( AuthorityInformationAccessOID, ExtendedKeyUsageOID, @@ -19,6 +20,8 @@ from cryptography.x509.oid import ( ObjectIdentifier, ) +from . import openssl_compat +from .keys import public_key_identifier from .names import ( DN_ATTRIBUTES, EKU_OIDS, @@ -443,6 +446,34 @@ def _policies_from_spec(spec: Mapping[str, Any]) -> x509.CertificatePolicies: 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( spec: Optional[Mapping[str, Any]], *, @@ -528,27 +559,17 @@ def build_extensions( x509.Extension( oid=x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER, 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"): 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( x509.Extension( oid=x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER, critical=_critical(aki_spec), - value=aki, + value=authority_key_identifier(issuer_cert, public_key), ) ) diff --git a/server/packages/kca-core/src/kca/core/imports.py b/server/packages/kca-core/src/kca/core/imports.py index 7afe776..026c01a 100644 --- a/server/packages/kca-core/src/kca/core/imports.py +++ b/server/packages/kca-core/src/kca/core/imports.py @@ -10,9 +10,10 @@ from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Sequence from cryptography import x509 from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 +from . import openssl_compat from .certificates import certificate_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 .requests import request_info 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]: try: key, cert, cas = pkcs12.load_key_and_certificates(data, password.encode() if password else None) - except Exception as exc: # noqa: BLE001 - raise ValueError("unable to read the PKCS#12 file (wrong password or unsupported encryption)") from exc + except Exception: # noqa: BLE001 - try OpenSSL-only curves + 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] = [] name = _guess_name(cert, filename) if cert is not None else _guess_name(None, filename) if cert is not None: diff --git a/server/packages/kca-core/src/kca/core/keys.py b/server/packages/kca-core/src/kca/core/keys.py index 798b94b..90c3bd1 100644 --- a/server/packages/kca-core/src/kca/core/keys.py +++ b/server/packages/kca-core/src/kca/core/keys.py @@ -9,8 +9,8 @@ import base64 import hashlib import io import struct -from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from dataclasses import dataclass, replace +from typing import Any, Dict, List, Optional, Tuple, TypeGuard, cast from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa @@ -21,21 +21,34 @@ from cryptography.hazmat.primitives.serialization import ( 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 | dsa.DSAPrivateKey | ec.EllipticCurvePrivateKey | ed25519.Ed25519PrivateKey ) -PublicKey = ( +NativePublicKey = ( rsa.RSAPublicKey | dsa.DSAPublicKey | ec.EllipticCurvePublicKey | ed25519.Ed25519PublicKey ) +PrivateKey = NativePrivateKey | openssl_compat.CompatKey +PublicKey = NativePublicKey | openssl_compat.CompatKey -#: Curves offered by the UI. ``cryptography`` supports all of them on every -#: platform we target. +#: Curves supported directly by ``cryptography``. The complete XCA catalogue +#: lives in ``algorithms.CURVES``; other entries use ``openssl_compat``. EC_CURVES: Dict[str, ec.EllipticCurve] = { "secp192r1": ec.SECP192R1(), "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()} -KEY_TYPES = ("rsa", "dsa", "ec", "ed25519") - DEFAULT_KEY_SIZE = 2048 MAX_KEY_SIZE = 8192 MIN_KEY_SIZE = 512 -#: Hash algorithms selectable for signatures. Ed25519 has no separate hash. +#: 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]] = { + "md5": hashes.MD5(), "sha1": hashes.SHA1(), "sha224": hashes.SHA224(), "sha256": hashes.SHA256(), "sha384": hashes.SHA384(), "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 +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: + if is_compat_key(key): + if key.private_der is None: + raise ValueError("a private key is required") + return key if not isinstance( 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: + if is_compat_key(key): + return key.public_key() if isinstance(key, (rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)): return 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: """Generate a private key of the requested type.""" 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") return dsa.generate_private_key(key_size=int(size)) if key_type == "ec": - curve_name = curve or "secp256r1" - if curve_name not in EC_CURVES: - raise ValueError(f"unsupported EC curve {curve_name!r}") - return ec.generate_private_key(EC_CURVES[curve_name]) + selected = curve_info(curve or "secp256r1") + if selected.name in EC_CURVES: + return ec.generate_private_key(EC_CURVES[selected.name]) + return _canonicalize_compat(openssl_compat.generate_ec(selected.openssl_name)) if key_type == "ed25519": return ed25519.Ed25519PrivateKey.generate() raise ValueError(f"unsupported key type {key_type!r}") def key_type_name(key: PrivateKey | PublicKey) -> str: + if is_compat_key(key): + return "EC" if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)): return "RSA" 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: + if is_compat_key(key): + return key.key_size if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey, dsa.DSAPrivateKey, dsa.DSAPublicKey)): return key.key_size 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]: + if is_compat_key(key): + return canonical_curve_name(key.curve) if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): return _CURVE_NAMES.get(key.curve.name, key.curve.name) return None def is_private_key(key: Any) -> bool: + if is_compat_key(key): + return key.private_der is not None return isinstance( key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey) ) def public_der(key: PrivateKey | PublicKey) -> bytes: - return _public_key(key).public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + 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: - 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: @@ -174,24 +241,17 @@ def key_info(key: PrivateKey | PublicKey) -> KeyInfo: def possible_hashes(key: PrivateKey | PublicKey) -> List[str]: - if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): - return [] - if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)): - return ["sha1", "sha224", "sha256"] - if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): - # Every supported curve is large enough for SHA-512 in practice. - return ["sha1", "sha224", "sha256", "sha384", "sha512"] - return ["sha1", "sha224", "sha256", "sha384", "sha512"] + return list(hashes_for_key_type(key_type_name(key))) def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional[hashes.HashAlgorithm]: - """The signature hash for ``key``; ``None`` means the key's built-in hash.""" - if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): + """Return a native hash after enforcing XCA's key/digest policy.""" + normalized = validate_signature_hash(key_type_name(key), name) + if normalized is None: return None - name = (name or "sha256").lower() - if name not in HASH_ALGORITHMS: - raise ValueError(f"unsupported hash algorithm {name!r}") - return HASH_ALGORITHMS[name] + if normalized not in HASH_ALGORITHMS: + raise ValueError(f"hash algorithm {normalized!r} requires OpenSSL compatibility signing") + return HASH_ALGORITHMS[normalized] # --------------------------------------------------------------------------- @@ -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: - 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: @@ -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: 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 = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode()) fmt = PrivateFormat.TraditionalOpenSSL if traditional else PrivateFormat.PKCS8 if traditional and isinstance(key, ed25519.Ed25519PrivateKey): raise ValueError("Ed25519 keys cannot be exported in traditional PEM format") - return key.private_bytes(Encoding.PEM, fmt, encryption) + return cast(NativePrivateKey, key).private_bytes(Encoding.PEM, fmt, encryption) def private_der_bytes(key: PrivateKey, password: Optional[str] = None) -> bytes: key = _require_private(key) + if is_compat_key(key): + return openssl_compat.private_der_bytes(key, password=password) encryption: serialization.KeySerializationEncryption 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: - 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() def ssh_private(key: PrivateKey, password: Optional[str] = None) -> bytes: """Serialize a private key in OpenSSH format.""" key = _require_private(key) + if is_compat_key(key): + raise ValueError("OpenSSH export does not support this EC curve") encryption: serialization.KeySerializationEncryption 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 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 try: 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: - 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: - 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: @@ -502,15 +593,27 @@ def public_key_sha256(key: PrivateKey | PublicKey) -> str: 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": "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": []}, ] + 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__ = [ + "CURVES", "DEFAULT_KEY_SIZE", "EC_CURVES", "HASH_ALGORITHMS", @@ -521,8 +624,11 @@ __all__ = [ "PrivateKey", "PublicKey", "curve_name", + "curve_options", "generate_key", + "hash_options", "hash_algorithm", + "is_compat_key", "is_private_key", "jwk_private", "jwk_public", @@ -539,7 +645,9 @@ __all__ = [ "public_der", "public_der_bytes", "public_key_from_der", + "public_key_identifier", "public_key_sha256", + "public_part", "public_pem", "pvk_decode_private", "pvk_encode_private", diff --git a/server/packages/kca-core/src/kca/core/openssl_abi.py b/server/packages/kca-core/src/kca/core/openssl_abi.py new file mode 100644 index 0000000..2486eb6 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/openssl_abi.py @@ -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", +] diff --git a/server/packages/kca-core/src/kca/core/openssl_compat.py b/server/packages/kca-core/src/kca/core/openssl_compat.py new file mode 100644 index 0000000..a9fd8eb --- /dev/null +++ b/server/packages/kca-core/src/kca/core/openssl_compat.py @@ -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", +] diff --git a/server/packages/kca-core/src/kca/core/requests.py b/server/packages/kca-core/src/kca/core/requests.py index cc0e87d..9a56019 100644 --- a/server/packages/kca-core/src/kca/core/requests.py +++ b/server/packages/kca-core/src/kca/core/requests.py @@ -6,13 +6,30 @@ from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional from cryptography import x509 -from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.name import _ASN1Type 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 .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 .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" UNSTRUCTURED_NAME_OID = "1.2.840.113549.1.9.2" @@ -37,10 +54,10 @@ def build_csr( subject: x509.Name, private_key: PrivateKey, extension_spec: Optional[Mapping[str, Any]] = None, - signature_hash: Optional[str] = "sha256", + signature_hash: Optional[str] = None, attributes: Optional[Mapping[str, str]] = None, ) -> x509.CertificateSigningRequest: - public_key = private_key.public_key() + public_key = public_part(private_key) extensions = build_extensions( extension_spec, public_key=public_key, @@ -58,6 +75,9 @@ def build_csr( 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) for extension in extensions: builder = builder.add_extension(extension.value, extension.critical) @@ -68,8 +88,17 @@ def build_csr( str(value).encode("utf-8"), _tag=_ASN1Type.UTF8String, ) - algorithm = hash_algorithm(signature_hash, private_key) - return builder.sign(private_key, algorithm) + if template_key is None: + 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: @@ -85,37 +114,36 @@ def csr_pem(csr: x509.CertificateSigningRequest) -> str: 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: - public_key = csr.public_key() return RequestInfo( subject=csr.subject, 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(), attributes=attribute_rows(csr), ) -_SIGNATURE_OIDS = { - "1.2.840.113549.1.1.5": "sha1WithRSAEncryption", - "1.2.840.113549.1.1.11": "sha256WithRSAEncryption", - "1.2.840.113549.1.1.12": "sha384WithRSAEncryption", - "1.2.840.113549.1.1.13": "sha512WithRSAEncryption", - "1.2.840.113549.1.1.14": "sha224WithRSAEncryption", - "1.2.840.10045.4.1": "ecdsa-with-SHA1", - "1.2.840.10045.4.3.2": "ecdsa-with-SHA256", - "1.2.840.10045.4.3.3": "ecdsa-with-SHA384", - "1.2.840.10045.4.3.4": "ecdsa-with-SHA512", - "1.3.101.112": "Ed25519", -} - - def _signature_name(csr: x509.CertificateSigningRequest) -> str: - oid = csr.signature_algorithm_oid - return _SIGNATURE_OIDS.get(oid.dotted_string, getattr(oid, "name", None) or oid.dotted_string) + return oid_signature_algorithm_name(csr.signature_algorithm_oid) 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), "extensions": extension_specs(csr.extensions), "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]: + from_oid = oid_signature_hash_name(csr.signature_algorithm_oid) + if from_oid is not None: + return from_oid try: algorithm = csr.signature_hash_algorithm - except Exception: # noqa: BLE001 - Ed25519 + except Exception: # noqa: BLE001 - Ed25519 or an unknown legacy OID return 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: - 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: @@ -229,6 +263,7 @@ __all__ = [ "csr_extension_specs", "csr_extensions", "csr_pem", + "csr_public_der", "csr_public_key", "csr_spec", "openssl_config", diff --git a/server/packages/kca-core/src/kca/core/signatures.py b/server/packages/kca-core/src/kca/core/signatures.py new file mode 100644 index 0000000..73e829a --- /dev/null +++ b/server/packages/kca-core/src/kca/core/signatures.py @@ -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"] diff --git a/server/packages/kca-core/src/kca/core/xca_db.py b/server/packages/kca-core/src/kca/core/xca_db.py index 0fcfdc1..80b6c88 100644 --- a/server/packages/kca-core/src/kca/core/xca_db.py +++ b/server/packages/kca-core/src/kca/core/xca_db.py @@ -17,14 +17,14 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, modes from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES from .certificates import parse_certificate from .crls import parse_crl from .imports import ParsedItem -from .keys import is_private_key, load_key_bytes +from .keys import is_private_key, load_key_bytes, public_key_from_der from .requests import parse_csr from .templates import template_from_xca_payload @@ -91,16 +91,18 @@ def _decrypt_private(encoded: str, password: Optional[str]) -> Optional[Any]: return None # Modern XCA: PKCS#8 EncryptedPrivateKeyInfo. try: - return serialization.load_der_private_key( - data, password=password.encode() if password else None - ) + key, private = load_key_bytes(data, password) + if private: + return key except Exception: # noqa: BLE001 pass # Pre-2.0 legacy: 8-byte IV + 3DES-CBC(traditional DER private key). legacy = _legacy_decrypt(data, password) if legacy: try: - return serialization.load_der_private_key(legacy, password=None) + key, private = load_key_bytes(legacy) + if private: + return key except Exception: # noqa: BLE001 pass return None @@ -183,7 +185,7 @@ def _read_database( continue public_row = public_rows[item_id] 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 continue key = public_key diff --git a/server/packages/kca-core/tests/test_algorithm_workflows.py b/server/packages/kca-core/tests/test_algorithm_workflows.py new file mode 100644 index 0000000..87e4960 --- /dev/null +++ b/server/packages/kca-core/tests/test_algorithm_workflows.py @@ -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() diff --git a/server/packages/kca-core/tests/test_algorithms.py b/server/packages/kca-core/tests/test_algorithms.py new file mode 100644 index 0000000..8a0f53d --- /dev/null +++ b/server/packages/kca-core/tests/test_algorithms.py @@ -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() diff --git a/server/packages/kca-core/tests/test_openssl_compat.py b/server/packages/kca-core/tests/test_openssl_compat.py new file mode 100644 index 0000000..1c75fb5 --- /dev/null +++ b/server/packages/kca-core/tests/test_openssl_compat.py @@ -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() diff --git a/server/packages/kca-core/tests/test_xca_db.py b/server/packages/kca-core/tests/test_xca_db.py index 37165a3..65643d3 100644 --- a/server/packages/kca-core/tests/test_xca_db.py +++ b/server/packages/kca-core/tests/test_xca_db.py @@ -130,6 +130,91 @@ class XcaDbTest(unittest.TestCase): self.assertEqual(items[3].name, "Synthetic CRL") 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__": unittest.main() diff --git a/server/tests/test_algorithm_routes.py b/server/tests/test_algorithm_routes.py new file mode 100644 index 0000000..cd38e3d --- /dev/null +++ b/server/tests/test_algorithm_routes.py @@ -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() diff --git a/server/uv.lock b/server/uv.lock index 4f6ebef..abbf89c 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -1102,12 +1102,14 @@ name = "kca-core" version = "0.1.0" source = { editable = "packages/kca-core" } dependencies = [ + { name = "cffi" }, { name = "cryptography" }, { name = "paramiko" }, ] [package.metadata] requires-dist = [ + { name = "cffi", specifier = ">=2" }, { name = "cryptography", specifier = ">=44" }, { name = "paramiko", specifier = ">=3.5" }, ] diff --git a/web/src/components/crypto.rs b/web/src/components/crypto.rs new file mode 100644 index 0000000..b48fcf5 --- /dev/null +++ b/web/src/components/crypto.rs @@ -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 { + 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 { + 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::>(); + 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 { + 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::>() + }; + 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 { + 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::().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 { + 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 { + 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 { + let mut groups: Vec<(String, Vec)> = 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::>(); + 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>, selected: Signal) -> 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::>(); + 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>, + key_type: Signal, + size: Signal, + curve: Signal, +) -> 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::>(); + 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>, + signing_key_type: impl Fn() -> Option + Copy + 'static, + selected: Signal, +) -> 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::>(), + ["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::>(), + ["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::>(), + 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"); + } +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index a74446c..fa9dcba 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,3 +1,4 @@ +pub mod crypto; pub mod extension_form; pub mod list_editor; pub mod nav; diff --git a/web/src/model.rs b/web/src/model.rs index 9909cf0..6391ee2 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -581,12 +581,16 @@ pub struct SummaryPage { #[derive(Debug, Clone, PartialEq, Deserialize, Default)] pub struct OptionsPage { #[serde(default)] - pub key_types: Vec, + pub key_types: Vec, #[serde(default)] pub hash_algorithms: Vec, #[serde(default)] pub curves: Vec, #[serde(default)] + pub curve_options: Vec, + #[serde(default)] + pub hash_options: Vec, + #[serde(default)] pub dn_attributes: Vec, #[serde(default)] pub eku: Vec, @@ -602,6 +606,44 @@ pub struct OptionsPage { pub templates: Vec, } +#[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>, + #[serde(default)] + pub curves: Option>, + #[serde(default)] + pub hashes: Option>, +} + +#[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)] pub struct DnAttribute { #[serde(default)] diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs index 08179e8..1c14347 100644 --- a/web/src/pages/certificate_detail.rs +++ b/web/src/pages/certificate_detail.rs @@ -4,15 +4,19 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api; +use crate::components::crypto::{apply_signature_hash, signature_hash_input}; use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::ui::{copy_button, empty_state, page_header, toast_error}; use crate::dom; -use crate::model::{fmt_date, CertificateDetail, Me, Revocation, TemplateSummary}; +use crate::model::{ + fmt_date, CertificateDetail, Me, OptionsPage, Revocation, TemplateSummary, +}; pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str()); let me = create_signal(None::); + let options = create_signal(None::); let cert = create_signal(None::); let error = create_signal(None::); 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 { me.set(Some(user)); } + if let Ok(page) = api::options(id).await { + options.set(Some(page)); + } loading.set(false); }); 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 payload = json!({ + let mut payload = json!({ "days": crl_days.get_clone().parse::().unwrap_or(30), - "signature_hash": crl_hash.get_clone(), "with_reason": true, "include_aki": true, }); + apply_signature_hash(&mut payload, ca_key_type().as_deref(), &crl_hash.get_clone()); spawn_local(async move { match api::generate_crl(id, cert_id_value, &payload).await { Ok(crl) => { @@ -518,12 +530,12 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { input(class="form-control", bind:value=crl_days) } div(class="col-md-3") { - label(class="form-label") { "Hash" } - select(class="form-select", bind:value=crl_hash) { - option(value="sha256") { "SHA-256" } - option(value="sha384") { "SHA-384" } - option(value="sha512") { "SHA-512" } - } + (signature_hash_input( + "Hash", + options, + ca_key_type, + crl_hash, + )) } div(class="col-md-3") { button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" } diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index 781b26c..cfd4005 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -4,6 +4,10 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; 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::{ 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::ui::{page_header, toast_error}; 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"]; @@ -73,6 +77,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let reactive_root = use_global_scope(); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); + let options = create_signal(None::); let keys = create_signal(Vec::::new()); let signers = create_signal(Vec::::new()); let templates = create_signal(Vec::::new()); @@ -82,6 +87,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let request_id = query_param("request").unwrap_or_default(); let request_mode = !request_id.is_empty(); let request_id_for_load = request_id.clone(); + let request_key_id = create_signal(String::new()); let name = create_signal(String::new()); let comment = create_signal(String::new()); @@ -90,6 +96,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { let new_key = create_signal(false); let key_type = create_signal("rsa".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_apply_mode = create_signal("all".to_string()); let ca_default_template = create_signal(None::); @@ -256,6 +263,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { if let Some(size) = key.get("size").and_then(|v| v.as_i64()) { 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()) { let names = items @@ -277,8 +287,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { } } } - if let Ok(options) = api::options(&id).await { - dn_attributes.set(options.dn_attributes); + if let Ok(page) = api::options(id).await { + dn_attributes.set(page.dn_attributes.clone()); + options.set(Some(page)); } if !request_mode { if let Some(cert_id) = from_cert.clone() { @@ -311,6 +322,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { if request_mode { if let Ok(value) = api::request(&id, &request_id_for_load).await { name.set(format!("{} certificate", value.summary.name)); + request_key_id.set(value.summary.key_id.clone().unwrap_or_default()); let subject_json = serde_json::to_value(&value.subject_entries).unwrap_or(json!([])); reactive_root.run_in(|| apply_subject_json(&form, &subject_json)); @@ -318,6 +330,34 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> 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| { if template.is_empty() { return; @@ -430,10 +470,14 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { "comment": comment.get_clone(), "subject": subject, "extensions": extensions, - "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "validity": 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() { payload["not_before"] = json!(not_before_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) -> View { payload["modify_subject"] = json!(true); payload["copy_request_extensions"] = json!(false); } else if new_key.get() { - payload["new_key"] = json!({ + let mut generated_key = json!({ "key_type": key_type.get_clone(), - "size": key_size.get_clone().parse::().unwrap_or(2048), "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() { payload["key_id"] = json!(key_id.get_clone()); } else { @@ -494,15 +544,18 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { } }; 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() }, "subject": subject, "extensions": extensions, - "signature_hash": signature_hash.get_clone(), "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, "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() { payload["not_before"] = json!(not_before_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) -> View { } } div(class="col-md-6") { - label(class="form-label") { "Signature hash" } - select(class="form-select", bind:value=signature_hash) { - option(value="sha256") { "SHA-256" } - option(value="sha384") { "SHA-384" } - option(value="sha512") { "SHA-512" } - option(value="sha1") { "SHA-1" } - } + (signature_hash_input( + "Signature hash", + options, + signing_key_type, + signature_hash, + )) } div(class="col-md-6") { label(class="form-label") { "Template" } @@ -661,15 +713,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { div(class="row g-3") { div(class="col-md-4") { label(class="form-label") { "Key type" } - select(class="form-select", bind:value=key_type) { - option(value="rsa") { "RSA" } - option(value="ec") { "EC" } - option(value="ed25519") { "ED25519" } - } + (key_type_select(options, key_type)) } div(class="col-md-4") { - label(class="form-label") { "Key size" } - input(class="form-control", bind:value=key_size) + (key_parameter_input(options, key_type, key_size, curve)) } } } diff --git a/web/src/pages/key_new.rs b/web/src/pages/key_new.rs index 2c827b9..ba03a04 100644 --- a/web/src/pages/key_new.rs +++ b/web/src/pages/key_new.rs @@ -4,25 +4,15 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; 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::ui::{page_header, toast_error}; -use crate::model::Me; - -const RSA_SIZES: &[&str] = &["1024", "2048", "3072", "4096", "8192"]; -const DSA_SIZES: &[&str] = &["1024", "2048", "3072"]; -const CURVES: &[&str] = &[ - "secp256r1", - "secp384r1", - "secp521r1", - "secp256k1", - "brainpoolP256r1", - "brainpoolP384r1", - "brainpoolP512r1", -]; +use crate::model::{Me, OptionsPage}; pub fn KeyNewPage(vault_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); + let options = create_signal(None::); let error = create_signal(None::); let name = 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)); } } + if let Ok(page) = api::options(id).await { + if options.is_alive() { + options.set(Some(page)); + } + } }); let submit = move |_| { @@ -51,11 +46,12 @@ pub fn KeyNewPage(vault_id: String) -> View { "comment": comment.get_clone(), "key_type": key_type_value, }); - if key_type_value == "ec" { - payload["curve"] = json!(curve.get_clone()); - } else if key_type_value == "rsa" || key_type_value == "dsa" { - payload["size"] = json!(size.get_clone().parse::().unwrap_or(2048)); - } + add_key_parameters( + &mut payload, + &key_type_value, + &size.get_clone(), + &curve.get_clone(), + ); saving.set(true); let id = id.clone(); 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::>() - }; - - 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::>()) - } - } - } 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::>()) - } - } - } - }; - let nav = move || match me.get_clone() { Some(user) => whoami(&user), None => view! {}, @@ -134,9 +91,11 @@ pub fn KeyNewPage(vault_id: String) -> View { div(class="row g-3 mb-3") { div(class="col-md-4") { label(class="form-label") { "Key type" } - select(class="form-select", bind:value=key_type) { (type_options()) } + (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") { label(class="form-label") { "Comment" } diff --git a/web/src/pages/request_new.rs b/web/src/pages/request_new.rs index c6a760b..5b6e053 100644 --- a/web/src/pages/request_new.rs +++ b/web/src/pages/request_new.rs @@ -4,13 +4,18 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; 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::ui::{page_header, toast_error}; -use crate::model::{KeySummary, Me}; +use crate::model::{KeySummary, Me, OptionsPage}; pub fn RequestNewPage(vault_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); + let options = create_signal(None::); let keys = create_signal(Vec::::new()); let error = create_signal(None::); let saving = create_signal(false); @@ -21,6 +26,7 @@ pub fn RequestNewPage(vault_id: String) -> View { let new_key = create_signal(false); let key_type = create_signal("rsa".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 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()); } } + 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 request_name = name.get_clone(); if request_name.trim().is_empty() { @@ -113,14 +135,24 @@ pub fn RequestNewPage(vault_id: String) -> View { "comment": comment.get_clone(), "subject": subject, "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() { - payload["new_key"] = json!({ + let mut generated_key = json!({ "key_type": key_type.get_clone(), - "size": key_size.get_clone().parse::().unwrap_or(2048), "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() { payload["key_id"] = json!(key_id.get_clone()); } else { @@ -180,13 +212,12 @@ pub fn RequestNewPage(vault_id: String) -> View { input(class="form-control", bind:value=name) } div(class="col-md-6") { - label(class="form-label") { "Signature hash" } - select(class="form-select", bind:value=signature_hash) { - option(value="sha256") { "SHA-256" } - option(value="sha384") { "SHA-384" } - option(value="sha512") { "SHA-512" } - option(value="sha1") { "SHA-1" } - } + (signature_hash_input( + "Signature hash", + options, + signing_key_type, + signature_hash, + )) } div(class="col-12") { label(class="form-label") { "Comment" } @@ -217,15 +248,10 @@ pub fn RequestNewPage(vault_id: String) -> View { div(class="row g-3") { div(class="col-md-4") { label(class="form-label") { "Key type" } - select(class="form-select", bind:value=key_type) { - option(value="rsa") { "RSA" } - option(value="ec") { "EC" } - option(value="ed25519") { "ED25519" } - } + (key_type_select(options, key_type)) } div(class="col-md-4") { - label(class="form-label") { "Key size" } - input(class="form-control", bind:value=key_size) + (key_parameter_input(options, key_type, key_size, curve)) } } } diff --git a/web/src/pages/settings.rs b/web/src/pages/settings.rs index e560950..2900499 100644 --- a/web/src/pages/settings.rs +++ b/web/src/pages/settings.rs @@ -4,13 +4,15 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; 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::ui::{page_header, toast_error}; -use crate::model::Me; +use crate::model::{Me, OptionsPage}; pub fn SettingsPage(vault_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); + let options = create_signal(None::); let error = create_signal(None::); let info = create_signal(None::); let loading = create_signal(true); @@ -25,6 +27,7 @@ pub fn SettingsPage(vault_id: String) -> View { let hide_unusable = create_signal(false); let default_key_type = create_signal("rsa".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_unit = create_signal("days".to_string()); @@ -32,6 +35,9 @@ pub fn SettingsPage(vault_id: String) -> View { if let Ok(user) = api::me().await { me.set(Some(user)); } + if let Ok(page) = api::options(id).await { + options.set(Some(page)); + } match api::vault_settings(&id).await { Ok(value) => { 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()) { 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(v) = validity.get("amount").and_then(|v| v.as_i64()) { @@ -87,7 +96,11 @@ pub fn SettingsPage(vault_id: String) -> View { "default_key": { "type": default_key_type.get_clone(), "size": default_key_size.get_clone().parse::().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": { "amount": default_validity.get_clone().parse::().unwrap_or(365), @@ -130,14 +143,12 @@ pub fn SettingsPage(vault_id: String) -> View { div(class="form-section-title") { "Cryptography" } div(class="row g-3") { div(class="col-md-4") { - label(class="form-label") { "Default signature hash" } - select(class="form-select", bind:value=default_hash) { - option(value="sha256") { "SHA-256" } - option(value="sha384") { "SHA-384" } - option(value="sha512") { "SHA-512" } - option(value="sha224") { "SHA-224" } - option(value="sha1") { "SHA-1" } - } + (signature_hash_input( + "Default signature hash", + options, + || None, + default_hash, + )) } div(class="col-md-4") { 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="col-md-4") { label(class="form-label") { "Default key type" } - select(class="form-select", bind:value=default_key_type) { - option(value="rsa") { "RSA" } - option(value="ec") { "EC" } - option(value="ed25519") { "ED25519" } - } + (key_type_select(options, default_key_type)) } div(class="col-md-4") { - label(class="form-label") { "Default key size" } - input(class="form-control", bind:value=default_key_size) + (key_parameter_input( + options, + default_key_type, + default_key_size, + default_key_curve, + )) } div(class="col-md-4 d-flex align-items-end") { button(class="btn btn-primary", disabled=saving.get(), on:click=save) {