Hybrid Cryptography - Steel-SecAdv-LLC/AMA-Cryptography GitHub Wiki
Hybrid Cryptography
Documentation for AMA Cryptography's hybrid classical + post-quantum cryptographic constructions, including the hybrid KEM combiner and hybrid signature scheme.
Motivation
Why hybrid?
- Defense-in-depth: If either the classical or PQC component is compromised, the hybrid scheme retains the security of the other.
- Migration: Systems can verify with classical keys today and add PQC verification as it becomes standard.
- Regulatory compliance: Some standards require classical algorithms; others mandate PQC. Hybrid satisfies both.
- Conservative posture: The security of ML-KEM and ML-DSA rests on lattice hardness assumptions. SPHINCS+-based hybrids add a second assumption based only on hash functions.
Hybrid KEM Combiner
The HybridCombiner class implements a binding construction combining X25519 (classical ECDH) and ML-KEM-1024 (post-quantum), following the dual-PRF HKDF approach of Bindel et al. (PQCrypto 2019).
Security Guarantee
IND-CCA2 hybrid security: The combined shared secret is indistinguishable from random as long as at least one of the two component KEMs is IND-CCA2 secure. An adversary must break both X25519 and ML-KEM-1024 simultaneously to compromise the combined secret.
Construction
# Exact layout as implemented by HybridCombiner.combine()
# (ama_cryptography/hybrid_combiner.py:184-209). All length fields are
# u32-big-endian (via struct.pack(">I", ...)) except the component count,
# which is a single u8 (struct.pack(">B", 2)).
salt = u32be(|ct_c|) || ct_c || u32be(|ct_p|) || ct_p # ciphertexts
ikm = ss_c || ss_p # shared secrets (no prefix)
info = label || u8(2) # component_count
|| u32be(|pk_c|) || pk_c
|| u32be(|pk_p|) || pk_p # public keys
label = b"ama-hybrid-kem-v2" # default (v2; v1 was pre-PR #224)
combined_ss = HKDF-SHA3-256(salt, ikm, info, output_len)
Length-prefix rationale: every variable-length field uses a fixed-size
length prefix (u32be for ciphertexts/public keys, u8 for the
component count) so concatenation is unambiguous. The u8(2) component
header together with the u32be(len) prefixes prevents the
component-stripping attack reported in audit finding C6 (PR #224,
v2.1.5) — an attacker cannot reshape the HKDF input to look like a
single-component (classical- or PQC-only) KEM output.
Binding rationale: ciphertexts live in salt and public keys live in
info so the HKDF output is cryptographically bound to both the
transport-time encapsulation (ct_c, ct_p) and the identities of the
key-holders (pk_c, pk_p). Shared secrets go in ikm on their own
because they are the only fields the attacker must not be able to
truncate — HKDF's ikm is processed through the Extract step whole.
HybridCombiner API
HybridCombiner is KEM-agnostic: you pass in the encapsulate /
decapsulate callables for each half, so the same class drives
X25519 ∥ ML-KEM-1024, ECDH ∥ ML-KEM, or any pairing. The
AmaCryptography(AlgorithmType.HYBRID_KEM) entry point wires this up
for the default X25519 + ML-KEM-1024 pair.
from ama_cryptography.hybrid_combiner import HybridCombiner, HybridEncapsulation
from ama_cryptography.crypto_api import AmaCryptography, AlgorithmType, KyberProvider
# ---- Option A: the algorithm-agnostic dispatcher (recommended)
hybrid = AmaCryptography(algorithm=AlgorithmType.HYBRID_KEM)
recipient = hybrid.generate_keypair()
enc = hybrid.encapsulate(recipient.public_key) # EncapsulatedSecret
shared = hybrid.decapsulate(enc.ciphertext, recipient.secret_key)
assert shared == enc.shared_secret
# ---- Option B: drive the combiner directly with explicit providers
from ama_cryptography.pqc_backends import (
generate_kyber_keypair,
kyber_encapsulate,
kyber_decapsulate,
)
# HybridCombiner expects bare-tuple callables:
# encapsulate_fn(pk) -> (ciphertext: bytes, shared_secret: bytes)
# decapsulate_fn(ct, sk) -> shared_secret: bytes
#
# AMA's Kyber API returns dataclasses, so wrap to expose the tuple shape:
def _kyber_encaps(pk: bytes):
enc = kyber_encapsulate(pk) # KyberEncapsulation
return enc.ciphertext, enc.shared_secret # (bytes, bytes)
# kyber_decapsulate already returns bytes, so no adapter is needed.
combiner = HybridCombiner()
classical_pk, classical_sk = b"...", b"..." # X25519 keypair (your wrapper)
pqc_kp = generate_kyber_keypair() # KyberKeyPair dataclass
pqc_pk = pqc_kp.public_key
pqc_sk = pqc_kp.secret_key
encapsulation: HybridEncapsulation = combiner.encapsulate_hybrid(
classical_encapsulate=my_x25519_encapsulate, # (pk) -> (ct, ss)
pqc_encapsulate=_kyber_encaps, # dataclass adapter above
classical_pk=classical_pk,
pqc_pk=pqc_pk,
)
recovered = combiner.decapsulate_hybrid(
classical_decapsulate=my_x25519_decapsulate, # (ct, sk) -> ss
pqc_decapsulate=kyber_decapsulate, # (ct, sk) -> ss (already bytes)
classical_ct=encapsulation.classical_ciphertext,
pqc_ct=encapsulation.pqc_ciphertext,
classical_sk=classical_sk,
pqc_sk=pqc_sk,
classical_pk=classical_pk,
pqc_pk=pqc_pk,
)
assert recovered == encapsulation.combined_secret
HybridEncapsulation Object
@dataclass
class HybridEncapsulation:
combined_secret: bytes # 32 bytes — derived by HKDF
classical_ciphertext: bytes # X25519 ephemeral public key
pqc_ciphertext: bytes # ML-KEM-1024 ciphertext (1568 bytes)
classical_shared_secret: bytes # X25519 shared secret (32 bytes)
pqc_shared_secret: bytes # Kyber shared secret (32 bytes)
Hybrid Signature Scheme
The Ed25519 + ML-DSA-65 dual-signature scheme is the recommended
production default. It is exposed through the unified
AmaCryptography entry point (AlgorithmType.HYBRID_SIG) and backed by
HybridSignatureProvider in ama_cryptography.crypto_api.
Security Guarantee
Dual-signature security: Both Ed25519 and ML-DSA-65 signatures must independently verify for the package to be accepted. An attacker must forge both simultaneously — one classical forgery (2^128 classical operations) and one quantum-resistant forgery (2^190 quantum operations).
Hybrid signing API
from ama_cryptography.crypto_api import AmaCryptography, AlgorithmType
crypto = AmaCryptography(algorithm=AlgorithmType.HYBRID_SIG)
kp = crypto.generate_keypair() # KeyPair.public_key = Ed25519_pk || ML-DSA_pk
# KeyPair.secret_key = Ed25519_sk || ML-DSA_sk
message = b"Data requiring quantum-resistant protection"
sig = crypto.sign(message, kp.secret_key) # Signature.signature = Ed25519_sig || ML-DSA_sig
valid = crypto.verify(message, sig, kp.public_key) # True only if BOTH verify
The KeyPair.public_key / KeyPair.secret_key fields are fixed-size
concatenations (32 + 1952 bytes public, 32 + 4032 bytes secret) and the
Signature.signature is a 64 + 3309 byte concatenation. If you need to
drive the two layers independently, construct Ed25519Provider and
MLDSAProvider directly — they share the same CryptoProvider contract
as the hybrid.
Using the Multi-Layer Package (Full Hybrid)
The legacy orchestrator still exposes the historical codes+helix package
flow — which internally uses hybrid Ed25519 + ML-DSA-65 signatures at
layer 3. It lives in ama_cryptography.legacy_compat (new code should
prefer AmaCryptography(AlgorithmType.HYBRID_SIG) above):
from ama_cryptography.legacy_compat import (
generate_key_management_system,
create_crypto_package,
verify_crypto_package,
)
kms = generate_key_management_system("MyOrg")
package = create_crypto_package(codes, helix_params, kms)
results = verify_crypto_package(codes, helix_params, package, kms.hmac_key)
# Check both classical and quantum signatures
print(f"Ed25519 valid: {results['ed25519']}")
print(f"ML-DSA-65 valid: {results['dilithium']}")
Algorithm Selection
from ama_cryptography.crypto_api import AmaCryptography, AlgorithmType
# Classical only (Ed25519)
crypto = AmaCryptography(algorithm=AlgorithmType.ED25519)
# Quantum-resistant only (ML-DSA-65)
crypto = AmaCryptography(algorithm=AlgorithmType.ML_DSA_65)
# Hybrid (Ed25519 + ML-DSA-65) — recommended for production
crypto = AmaCryptography(algorithm=AlgorithmType.HYBRID_SIG)
Thread Safety and Serialization
HybridCombiner and the HybridSignatureProvider behind
AlgorithmType.HYBRID_SIG are stateless — all state is local to each
method call, so they are safe to use concurrently from multiple threads.
HybridEncapsulation objects are serializable (can be converted to/from bytes or JSON for transmission).
Integration with Adaptive Posture
The Adaptive Posture system can automatically switch
between algorithm choices based on threat level — e.g., elevating from
HYBRID_SIG to ML_DSA_65 when timing anomalies signal potential
classical compromise:
from ama_cryptography.adaptive_posture import (
CryptoPostureController,
PostureEvaluator,
PostureAction,
ThreatLevel,
)
from ama_cryptography.crypto_api import AmaCryptography, AlgorithmType
from ama_cryptography.key_management import KeyRotationManager
from ama_cryptography_monitor import AmaCryptographyMonitor
# The controller wires monitor → evaluator → response internally.
# It exposes `evaluate_and_respond()` (no public `execute_action`).
monitor = AmaCryptographyMonitor(enabled=True)
controller = CryptoPostureController(monitor=monitor)
crypto = AmaCryptography(algorithm=AlgorithmType.HYBRID_SIG)
key_mgr = KeyRotationManager()
evaluation = controller.evaluate_and_respond() # returns PostureEvaluation
# PostureEvaluation.action is the applied action (NOT `.recommended_action`).
# Direct use of PostureEvaluator is only needed when you want to evaluate a
# raw monitor_report dict without driving the controller:
# evaluator = PostureEvaluator()
# evaluation = evaluator.evaluate(monitor_report) # positional arg
if evaluation.action != PostureAction.NONE:
# Surface the action in your application logs / alerting. The controller
# has already updated the crypto stance by the time this returns.
...
References
- Bindel, N., Brendel, J., Fischlin, M., Goncalves, B., Stebila, D. (2019). Hybrid Key Encapsulation Mechanisms and Authenticated Key Exchange. PQCrypto 2019.
- NIST SP 800-56C Rev. 2 — Recommendation for Key-Derivation Methods in Key-Establishment Schemes
- NIST FIPS 203 — Module-Lattice-Based Key-Encapsulation Mechanism Standard
- NIST FIPS 204 — Module-Lattice-Based Digital Signature Standard
See Post-Quantum Cryptography for PQC algorithm details, or Adaptive Posture for runtime algorithm switching.