| @@ -2,6 +2,23 @@ | ||
| ## Unreleased | ||
| + | - **`oversight_core.crypto`: Python parity for `OSGT-HW-P256-v1` | |
| + | (2026-05-08).** New `wrap_dek_for_recipient_p256` and `unwrap_dek_p256` | |
| + | mirror the Rust reference byte-for-byte: same HKDF info string | |
| + | (`"oversight-hw-p256-v1-dek-wrap"`), same AEAD AAD | |
| + | (`"oversight-hw-p256-dek"`), same SEC1 uncompressed (65 byte) wire | |
| + | format for the ephemeral public key, same wrapped envelope JSON shape | |
| + | including the explicit `"suite"` field. `unwrap_dek_p256` accepts | |
| + | either an `EllipticCurvePrivateKey`, a PKCS#8-encoded private key, or | |
| + | a raw integer scalar so a future PIV / PKCS#11 binding has a portable | |
| + | on-ramp. `oversight_core.container` now recognizes `suite_id = 3` and | |
| + | maps it to `OSGT-HW-P256-v1` in `SUITE_ID_TO_NAME`. New | |
| + | `tests/test_hw_p256.py` (10 tests) covers the round trip across all | |
| + | three private-key input forms, the on-wire envelope shape against | |
| + | `SPEC.md` § 5.2, and the negative paths (wrong recipient, wrong | |
| + | ephemeral key length, missing fields, AAD binding so a classic | |
| + | envelope's bytes do not silently decrypt through the hardware path). | |
| + | ||
| - **`oversight-container`: end-to-end seal/open for `OSGT-HW-P256-v1` | ||
| (2026-05-07).** New `seal_hw_p256` mirrors `seal` but consumes a P-256 | ||
| SEC1 uncompressed recipient public key and writes a container with |
| @@ -8,7 +8,7 @@ The `.sealed` container format. Binary layout: | ||
| ------ -------- --------------------------------------- | ||
| 0 6 magic: b"OSGT\\x01\\x00" | ||
| 6 1 format_version (=1) | ||
| - | 7 1 suite_id (1=CLASSIC_V1, 2=HYBRID_V1) | |
| + | 7 1 suite_id (1=CLASSIC_V1, 2=HYBRID_V1, 3=HW_P256_V1) | |
| 8 4 manifest_len (u32 big-endian) | ||
| 12 M manifest (canonical JSON, signed) | ||
| 12+M 4 wrapped_dek_len (u32 BE) | ||
| @@ -41,9 +41,11 @@ from .manifest import Manifest | ||
| MAGIC = b"OSGT\x01\x00" | ||
| SUITE_CLASSIC_V1_ID = 1 | ||
| SUITE_HYBRID_V1_ID = 2 | ||
| + | SUITE_HW_P256_V1_ID = 3 | |
| SUITE_ID_TO_NAME = { | ||
| SUITE_CLASSIC_V1_ID: crypto.SUITE_CLASSIC_V1, | ||
| SUITE_HYBRID_V1_ID: crypto.SUITE_HYBRID_V1, | ||
| + | SUITE_HW_P256_V1_ID: crypto.SUITE_HW_P256_V1, | |
| } | ||
| @@ -57,6 +57,10 @@ except Exception: | ||
| SUITE_CLASSIC_V1 = "OSGT-CLASSIC-v1" # X25519 + Ed25519 + XChaCha20-Poly1305 | ||
| SUITE_HYBRID_V1 = "OSGT-HYBRID-v1" # + ML-KEM-768 + ML-DSA-65 | ||
| + | SUITE_HW_P256_V1 = "OSGT-HW-P256-v1" # P-256 ECDH for PIV-compatible hardware tokens | |
| + | ||
| + | # P-256 SEC1 uncompressed public key length: 0x04 || X || Y, 65 bytes total. | |
| + | P256_PUBLIC_KEY_LEN = 65 | |
| XCHACHA_NONCE_LEN = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES # 24 | ||
| XCHACHA_KEY_LEN = crypto_aead_xchacha20poly1305_ietf_KEYBYTES # 32 | ||
| @@ -178,6 +182,137 @@ def unwrap_dek(wrapped: dict, recipient_x25519_priv: bytes) -> bytes: | ||
| ) | ||
| + | # ---------- key agreement: hardware-backed P-256 (OSGT-HW-P256-v1) ---------- | |
| + | ||
| + | def wrap_dek_for_recipient_p256( | |
| + | dek: bytes, | |
| + | recipient_p256_pub_sec1: bytes, | |
| + | ) -> dict: | |
| + | """ | |
| + | Encrypt a DEK for a P-256 recipient (typically backed by a PIV-compatible | |
| + | hardware token: YubiKey, Nitrokey, OnlyKey). | |
| + | ||
| + | `recipient_p256_pub_sec1` is the recipient's NIST P-256 public key in | |
| + | SEC1 uncompressed encoding (65 bytes, ``0x04 || X || Y``). | |
| + | ||
| + | Mirrors `oversight-rust/oversight-crypto::wrap_dek_for_recipient_p256` | |
| + | byte-for-byte: same HKDF info ``oversight-hw-p256-v1-dek-wrap``, same | |
| + | AEAD AAD ``oversight-hw-p256-dek``. Output JSON shape matches | |
| + | `WrappedDekP256::to_json_hex` so a sealed file produced by either | |
| + | implementation opens with either implementation. | |
| + | ||
| + | Returns a dict with: suite, ephemeral_pub, nonce, wrapped_dek (all hex). | |
| + | """ | |
| + | if len(recipient_p256_pub_sec1) != P256_PUBLIC_KEY_LEN: | |
| + | raise ValueError( | |
| + | f"recipient_p256_pub_sec1 must be {P256_PUBLIC_KEY_LEN} bytes " | |
| + | f"(SEC1 uncompressed), got {len(recipient_p256_pub_sec1)}" | |
| + | ) | |
| + | ||
| + | # Lazy import: cryptography always exposes ec, but keeping the symbol out | |
| + | # of module top-level matches the existing pattern for hybrid PQ imports. | |
| + | from cryptography.hazmat.primitives.asymmetric import ec as _ec | |
| + | ||
| + | peer = _ec.EllipticCurvePublicKey.from_encoded_point( | |
| + | _ec.SECP256R1(), recipient_p256_pub_sec1 | |
| + | ) | |
| + | ||
| + | eph = _ec.generate_private_key(_ec.SECP256R1()) | |
| + | shared = eph.exchange(_ec.ECDH(), peer) | |
| + | ||
| + | kek = HKDF( | |
| + | algorithm=hashes.SHA256(), | |
| + | length=32, | |
| + | salt=None, | |
| + | info=b"oversight-hw-p256-v1-dek-wrap", | |
| + | ).derive(shared) | |
| + | ||
| + | nonce, wrapped = aead_encrypt(kek, dek, aad=b"oversight-hw-p256-dek") | |
| + | ||
| + | eph_pub_bytes = eph.public_key().public_bytes( | |
| + | encoding=serialization.Encoding.X962, | |
| + | format=serialization.PublicFormat.UncompressedPoint, | |
| + | ) | |
| + | if len(eph_pub_bytes) != P256_PUBLIC_KEY_LEN: | |
| + | # Should be impossible with SECP256R1 + UncompressedPoint, but guard | |
| + | # explicitly so any future curve change surfaces as a clear error | |
| + | # rather than producing a malformed envelope. | |
| + | raise RuntimeError( | |
| + | f"P-256 ephemeral pub must be {P256_PUBLIC_KEY_LEN} bytes, got {len(eph_pub_bytes)}" | |
| + | ) | |
| + | ||
| + | return { | |
| + | "suite": SUITE_HW_P256_V1, | |
| + | "ephemeral_pub": eph_pub_bytes.hex(), | |
| + | "nonce": nonce.hex(), | |
| + | "wrapped_dek": wrapped.hex(), | |
| + | } | |
| + | ||
| + | ||
| + | def unwrap_dek_p256(wrapped: dict, recipient_p256_priv_pkcs8_or_int) -> bytes: | |
| + | """ | |
| + | Recover the DEK for an `OSGT-HW-P256-v1` envelope using the recipient's | |
| + | P-256 private key. | |
| + | ||
| + | `recipient_p256_priv_pkcs8_or_int` accepts either: | |
| + | - an `EllipticCurvePrivateKey` (e.g., loaded from PKCS#11 or generated | |
| + | in-process for tests), or | |
| + | - bytes containing a PKCS#8-encoded P-256 private key, or | |
| + | - an integer in the range [1, n-1] (for raw scalar import). | |
| + | ||
| + | Mirrors `oversight-rust/oversight-crypto::unwrap_dek_with_provider_p256`. | |
| + | """ | |
| + | from cryptography.hazmat.primitives.asymmetric import ec as _ec | |
| + | ||
| + | for required in ("ephemeral_pub", "nonce", "wrapped_dek"): | |
| + | if required not in wrapped: | |
| + | raise ValueError(f"hw-p256 envelope missing field: {required}") | |
| + | ||
| + | eph_pub_bytes = bytes.fromhex(wrapped["ephemeral_pub"]) | |
| + | if len(eph_pub_bytes) != P256_PUBLIC_KEY_LEN: | |
| + | raise ValueError( | |
| + | f"ephemeral_pub must be {P256_PUBLIC_KEY_LEN} bytes " | |
| + | f"(SEC1 uncompressed), got {len(eph_pub_bytes)}" | |
| + | ) | |
| + | ||
| + | # Coerce the recipient private key into an EllipticCurvePrivateKey. | |
| + | if isinstance(recipient_p256_priv_pkcs8_or_int, _ec.EllipticCurvePrivateKey): | |
| + | sk = recipient_p256_priv_pkcs8_or_int | |
| + | elif isinstance(recipient_p256_priv_pkcs8_or_int, (bytes, bytearray)): | |
| + | sk = serialization.load_der_private_key( | |
| + | bytes(recipient_p256_priv_pkcs8_or_int), password=None | |
| + | ) | |
| + | if not isinstance(sk, _ec.EllipticCurvePrivateKey): | |
| + | raise ValueError("PKCS#8 key is not an EllipticCurvePrivateKey") | |
| + | elif isinstance(recipient_p256_priv_pkcs8_or_int, int): | |
| + | sk = _ec.derive_private_key( | |
| + | recipient_p256_priv_pkcs8_or_int, _ec.SECP256R1() | |
| + | ) | |
| + | else: | |
| + | raise TypeError( | |
| + | "recipient private key must be EllipticCurvePrivateKey, PKCS#8 bytes, or int scalar" | |
| + | ) | |
| + | ||
| + | eph_pub = _ec.EllipticCurvePublicKey.from_encoded_point( | |
| + | _ec.SECP256R1(), eph_pub_bytes | |
| + | ) | |
| + | shared = sk.exchange(_ec.ECDH(), eph_pub) | |
| + | ||
| + | kek = HKDF( | |
| + | algorithm=hashes.SHA256(), | |
| + | length=32, | |
| + | salt=None, | |
| + | info=b"oversight-hw-p256-v1-dek-wrap", | |
| + | ).derive(shared) | |
| + | ||
| + | return aead_decrypt( | |
| + | kek, | |
| + | bytes.fromhex(wrapped["nonce"]), | |
| + | bytes.fromhex(wrapped["wrapped_dek"]), | |
| + | aad=b"oversight-hw-p256-dek", | |
| + | ) | |
| + | ||
| + | ||
| # ---------- signatures ---------- | ||
| def sign_manifest(manifest_bytes: bytes, ed25519_priv: bytes) -> bytes: |
| @@ -0,0 +1,133 @@ | ||
| + | """Unit tests for the OSGT-HW-P256-v1 (hardware-backed P-256) suite in | |
| + | oversight_core.crypto. The tests exercise the pure-Python wrap/unwrap path | |
| + | that mirrors oversight-rust's seal_hw_p256 + unwrap_dek_with_provider_p256; | |
| + | cross-language conformance against the Rust reference is layered in a | |
| + | separate harness. | |
| + | """ | |
| + | from __future__ import annotations | |
| + | ||
| + | import os | |
| + | import pytest | |
| + | ||
| + | from cryptography.hazmat.primitives import serialization | |
| + | from cryptography.hazmat.primitives.asymmetric import ec | |
| + | ||
| + | from oversight_core import crypto, container | |
| + | ||
| + | ||
| + | def _gen_p256_pair() -> tuple[ec.EllipticCurvePrivateKey, bytes]: | |
| + | """Generate a P-256 keypair, returning (priv, pub_sec1_uncompressed).""" | |
| + | sk = ec.generate_private_key(ec.SECP256R1()) | |
| + | pub_sec1 = sk.public_key().public_bytes( | |
| + | encoding=serialization.Encoding.X962, | |
| + | format=serialization.PublicFormat.UncompressedPoint, | |
| + | ) | |
| + | return sk, pub_sec1 | |
| + | ||
| + | ||
| + | def test_constants_match_rust_reference(): | |
| + | assert crypto.SUITE_HW_P256_V1 == "OSGT-HW-P256-v1" | |
| + | assert crypto.P256_PUBLIC_KEY_LEN == 65 | |
| + | assert container.SUITE_HW_P256_V1_ID == 3 | |
| + | assert container.SUITE_ID_TO_NAME[3] == "OSGT-HW-P256-v1" | |
| + | ||
| + | ||
| + | def test_wrap_unwrap_round_trip_with_private_key_object(): | |
| + | sk, pub = _gen_p256_pair() | |
| + | dek = os.urandom(32) | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(dek, pub) | |
| + | recovered = crypto.unwrap_dek_p256(wrapped, sk) | |
| + | assert recovered == dek | |
| + | ||
| + | ||
| + | def test_wrap_unwrap_round_trip_with_pkcs8_bytes(): | |
| + | # PivKeyProvider candidates store PKCS#8-encoded keys before passing them | |
| + | # to ECDH backends. Confirm that path works too. | |
| + | sk, pub = _gen_p256_pair() | |
| + | pkcs8 = sk.private_bytes( | |
| + | encoding=serialization.Encoding.DER, | |
| + | format=serialization.PrivateFormat.PKCS8, | |
| + | encryption_algorithm=serialization.NoEncryption(), | |
| + | ) | |
| + | dek = os.urandom(32) | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(dek, pub) | |
| + | recovered = crypto.unwrap_dek_p256(wrapped, pkcs8) | |
| + | assert recovered == dek | |
| + | ||
| + | ||
| + | def test_wrap_unwrap_round_trip_with_raw_int_scalar(): | |
| + | sk, pub = _gen_p256_pair() | |
| + | scalar = sk.private_numbers().private_value | |
| + | dek = os.urandom(32) | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(dek, pub) | |
| + | recovered = crypto.unwrap_dek_p256(wrapped, scalar) | |
| + | assert recovered == dek | |
| + | ||
| + | ||
| + | def test_envelope_shape_matches_spec(): | |
| + | sk, pub = _gen_p256_pair() | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(os.urandom(32), pub) | |
| + | # SPEC.md sec 5.2: OSGT-HW-P256-v1 wrapped_dek JSON has exactly these keys. | |
| + | assert set(wrapped.keys()) == {"suite", "ephemeral_pub", "nonce", "wrapped_dek"} | |
| + | assert wrapped["suite"] == "OSGT-HW-P256-v1" | |
| + | eph_pub = bytes.fromhex(wrapped["ephemeral_pub"]) | |
| + | assert len(eph_pub) == 65, "P-256 ephemeral pub MUST be 65 bytes (SEC1 uncompressed)" | |
| + | assert eph_pub[0] == 0x04, "SEC1 uncompressed encoding starts with 0x04" | |
| + | assert len(bytes.fromhex(wrapped["nonce"])) == 24, "XChaCha20 nonce MUST be 24 bytes" | |
| + | ||
| + | ||
| + | def test_wrong_recipient_rejected(): | |
| + | alice_sk, alice_pub = _gen_p256_pair() | |
| + | bob_sk, _ = _gen_p256_pair() | |
| + | dek = os.urandom(32) | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(dek, alice_pub) | |
| + | # Bob's key is a valid P-256 key but not the one this DEK is bound to. | |
| + | with pytest.raises(Exception): | |
| + | crypto.unwrap_dek_p256(wrapped, bob_sk) | |
| + | ||
| + | ||
| + | def test_wrap_rejects_wrong_pub_length(): | |
| + | with pytest.raises(ValueError, match="65 bytes"): | |
| + | crypto.wrap_dek_for_recipient_p256(os.urandom(32), b"\x04" + b"\x00" * 31) | |
| + | ||
| + | ||
| + | def test_unwrap_rejects_wrong_ephemeral_length(): | |
| + | sk, pub = _gen_p256_pair() | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(os.urandom(32), pub) | |
| + | # Truncate the ephemeral pub to the X25519 size (32 bytes) and confirm | |
| + | # we refuse rather than try to interpret it as a P-256 point. | |
| + | wrapped["ephemeral_pub"] = wrapped["ephemeral_pub"][:64] | |
| + | with pytest.raises(ValueError, match="65 bytes"): | |
| + | crypto.unwrap_dek_p256(wrapped, sk) | |
| + | ||
| + | ||
| + | def test_unwrap_rejects_missing_fields(): | |
| + | sk, _ = _gen_p256_pair() | |
| + | incomplete = {"suite": "OSGT-HW-P256-v1", "nonce": "00" * 24, "wrapped_dek": "deadbeef"} | |
| + | with pytest.raises(ValueError, match="ephemeral_pub"): | |
| + | crypto.unwrap_dek_p256(incomplete, sk) | |
| + | ||
| + | ||
| + | def test_aad_binding_classic_envelope_does_not_unwrap(): | |
| + | """ | |
| + | Sanity check that a classic-suite wrapped_dek (X25519, 32-byte | |
| + | ephemeral, info=oversight-v1-dek-wrap, AAD=oversight-dek) does not | |
| + | accidentally decrypt through the P-256 path even if you bend the | |
| + | field shapes. The two suites use different HKDF info strings and | |
| + | different AEAD AAD values; either of those diverging is enough to | |
| + | make AEAD authentication fail. | |
| + | """ | |
| + | # Build a malformed envelope that looks shaped-like-P256 but the | |
| + | # ciphertext was produced under classic AAD. The unwrap MUST fail. | |
| + | sk, pub = _gen_p256_pair() | |
| + | wrapped = crypto.wrap_dek_for_recipient_p256(os.urandom(32), pub) | |
| + | # Replace the wrapped_dek with one encrypted under classic-suite AAD | |
| + | # using the same key bytes (impossible in practice, but tests AAD | |
| + | # binding even when keys collide). | |
| + | bogus_aead_nonce, bogus_wrapped = crypto.aead_encrypt( | |
| + | b"\x00" * 32, b"would-be DEK", aad=b"oversight-dek" | |
| + | ) | |
| + | wrapped["nonce"] = bogus_aead_nonce.hex() | |
| + | wrapped["wrapped_dek"] = bogus_wrapped.hex() | |
| + | with pytest.raises(Exception): | |
| + | crypto.unwrap_dek_p256(wrapped, sk) |