From 427424421a4db8507c9db4b86a9baadabc504386 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 22 Aug 2026 12:49:09 +0300 Subject: [PATCH 1/3] feat: add support for ED25519 keys in mTLS helpers, refs #17 --- src/libpvarki/mtlshelp/csr.py | 37 +++++++++++++++++++++++------------ tests/mtls/test_helpers.py | 18 +++++++++-------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/libpvarki/mtlshelp/csr.py b/src/libpvarki/mtlshelp/csr.py index a445973..5ea2927 100644 --- a/src/libpvarki/mtlshelp/csr.py +++ b/src/libpvarki/mtlshelp/csr.py @@ -1,6 +1,6 @@ """Create keys and CSRs""" -from typing import Mapping, Sequence, Tuple, Iterable +from typing import Mapping, Sequence, Tuple, Iterable, Optional from pathlib import Path import logging import stat @@ -9,12 +9,12 @@ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric import rsa, ed25519 from cryptography.x509.oid import NameOID from cryptography.x509.name import _NAME_TO_NAMEOID LOGGER = logging.getLogger(__name__) -KPTYPE = rsa.RSAPrivateKey # TODO: should this be more than a type alias? +KPTYPE = rsa.RSAPrivateKey | ed25519.Ed25519PrivateKey PUBDIR_MODE = stat.S_IRWXU | stat.S_IRGRP | stat.S_IROTH | stat.S_IXGRP | stat.S_IXOTH PRIVDIR_MODE = stat.S_IRWXU @@ -44,9 +44,12 @@ def resolve_filepaths(basedir: Path, nameprefix: str) -> Tuple[Path, Path, Path] return privkeypath, pubkeypath, csrpath -def create_keypair(privkeypath: Path, pubkeypath: Path, ktype: str = "RSA", ksize: int = 4096) -> rsa.RSAPrivateKey: +def create_keypair(privkeypath: Path, pubkeypath: Path, ktype: str = "RSA", ksize: int = 4096) -> KPTYPE: """Generate a keypair, saves files to given paths (directory must exist) and returns the - keypair object""" + keypair object + + keysize only used for RSA + """ for check_path in (privkeypath, pubkeypath): if not check_path.parent.exists(): LOGGER.error("Path {} does not exist".format(check_path.parent)) @@ -56,9 +59,13 @@ def create_keypair(privkeypath: Path, pubkeypath: Path, ktype: str = "RSA", ksiz raise ValueError("Invalid path {}".format(check_path)) if check_path.exists(): LOGGER.warning("{} already exists, it will be overwritten".format(check_path)) - LOGGER.info("Generating {} keypair of size {}, this will take a moment".format(ktype, ksize)) + ckp: Optional[KPTYPE] = None if ktype == "RSA": + LOGGER.info("Generating {} keypair of size {}, this will take a moment".format(ktype, ksize)) ckp = rsa.generate_private_key(public_exponent=65537, key_size=ksize) + elif ktype == "ED25519": + LOGGER.info("Generating {} keypair".format(ktype)) + ckp = ed25519.Ed25519PrivateKey.generate() else: raise NotImplementedError(f"Key type {ktype} not supported") LOGGER.info("Keygen done") @@ -86,19 +93,23 @@ async def async_create_keypair( pubkeypath: Path, ktype: str = "RSA", ksize: int = 4096, -) -> rsa.RSAPrivateKey: +) -> KPTYPE: """Async wrapper for create_keypair see it for details""" return await asyncio.get_event_loop().run_in_executor(None, create_keypair, privkeypath, pubkeypath, ktype, ksize) def sign_and_write_csrfile( builder: x509.CertificateSigningRequestBuilder, - keypair: rsa.RSAPrivateKey, + keypair: KPTYPE, csrpath: Path, digest: str, ) -> str: """internal helper to be more DRY, returns the PEM""" - csr = builder.sign(keypair, HASHER_MAP[digest.lower()]()) + if isinstance(keypair, ed25519.Ed25519PrivateKey): + # Algorithm must be None when signing via ed25519 or ed448 + csr = builder.sign(keypair, None) + else: + csr = builder.sign(keypair, HASHER_MAP[digest.lower()]()) csr_pem = csr.public_bytes(serialization.Encoding.PEM) csrpath.write_bytes(csr_pem) csrpath.chmod(stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) # everyone can read @@ -144,7 +155,7 @@ def _build_csr_builder( def create_client_csr( - keypair: rsa.RSAPrivateKey, + keypair: KPTYPE, csrpath: Path, reqdn: Mapping[str, str], digest: str = "sha256", @@ -164,7 +175,7 @@ def create_client_csr( async def async_create_client_csr( - keypair: rsa.RSAPrivateKey, + keypair: KPTYPE, csrpath: Path, reqdn: Mapping[str, str], digest: str = "sha256", @@ -173,7 +184,7 @@ async def async_create_client_csr( return await asyncio.get_event_loop().run_in_executor(None, create_client_csr, keypair, csrpath, reqdn, digest) -def create_server_csr(keypair: rsa.RSAPrivateKey, csrpath: Path, names: Sequence[str], digest: str = "sha256") -> str: +def create_server_csr(keypair: KPTYPE, csrpath: Path, names: Sequence[str], digest: str = "sha256") -> str: """Generate CSR file with serverAuth extended usage, returns the PEM encoded contents First name will go to CN, all names will go to subjectAltNames @@ -194,7 +205,7 @@ def create_server_csr(keypair: rsa.RSAPrivateKey, csrpath: Path, names: Sequence async def async_create_server_csr( - keypair: rsa.RSAPrivateKey, + keypair: KPTYPE, csrpath: Path, names: Sequence[str], digest: str = "sha256", diff --git a/tests/mtls/test_helpers.py b/tests/mtls/test_helpers.py index 7c0f2d9..c9d8aed 100644 --- a/tests/mtls/test_helpers.py +++ b/tests/mtls/test_helpers.py @@ -63,33 +63,35 @@ def create_subdirs(datadir: Path) -> Tuple[Path, Path]: return privpath, pubpath -def test_keypair_create(tmp_path: Path) -> None: +@pytest.mark.parametrize("ktype", ("RSA", "ED25519")) +def test_keypair_create(tmp_path: Path, ktype: str) -> None: """Test normal create case""" privpath, pubpath = create_subdirs(tmp_path) - ckp = create_keypair(privpath, pubpath, ksize=1024) # small key to save time + ckp = create_keypair(privpath, pubpath, ktype=ktype, ksize=1024) # small key to save time check_keypair(ckp, privpath, pubpath) @pytest.mark.asyncio -async def test_keypair_create_async(tmp_path: Path) -> None: +@pytest.mark.parametrize("ktype", ("RSA", "ED25519")) +async def test_keypair_create_async(tmp_path: Path, ktype: str) -> None: """Test the async wrapper""" privpath, pubpath = create_subdirs(tmp_path) - ckp = await async_create_keypair(privpath, pubpath, ksize=1024) # small key to save time + ckp = await async_create_keypair(privpath, pubpath, ktype=ktype, ksize=1024) # small key to save time check_keypair(ckp, privpath, pubpath) -@pytest_asyncio.fixture -async def keypair(tmp_path: Path) -> AsyncGenerator[Tuple[KPTYPE, Path, Path], None]: +@pytest_asyncio.fixture(params=["RSA", "ED25519"]) +async def keypair(tmp_path: Path, request: pytest.FixtureRequest) -> AsyncGenerator[Tuple[KPTYPE, Path, Path], None]: """Fixture to create keypair""" privpath, pubpath = create_subdirs(tmp_path) - ckp = await async_create_keypair(privpath, pubpath, ksize=1024) # small key to save time + ckp = await async_create_keypair(privpath, pubpath, ktype=request.param, ksize=1024) # small key to save time check_keypair(ckp, privpath, pubpath) yield ckp, privpath, pubpath def check_csr(pemdata: str, expect_cn: str) -> None: """Check the CSR""" - assert pemdata.startswith("-----BEGIN CERTIFICATE REQUEST-----\nMII") + assert pemdata.startswith("-----BEGIN CERTIFICATE REQUEST-----\n") parsed = cryptography.x509.load_pem_x509_csr(pemdata.encode("utf-8")) dname = parsed.subject.rfc4514_string() LOGGER.debug("dname: {}".format(dname)) From a861880db85a18015e12ded5a8b895beb67bc06e Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 22 Aug 2026 12:53:19 +0300 Subject: [PATCH 2/3] chore: bump version --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- src/libpvarki/__init__.py | 2 +- tests/test_libpvarki.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2057a0f..235e36b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.2.3 +current_version = 2.3.0 commit = False tag = False diff --git a/pyproject.toml b/pyproject.toml index 824be4e..598f0a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libpvarki" -version = "2.2.3" +version = "2.3.0" description = "Common helpers like standard logging init" authors = ["Eero af Heurlin "] homepage = "https://github.com/pvarki/python-libpvarki/" diff --git a/src/libpvarki/__init__.py b/src/libpvarki/__init__.py index 77f5c97..66ac3ab 100644 --- a/src/libpvarki/__init__.py +++ b/src/libpvarki/__init__.py @@ -1,3 +1,3 @@ """Common helpers like standard logging init""" -__version__ = "2.2.3" # NOTE Use `bump2version --config-file patch` to bump versions correctly +__version__ = "2.3.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly diff --git a/tests/test_libpvarki.py b/tests/test_libpvarki.py index 47f0cd8..464fc36 100644 --- a/tests/test_libpvarki.py +++ b/tests/test_libpvarki.py @@ -5,4 +5,4 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "2.2.3" + assert __version__ == "2.3.0" From 526848b637cfef03184a32849ef2b2f10bf64819 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 22 Aug 2026 13:06:41 +0300 Subject: [PATCH 3/3] docs: document digest limitations --- src/libpvarki/mtlshelp/csr.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libpvarki/mtlshelp/csr.py b/src/libpvarki/mtlshelp/csr.py index 5ea2927..c024dc4 100644 --- a/src/libpvarki/mtlshelp/csr.py +++ b/src/libpvarki/mtlshelp/csr.py @@ -104,7 +104,10 @@ def sign_and_write_csrfile( csrpath: Path, digest: str, ) -> str: - """internal helper to be more DRY, returns the PEM""" + """internal helper to be more DRY, returns the PEM + + digest only used for RSA; also must be one of HASHER_MAP keys + """ if isinstance(keypair, ed25519.Ed25519PrivateKey): # Algorithm must be None when signing via ed25519 or ed448 csr = builder.sign(keypair, None)