Source code for quantum_safe.backends.liboqs

"""
quantum_safe.backends.liboqs
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Backend adapter for liboqs-python (https://github.com/open-quantum-safe/liboqs-python).

liboqs is the Open Quantum Safe project's C library, which is wrapped by the
liboqs-python package.  It has the broadest algorithm coverage of any backend
(BIKE, FrodoKEM, HQC, NTRU as well as all NIST standardized algorithms), so
it's the right choice when you need algorithms beyond the NIST standard set.

For NIST-only deployments (ML-KEM, ML-DSA, SLH-DSA), the RustCrypto backend
is faster and doesn't require a compiled C library.

Installation::

    pip install 'quantum-safe[liboqs]'

The actual install pulls liboqs-python which vendors a pre-built liboqs
binary for the common platforms.  If you're on an unusual architecture you
may need to build liboqs from source — see the OQS docs.

Thread safety: Each liboqs operation creates its own internal context, so
these classes are safe to use from multiple threads.  The oqs Python objects
themselves are NOT thread-safe (they hold mutable C state), so we create a
new one per call rather than sharing instances.
"""

from __future__ import annotations

import ctypes
from typing import Any, ClassVar

from quantum_safe.backends.base import AbstractKEMBackend, AbstractSignatureBackend, AlgorithmInfo
from quantum_safe.exceptions import (
    BackendNotAvailable,
    DecapsulationError,
    KeyGenerationError,
    UnsupportedAlgorithm,
)

# ---------------------------------------------------------------------------
# Algorithm metadata tables
#
# NIST security levels (from FIPS 203/204/205):
#   1 → comparable to AES-128
#   2 → comparable to SHA-256
#   3 → comparable to AES-192
#   4 → comparable to SHA-384
#   5 → comparable to AES-256
#
# Key/ciphertext sizes from the FIPS standards (not from liboqs internals,
# so they're stable references even if the backend changes its encoding).
# ---------------------------------------------------------------------------

_KEM_ALGORITHM_INFO: dict[str, AlgorithmInfo] = {
    "ML-KEM-512": AlgorithmInfo(
        name="ML-KEM-512",
        nist_level=1,
        public_key_size=800,
        secret_key_size=1632,
        ciphertext_size=768,
        is_kem=True,
        is_signature=False,
        is_nist_standard=True,
        notes="FIPS 203, security level 1. Use ML-KEM-768 for new deployments.",
    ),
    "ML-KEM-768": AlgorithmInfo(
        name="ML-KEM-768",
        nist_level=3,
        public_key_size=1184,
        secret_key_size=2400,
        ciphertext_size=1088,
        is_kem=True,
        is_signature=False,
        is_nist_standard=True,
        notes="FIPS 203, security level 3. Recommended default.",
    ),
    "ML-KEM-1024": AlgorithmInfo(
        name="ML-KEM-1024",
        nist_level=5,
        public_key_size=1568,
        secret_key_size=3168,
        ciphertext_size=1568,
        is_kem=True,
        is_signature=False,
        is_nist_standard=True,
        notes="FIPS 203, security level 5. Use when maximum security is required.",
    ),
    # Non-standard algorithms — available through liboqs only
    "BIKE-L1": AlgorithmInfo(
        name="BIKE-L1",
        nist_level=1,
        public_key_size=1541,
        secret_key_size=3114,
        ciphertext_size=1573,
        is_kem=True,
        is_signature=False,
        is_nist_standard=False,
        notes="NIST Round 4 candidate. Not standardized — research use only.",
    ),
    "HQC-128": AlgorithmInfo(
        name="HQC-128",
        nist_level=1,
        public_key_size=2249,
        secret_key_size=2289,
        ciphertext_size=4433,
        is_kem=True,
        is_signature=False,
        is_nist_standard=False,
        notes="NIST Round 4 candidate. Not standardized — research use only.",
    ),
}

_SIGNATURE_ALGORITHM_INFO: dict[str, AlgorithmInfo] = {
    "ML-DSA-44": AlgorithmInfo(
        name="ML-DSA-44",
        nist_level=2,
        public_key_size=1312,
        secret_key_size=2528,
        ciphertext_size=2420,  # signature size
        is_kem=False,
        is_signature=True,
        is_nist_standard=True,
        notes="FIPS 204, security level 2. Smallest ML-DSA variant.",
    ),
    "ML-DSA-65": AlgorithmInfo(
        name="ML-DSA-65",
        nist_level=3,
        public_key_size=1952,
        secret_key_size=4000,
        ciphertext_size=3293,  # signature size
        is_kem=False,
        is_signature=True,
        is_nist_standard=True,
        notes="FIPS 204, security level 3. Recommended default.",
    ),
    "ML-DSA-87": AlgorithmInfo(
        name="ML-DSA-87",
        nist_level=5,
        public_key_size=2592,
        secret_key_size=4864,
        ciphertext_size=4595,  # signature size
        is_kem=False,
        is_signature=True,
        is_nist_standard=True,
        notes="FIPS 204, security level 5. Maximum security.",
    ),
    "SLH-DSA-SHAKE-128s": AlgorithmInfo(
        name="SLH-DSA-SHAKE-128s",
        nist_level=1,
        public_key_size=32,
        secret_key_size=64,
        ciphertext_size=7856,  # signature size (small variant is slow, small sigs)
        is_kem=False,
        is_signature=True,
        is_nist_standard=True,
        notes="FIPS 205, security level 1, small signatures. Very slow to sign.",
    ),
    "SLH-DSA-SHAKE-128f": AlgorithmInfo(
        name="SLH-DSA-SHAKE-128f",
        nist_level=1,
        public_key_size=32,
        secret_key_size=64,
        ciphertext_size=17088,  # signature size (fast variant is fast, bigger sigs)
        is_kem=False,
        is_signature=True,
        is_nist_standard=True,
        notes="FIPS 205, security level 1, fast signing. Larger signatures.",
    ),
}

# liboqs uses different name formats internally. This maps our canonical names
# to the names liboqs expects.  We keep our names aligned with the FIPS
# standards, which use hyphens and no spaces.
_KEM_LIBOQS_NAMES: dict[str, str] = {
    "ML-KEM-512": "ML-KEM-512",
    "ML-KEM-768": "ML-KEM-768",
    "ML-KEM-1024": "ML-KEM-1024",
    "BIKE-L1": "BIKE-L1",
    "HQC-128": "HQC-128",
}

_SIG_LIBOQS_NAMES: dict[str, str] = {
    "ML-DSA-44": "ML-DSA-44",
    "ML-DSA-65": "ML-DSA-65",
    "ML-DSA-87": "ML-DSA-87",
    "SLH-DSA-SHAKE-128s": "SPHINCS+-SHAKE-128s-simple",  # liboqs name differs
    "SLH-DSA-SHAKE-128f": "SPHINCS+-SHAKE-128f-simple",
}


def _import_oqs() -> Any:  # noqa: ANN401
    """Import the oqs module or raise BackendNotAvailable."""
    try:
        import oqs

        return oqs
    except ImportError as exc:
        raise BackendNotAvailable("liboqs") from exc


[docs] class LiboqsKEMBackend(AbstractKEMBackend): """KEM backend using liboqs-python.""" name: ClassVar[str] = "liboqs"
[docs] def supported_algorithms(self) -> list[AlgorithmInfo]: """Return algorithms supported by this backend. We only return algorithms that are actually enabled in the installed liboqs build — some builds omit BIKE or HQC for size/license reasons. """ oqs = _import_oqs() enabled = set(oqs.get_enabled_kem_mechanisms()) result = [] for canonical, liboqs_name in _KEM_LIBOQS_NAMES.items(): if liboqs_name in enabled: info = _KEM_ALGORITHM_INFO.get(canonical) if info: result.append(info) return result
def _liboqs_name(self, algorithm: str) -> str: name = _KEM_LIBOQS_NAMES.get(algorithm) if name is None: raise UnsupportedAlgorithm( algorithm, available=list(_KEM_LIBOQS_NAMES.keys()), ) return name
[docs] def keygen(self, algorithm: str) -> tuple[bytes, bytes]: """Generate an ML-KEM (or other KEM) key pair. Returns (public_key_bytes, secret_key_bytes). """ oqs = _import_oqs() liboqs_name = self._liboqs_name(algorithm) try: # Each call creates a fresh oqs.KeyEncapsulation context. # This is slightly slower than reusing one, but avoids shared # mutable state across threads. kem = oqs.KeyEncapsulation(liboqs_name) pub = kem.generate_keypair() sec = kem.export_secret_key() return bytes(pub), bytes(sec) except Exception as exc: # oqs raises generic Exception types — we wrap them if "not supported" in str(exc).lower(): raise UnsupportedAlgorithm(algorithm) from exc raise KeyGenerationError( f"liboqs keygen failed for {algorithm}: {exc}", algorithm=algorithm, ) from exc
[docs] def encapsulate(self, algorithm: str, public_key: bytes) -> tuple[bytes, bytes]: """Encapsulate a shared secret under the given public key. Returns (ciphertext_bytes, shared_secret_bytes). """ oqs = _import_oqs() liboqs_name = self._liboqs_name(algorithm) try: kem = oqs.KeyEncapsulation(liboqs_name) ciphertext, shared_secret = kem.encap_secret(public_key) return bytes(ciphertext), bytes(shared_secret) except Exception as exc: from quantum_safe.exceptions import CryptoError raise CryptoError( f"liboqs encapsulation failed for {algorithm}: {exc}", algorithm=algorithm, ) from exc
[docs] def decapsulate(self, algorithm: str, secret_key: bytes, ciphertext: bytes) -> bytes: """Decapsulate a shared secret from a ciphertext. Returns shared_secret_bytes. IMPORTANT: liboqs ML-KEM implements implicit rejection (per FIPS 203 §6.3) — a malformed ciphertext returns a pseudorandom value rather than raising an exception. We check the output length to detect the most obvious failure modes, but we cannot distinguish a bad ciphertext from a good one by inspecting the output. """ oqs = _import_oqs() liboqs_name = self._liboqs_name(algorithm) # Use a mutable bytearray so we can zero the local copy after use. sk_buf = bytearray(secret_key) try: kem = oqs.KeyEncapsulation(liboqs_name, secret_key=bytes(sk_buf)) shared_secret = kem.decap_secret(ciphertext) result = bytes(shared_secret) # 32 bytes expected for all standardized algorithms if len(result) not in (32, 64): raise DecapsulationError(algo=algorithm) return result except DecapsulationError: raise except Exception as exc: raise DecapsulationError(algo=algorithm) from exc finally: n = len(sk_buf) if n: ctypes.memset((ctypes.c_char * n).from_buffer(sk_buf), 0, n)
[docs] def is_available(self) -> bool: """Check if liboqs is importable and has at least ML-KEM-768.""" try: oqs = _import_oqs() enabled = oqs.get_enabled_kem_mechanisms() return "ML-KEM-768" in enabled except Exception: # noqa: BLE001 return False
[docs] class LiboqsSignatureBackend(AbstractSignatureBackend): """Signature backend using liboqs-python.""" name: ClassVar[str] = "liboqs"
[docs] def supported_algorithms(self) -> list[AlgorithmInfo]: oqs = _import_oqs() enabled = set(oqs.get_enabled_sig_mechanisms()) result = [] for canonical, liboqs_name in _SIG_LIBOQS_NAMES.items(): if liboqs_name in enabled: info = _SIGNATURE_ALGORITHM_INFO.get(canonical) if info: result.append(info) return result
def _liboqs_name(self, algorithm: str) -> str: name = _SIG_LIBOQS_NAMES.get(algorithm) if name is None: raise UnsupportedAlgorithm( algorithm, available=list(_SIG_LIBOQS_NAMES.keys()), ) return name
[docs] def keygen(self, algorithm: str) -> tuple[bytes, bytes]: oqs = _import_oqs() liboqs_name = self._liboqs_name(algorithm) try: sig = oqs.Signature(liboqs_name) pub = sig.generate_keypair() sec = sig.export_secret_key() return bytes(pub), bytes(sec) except Exception as exc: raise KeyGenerationError( f"liboqs signature keygen failed for {algorithm}: {exc}", algorithm=algorithm, ) from exc
[docs] def sign( self, algorithm: str, secret_key: bytes, message: bytes, context: bytes = b"", ) -> bytes: """Sign a message. FIPS 204 §5.2 specifies that the context is prepended to the message before hashing. liboqs-python's Signature.sign() doesn't directly expose the context parameter in older versions, so we prepend it manually using the format:: context_len (1 byte) || context || message This is consistent with the HashML-DSA construction in FIPS 204 §5.4. When liboqs exposes context natively (v0.11+), we'll use that instead. """ oqs = _import_oqs() liboqs_name = self._liboqs_name(algorithm) if len(context) > 255: raise ValueError(f"context must be <=255 bytes, got {len(context)}") # Prepend context using length-prefixed format msg_with_context = bytes([len(context)]) + context + message # Use a mutable bytearray so we can zero the local copy after use. sk_buf = bytearray(secret_key) try: sig_obj = oqs.Signature(liboqs_name, secret_key=bytes(sk_buf)) signature = sig_obj.sign(msg_with_context) return bytes(signature) except Exception as exc: from quantum_safe.exceptions import CryptoError raise CryptoError( f"liboqs signing failed for {algorithm}: {exc}", algorithm=algorithm, ) from exc finally: n = len(sk_buf) if n: ctypes.memset((ctypes.c_char * n).from_buffer(sk_buf), 0, n)
[docs] def verify( self, algorithm: str, public_key: bytes, message: bytes, signature: bytes, context: bytes = b"", ) -> bool: """Verify a signature. Returns True if valid, False if not.""" oqs = _import_oqs() liboqs_name = self._liboqs_name(algorithm) if len(context) > 255: return False msg_with_context = bytes([len(context)]) + context + message try: sig_obj = oqs.Signature(liboqs_name) return bool(sig_obj.verify(msg_with_context, signature, public_key)) except Exception: # noqa: BLE001 # liboqs raises on invalid signatures in some versions — # we normalise to bool return return False
[docs] def is_available(self) -> bool: try: oqs = _import_oqs() enabled = oqs.get_enabled_sig_mechanisms() return "ML-DSA-65" in enabled except Exception: # noqa: BLE001 return False