Source code for quantum_safe.signatures.core

"""
quantum_safe.signatures.core
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The Sign class: single-algorithm PQC digital signatures.

Like KEM vs HybridKEM, most production callers should use HybridSign.
Sign is for benchmarking, protocol conformance testing, and cases where
you specifically need a single PQC algorithm.

Hedged signing
--------------
By default, Sign operates in hedged mode: before hashing the message, we
prepend 32 random bytes:

    actual_message = rand_32 || message

The signature is over this extended message. The rand_32 is stored in the
SignedMessage so the verifier knows to prepend them before verifying.

Why? Deterministic signing (sign the message directly) is vulnerable to
fault injection attacks where an adversary induces a hardware error during
the signing computation and recovers the secret key from the faulty output.
Hedged signing prevents this because the attacker can't control the random
prefix. It also provides an additional layer against nonce reuse (though
ML-DSA doesn't use a nonce the way ECDSA does, the principle applies).

The cost: 32 extra bytes in the SignedMessage. The benefit: resistance
against a whole class of side-channel and fault attacks that have been
demonstrated on lattice signatures in lab conditions.

Context strings
---------------
FIPS 204 ยง5.2 defines a context parameter (up to 255 bytes) that is
mixed into the signing hash. We use it for domain separation:

    signature = ML-DSA.Sign(sk, message, context)

This prevents cross-protocol attacks where a signature from one application
is replayed as valid in another. Always pass a context that uniquely
identifies your application and protocol version:

    sign(message, secret_key, context=b"myapp-v1-document-signing")
"""

from __future__ import annotations

import os
import time
import warnings
from typing import TYPE_CHECKING

from quantum_safe.backends import get_signature_backend
from quantum_safe.exceptions import InsecureOperationError, UnsupportedAlgorithm, VerificationError
from quantum_safe.signatures.algorithms import (
    HEDGED_RANDOMNESS_BYTES,
    get_algorithm_spec,
)
from quantum_safe.types import KeyPair, MigrationState, PublicKey, SecretKey
from quantum_safe.types.signatures import SignedMessage

if TYPE_CHECKING:
    from quantum_safe.backends.base import AbstractSignatureBackend


[docs] class Sign: """Single-algorithm PQC signature scheme. Args: algorithm: PQC signature algorithm. Defaults to ML-DSA-65. backend: Backend name: "auto", "liboqs", "rustcrypto". hedged: If True (default), prepend 32 random bytes before signing to prevent fault injection attacks. strict: If True, raise instead of warn for non-standard configs. Example:: from quantum_safe.signatures import Sign signer = Sign() # ML-DSA-65, hedged kp = signer.generate_keypair() sm = signer.sign(b"hello", kp.secret, context=b"myapp-v1") signer.verify(sm, kp.public) # raises VerificationError if invalid """ def __init__( self, algorithm: str = "ML-DSA-65", backend: str = "auto", hedged: bool = True, strict: bool = False, ) -> None: self._algorithm = algorithm self._hedged = hedged self._strict = strict self._spec = get_algorithm_spec(algorithm) self._backend: AbstractSignatureBackend = get_signature_backend(backend) if not self._spec.is_nist_standard: msg = ( f"Algorithm '{algorithm}' is not NIST-standardized. " "It may not be suitable for production." ) if strict: raise InsecureOperationError(msg, algorithm=algorithm) warnings.warn(msg, stacklevel=2) @property def algorithm(self) -> str: return self._algorithm @property def is_hedged(self) -> bool: return self._hedged @property def backend_name(self) -> str: return self._backend.name # ------------------------------------------------------------------ # Key generation # ------------------------------------------------------------------
[docs] def generate_keypair(self) -> KeyPair: """Generate a signing key pair. Returns: KeyPair with .public and .secret. The secret key is needed to sign; the public key is needed to verify. """ pub_bytes, sec_bytes = self._backend.keygen(self._algorithm) pub = PublicKey( raw=pub_bytes, algorithm=self._algorithm, migration_state=MigrationState.PQC_ONLY, backend_tag=self._backend.name, ) sec = SecretKey( raw=sec_bytes, algorithm=self._algorithm, migration_state=MigrationState.PQC_ONLY, backend_tag=self._backend.name, ) return KeyPair(public=pub, secret=sec)
# ------------------------------------------------------------------ # Signing # ------------------------------------------------------------------
[docs] def sign( self, message: bytes, secret_key: SecretKey, context: bytes = b"", ) -> SignedMessage: """Sign a message. Args: message: Arbitrary bytes to sign. There is no length limit, but for very large messages (> a few MB) consider signing a hash of the message instead. secret_key: The signer's secret key, matching this algorithm. context: Domain-separation context. Up to 255 bytes. Should include your app name and protocol version. Example: b"myapp-v2-user-attestation" Returns: SignedMessage containing the message, signature, algorithm, context, signer fingerprint, and timestamp. The SignedMessage is self-contained โ€” pass it to verify() directly. Raises: UnsupportedAlgorithm: if the key's algorithm doesn't match. """ if secret_key.algorithm != self._algorithm: raise UnsupportedAlgorithm( secret_key.algorithm, available=[self._algorithm], ) if len(context) > 255: raise ValueError(f"context must be <=255 bytes, got {len(context)}") # Hedged mode: prepend random bytes so two signings of the same message # with the same key produce different signatures AND resist fault attacks. if self._hedged: rand_prefix = os.urandom(HEDGED_RANDOMNESS_BYTES) msg_to_sign = rand_prefix + message else: rand_prefix = b"" msg_to_sign = message raw_sig = self._backend.sign( self._algorithm, secret_key.raw_bytes, msg_to_sign, context, ) # The stored signature blob: rand_prefix_len (1B) || rand_prefix || raw_sig # This lets verify() reconstruct msg_to_sign without out-of-band info. sig_blob = self._pack_sig_blob(rand_prefix, raw_sig) # We compute the signer fingerprint from the *public* key side, but we # only have the secret key here. We store an empty fingerprint and let # the caller fill it in if they want. HybridSign overrides this. return SignedMessage( message=message, signature=sig_blob, algorithm=self._algorithm, context=context, signer_fingerprint="", signed_at=time.time(), is_hybrid=False, )
[docs] def sign_with_fingerprint( self, message: bytes, keypair: KeyPair, context: bytes = b"", ) -> SignedMessage: """Like sign(), but also stores the signer's public key fingerprint. Use this when the verifier won't have the public key in advance and needs to look it up from a key store by fingerprint. """ sm = self.sign(message, keypair.secret, context) # We can't use dataclass._replace directly on a frozen dataclass field # assignment, so reconstruct with the fingerprint filled in. return SignedMessage( message=sm.message, signature=sm.signature, algorithm=sm.algorithm, context=sm.context, signer_fingerprint=keypair.public.fingerprint(), signed_at=sm.signed_at, is_hybrid=sm.is_hybrid, )
# ------------------------------------------------------------------ # Verification # ------------------------------------------------------------------
[docs] def verify(self, signed_message: SignedMessage, public_key: PublicKey) -> None: """Verify a signed message. Args: signed_message: A SignedMessage returned by sign(). public_key: The signer's public key. Returns: None on success. Raises: VerificationError: if the signature is invalid, the algorithm doesn't match, or the context doesn't match. UnsupportedAlgorithm: if the SignedMessage's algorithm differs from this Sign instance's algorithm. """ if signed_message.algorithm != self._algorithm: raise UnsupportedAlgorithm( signed_message.algorithm, available=[self._algorithm], ) if public_key.algorithm != self._algorithm: raise UnsupportedAlgorithm( public_key.algorithm, available=[self._algorithm], ) rand_prefix, raw_sig = self._unpack_sig_blob(signed_message.signature) msg_to_verify = rand_prefix + signed_message.message ok = self._backend.verify( self._algorithm, public_key.raw_bytes, msg_to_verify, raw_sig, signed_message.context, ) if not ok: raise VerificationError(algo=self._algorithm)
[docs] def verify_bytes( self, message: bytes, signature_blob: bytes, public_key: PublicKey, context: bytes = b"", ) -> None: """Verify a raw signature blob (for interop with external signers). Use this when the signature was produced outside this library and you have raw bytes rather than a SignedMessage. The blob must be in our packed format (rand_prefix_len || rand_prefix || raw_sig). For fully external signatures (produced by liboqs directly or another tool), use verify_raw() instead. """ rand_prefix, raw_sig = self._unpack_sig_blob(signature_blob) msg_to_verify = rand_prefix + message ok = self._backend.verify( self._algorithm, public_key.raw_bytes, msg_to_verify, raw_sig, context, ) if not ok: raise VerificationError(algo=self._algorithm)
[docs] def verify_raw( self, message: bytes, raw_signature: bytes, public_key: PublicKey, context: bytes = b"", ) -> None: """Verify a raw signature produced outside this library. Use this for interoperability with other ML-DSA implementations. Note: hedged mode doesn't apply here โ€” you're verifying a raw (non-hedged) signature. """ ok = self._backend.verify( self._algorithm, public_key.raw_bytes, message, raw_signature, context, ) if not ok: raise VerificationError(algo=self._algorithm)
# ------------------------------------------------------------------ # Internal: signature blob packing # ------------------------------------------------------------------ @staticmethod def _pack_sig_blob(rand_prefix: bytes, raw_sig: bytes) -> bytes: """Pack rand_prefix + raw_sig into a single blob. Format: prefix_len (1 byte) || rand_prefix || raw_sig prefix_len is 0 for non-hedged signatures, 32 for hedged. """ if len(rand_prefix) > 255: raise ValueError("rand_prefix too long") return bytes([len(rand_prefix)]) + rand_prefix + raw_sig @staticmethod def _unpack_sig_blob(blob: bytes) -> tuple[bytes, bytes]: """Unpack a signature blob into (rand_prefix, raw_sig).""" if len(blob) < 1: raise VerificationError() prefix_len = blob[0] if len(blob) < 1 + prefix_len: raise VerificationError() rand_prefix = blob[1 : 1 + prefix_len] raw_sig = blob[1 + prefix_len :] return rand_prefix, raw_sig def __repr__(self) -> str: return ( f"Sign(algo={self._algorithm!r}, hedged={self._hedged}, backend={self._backend.name!r})" )