Zion Boggan zionboggan.com ↗

oversight_core: Python parity for OSGT-HW-P256-v1

The Python reference implementation is documented as the protocol's
source of truth; bringing it up to the Rust side closes the cross-
language gap for the hardware-keys suite.

- crypto.py:
  - SUITE_HW_P256_V1 = "OSGT-HW-P256-v1" + P256_PUBLIC_KEY_LEN = 65
  - wrap_dek_for_recipient_p256(dek, recipient_p256_pub_sec1):
    generates an ephemeral P-256 keypair, runs ECDH against the
    recipient's SEC1-uncompressed public key, derives a 32-byte KEK via
    HKDF-SHA256 with info "oversight-hw-p256-v1-dek-wrap", and AEAD-
    encrypts the DEK under XChaCha20-Poly1305 with AAD
    "oversight-hw-p256-dek". Output JSON shape matches Rust's
    WrappedDekP256::to_json_hex including the "suite" field.
  - unwrap_dek_p256(wrapped, priv): accepts an
    EllipticCurvePrivateKey, PKCS#8-encoded bytes, or a raw integer
    scalar so a future PIV / PKCS#11 binding has a portable on-ramp.
- container.py:
  - SUITE_HW_P256_V1_ID = 3, registered in SUITE_ID_TO_NAME.
  - Header docstring updated to reflect the new suite id.
- tests/test_hw_p256.py: 10 unit tests covering round trip across all
  three private-key input forms, the SPEC sec 5.2 envelope shape
  (65-byte SEC1 ephemeral_pub, 24-byte XChaCha20 nonce, exactly four
  keys: suite/ephemeral_pub/nonce/wrapped_dek), wrong-recipient
  rejection, ephemeral-length validation, missing-field detection, and
  the AAD-binding regression that prevents a classic envelope's bytes
  from silently decrypting through the hardware path.

Public API additive; existing classic and hybrid call sites unchanged.
Cross-language conformance harness against the Rust reference is the
next bounded follow-up.
fd52d94   Zion Boggan committed on May 8, 2026 (1 month ago)
CHANGELOG.md +17 -0
@@ -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
oversight_core/container.py +3 -1
@@ -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,
}
oversight_core/crypto.py +135 -0
@@ -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:
tests/test_hw_p256.py +133 -0
@@ -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)