Add full XCA curve and digest parity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""XCA-compatible elliptic-curve and signature-digest catalogues.
|
||||
|
||||
The registry deliberately contains metadata only. Native ``cryptography``
|
||||
objects stay in :mod:`kca.core.keys`; curves which that library cannot
|
||||
represent are handled by :mod:`kca.core.openssl_compat`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurveInfo:
|
||||
"""A named curve exposed by XCA.
|
||||
|
||||
``name`` is KCA's stable API/storage name. ``openssl_name`` is the short
|
||||
name accepted by OpenSSL. They differ for the two aliases which KCA has
|
||||
historically called secp192r1/secp256r1 and XCA calls prime192v1/prime256v1.
|
||||
"""
|
||||
|
||||
name: str
|
||||
openssl_name: str
|
||||
group: str
|
||||
key_size: int
|
||||
field: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def comment(self) -> str:
|
||||
return f"{self.group} {self.field}-field curve ({self.key_size} bits)"
|
||||
|
||||
@property
|
||||
def insecure(self) -> bool:
|
||||
# Small curves are offered for compatibility, not for new PKIs.
|
||||
return self.key_size < 224
|
||||
|
||||
@property
|
||||
def aliases(self) -> Tuple[str, ...]:
|
||||
return (self.openssl_name,) if self.openssl_name != self.name else ()
|
||||
|
||||
def as_dict(self) -> Dict[str, object]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"label": self.label,
|
||||
"group": self.group,
|
||||
"comment": self.comment,
|
||||
"key_size": self.key_size,
|
||||
"aliases": list(self.aliases),
|
||||
"insecure": self.insecure,
|
||||
}
|
||||
|
||||
|
||||
def _curve(name: str, group: str, size: int, field: str = "prime", openssl_name: Optional[str] = None) -> CurveInfo:
|
||||
return CurveInfo(name, openssl_name or name, group, size, field)
|
||||
|
||||
|
||||
# This is the de-duplicated union of XCA's RFC5480, X9.62 and other curve
|
||||
# allowlists in builtin_curves.cpp. Keep the two historical KCA aliases stable.
|
||||
CURVES: Tuple[CurveInfo, ...] = (
|
||||
# RFC 5480 / NIST prime curves.
|
||||
_curve("secp192r1", "RFC 5480", 192, openssl_name="prime192v1"),
|
||||
_curve("secp224r1", "RFC 5480", 224),
|
||||
_curve("secp256r1", "RFC 5480", 256, openssl_name="prime256v1"),
|
||||
_curve("secp384r1", "RFC 5480", 384),
|
||||
_curve("secp521r1", "RFC 5480", 521),
|
||||
# X9.62 prime curves.
|
||||
_curve("prime192v2", "X9.62", 192),
|
||||
_curve("prime192v3", "X9.62", 192),
|
||||
_curve("prime239v1", "X9.62", 239),
|
||||
_curve("prime239v2", "X9.62", 239),
|
||||
_curve("prime239v3", "X9.62", 239),
|
||||
# X9.62 binary curves.
|
||||
_curve("c2pnb163v1", "X9.62 binary", 163, "binary"),
|
||||
_curve("c2pnb163v2", "X9.62 binary", 163, "binary"),
|
||||
_curve("c2pnb163v3", "X9.62 binary", 163, "binary"),
|
||||
_curve("c2pnb176v1", "X9.62 binary", 176, "binary"),
|
||||
_curve("c2tnb191v1", "X9.62 binary", 191, "binary"),
|
||||
_curve("c2tnb191v2", "X9.62 binary", 191, "binary"),
|
||||
_curve("c2tnb191v3", "X9.62 binary", 191, "binary"),
|
||||
_curve("c2pnb208w1", "X9.62 binary", 208, "binary"),
|
||||
_curve("c2tnb239v1", "X9.62 binary", 239, "binary"),
|
||||
_curve("c2tnb239v2", "X9.62 binary", 239, "binary"),
|
||||
_curve("c2tnb239v3", "X9.62 binary", 239, "binary"),
|
||||
_curve("c2pnb272w1", "X9.62 binary", 272, "binary"),
|
||||
_curve("c2pnb304w1", "X9.62 binary", 304, "binary"),
|
||||
_curve("c2tnb359v1", "X9.62 binary", 359, "binary"),
|
||||
_curve("c2pnb368w1", "X9.62 binary", 368, "binary"),
|
||||
_curve("c2tnb431r1", "X9.62 binary", 431, "binary"),
|
||||
# SECG binary curves.
|
||||
_curve("sect113r1", "SECG binary", 113, "binary"),
|
||||
_curve("sect113r2", "SECG binary", 113, "binary"),
|
||||
_curve("sect131r1", "SECG binary", 131, "binary"),
|
||||
_curve("sect131r2", "SECG binary", 131, "binary"),
|
||||
_curve("sect163k1", "SECG binary", 163, "binary"),
|
||||
_curve("sect163r1", "SECG binary", 163, "binary"),
|
||||
_curve("sect163r2", "SECG binary", 163, "binary"),
|
||||
_curve("sect193r1", "SECG binary", 193, "binary"),
|
||||
_curve("sect193r2", "SECG binary", 193, "binary"),
|
||||
_curve("sect233k1", "SECG binary", 233, "binary"),
|
||||
_curve("sect233r1", "SECG binary", 233, "binary"),
|
||||
_curve("sect239k1", "SECG binary", 239, "binary"),
|
||||
_curve("sect283k1", "SECG binary", 283, "binary"),
|
||||
_curve("sect283r1", "SECG binary", 283, "binary"),
|
||||
_curve("sect409k1", "SECG binary", 409, "binary"),
|
||||
_curve("sect409r1", "SECG binary", 409, "binary"),
|
||||
_curve("sect571k1", "SECG binary", 571, "binary"),
|
||||
_curve("sect571r1", "SECG binary", 571, "binary"),
|
||||
# Additional SECG prime curves.
|
||||
_curve("secp112r1", "SECG", 112),
|
||||
_curve("secp112r2", "SECG", 112),
|
||||
_curve("secp128r1", "SECG", 128),
|
||||
_curve("secp128r2", "SECG", 128),
|
||||
_curve("secp160k1", "SECG", 160),
|
||||
_curve("secp160r1", "SECG", 160),
|
||||
_curve("secp160r2", "SECG", 160),
|
||||
_curve("secp192k1", "SECG", 192),
|
||||
_curve("secp224k1", "SECG", 224),
|
||||
_curve("secp256k1", "SECG", 256),
|
||||
# WAP / WTLS named curves.
|
||||
_curve("wap-wsg-idm-ecid-wtls1", "WTLS", 113, "binary"),
|
||||
_curve("wap-wsg-idm-ecid-wtls3", "WTLS", 163, "binary"),
|
||||
_curve("wap-wsg-idm-ecid-wtls4", "WTLS", 113, "binary"),
|
||||
_curve("wap-wsg-idm-ecid-wtls5", "WTLS", 163, "binary"),
|
||||
_curve("wap-wsg-idm-ecid-wtls6", "WTLS", 112),
|
||||
_curve("wap-wsg-idm-ecid-wtls7", "WTLS", 160),
|
||||
_curve("wap-wsg-idm-ecid-wtls8", "WTLS", 112),
|
||||
_curve("wap-wsg-idm-ecid-wtls9", "WTLS", 160),
|
||||
_curve("wap-wsg-idm-ecid-wtls10", "WTLS", 233, "binary"),
|
||||
_curve("wap-wsg-idm-ecid-wtls11", "WTLS", 233, "binary"),
|
||||
_curve("wap-wsg-idm-ecid-wtls12", "WTLS", 224),
|
||||
# Brainpool random (r1) and twisted (t1) variants.
|
||||
_curve("brainpoolP160r1", "Brainpool", 160),
|
||||
_curve("brainpoolP160t1", "Brainpool", 160),
|
||||
_curve("brainpoolP192r1", "Brainpool", 192),
|
||||
_curve("brainpoolP192t1", "Brainpool", 192),
|
||||
_curve("brainpoolP224r1", "Brainpool", 224),
|
||||
_curve("brainpoolP224t1", "Brainpool", 224),
|
||||
_curve("brainpoolP256r1", "Brainpool", 256),
|
||||
_curve("brainpoolP256t1", "Brainpool", 256),
|
||||
_curve("brainpoolP320r1", "Brainpool", 320),
|
||||
_curve("brainpoolP320t1", "Brainpool", 320),
|
||||
_curve("brainpoolP384r1", "Brainpool", 384),
|
||||
_curve("brainpoolP384t1", "Brainpool", 384),
|
||||
_curve("brainpoolP512r1", "Brainpool", 512),
|
||||
_curve("brainpoolP512t1", "Brainpool", 512),
|
||||
)
|
||||
|
||||
CURVE_BY_NAME: Dict[str, CurveInfo] = {curve.name: curve for curve in CURVES}
|
||||
_CURVE_LOOKUP: Dict[str, CurveInfo] = {}
|
||||
for _info in CURVES:
|
||||
for _name in (_info.name, _info.openssl_name, *_info.aliases):
|
||||
_CURVE_LOOKUP[_name.lower()] = _info
|
||||
|
||||
|
||||
def curve_info(name: str) -> CurveInfo:
|
||||
"""Resolve a KCA or OpenSSL curve name, raising a public-facing error."""
|
||||
try:
|
||||
return _CURVE_LOOKUP[str(name).lower()]
|
||||
except (KeyError, AttributeError) as exc:
|
||||
raise ValueError(f"unsupported EC curve {name!r}") from exc
|
||||
|
||||
|
||||
def canonical_curve_name(name: str) -> str:
|
||||
return curve_info(name).name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HashInfo:
|
||||
name: str
|
||||
label: str
|
||||
insecure: bool = False
|
||||
|
||||
def as_dict(self) -> Dict[str, object]:
|
||||
return {"name": self.name, "label": self.label, "insecure": self.insecure}
|
||||
|
||||
|
||||
HASHES: Tuple[HashInfo, ...] = (
|
||||
HashInfo("md5", "MD5", True),
|
||||
HashInfo("ripemd160", "RIPEMD-160", True),
|
||||
HashInfo("sha1", "SHA-1", True),
|
||||
HashInfo("sha224", "SHA-224"),
|
||||
HashInfo("sha256", "SHA-256"),
|
||||
HashInfo("sha384", "SHA-384"),
|
||||
HashInfo("sha512", "SHA-512"),
|
||||
HashInfo("sha3-224", "SHA3-224"),
|
||||
HashInfo("sha3-256", "SHA3-256"),
|
||||
HashInfo("sha3-384", "SHA3-384"),
|
||||
HashInfo("sha3-512", "SHA3-512"),
|
||||
)
|
||||
HASH_BY_NAME: Dict[str, HashInfo] = {item.name: item for item in HASHES}
|
||||
|
||||
_SHA2 = ("sha224", "sha256", "sha384", "sha512")
|
||||
_SHA3 = ("sha3-224", "sha3-256", "sha3-384", "sha3-512")
|
||||
KEY_HASHES: Dict[str, Tuple[str, ...]] = {
|
||||
"rsa": ("md5", "ripemd160", "sha1", *_SHA2, *_SHA3),
|
||||
"dsa": ("sha1", "sha224", "sha256"),
|
||||
"ec": ("sha1", *_SHA2, *_SHA3),
|
||||
"ed25519": (),
|
||||
}
|
||||
KEY_TYPES = ("rsa", "dsa", "ec", "ed25519")
|
||||
|
||||
|
||||
def normalize_key_type(key_type: str) -> str:
|
||||
return str(key_type or "").lower()
|
||||
|
||||
|
||||
def possible_hashes(key_type: str) -> Tuple[str, ...]:
|
||||
try:
|
||||
return KEY_HASHES[normalize_key_type(key_type)]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"unsupported key type {key_type!r}") from exc
|
||||
|
||||
|
||||
def validate_signature_hash(key_type: str, name: Optional[str]) -> Optional[str]:
|
||||
"""Validate and normalize an explicitly requested signature hash."""
|
||||
kind = normalize_key_type(key_type)
|
||||
allowed = possible_hashes(kind)
|
||||
if not allowed:
|
||||
if name not in (None, ""):
|
||||
raise ValueError(f"{kind.upper()} signatures do not use a separate hash algorithm")
|
||||
return None
|
||||
normalized = str(name or "sha256").lower()
|
||||
if normalized not in allowed:
|
||||
raise ValueError(f"hash algorithm {normalized!r} is not supported with {kind.upper()} keys")
|
||||
return normalized
|
||||
|
||||
|
||||
def default_signature_hash(key_type: str, preferred: Optional[str] = None) -> Optional[str]:
|
||||
"""Choose a deterministic allowed hash for an omitted UI/API default."""
|
||||
allowed = possible_hashes(key_type)
|
||||
if not allowed:
|
||||
return None
|
||||
candidate = str(preferred or "sha256").lower()
|
||||
if candidate in allowed:
|
||||
return candidate
|
||||
if "sha256" in allowed:
|
||||
return "sha256"
|
||||
return allowed[0]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CURVES",
|
||||
"CURVE_BY_NAME",
|
||||
"CurveInfo",
|
||||
"HASHES",
|
||||
"HASH_BY_NAME",
|
||||
"HashInfo",
|
||||
"KEY_HASHES",
|
||||
"KEY_TYPES",
|
||||
"canonical_curve_name",
|
||||
"curve_info",
|
||||
"default_signature_hash",
|
||||
"normalize_key_type",
|
||||
"possible_hashes",
|
||||
"validate_signature_hash",
|
||||
]
|
||||
@@ -8,9 +8,11 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Minimal OpenSSL 3 ``libcrypto`` ABI bindings used by the compatibility layer.
|
||||
|
||||
This module deliberately uses cffi's ABI mode: no compiler, OpenSSL headers, or
|
||||
CLI executable is required at runtime. It only exposes process-wide library
|
||||
loading and scoped memory/DER helpers. All cryptographic objects are owned by
|
||||
callers for the duration of a single operation and are never shared with
|
||||
``cryptography`` or pyOpenSSL.
|
||||
|
||||
Set ``KCA_LIBCRYPTO`` to an absolute path (or another loader-compatible name) to
|
||||
select a particular OpenSSL 3 libcrypto. Otherwise the normal system loader
|
||||
and a small list of conventional OpenSSL 3 library names are tried.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
try:
|
||||
import cffi
|
||||
except ImportError: # pragma: no cover - depends on the installation
|
||||
cffi = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_CDEF = r"""
|
||||
typedef KCA_SIZE_T size_t;
|
||||
typedef KCA_UINTPTR_T uintptr_t;
|
||||
|
||||
typedef struct bio_st BIO;
|
||||
typedef struct bio_method_st BIO_METHOD;
|
||||
typedef struct evp_pkey_st EVP_PKEY;
|
||||
typedef struct evp_md_st EVP_MD;
|
||||
typedef struct evp_cipher_st EVP_CIPHER;
|
||||
typedef struct ec_key_st EC_KEY;
|
||||
typedef struct ec_group_st EC_GROUP;
|
||||
typedef struct rsa_st RSA;
|
||||
typedef struct x509_st X509;
|
||||
typedef struct X509_req_st X509_REQ;
|
||||
typedef struct X509_crl_st X509_CRL;
|
||||
typedef struct X509_pubkey_st X509_PUBKEY;
|
||||
typedef struct asn1_object_st ASN1_OBJECT;
|
||||
typedef struct X509_algor_st X509_ALGOR;
|
||||
typedef struct PKCS12_st PKCS12;
|
||||
typedef struct openssl_stack_st OPENSSL_STACK;
|
||||
|
||||
typedef int pem_password_cb(char *buf, int size, int rwflag, void *userdata);
|
||||
typedef struct {
|
||||
const unsigned char *data;
|
||||
size_t length;
|
||||
} KCA_PASSWORD;
|
||||
|
||||
typedef struct {
|
||||
int nid;
|
||||
const char *comment;
|
||||
} EC_builtin_curve;
|
||||
|
||||
const char *OpenSSL_version(int type);
|
||||
unsigned long OpenSSL_version_num(void);
|
||||
|
||||
unsigned long ERR_get_error(void);
|
||||
void ERR_error_string_n(unsigned long e, char *buf, size_t len);
|
||||
void ERR_clear_error(void);
|
||||
|
||||
const BIO_METHOD *BIO_s_mem(void);
|
||||
BIO *BIO_new(const BIO_METHOD *type);
|
||||
BIO *BIO_new_mem_buf(const void *buf, int len);
|
||||
int BIO_free(BIO *b);
|
||||
int BIO_read(BIO *b, void *data, int dlen);
|
||||
size_t BIO_ctrl_pending(BIO *b);
|
||||
|
||||
EVP_PKEY *EVP_PKEY_new(void);
|
||||
void EVP_PKEY_free(EVP_PKEY *pkey);
|
||||
int EVP_PKEY_get_bits(const EVP_PKEY *pkey);
|
||||
int EVP_PKEY_is_a(const EVP_PKEY *pkey, const char *name);
|
||||
int EVP_PKEY_get_utf8_string_param(const EVP_PKEY *pkey, const char *key_name,
|
||||
char *str, size_t max_buf_sz,
|
||||
size_t *out_len);
|
||||
int EVP_PKEY_set1_EC_KEY(EVP_PKEY *pkey, EC_KEY *key);
|
||||
EC_KEY *EVP_PKEY_get1_EC_KEY(EVP_PKEY *pkey);
|
||||
int EVP_PKEY_set1_RSA(EVP_PKEY *pkey, RSA *key);
|
||||
const EVP_CIPHER *EVP_aes_256_cbc(void);
|
||||
const EVP_MD *EVP_get_digestbyname(const char *name);
|
||||
|
||||
int OBJ_txt2nid(const char *s);
|
||||
int OBJ_sn2nid(const char *s);
|
||||
const char *OBJ_nid2sn(int n);
|
||||
|
||||
size_t EC_get_builtin_curves(EC_builtin_curve *r, size_t nitems);
|
||||
EC_KEY *EC_KEY_new_by_curve_name(int nid);
|
||||
void EC_KEY_free(EC_KEY *key);
|
||||
int EC_KEY_generate_key(EC_KEY *key);
|
||||
int EC_KEY_check_key(const EC_KEY *key);
|
||||
void EC_KEY_set_asn1_flag(EC_KEY *key, int asn1_flag);
|
||||
const EC_GROUP *EC_KEY_get0_group(const EC_KEY *key);
|
||||
int EC_GROUP_get_curve_name(const EC_GROUP *group);
|
||||
int EC_GROUP_get_degree(const EC_GROUP *group);
|
||||
RSA *RSA_new(void);
|
||||
void RSA_free(RSA *rsa);
|
||||
|
||||
EVP_PKEY *PEM_read_bio_PrivateKey(BIO *bp, EVP_PKEY **x,
|
||||
pem_password_cb *cb, void *u);
|
||||
EVP_PKEY *PEM_read_bio_PUBKEY(BIO *bp, EVP_PKEY **x,
|
||||
pem_password_cb *cb, void *u);
|
||||
RSA *PEM_read_bio_RSAPublicKey(BIO *bp, RSA **x,
|
||||
pem_password_cb *cb, void *u);
|
||||
int PEM_write_bio_PUBKEY(BIO *bp, const EVP_PKEY *x);
|
||||
int PEM_write_bio_PrivateKey_traditional(BIO *bp, const EVP_PKEY *x,
|
||||
const EVP_CIPHER *enc,
|
||||
const unsigned char *kstr, int klen,
|
||||
pem_password_cb *cb, void *u);
|
||||
int PEM_write_bio_PKCS8PrivateKey(BIO *bp, const EVP_PKEY *x,
|
||||
const EVP_CIPHER *enc, char *kstr, int klen,
|
||||
pem_password_cb *cb, void *u);
|
||||
|
||||
EVP_PKEY *d2i_PKCS8PrivateKey_bio(BIO *bp, EVP_PKEY **x,
|
||||
pem_password_cb *cb, void *u);
|
||||
int i2d_PKCS8PrivateKey_bio(BIO *bp, const EVP_PKEY *x,
|
||||
const EVP_CIPHER *enc, char *kstr, int klen,
|
||||
pem_password_cb *cb, void *u);
|
||||
EVP_PKEY *d2i_AutoPrivateKey(EVP_PKEY **a, const unsigned char **pp,
|
||||
long length);
|
||||
EVP_PKEY *d2i_PUBKEY(EVP_PKEY **a, const unsigned char **pp, long length);
|
||||
RSA *d2i_RSAPublicKey(RSA **a, const unsigned char **pp, long length);
|
||||
int i2d_PUBKEY_bio(BIO *bp, const EVP_PKEY *pkey);
|
||||
|
||||
X509 *d2i_X509(X509 **a, const unsigned char **pp, long length);
|
||||
void X509_free(X509 *a);
|
||||
X509_REQ *d2i_X509_REQ(X509_REQ **a, const unsigned char **pp, long length);
|
||||
void X509_REQ_free(X509_REQ *a);
|
||||
X509_CRL *d2i_X509_CRL(X509_CRL **a, const unsigned char **pp, long length);
|
||||
void X509_CRL_free(X509_CRL *a);
|
||||
|
||||
EVP_PKEY *X509_get_pubkey(X509 *x);
|
||||
EVP_PKEY *X509_REQ_get_pubkey(X509_REQ *req);
|
||||
int X509_set_pubkey(X509 *x, EVP_PKEY *pkey);
|
||||
int X509_REQ_set_pubkey(X509_REQ *x, EVP_PKEY *pkey);
|
||||
int X509_sign(X509 *x, EVP_PKEY *pkey, const EVP_MD *md);
|
||||
int X509_REQ_sign(X509_REQ *x, EVP_PKEY *pkey, const EVP_MD *md);
|
||||
int X509_CRL_sign(X509_CRL *x, EVP_PKEY *pkey, const EVP_MD *md);
|
||||
int X509_verify(X509 *a, EVP_PKEY *r);
|
||||
int X509_REQ_verify(X509_REQ *a, EVP_PKEY *r);
|
||||
int X509_CRL_verify(X509_CRL *a, EVP_PKEY *r);
|
||||
int i2d_X509_bio(BIO *bp, X509 *x);
|
||||
int i2d_X509_REQ_bio(BIO *bp, X509_REQ *req);
|
||||
int i2d_X509_CRL_bio(BIO *bp, X509_CRL *crl);
|
||||
|
||||
X509_PUBKEY *d2i_X509_PUBKEY(X509_PUBKEY **a, const unsigned char **pp,
|
||||
long length);
|
||||
void X509_PUBKEY_free(X509_PUBKEY *a);
|
||||
int X509_PUBKEY_get0_param(ASN1_OBJECT **ppkalg, const unsigned char **pk,
|
||||
int *ppklen, X509_ALGOR **pa, X509_PUBKEY *pub);
|
||||
|
||||
PKCS12 *d2i_PKCS12(PKCS12 **a, const unsigned char **pp, long length);
|
||||
void PKCS12_free(PKCS12 *p12);
|
||||
int PKCS12_parse(PKCS12 *p12, const char *pass, EVP_PKEY **pkey,
|
||||
X509 **cert, OPENSSL_STACK **ca);
|
||||
PKCS12 *PKCS12_create(const char *pass, const char *name, EVP_PKEY *pkey,
|
||||
X509 *cert, OPENSSL_STACK *ca, int nid_key,
|
||||
int nid_cert, int iter, int mac_iter, int keytype);
|
||||
int PKCS12_set_mac(PKCS12 *p12, const char *pass, int passlen,
|
||||
unsigned char *salt, int saltlen, int iter,
|
||||
const EVP_MD *md_type);
|
||||
int i2d_PKCS12_bio(BIO *bp, PKCS12 *p12);
|
||||
|
||||
OPENSSL_STACK *OPENSSL_sk_new_null(void);
|
||||
int OPENSSL_sk_num(const OPENSSL_STACK *st);
|
||||
void *OPENSSL_sk_value(const OPENSSL_STACK *st, int i);
|
||||
int OPENSSL_sk_push(OPENSSL_STACK *st, const void *data);
|
||||
void OPENSSL_sk_free(OPENSSL_STACK *st);
|
||||
"""
|
||||
|
||||
|
||||
class OpenSSLUnavailableError(RuntimeError):
|
||||
"""The requested OpenSSL 3 backend cannot be loaded."""
|
||||
|
||||
|
||||
def _unsigned_c_type(size: int) -> str:
|
||||
"""Select an ABI-compatible unsigned C type for size_t/uintptr_t."""
|
||||
candidates = (
|
||||
("unsigned char", ctypes.c_ubyte),
|
||||
("unsigned short", ctypes.c_ushort),
|
||||
("unsigned int", ctypes.c_uint),
|
||||
("unsigned long", ctypes.c_ulong),
|
||||
("unsigned long long", ctypes.c_ulonglong),
|
||||
)
|
||||
for name, ctype in candidates:
|
||||
if ctypes.sizeof(ctype) == size:
|
||||
return name
|
||||
raise OpenSSLUnavailableError(f"unsupported C integer width: {size}")
|
||||
|
||||
|
||||
_CDEF = _CDEF.replace(
|
||||
"typedef KCA_SIZE_T size_t;",
|
||||
f"typedef {_unsigned_c_type(ctypes.sizeof(ctypes.c_size_t))} size_t;",
|
||||
).replace(
|
||||
"typedef KCA_UINTPTR_T uintptr_t;",
|
||||
f"typedef {_unsigned_c_type(ctypes.sizeof(ctypes.c_void_p))} uintptr_t;",
|
||||
)
|
||||
|
||||
|
||||
def _library_candidates() -> list[str]:
|
||||
override = os.environ.get("KCA_LIBCRYPTO")
|
||||
if override is not None:
|
||||
if not override.strip():
|
||||
raise OpenSSLUnavailableError("KCA_LIBCRYPTO is empty")
|
||||
return [override]
|
||||
|
||||
candidates: list[str] = []
|
||||
discovered = ctypes.util.find_library("crypto")
|
||||
if discovered:
|
||||
candidates.append(discovered)
|
||||
candidates.extend(
|
||||
[
|
||||
"libcrypto.so.3",
|
||||
"libcrypto.so",
|
||||
"libcrypto.3.dylib",
|
||||
"libcrypto.dylib",
|
||||
"libcrypto-3-x64.dll",
|
||||
"libcrypto-3.dll",
|
||||
]
|
||||
)
|
||||
# Preserve loader order while avoiding repeated dlopen attempts.
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class OpenSSLBackend:
|
||||
"""A loaded libcrypto and its cffi namespace.
|
||||
|
||||
Instances are initialized by :func:`get_backend`; applications should not
|
||||
construct them directly. The cffi library handle is safe to reuse, while
|
||||
every object pointer created through it remains operation-local.
|
||||
"""
|
||||
|
||||
ffi: Any
|
||||
lib: Any
|
||||
path: str
|
||||
version: str
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "OpenSSLBackend":
|
||||
if cffi is None:
|
||||
raise OpenSSLUnavailableError("cffi is required for OpenSSL compatibility")
|
||||
candidates = _library_candidates()
|
||||
errors: list[str] = []
|
||||
override = os.environ.get("KCA_LIBCRYPTO") is not None
|
||||
for path in candidates:
|
||||
ffi = cffi.FFI()
|
||||
ffi.cdef(_CDEF)
|
||||
try:
|
||||
lib = ffi.dlopen(path)
|
||||
version_num = int(lib.OpenSSL_version_num())
|
||||
if version_num >> 28 != 3:
|
||||
raise OpenSSLUnavailableError(
|
||||
f"{path} is not OpenSSL 3.x"
|
||||
)
|
||||
version_ptr = lib.OpenSSL_version(0)
|
||||
if version_ptr == ffi.NULL:
|
||||
raise OpenSSLUnavailableError(
|
||||
f"cannot determine the OpenSSL version in {path}"
|
||||
)
|
||||
version = ffi.string(version_ptr).decode("utf-8", "replace")
|
||||
backend = cls(ffi=ffi, lib=lib, path=path, version=version)
|
||||
backend._install_password_callback()
|
||||
return backend
|
||||
except (OSError, OpenSSLUnavailableError) as exc:
|
||||
errors.append(f"{path}: {exc}")
|
||||
if override:
|
||||
break
|
||||
detail = "; ".join(errors) if errors else "no libcrypto candidates found"
|
||||
raise OpenSSLUnavailableError(f"unable to load OpenSSL 3 libcrypto ({detail})")
|
||||
|
||||
def _install_password_callback(self) -> None:
|
||||
ffi = self.ffi
|
||||
|
||||
@ffi.callback("int(char *, int, int, void *)")
|
||||
def password_callback(buf: Any, size: int, _rwflag: int, userdata: Any) -> int:
|
||||
# No interpreter input or prompting is permitted on an import path.
|
||||
if userdata == ffi.NULL or size <= 0:
|
||||
return 0
|
||||
state = ffi.cast("KCA_PASSWORD *", userdata)
|
||||
length = int(state.length)
|
||||
if length <= 0 or length > size:
|
||||
return 0
|
||||
ffi.memmove(buf, state.data, length)
|
||||
return length
|
||||
|
||||
# cffi only guarantees callback lifetime while the cdata is referenced.
|
||||
self._password_callback = password_callback
|
||||
|
||||
@contextmanager
|
||||
def operation(self) -> Iterator["OpenSSLBackend"]:
|
||||
"""Isolate OpenSSL's thread-local error queue for one public call."""
|
||||
self.lib.ERR_clear_error()
|
||||
try:
|
||||
yield self
|
||||
finally:
|
||||
self.lib.ERR_clear_error()
|
||||
|
||||
def error_messages(self) -> list[str]:
|
||||
messages: list[str] = []
|
||||
while True:
|
||||
code = int(self.lib.ERR_get_error())
|
||||
if not code:
|
||||
break
|
||||
buf = self.ffi.new("char[256]")
|
||||
self.lib.ERR_error_string_n(code, buf, len(buf))
|
||||
messages.append(self.ffi.string(buf).decode("utf-8", "replace"))
|
||||
return messages
|
||||
|
||||
def raise_value_error(self, action: str) -> None:
|
||||
"""Raise a password-free error, optionally with OpenSSL's diagnostics."""
|
||||
messages = self.error_messages()
|
||||
detail = f": {'; '.join(messages)}" if messages else ""
|
||||
raise ValueError(f"{action}{detail}")
|
||||
|
||||
@contextmanager
|
||||
def input_bio(self, data: bytes) -> Iterator[Any]:
|
||||
if not data:
|
||||
raise ValueError("input is empty")
|
||||
if len(data) > 0x7FFFFFFF:
|
||||
raise ValueError("input is too large")
|
||||
view = self.ffi.from_buffer(data)
|
||||
bio = self.lib.BIO_new_mem_buf(self.ffi.cast("const void *", view), len(data))
|
||||
if bio == self.ffi.NULL:
|
||||
self.raise_value_error("cannot allocate an input BIO")
|
||||
try:
|
||||
yield bio
|
||||
finally:
|
||||
self.lib.BIO_free(bio)
|
||||
|
||||
@contextmanager
|
||||
def output_bio(self) -> Iterator[Any]:
|
||||
bio = self.lib.BIO_new(self.lib.BIO_s_mem())
|
||||
if bio == self.ffi.NULL:
|
||||
self.raise_value_error("cannot allocate an output BIO")
|
||||
try:
|
||||
yield bio
|
||||
finally:
|
||||
self.lib.BIO_free(bio)
|
||||
|
||||
def output_bytes(self, bio: Any) -> bytes:
|
||||
size = int(self.lib.BIO_ctrl_pending(bio))
|
||||
if size > 0x7FFFFFFF:
|
||||
raise ValueError("OpenSSL output is too large")
|
||||
if size == 0:
|
||||
return b""
|
||||
buf = self.ffi.new("unsigned char[]", size)
|
||||
read = int(self.lib.BIO_read(bio, buf, size))
|
||||
if read != size:
|
||||
self.raise_value_error("cannot read OpenSSL output")
|
||||
return self.ffi.buffer(buf, size)[:]
|
||||
|
||||
def decode_der(
|
||||
self,
|
||||
data: bytes,
|
||||
decoder: Any,
|
||||
free_function: Any,
|
||||
description: str,
|
||||
) -> Any:
|
||||
"""Decode exactly one DER object and return its owning pointer.
|
||||
|
||||
The caller must release the returned pointer with ``free_function``.
|
||||
Most callers should prefer :meth:`decoded_der`, which scopes ownership
|
||||
automatically.
|
||||
"""
|
||||
if not isinstance(data, bytes):
|
||||
try:
|
||||
data = bytes(data)
|
||||
except Exception as exc: # noqa: BLE001 - normalize input errors
|
||||
raise ValueError(f"{description} must be DER bytes") from exc
|
||||
if not data:
|
||||
raise ValueError(f"{description} is empty")
|
||||
if len(data) > 0x7FFFFFFF:
|
||||
raise ValueError(f"{description} is too large")
|
||||
view = self.ffi.from_buffer(data)
|
||||
start = self.ffi.cast("const unsigned char *", view)
|
||||
cursor = self.ffi.new("const unsigned char **", start)
|
||||
obj = decoder(self.ffi.NULL, cursor, len(data))
|
||||
if obj == self.ffi.NULL:
|
||||
self.raise_value_error(f"cannot parse {description}")
|
||||
start_address = int(self.ffi.cast("uintptr_t", start))
|
||||
end_address = int(self.ffi.cast("uintptr_t", cursor[0]))
|
||||
if end_address - start_address != len(data):
|
||||
free_function(obj)
|
||||
raise ValueError(f"trailing data after {description}")
|
||||
return obj
|
||||
|
||||
@contextmanager
|
||||
def decoded_der(
|
||||
self,
|
||||
data: bytes,
|
||||
decoder: Any,
|
||||
free_function: Any,
|
||||
description: str,
|
||||
) -> Iterator[Any]:
|
||||
"""Decode exactly one DER object and scope its owning pointer."""
|
||||
obj = self.decode_der(data, decoder, free_function, description)
|
||||
try:
|
||||
yield obj
|
||||
finally:
|
||||
free_function(obj)
|
||||
|
||||
@contextmanager
|
||||
def password(self, password: Optional[str | bytes]) -> Iterator[Any]:
|
||||
"""Create operation-local callback state, wiping its C buffer after use."""
|
||||
if password is None:
|
||||
raw = None
|
||||
elif isinstance(password, str):
|
||||
raw = password.encode("utf-8")
|
||||
elif isinstance(password, (bytes, bytearray, memoryview)):
|
||||
raw = bytes(password)
|
||||
else:
|
||||
raise TypeError("password must be str, bytes, or None")
|
||||
|
||||
if raw is None:
|
||||
buf = self.ffi.NULL
|
||||
else:
|
||||
# A trailing NUL is not included in state.length and is not passed
|
||||
# to PKCS#8 as part of the password.
|
||||
buf = self.ffi.new("unsigned char[]", raw + b"\0")
|
||||
state = self.ffi.new("KCA_PASSWORD *")
|
||||
state.data = self.ffi.cast("const unsigned char *", buf)
|
||||
state.length = 0 if raw is None else len(raw)
|
||||
try:
|
||||
yield state
|
||||
finally:
|
||||
if raw:
|
||||
self.ffi.memmove(buf, b"\0" * (len(raw) + 1), len(raw) + 1)
|
||||
|
||||
|
||||
_backend_lock = threading.Lock()
|
||||
_backend_instance: Optional[OpenSSLBackend] = None
|
||||
|
||||
|
||||
def get_backend() -> OpenSSLBackend:
|
||||
"""Return the process-wide loaded library, initializing it on first use."""
|
||||
global _backend_instance
|
||||
if _backend_instance is None:
|
||||
with _backend_lock:
|
||||
if _backend_instance is None:
|
||||
_backend_instance = OpenSSLBackend.load()
|
||||
return _backend_instance
|
||||
|
||||
|
||||
def backend_available() -> bool:
|
||||
"""Return whether cffi and a usable system OpenSSL 3 libcrypto are present."""
|
||||
try:
|
||||
get_backend()
|
||||
except (OpenSSLUnavailableError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def library_path() -> str:
|
||||
"""Return the loader name/path selected for libcrypto."""
|
||||
return get_backend().path
|
||||
|
||||
|
||||
def openssl_version() -> str:
|
||||
"""Return the selected libcrypto's version string."""
|
||||
return get_backend().version
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OpenSSLBackend",
|
||||
"OpenSSLUnavailableError",
|
||||
"backend_available",
|
||||
"get_backend",
|
||||
"library_path",
|
||||
"openssl_version",
|
||||
]
|
||||
@@ -0,0 +1,941 @@
|
||||
"""OpenSSL 3 compatibility operations for keys and signed PKIX objects.
|
||||
|
||||
The native KCA paths use :mod:`cryptography`. This module is the isolated
|
||||
fallback for OpenSSL EC curves that cryptography does not expose and for
|
||||
signature digests that cryptography intentionally no longer supports. Every
|
||||
operation reparses canonical DER into a fresh ``EVP_PKEY`` and releases it
|
||||
before returning. No OpenSSL pointer is stored in a Python object or exchanged
|
||||
with cryptography/pyOpenSSL.
|
||||
|
||||
All inputs and outputs are immutable in-memory DER/PEM bytes. In particular,
|
||||
this module never invokes the OpenSSL CLI and never creates temporary files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
from .openssl_abi import (
|
||||
OpenSSLBackend,
|
||||
OpenSSLUnavailableError,
|
||||
backend_available,
|
||||
get_backend,
|
||||
library_path,
|
||||
openssl_version,
|
||||
)
|
||||
|
||||
# OpenSSL's EC_KEY ASN.1 flag value for a named-curve AlgorithmIdentifier.
|
||||
_OPENSSL_EC_NAMED_CURVE = 1
|
||||
_PKCS12_ITERATIONS = 10000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompatKey:
|
||||
"""Serializable key material returned by the compatibility backend.
|
||||
|
||||
``public_der`` is a SubjectPublicKeyInfo. A private key's ``private_der``
|
||||
is always canonical, unencrypted PKCS#8 DER; encryption is applied only by
|
||||
:func:`private_pem` or :func:`private_der_bytes`. ``curve`` is the OpenSSL
|
||||
short name for EC keys and ``None`` for other key types. EC ``key_size``
|
||||
is the curve field degree, matching XCA/cryptography rather than the size
|
||||
of the curve's group order.
|
||||
|
||||
The private DER is excluded from ``repr`` to avoid accidental key disclosure.
|
||||
"""
|
||||
|
||||
public_der: bytes
|
||||
private_der: Optional[bytes] = field(repr=False)
|
||||
curve: Optional[str]
|
||||
key_size: int
|
||||
is_private: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.is_private != (self.private_der is not None):
|
||||
raise ValueError("is_private must agree with the presence of private_der")
|
||||
if not self.public_der:
|
||||
raise ValueError("public_der cannot be empty")
|
||||
if self.key_size <= 0:
|
||||
raise ValueError("key_size must be positive")
|
||||
|
||||
def public_key(self) -> "CompatKey":
|
||||
"""Return a public-only copy, discarding the private component."""
|
||||
if not self.is_private:
|
||||
return self
|
||||
return CompatKey(
|
||||
public_der=self.public_der,
|
||||
private_der=None,
|
||||
curve=self.curve,
|
||||
key_size=self.key_size,
|
||||
is_private=False,
|
||||
)
|
||||
|
||||
|
||||
def _bytes(data: Any, description: str) -> bytes:
|
||||
if isinstance(data, bytes):
|
||||
return data
|
||||
try:
|
||||
return bytes(data)
|
||||
except Exception as exc: # noqa: BLE001 - normalize buffer input errors
|
||||
raise ValueError(f"{description} must be bytes") from exc
|
||||
|
||||
|
||||
def _ascii(value: str, description: str) -> bytes:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{description} must be a non-empty string")
|
||||
try:
|
||||
raw = value.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError(f"{description} must be an ASCII OpenSSL name") from exc
|
||||
if b"\0" in raw:
|
||||
raise ValueError(f"{description} contains a NUL byte")
|
||||
return raw
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _owned_evp(backend: OpenSSLBackend, pkey: Any) -> Iterator[Any]:
|
||||
try:
|
||||
yield pkey
|
||||
finally:
|
||||
backend.lib.EVP_PKEY_free(pkey)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _owned(backend: OpenSSLBackend, ptr: Any, free_function: Any) -> Iterator[Any]:
|
||||
try:
|
||||
yield ptr
|
||||
finally:
|
||||
free_function(ptr)
|
||||
|
||||
|
||||
def _evp_from_rsa(backend: OpenSSLBackend, rsa: Any) -> Any:
|
||||
"""Copy an RSA reference into a new EVP_PKEY, then release the RSA pointer."""
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
try:
|
||||
pkey = lib.EVP_PKEY_new()
|
||||
if pkey == ffi.NULL:
|
||||
backend.raise_value_error("cannot allocate a public key")
|
||||
try:
|
||||
if int(lib.EVP_PKEY_set1_RSA(pkey, rsa)) != 1:
|
||||
backend.raise_value_error("cannot convert the RSA public key")
|
||||
except Exception:
|
||||
lib.EVP_PKEY_free(pkey)
|
||||
raise
|
||||
return pkey
|
||||
finally:
|
||||
lib.RSA_free(rsa)
|
||||
|
||||
|
||||
def _read_der_private_evp(
|
||||
backend: OpenSSLBackend, data: bytes, password: Optional[str | bytes]
|
||||
) -> Any:
|
||||
"""Return an owning EVP_PKEY decoded from encrypted/unencrypted PKCS#8 or traditional DER."""
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
data = _bytes(data, "private key")
|
||||
|
||||
# This BIO decoder handles both encrypted and unencrypted PKCS#8 and never
|
||||
# prompts: our callback returns failure when no password was supplied.
|
||||
with backend.password(password) as state:
|
||||
with backend.input_bio(data) as bio:
|
||||
pkey = lib.d2i_PKCS8PrivateKey_bio(
|
||||
bio, ffi.NULL, backend._password_callback, state
|
||||
)
|
||||
trailing = int(lib.BIO_ctrl_pending(bio)) if pkey != ffi.NULL else 0
|
||||
if pkey != ffi.NULL:
|
||||
if trailing == 0:
|
||||
return pkey
|
||||
lib.EVP_PKEY_free(pkey)
|
||||
lib.ERR_clear_error()
|
||||
|
||||
# d2i_AutoPrivateKey covers unencrypted traditional RSA/DSA/EC DER. DER
|
||||
# has no standard encrypted traditional representation.
|
||||
try:
|
||||
return backend.decode_der(
|
||||
data, lib.d2i_AutoPrivateKey, lib.EVP_PKEY_free, "DER private key"
|
||||
)
|
||||
except ValueError:
|
||||
lib.ERR_clear_error()
|
||||
raise ValueError(
|
||||
"cannot parse DER private key (wrong password or unsupported format)"
|
||||
) from None
|
||||
|
||||
|
||||
def _read_der_public_evp(backend: OpenSSLBackend, data: bytes) -> Any:
|
||||
lib = backend.lib
|
||||
data = _bytes(data, "public key")
|
||||
try:
|
||||
return backend.decode_der(
|
||||
data, lib.d2i_PUBKEY, lib.EVP_PKEY_free, "DER SubjectPublicKeyInfo"
|
||||
)
|
||||
except ValueError:
|
||||
lib.ERR_clear_error()
|
||||
|
||||
# PKCS#1 is the traditional standalone RSA public-key representation.
|
||||
try:
|
||||
rsa = backend.decode_der(
|
||||
data, lib.d2i_RSAPublicKey, lib.RSA_free, "DER RSA public key"
|
||||
)
|
||||
except ValueError:
|
||||
lib.ERR_clear_error()
|
||||
raise ValueError("cannot parse DER public key") from None
|
||||
return _evp_from_rsa(backend, rsa)
|
||||
|
||||
|
||||
def _read_pem_private_evp(
|
||||
backend: OpenSSLBackend, data: bytes, password: Optional[str | bytes]
|
||||
) -> Any:
|
||||
ffi = backend.ffi
|
||||
with backend.password(password) as state:
|
||||
with backend.input_bio(data) as bio:
|
||||
pkey = backend.lib.PEM_read_bio_PrivateKey(
|
||||
bio, ffi.NULL, backend._password_callback, state
|
||||
)
|
||||
if pkey == ffi.NULL:
|
||||
backend.lib.ERR_clear_error()
|
||||
raise ValueError(
|
||||
"cannot parse PEM private key (wrong password or unsupported format)"
|
||||
)
|
||||
return pkey
|
||||
|
||||
|
||||
def _read_pem_public_evp(backend: OpenSSLBackend, data: bytes) -> Any:
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
with backend.input_bio(data) as bio:
|
||||
pkey = lib.PEM_read_bio_PUBKEY(bio, ffi.NULL, ffi.NULL, ffi.NULL)
|
||||
if pkey != ffi.NULL:
|
||||
return pkey
|
||||
lib.ERR_clear_error()
|
||||
|
||||
with backend.input_bio(data) as bio:
|
||||
rsa = lib.PEM_read_bio_RSAPublicKey(bio, ffi.NULL, ffi.NULL, ffi.NULL)
|
||||
if rsa == ffi.NULL:
|
||||
lib.ERR_clear_error()
|
||||
raise ValueError("cannot parse PEM public key")
|
||||
return _evp_from_rsa(backend, rsa)
|
||||
|
||||
|
||||
def _pkey_public_der(backend: OpenSSLBackend, pkey: Any) -> bytes:
|
||||
with backend.output_bio() as bio:
|
||||
if int(backend.lib.i2d_PUBKEY_bio(bio, pkey)) != 1:
|
||||
backend.raise_value_error("cannot encode SubjectPublicKeyInfo")
|
||||
return backend.output_bytes(bio)
|
||||
|
||||
|
||||
def _pkey_private_der(
|
||||
backend: OpenSSLBackend,
|
||||
pkey: Any,
|
||||
password_state: Any = None,
|
||||
*,
|
||||
encrypt: bool = False,
|
||||
) -> bytes:
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
cipher = lib.EVP_aes_256_cbc() if encrypt else ffi.NULL
|
||||
if cipher == ffi.NULL and encrypt:
|
||||
backend.raise_value_error("AES-256-CBC is unavailable")
|
||||
if encrypt:
|
||||
password = ffi.cast("char *", password_state.data)
|
||||
password_length = int(password_state.length)
|
||||
else:
|
||||
password = ffi.NULL
|
||||
password_length = 0
|
||||
with backend.output_bio() as bio:
|
||||
result = lib.i2d_PKCS8PrivateKey_bio(
|
||||
bio,
|
||||
pkey,
|
||||
cipher,
|
||||
password,
|
||||
password_length,
|
||||
ffi.NULL,
|
||||
ffi.NULL,
|
||||
)
|
||||
if int(result) != 1:
|
||||
backend.raise_value_error("cannot encode the PKCS#8 private key")
|
||||
return backend.output_bytes(bio)
|
||||
|
||||
|
||||
def _pkey_curve_name(backend: OpenSSLBackend, pkey: Any) -> Optional[str]:
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
if int(lib.EVP_PKEY_is_a(pkey, b"EC")) != 1:
|
||||
return None
|
||||
|
||||
ec_key = lib.EVP_PKEY_get1_EC_KEY(pkey)
|
||||
if ec_key != ffi.NULL:
|
||||
try:
|
||||
group = lib.EC_KEY_get0_group(ec_key)
|
||||
nid = int(lib.EC_GROUP_get_curve_name(group)) if group != ffi.NULL else 0
|
||||
name = lib.OBJ_nid2sn(nid) if nid else ffi.NULL
|
||||
if name != ffi.NULL:
|
||||
return ffi.string(name).decode("ascii")
|
||||
finally:
|
||||
lib.EC_KEY_free(ec_key)
|
||||
lib.ERR_clear_error()
|
||||
|
||||
# Provider-only EVP_PKEYs do not necessarily expose a legacy EC_KEY. The
|
||||
# standard "group" UTF-8 parameter is the equivalent named-curve metadata.
|
||||
out_len = ffi.new("size_t *")
|
||||
buf = ffi.new("char[256]")
|
||||
if int(lib.EVP_PKEY_get_utf8_string_param(pkey, b"group", buf, len(buf), out_len)) == 1:
|
||||
length = min(int(out_len[0]), len(buf) - 1)
|
||||
return ffi.buffer(buf, length)[:].split(b"\0", 1)[0].decode("utf-8", "replace")
|
||||
lib.ERR_clear_error()
|
||||
return None
|
||||
|
||||
|
||||
def _pkey_key_size(backend: OpenSSLBackend, pkey: Any) -> int:
|
||||
# EVP_PKEY_get_bits() reports the group-order size for some EC curves.
|
||||
# XCA and cryptography instead expose the field degree (for example, 176
|
||||
# for c2pnb176v1 and 160 for secp160k1), so prefer EC_GROUP_get_degree().
|
||||
if int(backend.lib.EVP_PKEY_is_a(pkey, b"EC")) == 1:
|
||||
ec_key = backend.lib.EVP_PKEY_get1_EC_KEY(pkey)
|
||||
if ec_key != backend.ffi.NULL:
|
||||
try:
|
||||
group = backend.lib.EC_KEY_get0_group(ec_key)
|
||||
degree = int(backend.lib.EC_GROUP_get_degree(group)) if group != backend.ffi.NULL else 0
|
||||
if degree > 0:
|
||||
return degree
|
||||
finally:
|
||||
backend.lib.EC_KEY_free(ec_key)
|
||||
backend.lib.ERR_clear_error()
|
||||
return int(backend.lib.EVP_PKEY_get_bits(pkey))
|
||||
|
||||
|
||||
def _key_from_evp(backend: OpenSSLBackend, pkey: Any, private: bool) -> CompatKey:
|
||||
key_size = _pkey_key_size(backend, pkey)
|
||||
if key_size <= 0:
|
||||
backend.raise_value_error("cannot determine the key size")
|
||||
public_der = _pkey_public_der(backend, pkey)
|
||||
private_der = _pkey_private_der(backend, pkey) if private else None
|
||||
return CompatKey(
|
||||
public_der=public_der,
|
||||
private_der=private_der,
|
||||
curve=_pkey_curve_name(backend, pkey),
|
||||
key_size=key_size,
|
||||
is_private=private,
|
||||
)
|
||||
|
||||
|
||||
def available_ec_curves() -> list[str]:
|
||||
"""Return OpenSSL's compiled-in named EC curves, by canonical short name."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
count = int(lib.EC_get_builtin_curves(ffi.NULL, 0))
|
||||
if count <= 0:
|
||||
backend.raise_value_error("cannot enumerate OpenSSL EC curves")
|
||||
curves = ffi.new("EC_builtin_curve[]", count)
|
||||
actual = int(lib.EC_get_builtin_curves(curves, count))
|
||||
names: list[str] = []
|
||||
for index in range(min(count, actual)):
|
||||
short_name = lib.OBJ_nid2sn(curves[index].nid)
|
||||
if short_name != ffi.NULL:
|
||||
names.append(ffi.string(short_name).decode("ascii"))
|
||||
return names
|
||||
|
||||
|
||||
def generate_ec(name: str) -> CompatKey:
|
||||
"""Generate a private EC key for any named curve provided by libcrypto."""
|
||||
backend = get_backend()
|
||||
curve_name = _ascii(name, "curve name")
|
||||
with backend.operation():
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
nid = int(lib.OBJ_txt2nid(curve_name))
|
||||
if nid == 0:
|
||||
raise ValueError(f"unknown OpenSSL EC curve {name!r}")
|
||||
ec_key = lib.EC_KEY_new_by_curve_name(nid)
|
||||
if ec_key == ffi.NULL:
|
||||
backend.raise_value_error(f"OpenSSL cannot initialize EC curve {name!r}")
|
||||
try:
|
||||
lib.EC_KEY_set_asn1_flag(ec_key, _OPENSSL_EC_NAMED_CURVE)
|
||||
if int(lib.EC_KEY_generate_key(ec_key)) != 1:
|
||||
backend.raise_value_error(f"OpenSSL cannot generate an EC key for {name!r}")
|
||||
if int(lib.EC_KEY_check_key(ec_key)) != 1:
|
||||
backend.raise_value_error("OpenSSL generated an invalid EC key")
|
||||
pkey = lib.EVP_PKEY_new()
|
||||
if pkey == ffi.NULL:
|
||||
backend.raise_value_error("cannot allocate an EVP_PKEY")
|
||||
try:
|
||||
if int(lib.EVP_PKEY_set1_EC_KEY(pkey, ec_key)) != 1:
|
||||
backend.raise_value_error("cannot wrap the generated EC key")
|
||||
return _key_from_evp(backend, pkey, private=True)
|
||||
finally:
|
||||
lib.EVP_PKEY_free(pkey)
|
||||
finally:
|
||||
lib.EC_KEY_free(ec_key)
|
||||
|
||||
|
||||
def private_key_from_der(
|
||||
data: bytes, password: Optional[str | bytes] = None
|
||||
) -> CompatKey:
|
||||
"""Load a DER private key (traditional or PKCS#8, including encrypted PKCS#8)."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
pkey = _read_der_private_evp(backend, data, password)
|
||||
with _owned_evp(backend, pkey):
|
||||
return _key_from_evp(backend, pkey, private=True)
|
||||
|
||||
|
||||
def public_key_from_der(data: bytes) -> CompatKey:
|
||||
"""Load a DER SubjectPublicKeyInfo or traditional PKCS#1 RSA public key."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
pkey = _read_der_public_evp(backend, data)
|
||||
with _owned_evp(backend, pkey):
|
||||
return _key_from_evp(backend, pkey, private=False)
|
||||
|
||||
|
||||
def load_key_bytes(data: bytes, password: Optional[str | bytes] = None) -> CompatKey:
|
||||
"""Load a private or public key from PEM or DER.
|
||||
|
||||
Private parsing is attempted first so encrypted private keys never get
|
||||
mistaken for public data. The returned :class:`CompatKey` records whether
|
||||
a private component was present.
|
||||
"""
|
||||
backend = get_backend()
|
||||
raw = _bytes(data, "key")
|
||||
is_pem = raw.lstrip().startswith(b"-----BEGIN")
|
||||
with backend.operation():
|
||||
errors: list[ValueError] = []
|
||||
readers: tuple[tuple[Any, bool], ...] = (
|
||||
(_read_pem_private_evp, True),
|
||||
(_read_pem_public_evp, False),
|
||||
) if is_pem else (
|
||||
(_read_der_private_evp, True),
|
||||
(_read_der_public_evp, False),
|
||||
)
|
||||
for reader, private in readers:
|
||||
try:
|
||||
if private:
|
||||
pkey = reader(backend, raw, password)
|
||||
else:
|
||||
pkey = reader(backend, raw)
|
||||
except ValueError as exc:
|
||||
errors.append(exc)
|
||||
backend.lib.ERR_clear_error()
|
||||
continue
|
||||
with _owned_evp(backend, pkey):
|
||||
return _key_from_evp(backend, pkey, private=private)
|
||||
raise ValueError("unable to parse key as PEM/DER private or public key") from errors[-1]
|
||||
|
||||
|
||||
def _require_private(key: CompatKey) -> bytes:
|
||||
if not isinstance(key, CompatKey):
|
||||
raise TypeError("key must be a CompatKey")
|
||||
if not key.is_private or key.private_der is None:
|
||||
raise ValueError("a private CompatKey is required")
|
||||
return key.private_der
|
||||
|
||||
|
||||
def _encryption_requested(password: Optional[str | bytes]) -> bool:
|
||||
if password is None:
|
||||
return False
|
||||
if isinstance(password, (str, bytes, bytearray, memoryview)):
|
||||
# Match the native KCA serialization API: an empty password selects
|
||||
# NoEncryption rather than producing an unreadable empty-passphrase PEM.
|
||||
return len(password) > 0
|
||||
raise TypeError("password must be str, bytes, or None")
|
||||
|
||||
|
||||
def public_pem(key: CompatKey) -> str:
|
||||
"""Serialize the public component as a SubjectPublicKeyInfo PEM string."""
|
||||
if not isinstance(key, CompatKey):
|
||||
raise TypeError("key must be a CompatKey")
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
pkey = _read_der_public_evp(backend, key.public_der)
|
||||
with _owned_evp(backend, pkey):
|
||||
with backend.output_bio() as bio:
|
||||
if int(backend.lib.PEM_write_bio_PUBKEY(bio, pkey)) != 1:
|
||||
backend.raise_value_error("cannot encode public PEM")
|
||||
return backend.output_bytes(bio).decode("ascii")
|
||||
|
||||
|
||||
def private_pem(
|
||||
key: CompatKey,
|
||||
password: Optional[str | bytes] = None,
|
||||
traditional: bool = False,
|
||||
) -> bytes:
|
||||
"""Serialize a private key as PKCS#8 PEM, or traditional OpenSSL PEM.
|
||||
|
||||
A non-empty ``password`` selects AES-256-CBC encryption. Traditional
|
||||
encryption is the legacy PEM ``DEK-Info`` form; PKCS#8 uses PBES2/PBKDF2 as
|
||||
provided by OpenSSL. ``None`` and an empty password produce unencrypted PEM.
|
||||
"""
|
||||
private_der = _require_private(key)
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
pkey = _read_der_private_evp(backend, private_der, None)
|
||||
with _owned_evp(backend, pkey):
|
||||
with backend.password(password) as state:
|
||||
encrypt = _encryption_requested(password)
|
||||
cipher = backend.lib.EVP_aes_256_cbc() if encrypt else backend.ffi.NULL
|
||||
if encrypt and cipher == backend.ffi.NULL:
|
||||
backend.raise_value_error("AES-256-CBC is unavailable")
|
||||
with backend.output_bio() as bio:
|
||||
if traditional:
|
||||
result = backend.lib.PEM_write_bio_PrivateKey_traditional(
|
||||
bio,
|
||||
pkey,
|
||||
cipher,
|
||||
state.data if encrypt else backend.ffi.NULL,
|
||||
int(state.length) if encrypt else 0,
|
||||
backend.ffi.NULL,
|
||||
backend.ffi.NULL,
|
||||
)
|
||||
else:
|
||||
result = backend.lib.PEM_write_bio_PKCS8PrivateKey(
|
||||
bio,
|
||||
pkey,
|
||||
backend.ffi.cast("const EVP_CIPHER *", cipher),
|
||||
backend.ffi.cast("char *", state.data) if encrypt else backend.ffi.NULL,
|
||||
int(state.length) if encrypt else 0,
|
||||
backend.ffi.NULL,
|
||||
backend.ffi.NULL,
|
||||
)
|
||||
if int(result) != 1:
|
||||
backend.raise_value_error("cannot encode private PEM")
|
||||
return backend.output_bytes(bio)
|
||||
|
||||
|
||||
def private_der_bytes(
|
||||
key: CompatKey, password: Optional[str | bytes] = None
|
||||
) -> bytes:
|
||||
"""Serialize private PKCS#8 DER, encrypting with AES-256-CBC when requested."""
|
||||
private_der = _require_private(key)
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
pkey = _read_der_private_evp(backend, private_der, None)
|
||||
with _owned_evp(backend, pkey):
|
||||
with backend.password(password) as state:
|
||||
return _pkey_private_der(
|
||||
backend, pkey, state, encrypt=_encryption_requested(password)
|
||||
)
|
||||
|
||||
|
||||
def certificate_public_der(cert_der: bytes) -> bytes:
|
||||
"""Extract a certificate's SubjectPublicKeyInfo without decoding its key in Python."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
cert_der, backend.lib.d2i_X509, backend.lib.X509_free, "DER certificate"
|
||||
) as cert:
|
||||
pkey = backend.lib.X509_get_pubkey(cert)
|
||||
if pkey == backend.ffi.NULL:
|
||||
backend.raise_value_error("certificate has no supported public key")
|
||||
with _owned_evp(backend, pkey):
|
||||
return _pkey_public_der(backend, pkey)
|
||||
|
||||
|
||||
def csr_public_der(csr_der: bytes) -> bytes:
|
||||
"""Extract a PKCS#10 request's SubjectPublicKeyInfo."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
csr_der, backend.lib.d2i_X509_REQ, backend.lib.X509_REQ_free, "DER CSR"
|
||||
) as req:
|
||||
pkey = backend.lib.X509_REQ_get_pubkey(req)
|
||||
if pkey == backend.ffi.NULL:
|
||||
backend.raise_value_error("CSR has no supported public key")
|
||||
with _owned_evp(backend, pkey):
|
||||
return _pkey_public_der(backend, pkey)
|
||||
|
||||
|
||||
def spki_key_identifier(spki_der: bytes) -> bytes:
|
||||
"""Return RFC 5280 method 1 SKI: SHA-1 of the subjectPublicKey BIT STRING payload.
|
||||
|
||||
The tag, length, and unused-bits octet are not hashed, and the enclosing
|
||||
SubjectPublicKeyInfo sequence is not hashed either.
|
||||
"""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
spki_der,
|
||||
backend.lib.d2i_X509_PUBKEY,
|
||||
backend.lib.X509_PUBKEY_free,
|
||||
"DER SubjectPublicKeyInfo",
|
||||
) as spki:
|
||||
public_bytes = backend.ffi.new("const unsigned char **")
|
||||
public_length = backend.ffi.new("int *")
|
||||
result = backend.lib.X509_PUBKEY_get0_param(
|
||||
backend.ffi.NULL,
|
||||
public_bytes,
|
||||
public_length,
|
||||
backend.ffi.NULL,
|
||||
spki,
|
||||
)
|
||||
if int(result) != 1 or public_bytes[0] == backend.ffi.NULL:
|
||||
backend.raise_value_error("cannot extract the subjectPublicKey BIT STRING")
|
||||
length = int(public_length[0])
|
||||
if length < 0:
|
||||
raise ValueError("invalid subjectPublicKey length")
|
||||
payload = backend.ffi.buffer(public_bytes[0], length)[:]
|
||||
return hashlib.sha1(payload, usedforsecurity=False).digest()
|
||||
|
||||
|
||||
def _digest(backend: OpenSSLBackend, digest: Optional[str]) -> Any:
|
||||
if digest is None:
|
||||
# Ed25519 and other one-shot signature algorithms require a null EVP_MD.
|
||||
return backend.ffi.NULL
|
||||
name = _ascii(digest, "digest")
|
||||
md = backend.lib.EVP_get_digestbyname(name)
|
||||
if md == backend.ffi.NULL:
|
||||
backend.lib.ERR_clear_error()
|
||||
raise ValueError(f"unsupported OpenSSL digest {digest!r}")
|
||||
return md
|
||||
|
||||
|
||||
def resign_certificate(
|
||||
template_der: bytes,
|
||||
subject_public_der: bytes,
|
||||
issuer_private_der: bytes,
|
||||
digest: Optional[str],
|
||||
) -> bytes:
|
||||
"""Replace a certificate subject key, re-sign in place, and return new DER."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
template_der, backend.lib.d2i_X509, backend.lib.X509_free, "DER certificate template"
|
||||
) as cert:
|
||||
subject = _read_der_public_evp(backend, subject_public_der)
|
||||
with _owned_evp(backend, subject):
|
||||
if int(backend.lib.X509_set_pubkey(cert, subject)) != 1:
|
||||
backend.raise_value_error("cannot replace the certificate public key")
|
||||
issuer = _read_der_private_evp(backend, issuer_private_der, None)
|
||||
with _owned_evp(backend, issuer):
|
||||
if int(backend.lib.X509_sign(cert, issuer, _digest(backend, digest))) <= 0:
|
||||
backend.raise_value_error("cannot sign the certificate")
|
||||
with backend.output_bio() as bio:
|
||||
if int(backend.lib.i2d_X509_bio(bio, cert)) != 1:
|
||||
backend.raise_value_error("cannot encode the signed certificate")
|
||||
return backend.output_bytes(bio)
|
||||
|
||||
|
||||
def resign_csr(
|
||||
template_der: bytes,
|
||||
subject_public_der: bytes,
|
||||
private_der: bytes,
|
||||
digest: Optional[str],
|
||||
) -> bytes:
|
||||
"""Replace a CSR subject key, re-sign with the private key, and return DER."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
template_der, backend.lib.d2i_X509_REQ, backend.lib.X509_REQ_free, "DER CSR template"
|
||||
) as req:
|
||||
subject = _read_der_public_evp(backend, subject_public_der)
|
||||
with _owned_evp(backend, subject):
|
||||
if int(backend.lib.X509_REQ_set_pubkey(req, subject)) != 1:
|
||||
backend.raise_value_error("cannot replace the CSR public key")
|
||||
private = _read_der_private_evp(backend, private_der, None)
|
||||
with _owned_evp(backend, private):
|
||||
if int(backend.lib.X509_REQ_sign(req, private, _digest(backend, digest))) <= 0:
|
||||
backend.raise_value_error("cannot sign the CSR")
|
||||
with backend.output_bio() as bio:
|
||||
if int(backend.lib.i2d_X509_REQ_bio(bio, req)) != 1:
|
||||
backend.raise_value_error("cannot encode the signed CSR")
|
||||
return backend.output_bytes(bio)
|
||||
|
||||
|
||||
def resign_crl(template_der: bytes, issuer_private_der: bytes, digest: Optional[str]) -> bytes:
|
||||
"""Re-sign a DER CRL template with the supplied issuer private key."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
template_der, backend.lib.d2i_X509_CRL, backend.lib.X509_CRL_free, "DER CRL template"
|
||||
) as crl:
|
||||
issuer = _read_der_private_evp(backend, issuer_private_der, None)
|
||||
with _owned_evp(backend, issuer):
|
||||
if int(backend.lib.X509_CRL_sign(crl, issuer, _digest(backend, digest))) <= 0:
|
||||
backend.raise_value_error("cannot sign the CRL")
|
||||
with backend.output_bio() as bio:
|
||||
if int(backend.lib.i2d_X509_CRL_bio(bio, crl)) != 1:
|
||||
backend.raise_value_error("cannot encode the signed CRL")
|
||||
return backend.output_bytes(bio)
|
||||
|
||||
|
||||
def _verification_result(backend: OpenSSLBackend, result: int, description: str) -> bool:
|
||||
if result == 1:
|
||||
return True
|
||||
if result == 0:
|
||||
# A normal bad signature is not exceptional and must not become a
|
||||
# misleading "unsupported algorithm" error.
|
||||
backend.lib.ERR_clear_error()
|
||||
return False
|
||||
messages = backend.error_messages()
|
||||
# X509_verify() uses -1 rather than 0 when an otherwise valid issuer key
|
||||
# has a different type from the signature algorithm (for example, an EC
|
||||
# key supplied for an RSA-signed certificate). That is still simply a
|
||||
# failed verification with the supplied key, not malformed input.
|
||||
mismatch_markers = ("wrong public key type", "key type mismatch")
|
||||
if any(
|
||||
marker in message.lower()
|
||||
for message in messages
|
||||
for marker in mismatch_markers
|
||||
):
|
||||
return False
|
||||
detail = f": {'; '.join(messages)}" if messages else ""
|
||||
raise ValueError(f"cannot verify {description}{detail}")
|
||||
|
||||
|
||||
def verify_certificate(cert_der: bytes, issuer_cert_der: bytes) -> bool:
|
||||
"""Verify only a certificate signature with the issuer certificate's key."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
cert_der, backend.lib.d2i_X509, backend.lib.X509_free, "DER certificate"
|
||||
) as cert:
|
||||
with backend.decoded_der(
|
||||
issuer_cert_der,
|
||||
backend.lib.d2i_X509,
|
||||
backend.lib.X509_free,
|
||||
"DER issuer certificate",
|
||||
) as issuer_cert:
|
||||
issuer = backend.lib.X509_get_pubkey(issuer_cert)
|
||||
if issuer == backend.ffi.NULL:
|
||||
backend.raise_value_error("issuer certificate has no supported public key")
|
||||
with _owned_evp(backend, issuer):
|
||||
result = int(backend.lib.X509_verify(cert, issuer))
|
||||
return _verification_result(backend, result, "certificate signature")
|
||||
|
||||
|
||||
def verify_csr(csr_der: bytes) -> bool:
|
||||
"""Verify a CSR signature with its contained subject public key."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
csr_der, backend.lib.d2i_X509_REQ, backend.lib.X509_REQ_free, "DER CSR"
|
||||
) as req:
|
||||
public = backend.lib.X509_REQ_get_pubkey(req)
|
||||
if public == backend.ffi.NULL:
|
||||
backend.raise_value_error("CSR has no supported public key")
|
||||
with _owned_evp(backend, public):
|
||||
result = int(backend.lib.X509_REQ_verify(req, public))
|
||||
return _verification_result(backend, result, "CSR signature")
|
||||
|
||||
|
||||
def verify_crl(crl_der: bytes, issuer_cert_der: bytes) -> bool:
|
||||
"""Verify only a CRL signature with the issuer certificate's key."""
|
||||
backend = get_backend()
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
crl_der, backend.lib.d2i_X509_CRL, backend.lib.X509_CRL_free, "DER CRL"
|
||||
) as crl:
|
||||
with backend.decoded_der(
|
||||
issuer_cert_der,
|
||||
backend.lib.d2i_X509,
|
||||
backend.lib.X509_free,
|
||||
"DER issuer certificate",
|
||||
) as issuer_cert:
|
||||
issuer = backend.lib.X509_get_pubkey(issuer_cert)
|
||||
if issuer == backend.ffi.NULL:
|
||||
backend.raise_value_error("issuer certificate has no supported public key")
|
||||
with _owned_evp(backend, issuer):
|
||||
result = int(backend.lib.X509_CRL_verify(crl, issuer))
|
||||
return _verification_result(backend, result, "CRL signature")
|
||||
|
||||
|
||||
def _pkcs12_cstring(value: Optional[str | bytes], description: str) -> tuple[Optional[bytes], bool]:
|
||||
if value is None:
|
||||
return None, False
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8")
|
||||
elif isinstance(value, (bytes, bytearray, memoryview)):
|
||||
raw = bytes(value)
|
||||
else:
|
||||
raise TypeError(f"{description} must be str, bytes, or None")
|
||||
if b"\0" in raw:
|
||||
raise ValueError(f"PKCS#12 {description} cannot contain a NUL byte")
|
||||
return raw, True
|
||||
|
||||
|
||||
def load_pkcs12(
|
||||
data: bytes, password: Optional[str | bytes]
|
||||
) -> tuple[Optional[bytes], Optional[bytes], list[bytes]]:
|
||||
"""Parse PKCS#12 DER into ``(private_pkcs8, main_certificate, ca_certificates)``.
|
||||
|
||||
OpenSSL's PKCS#12 API uses C strings for passphrases, so embedded NUL bytes
|
||||
are rejected. Wrong passwords and malformed containers raise ``ValueError``
|
||||
without including the supplied password in the error.
|
||||
"""
|
||||
backend = get_backend()
|
||||
raw_password, supplied = _pkcs12_cstring(password, "password")
|
||||
with backend.operation():
|
||||
with backend.decoded_der(
|
||||
data, backend.lib.d2i_PKCS12, backend.lib.PKCS12_free, "DER PKCS#12 container"
|
||||
) as p12:
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
password_buf = ffi.new("char[]", (raw_password or b"") + b"\0") if supplied else ffi.NULL
|
||||
pkey_out = ffi.new("EVP_PKEY **")
|
||||
cert_out = ffi.new("X509 **")
|
||||
ca_out = ffi.new("OPENSSL_STACK **")
|
||||
try:
|
||||
if int(lib.PKCS12_parse(p12, password_buf, pkey_out, cert_out, ca_out)) != 1:
|
||||
lib.ERR_clear_error()
|
||||
raise ValueError("cannot parse PKCS#12 (wrong password or unsupported content)")
|
||||
|
||||
private_der: Optional[bytes] = None
|
||||
cert_der: Optional[bytes] = None
|
||||
ca_ders: list[bytes] = []
|
||||
if pkey_out[0] != ffi.NULL:
|
||||
private_der = _pkey_private_der(backend, pkey_out[0])
|
||||
if cert_out[0] != ffi.NULL:
|
||||
with backend.output_bio() as bio:
|
||||
if int(lib.i2d_X509_bio(bio, cert_out[0])) != 1:
|
||||
backend.raise_value_error("cannot encode the PKCS#12 certificate")
|
||||
cert_der = backend.output_bytes(bio)
|
||||
if ca_out[0] != ffi.NULL:
|
||||
count = int(lib.OPENSSL_sk_num(ca_out[0]))
|
||||
for index in range(count):
|
||||
cert_ptr = lib.OPENSSL_sk_value(ca_out[0], index)
|
||||
if cert_ptr == ffi.NULL:
|
||||
continue
|
||||
with backend.output_bio() as bio:
|
||||
if int(lib.i2d_X509_bio(bio, ffi.cast("X509 *", cert_ptr))) != 1:
|
||||
backend.raise_value_error("cannot encode a PKCS#12 CA certificate")
|
||||
ca_ders.append(backend.output_bytes(bio))
|
||||
return private_der, cert_der, ca_ders
|
||||
finally:
|
||||
if pkey_out[0] != ffi.NULL:
|
||||
lib.EVP_PKEY_free(pkey_out[0])
|
||||
if cert_out[0] != ffi.NULL:
|
||||
lib.X509_free(cert_out[0])
|
||||
if ca_out[0] != ffi.NULL:
|
||||
count = int(lib.OPENSSL_sk_num(ca_out[0]))
|
||||
for index in range(count):
|
||||
cert_ptr = lib.OPENSSL_sk_value(ca_out[0], index)
|
||||
if cert_ptr != ffi.NULL:
|
||||
lib.X509_free(ffi.cast("X509 *", cert_ptr))
|
||||
lib.OPENSSL_sk_free(ca_out[0])
|
||||
if supplied and raw_password:
|
||||
ffi.memmove(password_buf, b"\0" * (len(raw_password) + 1), len(raw_password) + 1)
|
||||
|
||||
|
||||
def serialize_pkcs12(
|
||||
name: str,
|
||||
private_pkcs8_der: bytes,
|
||||
cert_der: bytes,
|
||||
ca_cert_ders: Iterable[bytes],
|
||||
password: Optional[str | bytes],
|
||||
) -> bytes:
|
||||
"""Create an in-memory PKCS#12 container with AES-256 and SHA-256.
|
||||
|
||||
A non-empty password selects PBES2/PBKDF2 with AES-256-CBC for both key and
|
||||
certificate encryption, plus an HMAC-SHA-256 integrity MAC. ``None`` and
|
||||
an empty password preserve cryptography's NoEncryption behavior. ``name``
|
||||
is the OpenSSL friendlyName attached to the key and main certificate.
|
||||
"""
|
||||
backend = get_backend()
|
||||
raw_name, _ = _pkcs12_cstring(name, "friendly name")
|
||||
raw_password, _ = _pkcs12_cstring(password, "password")
|
||||
encrypt = bool(raw_password)
|
||||
if raw_name is None:
|
||||
raise TypeError("PKCS#12 friendly name cannot be None")
|
||||
with backend.operation():
|
||||
ffi = backend.ffi
|
||||
lib = backend.lib
|
||||
name_buf = ffi.new("char[]", raw_name + b"\0")
|
||||
password_buf = ffi.new("char[]", (raw_password or b"") + b"\0") if encrypt else ffi.NULL
|
||||
ca_stack = ffi.NULL
|
||||
ca_owned: list[Any] = []
|
||||
try:
|
||||
pkey = _read_der_private_evp(backend, private_pkcs8_der, None)
|
||||
with _owned_evp(backend, pkey):
|
||||
with backend.decoded_der(
|
||||
cert_der, lib.d2i_X509, lib.X509_free, "DER PKCS#12 certificate"
|
||||
) as cert:
|
||||
ca_list = list(ca_cert_ders)
|
||||
if ca_list:
|
||||
ca_stack = lib.OPENSSL_sk_new_null()
|
||||
if ca_stack == ffi.NULL:
|
||||
backend.raise_value_error("cannot allocate the PKCS#12 CA stack")
|
||||
for ca_der in ca_list:
|
||||
# Keep owning pointers until PKCS12_create has consumed
|
||||
# the stack; the stack itself does not own these entries.
|
||||
ca_cert = backend.decode_der(
|
||||
ca_der, lib.d2i_X509, lib.X509_free, "DER PKCS#12 CA certificate"
|
||||
)
|
||||
ca_owned.append(ca_cert)
|
||||
if int(lib.OPENSSL_sk_push(ca_stack, ca_cert)) <= 0:
|
||||
backend.raise_value_error("cannot build the PKCS#12 CA stack")
|
||||
aes_nid = int(lib.OBJ_txt2nid(b"AES-256-CBC"))
|
||||
if aes_nid == 0:
|
||||
raise ValueError("OpenSSL does not provide AES-256-CBC for PKCS#12")
|
||||
p12 = lib.PKCS12_create(
|
||||
password_buf,
|
||||
name_buf,
|
||||
pkey,
|
||||
cert,
|
||||
ca_stack,
|
||||
aes_nid if encrypt else -1,
|
||||
aes_nid if encrypt else -1,
|
||||
_PKCS12_ITERATIONS,
|
||||
_PKCS12_ITERATIONS,
|
||||
0,
|
||||
)
|
||||
if p12 == ffi.NULL:
|
||||
backend.raise_value_error("cannot create PKCS#12")
|
||||
with _owned(backend, p12, lib.PKCS12_free):
|
||||
# Explicitly set SHA-256 for OpenSSL versions whose
|
||||
# PKCS12_create default MAC is still SHA-1.
|
||||
sha256 = lib.EVP_get_digestbyname(b"sha256")
|
||||
if sha256 == ffi.NULL:
|
||||
raise ValueError("OpenSSL does not provide SHA-256 for PKCS#12")
|
||||
if int(
|
||||
lib.PKCS12_set_mac(
|
||||
p12,
|
||||
password_buf,
|
||||
len(raw_password) if encrypt and raw_password is not None else -1,
|
||||
ffi.NULL,
|
||||
0,
|
||||
_PKCS12_ITERATIONS,
|
||||
sha256,
|
||||
)
|
||||
) != 1:
|
||||
backend.raise_value_error("cannot protect the PKCS#12 MAC")
|
||||
with backend.output_bio() as bio:
|
||||
if int(lib.i2d_PKCS12_bio(bio, p12)) != 1:
|
||||
backend.raise_value_error("cannot encode PKCS#12")
|
||||
return backend.output_bytes(bio)
|
||||
finally:
|
||||
for ca_cert in ca_owned:
|
||||
lib.X509_free(ca_cert)
|
||||
if ca_stack != ffi.NULL:
|
||||
lib.OPENSSL_sk_free(ca_stack)
|
||||
if encrypt and raw_password:
|
||||
ffi.memmove(password_buf, b"\0" * (len(raw_password) + 1), len(raw_password) + 1)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CompatKey",
|
||||
"OpenSSLUnavailableError",
|
||||
"available_ec_curves",
|
||||
"backend_available",
|
||||
"certificate_public_der",
|
||||
"csr_public_der",
|
||||
"generate_ec",
|
||||
"library_path",
|
||||
"load_key_bytes",
|
||||
"load_pkcs12",
|
||||
"openssl_version",
|
||||
"private_der_bytes",
|
||||
"private_key_from_der",
|
||||
"private_pem",
|
||||
"public_key_from_der",
|
||||
"public_pem",
|
||||
"resign_certificate",
|
||||
"resign_crl",
|
||||
"resign_csr",
|
||||
"serialize_pkcs12",
|
||||
"spki_key_identifier",
|
||||
"verify_certificate",
|
||||
"verify_crl",
|
||||
"verify_csr",
|
||||
]
|
||||
@@ -6,13 +6,30 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from 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",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Signature AlgorithmIdentifier names and digest lookup.
|
||||
|
||||
``cryptography`` intentionally does not expose hash objects for every legacy
|
||||
signature OID. KCA still needs to display, renew and transform those objects,
|
||||
so this small OID table is independent of its signing implementation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignatureInfo:
|
||||
name: str
|
||||
key_type: str
|
||||
hash_name: Optional[str]
|
||||
|
||||
|
||||
def _signature(name: str, key_type: str, hash_name: Optional[str]) -> SignatureInfo:
|
||||
return SignatureInfo(name, key_type, hash_name)
|
||||
|
||||
|
||||
SIGNATURE_OIDS: Dict[str, SignatureInfo] = {
|
||||
# RSA PKCS#1 v1.5.
|
||||
"1.2.840.113549.1.1.4": _signature("md5WithRSAEncryption", "rsa", "md5"),
|
||||
"1.2.840.113549.1.1.5": _signature("sha1WithRSAEncryption", "rsa", "sha1"),
|
||||
"1.2.840.113549.1.1.14": _signature("sha224WithRSAEncryption", "rsa", "sha224"),
|
||||
"1.2.840.113549.1.1.11": _signature("sha256WithRSAEncryption", "rsa", "sha256"),
|
||||
"1.2.840.113549.1.1.12": _signature("sha384WithRSAEncryption", "rsa", "sha384"),
|
||||
"1.2.840.113549.1.1.13": _signature("sha512WithRSAEncryption", "rsa", "sha512"),
|
||||
"1.3.36.3.3.1.2": _signature("ripemd160WithRSA", "rsa", "ripemd160"),
|
||||
# NIST RSA/SHA-3 signature algorithms.
|
||||
"2.16.840.1.101.3.4.3.13": _signature("RSA-SHA3-224", "rsa", "sha3-224"),
|
||||
"2.16.840.1.101.3.4.3.14": _signature("RSA-SHA3-256", "rsa", "sha3-256"),
|
||||
"2.16.840.1.101.3.4.3.15": _signature("RSA-SHA3-384", "rsa", "sha3-384"),
|
||||
"2.16.840.1.101.3.4.3.16": _signature("RSA-SHA3-512", "rsa", "sha3-512"),
|
||||
# ECDSA.
|
||||
"1.2.840.10045.4.1": _signature("ecdsa-with-SHA1", "ec", "sha1"),
|
||||
"1.2.840.10045.4.3.1": _signature("ecdsa-with-SHA224", "ec", "sha224"),
|
||||
"1.2.840.10045.4.3.2": _signature("ecdsa-with-SHA256", "ec", "sha256"),
|
||||
"1.2.840.10045.4.3.3": _signature("ecdsa-with-SHA384", "ec", "sha384"),
|
||||
"1.2.840.10045.4.3.4": _signature("ecdsa-with-SHA512", "ec", "sha512"),
|
||||
"2.16.840.1.101.3.4.3.9": _signature("ecdsa_with_SHA3-224", "ec", "sha3-224"),
|
||||
"2.16.840.1.101.3.4.3.10": _signature("ecdsa_with_SHA3-256", "ec", "sha3-256"),
|
||||
"2.16.840.1.101.3.4.3.11": _signature("ecdsa_with_SHA3-384", "ec", "sha3-384"),
|
||||
"2.16.840.1.101.3.4.3.12": _signature("ecdsa_with_SHA3-512", "ec", "sha3-512"),
|
||||
# DSA.
|
||||
"1.2.840.10040.4.3": _signature("dsa_with_SHA1", "dsa", "sha1"),
|
||||
"2.16.840.1.101.3.4.3.1": _signature("dsa_with_SHA224", "dsa", "sha224"),
|
||||
"2.16.840.1.101.3.4.3.2": _signature("dsa_with_SHA256", "dsa", "sha256"),
|
||||
# Ed25519 has no separately encoded digest.
|
||||
"1.3.101.112": _signature("Ed25519", "ed25519", None),
|
||||
}
|
||||
|
||||
|
||||
def signature_info(oid: object) -> Optional[SignatureInfo]:
|
||||
dotted = getattr(oid, "dotted_string", str(oid))
|
||||
return SIGNATURE_OIDS.get(dotted)
|
||||
|
||||
|
||||
def signature_algorithm_name(oid: object) -> str:
|
||||
dotted = getattr(oid, "dotted_string", str(oid))
|
||||
info = SIGNATURE_OIDS.get(dotted)
|
||||
if info is not None:
|
||||
return info.name
|
||||
name = getattr(oid, "_name", None) or getattr(oid, "name", None)
|
||||
if name and name not in ("Unknown OID", "Undefined"):
|
||||
return str(name)
|
||||
return dotted
|
||||
|
||||
|
||||
def signature_hash_name(oid: object) -> Optional[str]:
|
||||
info = signature_info(oid)
|
||||
if info is not None:
|
||||
return info.hash_name
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["SIGNATURE_OIDS", "SignatureInfo", "signature_algorithm_name", "signature_hash_name", "signature_info"]
|
||||
@@ -17,14 +17,14 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from 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
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""End-to-end core workflows for XCA curve and signature-hash parity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from kca.core import algorithms, certificates, crls, exports, imports, keys, requests
|
||||
from kca.core.extensions import default_extension_spec
|
||||
from kca.core.names import entries_from_json, name_from_entries
|
||||
|
||||
|
||||
def _name(cn: str = "Algorithm parity CA") -> x509.Name:
|
||||
return name_from_entries(entries_from_json([{"name": "CN", "value": cn}]))
|
||||
|
||||
|
||||
def _ca_spec() -> dict:
|
||||
spec = default_extension_spec()
|
||||
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
|
||||
spec["key_usage"] = {"critical": True, "usages": ["digital_signature", "key_cert_sign", "crl_sign"]}
|
||||
spec["subject_key_identifier"] = {"critical": False}
|
||||
spec["authority_key_identifier"] = {"critical": False}
|
||||
return spec
|
||||
|
||||
|
||||
class AlgorithmWorkflowTest(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def assert_workflow(self, key: object, signature_hash: str | None, curve: str | None = None) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
public = keys.public_part(key)
|
||||
public_der = keys.public_der(public)
|
||||
|
||||
csr = requests.build_csr(
|
||||
subject=_name("Algorithm parity CSR"),
|
||||
private_key=key,
|
||||
extension_spec=_ca_spec(),
|
||||
signature_hash=signature_hash,
|
||||
attributes={requests.CHALLENGE_PASSWORD_OID: "challenge"},
|
||||
)
|
||||
self.assertTrue(requests.verify_csr(csr))
|
||||
request_info = requests.request_info(csr)
|
||||
self.assertEqual(request_info.public_key_der, public_der)
|
||||
self.assertEqual(requests.csr_spec(csr)["signature_hash"], signature_hash)
|
||||
self.assertEqual(requests.attribute_values(csr)[requests.CHALLENGE_PASSWORD_OID], "challenge")
|
||||
|
||||
cert = certificates.build_self_signed(
|
||||
subject=_name(),
|
||||
private_key=key,
|
||||
serial=1,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
signature_hash=signature_hash,
|
||||
)
|
||||
self.assertTrue(certificates.is_self_signed(cert))
|
||||
self.assertTrue(certificates.verify_directly_issued_by(cert, cert))
|
||||
cert_info = certificates.certificate_info(cert)
|
||||
self.assertEqual(cert_info.public_key_der, public_der)
|
||||
self.assertEqual(certificates.signature_hash_name(cert), signature_hash)
|
||||
ski = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
|
||||
self.assertEqual(ski.digest, keys.public_key_identifier(public))
|
||||
aki = cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
|
||||
self.assertEqual(aki.key_identifier, ski.digest)
|
||||
|
||||
crl = crls.build_crl(
|
||||
issuer_cert=cert,
|
||||
issuer_private_key=key,
|
||||
revocations=[],
|
||||
last_update=now,
|
||||
next_update=now + timedelta(days=7),
|
||||
crl_number=1,
|
||||
signature_hash=signature_hash,
|
||||
)
|
||||
self.assertTrue(crls.verify_crl(crl, cert))
|
||||
self.assertEqual(crl.signature_algorithm_oid, cert.signature_algorithm_oid)
|
||||
|
||||
expected_curve = curve if curve is not None else keys.curve_name(key)
|
||||
reloaded_private = keys.private_key_from_der(keys.private_der(key))
|
||||
self.assertEqual(keys.public_der(reloaded_private), public_der)
|
||||
self.assertEqual(keys.curve_name(reloaded_private), expected_curve)
|
||||
reloaded_public = keys.public_key_from_der(public_der)
|
||||
self.assertEqual(keys.public_der(reloaded_public), public_der)
|
||||
self.assertEqual(keys.curve_name(reloaded_public), expected_curve)
|
||||
|
||||
def test_every_xca_curve(self) -> None:
|
||||
self.assertEqual(len(algorithms.CURVES), 79)
|
||||
for curve in algorithms.CURVES:
|
||||
with self.subTest(curve=curve.name):
|
||||
key = keys.generate_key("ec", curve=curve.name)
|
||||
info = keys.key_info(key)
|
||||
self.assertEqual(info.key_type, "EC")
|
||||
self.assertEqual(info.curve, curve.name)
|
||||
self.assertTrue(info.is_private)
|
||||
self.assert_workflow(key, "sha256", curve.name)
|
||||
|
||||
def test_mixed_native_and_compat_issuers(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
for ca_curve, leaf_curve in (("secp112r1", "secp256r1"), ("secp256r1", "sect163k1")):
|
||||
with self.subTest(ca_curve=ca_curve, leaf_curve=leaf_curve):
|
||||
ca_key = keys.generate_key("ec", curve=ca_curve)
|
||||
leaf_key = keys.generate_key("ec", curve=leaf_curve)
|
||||
ca = certificates.build_self_signed(
|
||||
subject=_name("Mixed CA"),
|
||||
private_key=ca_key,
|
||||
serial=10,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
leaf_spec = default_extension_spec()
|
||||
leaf_spec["subject_key_identifier"] = {"critical": False}
|
||||
leaf_spec["authority_key_identifier"] = {"critical": False}
|
||||
leaf = certificates.build_signed(
|
||||
subject=_name("Mixed leaf"),
|
||||
public_key=keys.public_part(leaf_key),
|
||||
issuer_cert=ca,
|
||||
issuer_private_key=ca_key,
|
||||
serial=11,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=10),
|
||||
extension_spec=leaf_spec,
|
||||
)
|
||||
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
|
||||
self.assertFalse(certificates.verify_directly_issued_by(leaf, leaf))
|
||||
self.assertEqual(certificates.certificate_info(leaf).public_key_der, keys.public_der(leaf_key))
|
||||
ca_ski = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
|
||||
leaf_aki = leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
|
||||
self.assertEqual(leaf_aki.key_identifier, ca_ski.digest)
|
||||
|
||||
def test_ed25519_issuer_with_compat_subject(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
ca_key = keys.generate_key("ed25519")
|
||||
leaf_key = keys.generate_key("ec", curve="sect163k1")
|
||||
ca = certificates.build_self_signed(
|
||||
subject=_name("Ed25519 CA"),
|
||||
private_key=ca_key,
|
||||
serial=20,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
signature_hash=None,
|
||||
)
|
||||
leaf = certificates.build_signed(
|
||||
subject=_name("Ed25519 signed compat leaf"),
|
||||
public_key=keys.public_part(leaf_key),
|
||||
issuer_cert=ca,
|
||||
issuer_private_key=ca_key,
|
||||
serial=21,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=10),
|
||||
extension_spec=default_extension_spec(),
|
||||
signature_hash=None,
|
||||
)
|
||||
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
|
||||
self.assertEqual(certificates.certificate_info(leaf).public_key_der, keys.public_der(leaf_key))
|
||||
self.assertIsNone(certificates.signature_hash_name(leaf))
|
||||
|
||||
def test_issuer_name_is_checked_in_openssl_fallback(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
ca_key = keys.generate_key("ec", curve="secp112r1")
|
||||
first = certificates.build_self_signed(
|
||||
subject=_name("First issuer"),
|
||||
private_key=ca_key,
|
||||
serial=30,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
other_with_same_key = certificates.build_self_signed(
|
||||
subject=_name("Other issuer with the same key"),
|
||||
private_key=ca_key,
|
||||
serial=31,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
leaf = certificates.build_signed(
|
||||
subject=_name("Leaf"),
|
||||
public_key=keys.public_part(ca_key),
|
||||
issuer_cert=first,
|
||||
issuer_private_key=ca_key,
|
||||
serial=32,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=10),
|
||||
extension_spec=default_extension_spec(),
|
||||
)
|
||||
self.assertTrue(certificates.verify_directly_issued_by(leaf, first))
|
||||
self.assertFalse(certificates.verify_directly_issued_by(leaf, other_with_same_key))
|
||||
|
||||
def test_xca_signature_hash_matrix(self) -> None:
|
||||
cases = [
|
||||
("rsa", {"size": 1024}, algorithms.possible_hashes("rsa")),
|
||||
("ec", {"curve": "secp256r1"}, algorithms.possible_hashes("ec")),
|
||||
("dsa", {"size": 1024}, algorithms.possible_hashes("dsa")),
|
||||
]
|
||||
for key_type, kwargs, hashes in cases:
|
||||
key = keys.generate_key(key_type, **kwargs)
|
||||
for signature_hash in hashes:
|
||||
with self.subTest(key_type=key_type, signature_hash=signature_hash):
|
||||
self.assert_workflow(key, signature_hash)
|
||||
|
||||
def test_ed25519_has_no_separate_hash(self) -> None:
|
||||
key = keys.generate_key("ed25519")
|
||||
self.assert_workflow(key, None)
|
||||
with self.assertRaises(ValueError):
|
||||
requests.build_csr(subject=_name(), private_key=key, signature_hash="sha256")
|
||||
|
||||
def test_invalid_key_hash_combinations(self) -> None:
|
||||
ec_key = keys.generate_key("ec", curve="secp256r1")
|
||||
dsa_key = keys.generate_key("dsa", size=1024)
|
||||
with self.assertRaises(ValueError):
|
||||
requests.build_csr(subject=_name(), private_key=ec_key, signature_hash="md5")
|
||||
with self.assertRaises(ValueError):
|
||||
requests.build_csr(subject=_name(), private_key=dsa_key, signature_hash="sha3-256")
|
||||
|
||||
def test_representative_encrypted_compat_roundtrips(self) -> None:
|
||||
for curve in ("secp112r1", "prime192v2", "sect163k1", "brainpoolP160t1", "wap-wsg-idm-ecid-wtls6"):
|
||||
with self.subTest(curve=curve):
|
||||
key = keys.generate_key("ec", curve=curve)
|
||||
for traditional in (False, True):
|
||||
for password in (None, "secret"):
|
||||
encoded = keys.private_pem(key, password=password, traditional=traditional)
|
||||
loaded, private = keys.load_key_bytes(encoded, password=password)
|
||||
self.assertTrue(private)
|
||||
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
|
||||
encrypted_der = keys.private_der_bytes(key, password="secret")
|
||||
loaded = keys.private_key_from_der(encrypted_der, password="secret")
|
||||
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
|
||||
public, private = keys.load_key_bytes(keys.public_pem(key).encode("ascii"))
|
||||
self.assertFalse(private)
|
||||
self.assertEqual(keys.public_der(public), keys.public_der(key))
|
||||
|
||||
def test_pkcs12_compat_export_import(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
key = keys.generate_key("ec", curve="secp112r1")
|
||||
cert = certificates.build_self_signed(
|
||||
subject=_name("PKCS12 compat"),
|
||||
private_key=key,
|
||||
serial=40,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
encoded = exports.export_certificate(
|
||||
cert,
|
||||
"pkcs12",
|
||||
name="compat",
|
||||
private_key=key,
|
||||
password="p12-secret",
|
||||
).data
|
||||
items = imports.parse_pkcs12(encoded, password="p12-secret")
|
||||
by_kind = {}
|
||||
for item in items:
|
||||
by_kind.setdefault(item.kind, []).append(item)
|
||||
self.assertEqual(len(by_kind["certificate"]), 1)
|
||||
self.assertEqual(len(by_kind["key"]), 1)
|
||||
self.assertEqual(
|
||||
certificates.certificate_der(by_kind["certificate"][0].obj),
|
||||
certificates.certificate_der(cert),
|
||||
)
|
||||
self.assertTrue(by_kind["key"][0].is_private)
|
||||
self.assertEqual(keys.public_der(by_kind["key"][0].obj), keys.public_der(key))
|
||||
|
||||
def test_non_ec_keys_survive_openssl_fallback_import(self) -> None:
|
||||
key = keys.generate_key("rsa", size=1024)
|
||||
der = keys.private_der(key)
|
||||
original = serialization.load_der_private_key
|
||||
calls = 0
|
||||
|
||||
def legacy_only(data: bytes, password: object = None) -> object:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise ValueError("simulated legacy-only encoding")
|
||||
return original(data, password=password)
|
||||
|
||||
with patch("kca.core.keys.serialization.load_der_private_key", new=legacy_only):
|
||||
loaded = keys.private_key_from_der(der)
|
||||
self.assertIsInstance(loaded, rsa.RSAPrivateKey)
|
||||
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
|
||||
self.assertEqual(calls, 2)
|
||||
|
||||
def test_blank_pkcs12_passwords_remain_unencrypted(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
compat_key = keys.generate_key("ec", curve="secp112r1")
|
||||
compat_ca = certificates.build_self_signed(
|
||||
subject=_name("Unencrypted compat CA"),
|
||||
private_key=compat_key,
|
||||
serial=50,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
leaf_key = keys.generate_key("ec", curve="secp256r1")
|
||||
leaf = certificates.build_signed(
|
||||
subject=_name("Mixed-chain leaf"),
|
||||
public_key=keys.public_part(leaf_key),
|
||||
issuer_cert=compat_ca,
|
||||
issuer_private_key=compat_key,
|
||||
serial=51,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=10),
|
||||
extension_spec=default_extension_spec(),
|
||||
)
|
||||
pbes2_oid = bytes.fromhex("06092a864886f70d01050d")
|
||||
for password in (None, ""):
|
||||
with self.subTest(password=password):
|
||||
encoded = exports.export_certificate(
|
||||
leaf,
|
||||
"pkcs12-chain",
|
||||
name="unencrypted",
|
||||
chain=[leaf, compat_ca],
|
||||
private_key=leaf_key,
|
||||
password=password,
|
||||
).data
|
||||
self.assertNotIn(pbes2_oid, encoded)
|
||||
items = imports.parse_pkcs12(encoded, password=password)
|
||||
imported_keys = [item for item in items if item.kind == "key"]
|
||||
self.assertEqual(len(imported_keys), 1)
|
||||
self.assertEqual(keys.public_der(imported_keys[0].obj), keys.public_der(leaf_key))
|
||||
|
||||
def test_option_metadata_is_complete(self) -> None:
|
||||
options = keys.key_type_options()
|
||||
self.assertEqual(len(options), 4)
|
||||
by_type = {item["type"]: item for item in options}
|
||||
self.assertEqual(len(by_type["ec"]["curves"]), 79)
|
||||
for key_type, option in by_type.items():
|
||||
self.assertEqual(option["hashes"], list(algorithms.possible_hashes(key_type)))
|
||||
self.assertEqual(len(keys.curve_options()), 79)
|
||||
self.assertEqual(len(keys.hash_options()), 11)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""XCA algorithm catalogue and signature metadata tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from kca.core import algorithms, signatures
|
||||
|
||||
|
||||
EXPECTED_CURVES = {
|
||||
"secp192r1", "secp224r1", "secp256r1", "secp384r1", "secp521r1",
|
||||
"prime192v2", "prime192v3", "prime239v1", "prime239v2", "prime239v3",
|
||||
"c2pnb163v1", "c2pnb163v2", "c2pnb163v3", "c2pnb176v1",
|
||||
"c2tnb191v1", "c2tnb191v2", "c2tnb191v3", "c2pnb208w1",
|
||||
"c2tnb239v1", "c2tnb239v2", "c2tnb239v3", "c2pnb272w1",
|
||||
"c2pnb304w1", "c2tnb359v1", "c2pnb368w1", "c2tnb431r1",
|
||||
"sect113r1", "sect113r2", "sect131r1", "sect131r2", "sect163k1",
|
||||
"sect163r1", "sect163r2", "sect193r1", "sect193r2", "sect233k1",
|
||||
"sect233r1", "sect239k1", "sect283k1", "sect283r1", "sect409k1",
|
||||
"sect409r1", "sect571k1", "sect571r1",
|
||||
"secp112r1", "secp112r2", "secp128r1", "secp128r2", "secp160k1",
|
||||
"secp160r1", "secp160r2", "secp192k1", "secp224k1", "secp256k1",
|
||||
"wap-wsg-idm-ecid-wtls1", "wap-wsg-idm-ecid-wtls3",
|
||||
"wap-wsg-idm-ecid-wtls4", "wap-wsg-idm-ecid-wtls5",
|
||||
"wap-wsg-idm-ecid-wtls6", "wap-wsg-idm-ecid-wtls7",
|
||||
"wap-wsg-idm-ecid-wtls8", "wap-wsg-idm-ecid-wtls9",
|
||||
"wap-wsg-idm-ecid-wtls10", "wap-wsg-idm-ecid-wtls11",
|
||||
"wap-wsg-idm-ecid-wtls12",
|
||||
"brainpoolP160r1", "brainpoolP160t1", "brainpoolP192r1",
|
||||
"brainpoolP192t1", "brainpoolP224r1", "brainpoolP224t1",
|
||||
"brainpoolP256r1", "brainpoolP256t1", "brainpoolP320r1",
|
||||
"brainpoolP320t1", "brainpoolP384r1", "brainpoolP384t1",
|
||||
"brainpoolP512r1", "brainpoolP512t1",
|
||||
}
|
||||
|
||||
|
||||
class AlgorithmCatalogueTest(unittest.TestCase):
|
||||
def test_exact_xca_curve_set(self) -> None:
|
||||
self.assertEqual({curve.name for curve in algorithms.CURVES}, EXPECTED_CURVES)
|
||||
self.assertEqual(len(algorithms.CURVES), 79)
|
||||
self.assertEqual(len({curve.openssl_name for curve in algorithms.CURVES}), 79)
|
||||
|
||||
def test_aliases_preserve_existing_kca_names(self) -> None:
|
||||
self.assertEqual(algorithms.canonical_curve_name("prime192v1"), "secp192r1")
|
||||
self.assertEqual(algorithms.canonical_curve_name("prime256v1"), "secp256r1")
|
||||
self.assertEqual(algorithms.curve_info("BRAINPOOLP256R1").name, "brainpoolP256r1")
|
||||
with self.assertRaises(ValueError):
|
||||
algorithms.curve_info("SM2")
|
||||
|
||||
def test_xca_hash_matrix(self) -> None:
|
||||
self.assertEqual(
|
||||
algorithms.possible_hashes("RSA"),
|
||||
("md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384", "sha3-512"),
|
||||
)
|
||||
self.assertEqual(
|
||||
algorithms.possible_hashes("ec"),
|
||||
("sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384", "sha3-512"),
|
||||
)
|
||||
self.assertEqual(algorithms.possible_hashes("dsa"), ("sha1", "sha224", "sha256"))
|
||||
self.assertEqual(algorithms.possible_hashes("ed25519"), ())
|
||||
self.assertEqual(algorithms.default_signature_hash("ec", "md5"), "sha256")
|
||||
self.assertIsNone(algorithms.default_signature_hash("ed25519", "md5"))
|
||||
with self.assertRaises(ValueError):
|
||||
algorithms.validate_signature_hash("ec", "md5")
|
||||
with self.assertRaises(ValueError):
|
||||
algorithms.validate_signature_hash("dsa", "sha3-256")
|
||||
with self.assertRaises(ValueError):
|
||||
algorithms.validate_signature_hash("ed25519", "sha256")
|
||||
|
||||
def test_signature_oid_names_and_hashes(self) -> None:
|
||||
class OID:
|
||||
def __init__(self, dotted: str) -> None:
|
||||
self.dotted_string = dotted
|
||||
self._name = "Unknown OID"
|
||||
|
||||
cases = {
|
||||
"1.2.840.113549.1.1.4": ("md5WithRSAEncryption", "md5"),
|
||||
"1.3.36.3.3.1.2": ("ripemd160WithRSA", "ripemd160"),
|
||||
"2.16.840.1.101.3.4.3.14": ("RSA-SHA3-256", "sha3-256"),
|
||||
"2.16.840.1.101.3.4.3.10": ("ecdsa_with_SHA3-256", "sha3-256"),
|
||||
"1.2.840.10040.4.3": ("dsa_with_SHA1", "sha1"),
|
||||
}
|
||||
for dotted, (name, hash_name) in cases.items():
|
||||
with self.subTest(oid=dotted):
|
||||
oid = OID(dotted)
|
||||
self.assertEqual(signatures.signature_algorithm_name(oid), name)
|
||||
self.assertEqual(signatures.signature_hash_name(oid), hash_name)
|
||||
self.assertEqual(signatures.signature_algorithm_name(OID("1.3.101.112")), "Ed25519")
|
||||
self.assertIsNone(signatures.signature_hash_name(OID("1.3.101.112")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,449 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from kca.core import openssl_compat as compat
|
||||
|
||||
UNSUPPORTED_CURVES = (
|
||||
"secp112r1",
|
||||
"prime192v2",
|
||||
"sect163k1",
|
||||
"brainpoolP160t1",
|
||||
"wap-wsg-idm-ecid-wtls6",
|
||||
)
|
||||
|
||||
|
||||
def _require_curve(name: str) -> None:
|
||||
available = compat.available_ec_curves()
|
||||
canonical = "prime256v1" if name == "secp256r1" else name
|
||||
if canonical not in available:
|
||||
raise unittest.SkipTest(f"system OpenSSL does not provide EC curve {name}")
|
||||
|
||||
|
||||
def _self_signed_certificate(
|
||||
private_key: rsa.RSAPrivateKey,
|
||||
common_name: str,
|
||||
serial: int,
|
||||
) -> x509.Certificate:
|
||||
now = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
|
||||
return (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(serial)
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False,
|
||||
)
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
|
||||
def _build_native_templates() -> dict[str, object]:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
certificate = _self_signed_certificate(private_key, "OpenSSL compatibility", 1000)
|
||||
extra_ca = _self_signed_certificate(private_key, "OpenSSL compatibility extra CA", 1001)
|
||||
now = datetime(2024, 2, 1, tzinfo=timezone.utc)
|
||||
csr = (
|
||||
x509.CertificateSigningRequestBuilder()
|
||||
.subject_name(certificate.subject)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
revoked = (
|
||||
x509.RevokedCertificateBuilder()
|
||||
.serial_number(42)
|
||||
.revocation_date(now)
|
||||
.build()
|
||||
)
|
||||
crl = (
|
||||
x509.CertificateRevocationListBuilder()
|
||||
.issuer_name(certificate.subject)
|
||||
.last_update(now)
|
||||
.next_update(now + timedelta(days=30))
|
||||
.add_revoked_certificate(revoked)
|
||||
.add_extension(x509.CRLNumber(7), critical=False)
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
return {
|
||||
"key_object": private_key,
|
||||
"private_der": private_key.private_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
),
|
||||
"public_der": private_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
),
|
||||
"cert_object": certificate,
|
||||
"cert_der": certificate.public_bytes(serialization.Encoding.DER),
|
||||
"csr_der": csr.public_bytes(serialization.Encoding.DER),
|
||||
"crl_der": crl.public_bytes(serialization.Encoding.DER),
|
||||
"extra_ca_object": extra_ca,
|
||||
"extra_ca_der": extra_ca.public_bytes(serialization.Encoding.DER),
|
||||
}
|
||||
|
||||
|
||||
def _resign_all(
|
||||
templates: dict[str, object], key: compat.CompatKey, digest: str
|
||||
) -> tuple[bytes, bytes, bytes]:
|
||||
cert = compat.resign_certificate(
|
||||
templates["cert_der"], key.public_der, key.private_der, digest
|
||||
)
|
||||
csr = compat.resign_csr(
|
||||
templates["csr_der"], key.public_der, key.private_der, digest
|
||||
)
|
||||
crl = compat.resign_crl(templates["crl_der"], key.private_der, digest)
|
||||
return cert, csr, crl
|
||||
|
||||
|
||||
class OpenSSLCompatTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.native_templates = _build_native_templates()
|
||||
|
||||
def test_backend_and_curve_discovery(self) -> None:
|
||||
self.assertTrue(compat.backend_available())
|
||||
self.assertTrue(compat.openssl_version().startswith("OpenSSL 3."))
|
||||
self.assertTrue(compat.library_path())
|
||||
curves = compat.available_ec_curves()
|
||||
self.assertTrue(curves)
|
||||
self.assertEqual(len(curves), len(set(curves)))
|
||||
self.assertIn("prime256v1", curves)
|
||||
|
||||
def test_ec_key_size_is_curve_field_degree(self) -> None:
|
||||
for curve, key_size in (("c2pnb176v1", 176), ("secp160k1", 160)):
|
||||
with self.subTest(curve=curve):
|
||||
_require_curve(curve)
|
||||
self.assertEqual(compat.generate_ec(curve).key_size, key_size)
|
||||
|
||||
def test_native_ec_interoperates_with_cryptography(self) -> None:
|
||||
_require_curve("prime256v1")
|
||||
key = compat.generate_ec("prime256v1")
|
||||
self.assertTrue(key.is_private)
|
||||
self.assertEqual(key.curve, "prime256v1")
|
||||
self.assertEqual(key.key_size, 256)
|
||||
|
||||
# Native keys still cross the API boundary only as DER, never as pointers.
|
||||
native_private = serialization.load_der_private_key(key.private_der, password=None)
|
||||
native_public = serialization.load_der_public_key(key.public_der)
|
||||
self.assertEqual(
|
||||
native_private.private_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
),
|
||||
key.private_der,
|
||||
)
|
||||
self.assertEqual(
|
||||
native_public.public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
),
|
||||
key.public_der,
|
||||
)
|
||||
|
||||
def test_arbitrary_curve_and_key_round_trips(self) -> None:
|
||||
for curve in UNSUPPORTED_CURVES:
|
||||
with self.subTest(curve=curve):
|
||||
_require_curve(curve)
|
||||
key = compat.generate_ec(curve)
|
||||
self.assertEqual(key.curve, curve)
|
||||
self.assertTrue(key.is_private)
|
||||
self.assertGreater(key.key_size, 0)
|
||||
self.assertIsNotNone(key.private_der)
|
||||
|
||||
public = key.public_key()
|
||||
self.assertFalse(public.is_private)
|
||||
self.assertIsNone(public.private_der)
|
||||
self.assertIs(public.public_key(), public)
|
||||
self.assertEqual(public.public_der, key.public_der)
|
||||
self.assertEqual(compat.public_key_from_der(key.public_der).public_der, key.public_der)
|
||||
self.assertEqual(compat.load_key_bytes(key.public_der), public)
|
||||
self.assertEqual(compat.private_key_from_der(key.private_der), key)
|
||||
self.assertEqual(compat.load_key_bytes(key.private_der), key)
|
||||
|
||||
public_pem = compat.public_pem(key)
|
||||
self.assertIsInstance(public_pem, str)
|
||||
self.assertTrue(public_pem.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||
self.assertEqual(compat.load_key_bytes(public_pem.encode("ascii")), public)
|
||||
|
||||
pkcs8_pem = compat.private_pem(key)
|
||||
self.assertTrue(pkcs8_pem.startswith(b"-----BEGIN PRIVATE KEY-----"))
|
||||
self.assertEqual(compat.load_key_bytes(pkcs8_pem), key)
|
||||
traditional_pem = compat.private_pem(key, traditional=True)
|
||||
self.assertTrue(traditional_pem.startswith(b"-----BEGIN EC PRIVATE KEY-----"))
|
||||
self.assertEqual(compat.load_key_bytes(traditional_pem), key)
|
||||
|
||||
encrypted_pem = compat.private_pem(key, password="correct horse")
|
||||
self.assertTrue(encrypted_pem.startswith(b"-----BEGIN ENCRYPTED PRIVATE KEY-----"))
|
||||
self.assertEqual(compat.load_key_bytes(encrypted_pem, password="correct horse"), key)
|
||||
encrypted_traditional = compat.private_pem(
|
||||
key, password="correct horse", traditional=True
|
||||
)
|
||||
self.assertTrue(encrypted_traditional.startswith(b"-----BEGIN EC PRIVATE KEY-----"))
|
||||
self.assertIn(b"DEK-Info:", encrypted_traditional)
|
||||
self.assertEqual(
|
||||
compat.load_key_bytes(encrypted_traditional, password="correct horse"), key
|
||||
)
|
||||
|
||||
self.assertEqual(compat.private_der_bytes(key), key.private_der)
|
||||
encrypted_der = compat.private_der_bytes(key, password="correct horse")
|
||||
self.assertNotEqual(encrypted_der, key.private_der)
|
||||
self.assertEqual(
|
||||
compat.private_key_from_der(encrypted_der, password="correct horse"), key
|
||||
)
|
||||
self.assertEqual(compat.load_key_bytes(encrypted_der, password="correct horse"), key)
|
||||
|
||||
def test_wrong_password_errors_do_not_disclose_password(self) -> None:
|
||||
_require_curve("secp112r1")
|
||||
key = compat.generate_ec("secp112r1")
|
||||
password = "not-for-error-output"
|
||||
encrypted = compat.private_der_bytes(key, password=password)
|
||||
attempted_password = "definitely-wrong-secret"
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
compat.private_key_from_der(encrypted, password=attempted_password)
|
||||
self.assertNotIn(password, str(caught.exception))
|
||||
self.assertNotIn(attempted_password, str(caught.exception))
|
||||
with self.assertRaises(ValueError):
|
||||
compat.private_pem(key.public_key())
|
||||
self.assertEqual(compat.private_pem(key, password=""), compat.private_pem(key))
|
||||
self.assertEqual(compat.private_der_bytes(key, password=""), key.private_der)
|
||||
|
||||
def test_native_rsa_traditional_and_pkcs1_der(self) -> None:
|
||||
templates = self.native_templates
|
||||
private_key = templates["key_object"]
|
||||
traditional_der = private_key.private_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
pkcs1_public_der = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.PKCS1,
|
||||
)
|
||||
for encoded in (templates["private_der"], traditional_der):
|
||||
loaded = compat.load_key_bytes(encoded)
|
||||
self.assertTrue(loaded.is_private)
|
||||
self.assertIsNone(loaded.curve)
|
||||
self.assertEqual(loaded.key_size, 2048)
|
||||
self.assertEqual(loaded.public_der, templates["public_der"])
|
||||
public = compat.public_key_from_der(pkcs1_public_der)
|
||||
self.assertEqual(public.public_der, templates["public_der"])
|
||||
pkcs1_public_pem = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.PKCS1,
|
||||
)
|
||||
self.assertEqual(compat.load_key_bytes(pkcs1_public_pem).public_der, templates["public_der"])
|
||||
|
||||
encrypted_by_cryptography = private_key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.BestAvailableEncryption(b"import-password"),
|
||||
)
|
||||
self.assertEqual(
|
||||
compat.load_key_bytes(encrypted_by_cryptography, "import-password").public_der,
|
||||
templates["public_der"],
|
||||
)
|
||||
|
||||
def test_spki_key_identifier_known_answer(self) -> None:
|
||||
# RFC 8032 test 1 Ed25519 public key in SubjectPublicKeyInfo. The expected
|
||||
# value is SHA-1 of only the 32 bytes in the BIT STRING, independently of
|
||||
# cryptography's SubjectKeyIdentifier implementation.
|
||||
spki = bytes.fromhex(
|
||||
"302a300506032b6570032100"
|
||||
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
)
|
||||
self.assertEqual(
|
||||
compat.spki_key_identifier(spki).hex(),
|
||||
"5b27aa5589179770e47575b162a1ded97b8bfc6d",
|
||||
)
|
||||
self.assertNotEqual(compat.spki_key_identifier(spki), hashlib.sha1(spki).digest())
|
||||
|
||||
def test_unsupported_curve_cert_csr_crl_sha256(self) -> None:
|
||||
for curve in UNSUPPORTED_CURVES:
|
||||
with self.subTest(curve=curve):
|
||||
_require_curve(curve)
|
||||
key = compat.generate_ec(curve)
|
||||
cert, csr, crl = _resign_all(self.native_templates, key, "sha256")
|
||||
|
||||
self.assertEqual(compat.certificate_public_der(cert), key.public_der)
|
||||
self.assertEqual(compat.csr_public_der(csr), key.public_der)
|
||||
self.assertTrue(compat.verify_certificate(cert, cert))
|
||||
self.assertTrue(compat.verify_csr(csr))
|
||||
self.assertTrue(compat.verify_crl(crl, cert))
|
||||
# No call to cryptography.Certificate.public_key() or CSR.public_key()
|
||||
# is needed (or valid) for these unsupported curves.
|
||||
|
||||
def test_unsupported_subject_with_native_rsa_issuer(self) -> None:
|
||||
_require_curve("sect163k1")
|
||||
subject_key = compat.generate_ec("sect163k1")
|
||||
cert = compat.resign_certificate(
|
||||
self.native_templates["cert_der"],
|
||||
subject_key.public_der,
|
||||
self.native_templates["private_der"],
|
||||
"sha256",
|
||||
)
|
||||
self.assertEqual(compat.certificate_public_der(cert), subject_key.public_der)
|
||||
self.assertTrue(compat.verify_certificate(cert, self.native_templates["cert_der"]))
|
||||
self.assertFalse(compat.verify_certificate(cert, cert))
|
||||
|
||||
def test_rsa_legacy_digest_cert_csr_crl(self) -> None:
|
||||
for digest in ("sha256", "sha1", "md5", "ripemd160"):
|
||||
with self.subTest(digest=digest):
|
||||
key = compat.private_key_from_der(self.native_templates["private_der"])
|
||||
cert, csr, crl = _resign_all(self.native_templates, key, digest)
|
||||
self.assertTrue(compat.verify_certificate(cert, cert))
|
||||
self.assertTrue(compat.verify_csr(csr))
|
||||
self.assertTrue(compat.verify_crl(crl, cert))
|
||||
|
||||
# The signature algorithm must actually change, not merely verify
|
||||
# under a cached template signature.
|
||||
parsed_cert = x509.load_der_x509_certificate(cert)
|
||||
parsed_csr = x509.load_der_x509_csr(csr)
|
||||
parsed_crl = x509.load_der_x509_crl(crl)
|
||||
expected_oid = {
|
||||
"sha256": "1.2.840.113549.1.1.11",
|
||||
"sha1": "1.2.840.113549.1.1.5",
|
||||
"md5": "1.2.840.113549.1.1.4",
|
||||
"ripemd160": "1.3.36.3.3.1.2",
|
||||
}[digest]
|
||||
self.assertEqual(parsed_cert.signature_algorithm_oid.dotted_string, expected_oid)
|
||||
self.assertEqual(parsed_csr.signature_algorithm_oid.dotted_string, expected_oid)
|
||||
self.assertEqual(parsed_crl.signature_algorithm_oid.dotted_string, expected_oid)
|
||||
|
||||
def test_legacy_and_sha3_ec_cert_csr_crl(self) -> None:
|
||||
for digest in ("sha1", "sha3-256"):
|
||||
with self.subTest(digest=digest):
|
||||
_require_curve("prime256v1")
|
||||
key = compat.generate_ec("prime256v1")
|
||||
cert, csr, crl = _resign_all(self.native_templates, key, digest)
|
||||
self.assertTrue(compat.verify_certificate(cert, cert))
|
||||
self.assertTrue(compat.verify_csr(csr))
|
||||
self.assertTrue(compat.verify_crl(crl, cert))
|
||||
self.assertEqual(
|
||||
x509.load_der_x509_certificate(cert).signature_hash_algorithm.name, digest
|
||||
)
|
||||
self.assertEqual(x509.load_der_x509_csr(csr).signature_hash_algorithm.name, digest)
|
||||
self.assertEqual(x509.load_der_x509_crl(crl).signature_hash_algorithm.name, digest)
|
||||
|
||||
def test_bad_signatures_return_false(self) -> None:
|
||||
key = compat.private_key_from_der(self.native_templates["private_der"])
|
||||
cert, csr, crl = _resign_all(self.native_templates, key, "sha256")
|
||||
|
||||
other_native = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
other_cert = _self_signed_certificate(other_native, "Wrong issuer", 2000)
|
||||
other_der = other_cert.public_bytes(serialization.Encoding.DER)
|
||||
self.assertFalse(compat.verify_certificate(cert, other_der))
|
||||
self.assertFalse(compat.verify_crl(crl, other_der))
|
||||
|
||||
damaged_csr = bytearray(csr)
|
||||
damaged_csr[-1] ^= 1
|
||||
self.assertFalse(compat.verify_csr(bytes(damaged_csr)))
|
||||
|
||||
def test_malformed_and_unsupported_inputs_raise_value_error(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
compat.public_key_from_der(b"not DER")
|
||||
with self.assertRaises(ValueError):
|
||||
compat.private_key_from_der(b"not DER")
|
||||
with self.assertRaises(ValueError):
|
||||
compat.certificate_public_der(b"not DER")
|
||||
with self.assertRaises(ValueError):
|
||||
compat.csr_public_der(b"not DER")
|
||||
with self.assertRaises(ValueError):
|
||||
compat.spki_key_identifier(b"not DER")
|
||||
with self.assertRaises(ValueError):
|
||||
compat.verify_certificate(b"not DER", self.native_templates["cert_der"])
|
||||
with self.assertRaises(ValueError):
|
||||
compat.verify_csr(b"not DER")
|
||||
with self.assertRaises(ValueError):
|
||||
compat.verify_crl(b"not DER", self.native_templates["cert_der"])
|
||||
with self.assertRaisesRegex(ValueError, "digest"):
|
||||
compat.resign_crl(
|
||||
self.native_templates["crl_der"],
|
||||
self.native_templates["private_der"],
|
||||
"not-a-digest",
|
||||
)
|
||||
|
||||
def test_pkcs12_modern_serialize_and_load(self) -> None:
|
||||
encoded = compat.serialize_pkcs12(
|
||||
"compat friendly name",
|
||||
self.native_templates["private_der"],
|
||||
self.native_templates["cert_der"],
|
||||
[self.native_templates["extra_ca_der"]],
|
||||
"p12-password",
|
||||
)
|
||||
# id-pbes2, aes256-CBC, and id-sha256: modern PBKDF2 encryption and MAC,
|
||||
# not legacy RC2/3DES PBE with SHA-1.
|
||||
self.assertIn(bytes.fromhex("06092a864886f70d01050d"), encoded)
|
||||
self.assertIn(bytes.fromhex("060960864801650304012a"), encoded)
|
||||
self.assertIn(bytes.fromhex("0609608648016503040201"), encoded)
|
||||
|
||||
private_der, cert_der, ca_ders = compat.load_pkcs12(encoded, "p12-password")
|
||||
self.assertIsNotNone(private_der)
|
||||
self.assertEqual(
|
||||
compat.private_key_from_der(private_der).public_der,
|
||||
self.native_templates["public_der"],
|
||||
)
|
||||
self.assertEqual(cert_der, self.native_templates["cert_der"])
|
||||
self.assertEqual(ca_ders, [self.native_templates["extra_ca_der"]])
|
||||
|
||||
native_key, native_cert, native_cas = pkcs12.load_key_and_certificates(
|
||||
encoded, b"p12-password"
|
||||
)
|
||||
self.assertIsNotNone(native_key)
|
||||
self.assertIsNotNone(native_cert)
|
||||
self.assertIsNotNone(native_cas)
|
||||
self.assertEqual(len(native_cas), 1)
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
compat.load_pkcs12(encoded, "wrong-password")
|
||||
self.assertNotIn("wrong-password", str(caught.exception))
|
||||
|
||||
def test_pkcs12_loads_cryptography_container(self) -> None:
|
||||
encoded = pkcs12.serialize_key_and_certificates(
|
||||
b"native friendly name",
|
||||
self.native_templates["key_object"],
|
||||
self.native_templates["cert_object"],
|
||||
[self.native_templates["extra_ca_object"]],
|
||||
serialization.BestAvailableEncryption(b"native-p12-password"),
|
||||
)
|
||||
private_der, cert_der, ca_ders = compat.load_pkcs12(encoded, "native-p12-password")
|
||||
self.assertIsNotNone(private_der)
|
||||
self.assertEqual(
|
||||
compat.private_key_from_der(private_der).public_der,
|
||||
self.native_templates["public_der"],
|
||||
)
|
||||
self.assertEqual(cert_der, self.native_templates["cert_der"])
|
||||
self.assertEqual(ca_ders, [self.native_templates["extra_ca_der"]])
|
||||
|
||||
def test_pkcs12_supports_unsupported_ec_curve(self) -> None:
|
||||
_require_curve("secp112r1")
|
||||
key = compat.generate_ec("secp112r1")
|
||||
cert = compat.resign_certificate(
|
||||
self.native_templates["cert_der"], key.public_der, key.private_der, "sha256"
|
||||
)
|
||||
encoded = compat.serialize_pkcs12("ec compat", key.private_der, cert, [], "ec-p12")
|
||||
private_der, loaded_cert, ca_ders = compat.load_pkcs12(encoded, "ec-p12")
|
||||
self.assertIsNotNone(private_der)
|
||||
self.assertEqual(compat.private_key_from_der(private_der).public_der, key.public_der)
|
||||
self.assertEqual(loaded_cert, cert)
|
||||
self.assertEqual(ca_ders, [])
|
||||
self.assertTrue(compat.verify_certificate(loaded_cert, loaded_cert))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -130,6 +130,91 @@ class XcaDbTest(unittest.TestCase):
|
||||
self.assertEqual(items[3].name, "Synthetic CRL")
|
||||
self.assertEqual(items[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()
|
||||
|
||||
Reference in New Issue
Block a user