Zion Boggan
repos/Oversight/tests/test_pq.py
zionboggan.com ↗
136 lines · python
History for this file →
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"