Source code for quantum_safe.types.signatures

"""
quantum_safe.types.signatures
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Typed wrapper for signature operation outputs.

The key design decision here: Sign() returns a SignedMessage, not raw bytes.
The SignedMessage carries the algorithm name, signing context, and a timestamp
so the verifier doesn't need any out-of-band information to know what it's
verifying.

This solves a real operational problem: when you store a signature alongside
data (in a database, an audit log, an S3 bucket), you need to know which
algorithm produced it years later when you verify it. Raw bytes give you
nothing. A SignedMessage gives you everything.

For hybrid signatures (Ed25519 + ML-DSA), both sub-signatures are stored
and both must verify successfully.

References:
  - FIPS 204 §3 — ML-DSA signature format
  - FIPS 204 §5.2 — context string
  - draft-ietf-jose-pqc-signatures — JWT algorithm identifiers
"""

from __future__ import annotations

import base64
import time
from dataclasses import dataclass, field
from typing import Any

from quantum_safe._internal import serialization as _ser
from quantum_safe.exceptions import KeyParseError, VerificationError

# Maximum context length (FIPS 204 §5.2 allows up to 255 bytes)
_MAX_CONTEXT_LEN = 255

# CBOR-serializable version for SignedMessage storage
_SIGNED_MSG_VERSION = 1


[docs] @dataclass(frozen=True) class SignedMessage: """A message with its signature(s) and metadata. Immutable (frozen dataclass) — once created, the message and signature cannot be changed. This prevents accidental mutation of audit records. Attributes: message: The original message that was signed. signature: The signature bytes. For hybrid, this contains both sub-signatures in a length-prefixed format. algorithm: The signing algorithm, e.g. 'ML-DSA-65' or 'Ed25519+ML-DSA-65'. context: Domain-separation context (up to 255 bytes). Should include your application name and version. signer_fingerprint: The fingerprint of the public key used to sign, for quick lookup without re-verifying. signed_at: Unix timestamp (float) of when the signature was created. This is metadata only — it's not part of the signed content and should not be relied upon for security decisions. is_hybrid: Whether this is a hybrid (classical + PQC) signature. """ message: bytes signature: bytes algorithm: str context: bytes = field(default=b"") signer_fingerprint: str = field(default="") signed_at: float = field(default_factory=time.time) is_hybrid: bool = field(default=False) def __post_init__(self) -> None: if len(self.context) > _MAX_CONTEXT_LEN: raise ValueError( f"context must be at most {_MAX_CONTEXT_LEN} bytes, got {len(self.context)}" ) if not self.message: raise ValueError("message cannot be empty") if not self.signature: raise ValueError("signature cannot be empty") def __repr__(self) -> str: sig_preview = self.signature[:8].hex() return ( f"SignedMessage(" f"algo={self.algorithm!r}, " f"msg_len={len(self.message)}, " f"sig={sig_preview}..., " f"context={self.context!r}" f")" ) # ------------------------------------------------------------------ # Serialization # ------------------------------------------------------------------
[docs] def to_cbor(self) -> bytes: """Serialize to CBOR for storage or transmission.""" return _ser.dumps( { "v": _SIGNED_MSG_VERSION, "msg": self.message, "sig": self.signature, "algo": self.algorithm, "ctx": self.context, "fp": self.signer_fingerprint, "ts": self.signed_at, "hybrid": self.is_hybrid, } )
[docs] @classmethod def from_cbor(cls, data: bytes) -> SignedMessage: """Deserialize from CBOR bytes.""" try: d = _ser.loads(data) except Exception as exc: raise KeyParseError("cbor", f"SignedMessage decode failed: {exc}") from exc if d.get("v", 0) != _SIGNED_MSG_VERSION: raise KeyParseError( "cbor", f"unsupported SignedMessage version {d.get('v')}, expected {_SIGNED_MSG_VERSION}", ) return cls( message=bytes(d["msg"]), signature=bytes(d["sig"]), algorithm=d["algo"], context=bytes(d.get("ctx", b"")), signer_fingerprint=d.get("fp", ""), signed_at=float(d.get("ts", 0.0)), is_hybrid=bool(d.get("hybrid", False)), )
[docs] def to_jwt_payload(self) -> dict[str, Any]: """Produce a JWT payload dict for this signed message. The JWS (JSON Web Signature) representation follows draft-ietf-jose-pqc-signatures for algorithm identifiers. Note: this produces the *payload* dict. To get a full JWT string, use quantum_safe.protocols.jwt.sign() instead. """ return { "msg": base64.urlsafe_b64encode(self.message).rstrip(b"=").decode(), "sig": base64.urlsafe_b64encode(self.signature).rstrip(b"=").decode(), "alg": self.algorithm, "ctx": base64.urlsafe_b64encode(self.context).rstrip(b"=").decode(), "fp": self.signer_fingerprint, "iat": int(self.signed_at), }
[docs] @dataclass(frozen=True) class HybridSignature: """Internal representation of a hybrid signature. Stores the classical and PQC sub-signatures separately before they're combined into a SignedMessage. This is an intermediate type used inside HybridSign; callers don't normally deal with it directly. Wire format (CBOR-encoded):: { "classical_sig": bytes, "pqc_sig": bytes, "classical_algo": str, "pqc_algo": str, } """ classical_sig: bytes pqc_sig: bytes classical_algo: str pqc_algo: str @property def combined_algorithm(self) -> str: return f"{self.classical_algo}+{self.pqc_algo}"
[docs] def to_bytes(self) -> bytes: """Encode as CBOR for embedding in a SignedMessage.signature field.""" return _ser.dumps( { "classical_sig": self.classical_sig, "pqc_sig": self.pqc_sig, "classical_algo": self.classical_algo, "pqc_algo": self.pqc_algo, } )
[docs] @classmethod def from_bytes(cls, data: bytes) -> HybridSignature: """Decode from CBOR bytes.""" try: d = _ser.loads(data) except Exception as exc: raise VerificationError() from exc if not isinstance(d, dict): raise VerificationError() try: return cls( classical_sig=bytes(d["classical_sig"]), pqc_sig=bytes(d["pqc_sig"]), classical_algo=d["classical_algo"], pqc_algo=d["pqc_algo"], ) except KeyError as exc: raise VerificationError() from exc