| 1 | """Unit tests for the OSGT-HW-P256-v1 (hardware-backed P-256) suite in |
| 2 | oversight_core.crypto. The tests exercise the pure-Python wrap/unwrap path |
| 3 | that mirrors oversight-rust's seal_hw_p256 + unwrap_dek_with_provider_p256; |
| 4 | cross-language conformance against the Rust reference is layered in a |
| 5 | separate harness. |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import os |
| 10 | import pytest |
| 11 | |
| 12 | from cryptography.hazmat.primitives import serialization |
| 13 | from cryptography.hazmat.primitives.asymmetric import ec |
| 14 | |
| 15 | from oversight_core import crypto, container |
| 16 | |
| 17 | |
| 18 | def _gen_p256_pair() -> tuple[ec.EllipticCurvePrivateKey, bytes]: |
| 19 | """Generate a P-256 keypair, returning (priv, pub_sec1_uncompressed).""" |
| 20 | sk = ec.generate_private_key(ec.SECP256R1()) |
| 21 | pub_sec1 = sk.public_key().public_bytes( |
| 22 | encoding=serialization.Encoding.X962, |
| 23 | format=serialization.PublicFormat.UncompressedPoint, |
| 24 | ) |
| 25 | return sk, pub_sec1 |
| 26 | |
| 27 | |
| 28 | def test_constants_match_rust_reference(): |
| 29 | assert crypto.SUITE_HW_P256_V1 == "OSGT-HW-P256-v1" |
| 30 | assert crypto.P256_PUBLIC_KEY_LEN == 65 |
| 31 | assert container.SUITE_HW_P256_V1_ID == 3 |
| 32 | assert container.SUITE_ID_TO_NAME[3] == "OSGT-HW-P256-v1" |
| 33 | |
| 34 | |
| 35 | def test_wrap_unwrap_round_trip_with_private_key_object(): |
| 36 | sk, pub = _gen_p256_pair() |
| 37 | dek = os.urandom(32) |
| 38 | wrapped = crypto.wrap_dek_for_recipient_p256(dek, pub) |
| 39 | recovered = crypto.unwrap_dek_p256(wrapped, sk) |
| 40 | assert recovered == dek |
| 41 | |
| 42 | |
| 43 | def test_wrap_unwrap_round_trip_with_pkcs8_bytes(): |
| 44 | sk, pub = _gen_p256_pair() |
| 45 | pkcs8 = sk.private_bytes( |
| 46 | encoding=serialization.Encoding.DER, |
| 47 | format=serialization.PrivateFormat.PKCS8, |
| 48 | encryption_algorithm=serialization.NoEncryption(), |
| 49 | ) |
| 50 | dek = os.urandom(32) |
| 51 | wrapped = crypto.wrap_dek_for_recipient_p256(dek, pub) |
| 52 | recovered = crypto.unwrap_dek_p256(wrapped, pkcs8) |
| 53 | assert recovered == dek |
| 54 | |
| 55 | |
| 56 | def test_wrap_unwrap_round_trip_with_raw_int_scalar(): |
| 57 | sk, pub = _gen_p256_pair() |
| 58 | scalar = sk.private_numbers().private_value |
| 59 | dek = os.urandom(32) |
| 60 | wrapped = crypto.wrap_dek_for_recipient_p256(dek, pub) |
| 61 | recovered = crypto.unwrap_dek_p256(wrapped, scalar) |
| 62 | assert recovered == dek |
| 63 | |
| 64 | |
| 65 | def test_envelope_shape_matches_spec(): |
| 66 | sk, pub = _gen_p256_pair() |
| 67 | wrapped = crypto.wrap_dek_for_recipient_p256(os.urandom(32), pub) |
| 68 | assert set(wrapped.keys()) == {"suite", "ephemeral_pub", "nonce", "wrapped_dek"} |
| 69 | assert wrapped["suite"] == "OSGT-HW-P256-v1" |
| 70 | eph_pub = bytes.fromhex(wrapped["ephemeral_pub"]) |
| 71 | assert len(eph_pub) == 65, "P-256 ephemeral pub MUST be 65 bytes (SEC1 uncompressed)" |
| 72 | assert eph_pub[0] == 0x04, "SEC1 uncompressed encoding starts with 0x04" |
| 73 | assert len(bytes.fromhex(wrapped["nonce"])) == 24, "XChaCha20 nonce MUST be 24 bytes" |
| 74 | |
| 75 | |
| 76 | def test_wrong_recipient_rejected(): |
| 77 | alice_sk, alice_pub = _gen_p256_pair() |
| 78 | bob_sk, _ = _gen_p256_pair() |
| 79 | dek = os.urandom(32) |
| 80 | wrapped = crypto.wrap_dek_for_recipient_p256(dek, alice_pub) |
| 81 | with pytest.raises(Exception): |
| 82 | crypto.unwrap_dek_p256(wrapped, bob_sk) |
| 83 | |
| 84 | |
| 85 | def test_wrap_rejects_wrong_pub_length(): |
| 86 | with pytest.raises(ValueError, match="65 bytes"): |
| 87 | crypto.wrap_dek_for_recipient_p256(os.urandom(32), b"\x04" + b"\x00" * 31) |
| 88 | |
| 89 | |
| 90 | def test_unwrap_rejects_wrong_ephemeral_length(): |
| 91 | sk, pub = _gen_p256_pair() |
| 92 | wrapped = crypto.wrap_dek_for_recipient_p256(os.urandom(32), pub) |
| 93 | wrapped["ephemeral_pub"] = wrapped["ephemeral_pub"][:64] |
| 94 | with pytest.raises(ValueError, match="65 bytes"): |
| 95 | crypto.unwrap_dek_p256(wrapped, sk) |
| 96 | |
| 97 | |
| 98 | def test_unwrap_rejects_missing_fields(): |
| 99 | sk, _ = _gen_p256_pair() |
| 100 | incomplete = {"suite": "OSGT-HW-P256-v1", "nonce": "00" * 24, "wrapped_dek": "deadbeef"} |
| 101 | with pytest.raises(ValueError, match="ephemeral_pub"): |
| 102 | crypto.unwrap_dek_p256(incomplete, sk) |
| 103 | |
| 104 | |
| 105 | def test_aad_binding_classic_envelope_does_not_unwrap(): |
| 106 | """ |
| 107 | Sanity check that a classic-suite wrapped_dek (X25519, 32-byte |
| 108 | ephemeral, info=oversight-v1-dek-wrap, AAD=oversight-dek) does not |
| 109 | accidentally decrypt through the P-256 path even if you bend the |
| 110 | field shapes. The two suites use different HKDF info strings and |
| 111 | different AEAD AAD values; either of those diverging is enough to |
| 112 | make AEAD authentication fail. |
| 113 | """ |
| 114 | sk, pub = _gen_p256_pair() |
| 115 | wrapped = crypto.wrap_dek_for_recipient_p256(os.urandom(32), pub) |
| 116 | bogus_aead_nonce, bogus_wrapped = crypto.aead_encrypt( |
| 117 | b"\x00" * 32, b"would-be DEK", aad=b"oversight-dek" |
| 118 | ) |
| 119 | wrapped["nonce"] = bogus_aead_nonce.hex() |
| 120 | wrapped["wrapped_dek"] = bogus_wrapped.hex() |
| 121 | with pytest.raises(Exception): |
| 122 | crypto.unwrap_dek_p256(wrapped, sk) |