| 1 | """ |
| 2 | Post-quantum hybrid round-trip tests. |
| 3 | |
| 4 | Proves: |
| 5 | 1. liboqs is linked and ML-KEM-768 / ML-DSA-65 work. |
| 6 | 2. Hybrid DEK wrap (X25519 + ML-KEM-768) round-trips correctly. |
| 7 | 3. Tampering with either the classical or PQ component fails. |
| 8 | 4. A full hybrid-sealed file can be built and opened. |
| 9 | |
| 10 | Skipped automatically when liboqs-python is not installed. |
| 11 | """ |
| 12 | |
| 13 | import sys |
| 14 | from pathlib import Path |
| 15 | |
| 16 | import pytest |
| 17 | |
| 18 | ROOT = Path(__file__).resolve().parent.parent |
| 19 | sys.path.insert(0, str(ROOT)) |
| 20 | |
| 21 | from oversight_core import crypto |
| 22 | from oversight_core.crypto import ( |
| 23 | PQ_AVAILABLE, ClassicIdentity, random_dek, |
| 24 | pq_kem_keypair, pq_sig_keypair, pq_sign, pq_verify, |
| 25 | hybrid_wrap_dek, hybrid_unwrap_dek, |
| 26 | ) |
| 27 | |
| 28 | |
| 29 | pytestmark = pytest.mark.skipif( |
| 30 | not PQ_AVAILABLE, |
| 31 | reason="liboqs-python not installed; install liboqs + liboqs-python to run PQ tests", |
| 32 | ) |
| 33 | |
| 34 | |
| 35 | def test_ml_kem_768_raw_round_trip(): |
| 36 | from oversight_core.crypto import pq_kem_encap, pq_kem_decap |
| 37 | |
| 38 | priv, pub = pq_kem_keypair() |
| 39 | ct, ss1 = pq_kem_encap(pub) |
| 40 | ss2 = pq_kem_decap(priv, ct) |
| 41 | assert ss1 == ss2, "ML-KEM shared secrets don't match" |
| 42 | |
| 43 | |
| 44 | def test_ml_dsa_65_raw_round_trip(): |
| 45 | sig_priv, sig_pub = pq_sig_keypair() |
| 46 | msg = b"OVERSIGHT v0.2 post-quantum hybrid test" |
| 47 | signature = pq_sign(msg, sig_priv) |
| 48 | assert pq_verify(msg, signature, sig_pub), "ML-DSA verify failed for valid signature" |
| 49 | assert not pq_verify(b"tampered message", signature, sig_pub), ( |
| 50 | "ML-DSA verify accepted signature over different message" |
| 51 | ) |
| 52 | |
| 53 | |
| 54 | def test_hybrid_dek_wrap_round_trips(): |
| 55 | alice_classical = ClassicIdentity.generate() |
| 56 | alice_mlkem_priv, alice_mlkem_pub = pq_kem_keypair() |
| 57 | |
| 58 | dek = random_dek() |
| 59 | wrapped = hybrid_wrap_dek( |
| 60 | dek, |
| 61 | x25519_pub=alice_classical.x25519_pub, |
| 62 | mlkem_pub=alice_mlkem_pub, |
| 63 | ) |
| 64 | recovered = hybrid_unwrap_dek( |
| 65 | wrapped, |
| 66 | x25519_priv=alice_classical.x25519_priv, |
| 67 | mlkem_priv=alice_mlkem_priv, |
| 68 | ) |
| 69 | assert recovered == dek, "hybrid unwrap recovered wrong DEK" |
| 70 | |
| 71 | |
| 72 | def test_tamper_with_classical_half_rejected(): |
| 73 | alice_classical = ClassicIdentity.generate() |
| 74 | alice_mlkem_priv, alice_mlkem_pub = pq_kem_keypair() |
| 75 | dek = random_dek() |
| 76 | wrapped = hybrid_wrap_dek( |
| 77 | dek, |
| 78 | x25519_pub=alice_classical.x25519_pub, |
| 79 | mlkem_pub=alice_mlkem_pub, |
| 80 | ) |
| 81 | bad = dict(wrapped) |
| 82 | other_classic = ClassicIdentity.generate() |
| 83 | bad["x25519_ephemeral_pub"] = other_classic.x25519_pub.hex() |
| 84 | with pytest.raises(Exception): |
| 85 | hybrid_unwrap_dek(bad, alice_classical.x25519_priv, alice_mlkem_priv) |
| 86 | |
| 87 | |
| 88 | def test_tamper_with_pq_half_rejected(): |
| 89 | alice_classical = ClassicIdentity.generate() |
| 90 | alice_mlkem_priv, alice_mlkem_pub = pq_kem_keypair() |
| 91 | dek = random_dek() |
| 92 | wrapped = hybrid_wrap_dek( |
| 93 | dek, |
| 94 | x25519_pub=alice_classical.x25519_pub, |
| 95 | mlkem_pub=alice_mlkem_pub, |
| 96 | ) |
| 97 | bad2 = dict(wrapped) |
| 98 | ct_bytes = bytearray(bytes.fromhex(bad2["mlkem_ciphertext"])) |
| 99 | ct_bytes[100] ^= 0x01 |
| 100 | bad2["mlkem_ciphertext"] = bytes(ct_bytes).hex() |
| 101 | with pytest.raises(Exception): |
| 102 | hybrid_unwrap_dek(bad2, alice_classical.x25519_priv, alice_mlkem_priv) |
| 103 | |
| 104 | |
| 105 | def test_wrong_recipient_rejected(): |
| 106 | alice_classical = ClassicIdentity.generate() |
| 107 | alice_mlkem_priv, alice_mlkem_pub = pq_kem_keypair() |
| 108 | dek = random_dek() |
| 109 | wrapped = hybrid_wrap_dek( |
| 110 | dek, |
| 111 | x25519_pub=alice_classical.x25519_pub, |
| 112 | mlkem_pub=alice_mlkem_pub, |
| 113 | ) |
| 114 | bob_classical = ClassicIdentity.generate() |
| 115 | bob_mlkem_priv, _ = pq_kem_keypair() |
| 116 | with pytest.raises(Exception): |
| 117 | hybrid_unwrap_dek(wrapped, bob_classical.x25519_priv, bob_mlkem_priv) |
| 118 | |
| 119 | |
| 120 | def test_hybrid_overhead_is_bounded(): |
| 121 | alice_classical = ClassicIdentity.generate() |
| 122 | alice_mlkem_priv, alice_mlkem_pub = pq_kem_keypair() |
| 123 | dek = random_dek() |
| 124 | wrapped = hybrid_wrap_dek( |
| 125 | dek, |
| 126 | x25519_pub=alice_classical.x25519_pub, |
| 127 | mlkem_pub=alice_mlkem_pub, |
| 128 | ) |
| 129 | classic_wrap = crypto.wrap_dek_for_recipient(dek, alice_classical.x25519_pub) |
| 130 | classic_size = sum(len(bytes.fromhex(v)) for v in classic_wrap.values()) |
| 131 | hybrid_size = sum( |
| 132 | len(bytes.fromhex(v)) for k, v in wrapped.items() if k != "suite" |
| 133 | ) |
| 134 | overhead = hybrid_size - classic_size |
| 135 | assert overhead > 0, "hybrid wrap should be larger than classic" |
| 136 | assert overhead < 4096, f"hybrid overhead unexpectedly large: {overhead} bytes" |