Zion Boggan zionboggan.com ↗

oversight-crypto: OSGT-HW-P256-v1 suite (P-256 ECDH wrap/unwrap)

Brings P-256 ECDH into oversight-crypto so the future PivKeyProvider
(PKCS#11 against YubiKey/Nitrokey/OnlyKey) has a complete pure-Rust
reference path to plug into.

- SUITE_HW_P256_V1 = "OSGT-HW-P256-v1" constant.
- wrap_dek_for_recipient_p256: accepts SEC1 uncompressed (65 byte)
  recipient pub, generates ephemeral P-256 keypair, ECDH, HKDF info
  "oversight-hw-p256-v1-dek-wrap", AEAD AAD "oversight-hw-p256-dek".
- WrappedDekP256 + JSON serializer that records "suite": "OSGT-HW-P256-v1"
  so a polymorphic envelope reader can dispatch by suite-id without
  guessing on ephemeral key length.
- SoftwareP256Identity + SoftwareP256KeyProvider: in-memory KeyProvider
  impl. Mirrors what PivKeyProvider will look like minus the on-device
  scalar.
- unwrap_dek_with_provider_p256: rejects non-P256 providers explicitly
  (silently producing garbage on cross-suite mismatch would be worse
  than refusing). Classic X25519 path is unaffected (regression test
  added).
- 8 new unit tests; oversight-crypto now 21/21; workspace builds clean.
- p256 RustCrypto crate added to workspace deps with ecdh+arithmetic.

PivKeyProvider lands as a follow-up, plus manifest/container plumbing
for the new suite.
a6cb2b9   Zion Boggan committed on May 7, 2026 (1 month ago)
CHANGELOG.md +15 -0
@@ -2,6 +2,21 @@
## Unreleased
+- **`oversight-crypto`: `OSGT-HW-P256-v1` suite implementation (2026-05-07).**
+ P-256 ECDH wrap/unwrap landed alongside the X25519 path so hardware-backed
+ recipients (YubiKey / Nitrokey / OnlyKey via PIV) have a complete pure-Rust
+ reference to plug into. `wrap_dek_for_recipient_p256` accepts SEC1
+ uncompressed (65 byte) recipient public keys and produces `WrappedDekP256`.
+ `SoftwareP256KeyProvider` is the in-memory `KeyProvider` impl that
+ `PivKeyProvider` will mirror against PKCS#11 next. Cross-suite envelopes
+ are rejected explicitly: an X25519 provider passed to a P-256 envelope
+ errors out instead of producing garbage. Eight new unit tests covering
+ round trips, wrong-recipient rejection, cross-suite rejection, JSON
+ envelope round-trip, and a regression check that the classic path still
+ works. `SUITE_HW_P256_V1` constant exported. Adds `p256` (RustCrypto) to
+ workspace deps with `ecdh` + `arithmetic` features. `oversight-crypto`
+ passes 21/21; workspace build clean.
+
- **`oversight-crypto`: `KeyProvider` trait + `FileKeyProvider` (2026-05-07).**
The recipient-side ECDH path is now abstracted behind `pub trait KeyProvider`,
with `FileKeyProvider` shipping as the X25519 file-backed default. New
docs/ROADMAP.md +9 -3
@@ -212,10 +212,16 @@ recipient-side ECDH so a hardware backend can plug in without changing
call sites; `unwrap_dek_with_provider` is the new entry point and is
byte-identical to `unwrap_dek` for file-backed keys.
+**`OSGT-HW-P256-v1` suite implementation landed 2026-05-07.** P-256 ECDH
+wrap/unwrap, `WrappedDekP256` envelope, and `SoftwareP256KeyProvider`
+(in-memory P-256 reference impl) are in `oversight-crypto`. Cross-suite
+envelopes are rejected explicitly. 21/21 tests in the crate pass.
+
The remaining work is the `PivKeyProvider` (PKCS#11 against a YubiKey /
-Nitrokey / OnlyKey PIV slot) and the `OSGT-HW-P256-v1` suite that goes
-with it: P-256 ECDH wire format on the wrap side, manifest suite-id
-plumbing, and the open-side decrypt path that branches on suite. The
+Nitrokey / OnlyKey PIV slot) - a different `KeyProvider` impl that calls
+into `cryptoki` instead of holding the scalar in process - plus the
+manifest / container plumbing that lets `OSGT-HW-P256-v1` ride the
+existing seal pipeline. The
registry records whether each recipient pubkey is file-backed or
hardware-backed so issuers can require hardware backing for sensitive
material.
oversight-rust/Cargo.lock +133 -0
@@ -206,6 +206,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -456,6 +462,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -501,6 +519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
+ "pem-rfc7468",
"zeroize",
]
@@ -531,6 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+ "const-oid",
"crypto-common",
"subtle",
]
@@ -552,6 +572,20 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -586,6 +620,27 @@ dependencies = [
"serde",
]
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -637,6 +692,16 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -774,6 +839,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -800,6 +866,17 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1367,6 +1444,7 @@ dependencies = [
"ed25519-dalek",
"hex",
"hkdf",
+ "p256",
"rand_core",
"serde_json",
"sha2",
@@ -1490,6 +1568,18 @@ dependencies = [
"rand_core",
]
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -1519,6 +1609,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1596,6 +1695,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1709,6 +1817,16 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -1804,6 +1922,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "semver"
version = "1.0.28"
@@ -1929,6 +2061,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
+ "digest",
"rand_core",
]
oversight-rust/Cargo.toml +3 -0
@@ -29,6 +29,9 @@ authors = ["Oversight contributors"]
# ed25519-dalek: same. Both have been independently audited.
x25519-dalek = { version = "2", features = ["static_secrets"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
+# p256: NIST P-256 ECDH for hardware-backed recipients (PIV, PKCS#11).
+# Features: `ecdh` exposes diffie_hellman, `arithmetic` exposes scalar/point ops.
+p256 = { version = "0.13", features = ["ecdh", "arithmetic"] }
chacha20poly1305 = { version = "0.10", features = ["alloc"] }
hkdf = "0.12"
sha2 = "0.10"
oversight-rust/oversight-crypto/Cargo.toml +1 -0
@@ -9,6 +9,7 @@ description = "Cryptographic primitives for Oversight: X25519, Ed25519, XChaCha2
[dependencies]
x25519-dalek.workspace = true
ed25519-dalek.workspace = true
+p256.workspace = true
chacha20poly1305.workspace = true
hkdf.workspace = true
sha2.workspace = true
oversight-rust/oversight-crypto/src/lib.rs +315 -0
@@ -38,6 +38,11 @@ use sha2::{Digest, Sha256};
use thiserror::Error;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
use zeroize::{Zeroize, Zeroizing};
+use p256::{
+ ecdh::diffie_hellman as p256_diffie_hellman,
+ elliptic_curve::sec1::ToEncodedPoint,
+ PublicKey as P256PublicKey, SecretKey as P256SecretKey,
+};
pub const XCHACHA_KEY_LEN: usize = 32;
pub const XCHACHA_NONCE_LEN: usize = 24;
@@ -45,9 +50,15 @@ pub const X25519_KEY_LEN: usize = 32;
pub const ED25519_KEY_LEN: usize = 32;
pub const ED25519_SIG_LEN: usize = 64;
pub const DEK_LEN: usize = 32;
+/// P-256 public key in SEC1 uncompressed encoding (`0x04 || X || Y`).
+pub const P256_PUBLIC_KEY_LEN: usize = 65;
pub const SUITE_CLASSIC_V1: &str = "OSGT-CLASSIC-v1";
pub const SUITE_HYBRID_V1: &str = "OSGT-HYBRID-v1";
+/// Hardware-backed recipients use P-256 ECDH so PIV-compatible tokens
+/// (YubiKey, Nitrokey, OnlyKey) can perform the key agreement on-device
+/// without exposing the private scalar. See `docs/HARDWARE_KEYS.md`.
+pub const SUITE_HW_P256_V1: &str = "OSGT-HW-P256-v1";
#[derive(Debug, Error)]
pub enum CryptoError {
@@ -432,6 +443,210 @@ pub fn unwrap_dek_with_provider(
Ok(Zeroizing::new(plaintext))
}
+// -------------------------- P-256 (hardware-backed suite) -------------------
+
+/// In-memory P-256 keypair. Mirrors [`ClassicIdentity`] but for the
+/// `OSGT-HW-P256-v1` suite. Use this in tests and as a software fallback when
+/// a hardware token is not plugged in. Real hardware-backed providers (PIV
+/// over PKCS#11) implement [`KeyProvider`] without holding the private scalar
+/// in process memory.
+pub struct SoftwareP256Identity {
+ secret: P256SecretKey,
+ public_sec1: [u8; P256_PUBLIC_KEY_LEN],
+}
+
+impl SoftwareP256Identity {
+ pub fn generate() -> Self {
+ let secret = P256SecretKey::random(&mut OsRng);
+ let public = secret.public_key();
+ let encoded = public.to_encoded_point(false);
+ let bytes = encoded.as_bytes();
+ debug_assert_eq!(bytes.len(), P256_PUBLIC_KEY_LEN);
+ let mut public_sec1 = [0u8; P256_PUBLIC_KEY_LEN];
+ public_sec1.copy_from_slice(bytes);
+ Self { secret, public_sec1 }
+ }
+
+ pub fn public_key_sec1(&self) -> &[u8; P256_PUBLIC_KEY_LEN] {
+ &self.public_sec1
+ }
+}
+
+/// Wrapped DEK for the `OSGT-HW-P256-v1` suite. Differs from [`WrappedDek`]
+/// only in the size and encoding of the ephemeral public key (SEC1
+/// uncompressed, 65 bytes).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WrappedDekP256 {
+ pub ephemeral_pub: [u8; P256_PUBLIC_KEY_LEN],
+ pub nonce: [u8; XCHACHA_NONCE_LEN],
+ pub wrapped_dek: Vec<u8>,
+}
+
+impl WrappedDekP256 {
+ pub fn to_json_hex(&self) -> serde_json::Value {
+ serde_json::json!({
+ "suite": SUITE_HW_P256_V1,
+ "ephemeral_pub": hex::encode(self.ephemeral_pub),
+ "nonce": hex::encode(self.nonce),
+ "wrapped_dek": hex::encode(&self.wrapped_dek),
+ })
+ }
+
+ pub fn from_json_hex(v: &serde_json::Value) -> Result<Self, CryptoError> {
+ fn field(v: &serde_json::Value, name: &'static str) -> Result<String, CryptoError> {
+ v.get(name)
+ .and_then(|x| x.as_str())
+ .map(str::to_string)
+ .ok_or(CryptoError::MissingField(name))
+ }
+ let eph_bytes = hex::decode(field(v, "ephemeral_pub")?)?;
+ let nonce_bytes = hex::decode(field(v, "nonce")?)?;
+ let wrapped = hex::decode(field(v, "wrapped_dek")?)?;
+ if eph_bytes.len() != P256_PUBLIC_KEY_LEN {
+ return Err(CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: eph_bytes.len(),
+ });
+ }
+ if nonce_bytes.len() != XCHACHA_NONCE_LEN {
+ return Err(CryptoError::InvalidKeyLength {
+ expected: XCHACHA_NONCE_LEN,
+ got: nonce_bytes.len(),
+ });
+ }
+ let mut eph = [0u8; P256_PUBLIC_KEY_LEN];
+ eph.copy_from_slice(&eph_bytes);
+ let mut nonce = [0u8; XCHACHA_NONCE_LEN];
+ nonce.copy_from_slice(&nonce_bytes);
+ Ok(WrappedDekP256 { ephemeral_pub: eph, nonce, wrapped_dek: wrapped })
+ }
+}
+
+/// Wrap a DEK for a P-256 recipient. The sender holds no hardware key; the
+/// ephemeral keypair is generated locally in software and the recipient's
+/// public key is consumed in SEC1 form.
+pub fn wrap_dek_for_recipient_p256(
+ dek: &[u8],
+ recipient_p256_pub_sec1: &[u8],
+) -> Result<WrappedDekP256, CryptoError> {
+ if recipient_p256_pub_sec1.len() != P256_PUBLIC_KEY_LEN {
+ return Err(CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: recipient_p256_pub_sec1.len(),
+ });
+ }
+ let recipient_pub = P256PublicKey::from_sec1_bytes(recipient_p256_pub_sec1)
+ .map_err(|_| CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: recipient_p256_pub_sec1.len(),
+ })?;
+
+ let eph_secret = P256SecretKey::random(&mut OsRng);
+ let eph_pub = eph_secret.public_key();
+ let eph_pub_encoded = eph_pub.to_encoded_point(false);
+ let eph_pub_bytes = eph_pub_encoded.as_bytes();
+ if eph_pub_bytes.len() != P256_PUBLIC_KEY_LEN {
+ return Err(CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: eph_pub_bytes.len(),
+ });
+ }
+ let mut eph_pub_arr = [0u8; P256_PUBLIC_KEY_LEN];
+ eph_pub_arr.copy_from_slice(eph_pub_bytes);
+
+ let shared = p256_diffie_hellman(eph_secret.to_nonzero_scalar(), recipient_pub.as_affine());
+ let shared_bytes = shared.raw_secret_bytes();
+
+ let hk = Hkdf::<Sha256>::new(None, shared_bytes.as_ref());
+ let mut kek = Zeroizing::new([0u8; 32]);
+ hk.expand(b"oversight-hw-p256-v1-dek-wrap", kek.as_mut())
+ .map_err(|_| CryptoError::Hkdf)?;
+
+ let (nonce, wrapped) = aead_encrypt(kek.as_ref(), dek, b"oversight-hw-p256-dek")?;
+ Ok(WrappedDekP256 { ephemeral_pub: eph_pub_arr, nonce, wrapped_dek: wrapped })
+}
+
+/// Software-backed P-256 [`KeyProvider`]. Useful for tests and as a fallback
+/// when the user does not have a hardware token available. A future
+/// `PivKeyProvider` implements the same trait against PKCS#11.
+pub struct SoftwareP256KeyProvider {
+ inner: SoftwareP256Identity,
+ label: Option<String>,
+}
+
+impl SoftwareP256KeyProvider {
+ pub fn new(identity: SoftwareP256Identity) -> Self {
+ Self { inner: identity, label: None }
+ }
+
+ pub fn with_label(identity: SoftwareP256Identity, label: impl Into<String>) -> Self {
+ Self { inner: identity, label: Some(label.into()) }
+ }
+
+ pub fn identity(&self) -> &SoftwareP256Identity {
+ &self.inner
+ }
+}
+
+impl KeyProvider for SoftwareP256KeyProvider {
+ fn algorithm(&self) -> KeyAlgorithm {
+ KeyAlgorithm::P256
+ }
+
+ fn public_key(&self) -> &[u8] {
+ &self.inner.public_sec1
+ }
+
+ fn ecdh(&self, peer_pub: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
+ if peer_pub.len() != P256_PUBLIC_KEY_LEN {
+ return Err(CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: peer_pub.len(),
+ });
+ }
+ let peer = P256PublicKey::from_sec1_bytes(peer_pub).map_err(|_| {
+ CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: peer_pub.len(),
+ }
+ })?;
+ let shared = p256_diffie_hellman(self.inner.secret.to_nonzero_scalar(), peer.as_affine());
+ Ok(Zeroizing::new(shared.raw_secret_bytes().to_vec()))
+ }
+
+ fn label(&self) -> Option<&str> {
+ self.label.as_deref()
+ }
+}
+
+/// Recipient-side DEK unwrap for the `OSGT-HW-P256-v1` suite. Mirrors
+/// [`unwrap_dek_with_provider`] but for P-256 envelopes.
+pub fn unwrap_dek_with_provider_p256(
+ wrapped: &WrappedDekP256,
+ provider: &dyn KeyProvider,
+) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
+ if provider.algorithm() != KeyAlgorithm::P256 {
+ return Err(CryptoError::InvalidKeyLength {
+ expected: P256_PUBLIC_KEY_LEN,
+ got: provider.public_key().len(),
+ });
+ }
+ let shared = provider.ecdh(&wrapped.ephemeral_pub)?;
+
+ let hk = Hkdf::<Sha256>::new(None, shared.as_ref());
+ let mut kek = Zeroizing::new([0u8; 32]);
+ hk.expand(b"oversight-hw-p256-v1-dek-wrap", kek.as_mut())
+ .map_err(|_| CryptoError::Hkdf)?;
+
+ let plaintext = aead_decrypt(
+ kek.as_ref(),
+ &wrapped.nonce,
+ &wrapped.wrapped_dek,
+ b"oversight-hw-p256-dek",
+ )?;
+ Ok(Zeroizing::new(plaintext))
+}
+
// -------------------------- Signatures --------------------------
pub fn sign_message(msg: &[u8], ed25519_priv: &[u8]) -> Result<[u8; ED25519_SIG_LEN], CryptoError> {
@@ -630,4 +845,104 @@ mod tests {
let res = unwrap_dek_with_provider(&wrapped, &provider_bob);
assert!(res.is_err(), "Bob's provider must not unwrap a DEK addressed to Alice");
}
+
+ // ------- P-256 (OSGT-HW-P256-v1) ----------------------------------------
+
+ #[test]
+ fn p256_identity_public_key_starts_with_sec1_uncompressed_tag() {
+ // SEC1 uncompressed encoding always starts with 0x04.
+ let id = SoftwareP256Identity::generate();
+ assert_eq!(id.public_key_sec1()[0], 0x04);
+ assert_eq!(id.public_key_sec1().len(), P256_PUBLIC_KEY_LEN);
+ }
+
+ #[test]
+ fn p256_provider_advertises_p256() {
+ let id = SoftwareP256Identity::generate();
+ let pub_copy = *id.public_key_sec1();
+ let provider = SoftwareP256KeyProvider::new(id);
+ assert_eq!(provider.algorithm(), KeyAlgorithm::P256);
+ assert_eq!(provider.public_key(), &pub_copy[..]);
+ }
+
+ #[test]
+ fn p256_wrap_unwrap_round_trip() {
+ let alice = SoftwareP256Identity::generate();
+ let alice_pub = *alice.public_key_sec1();
+ let provider = SoftwareP256KeyProvider::new(alice);
+
+ let dek = random_dek();
+ let wrapped = wrap_dek_for_recipient_p256(dek.as_ref(), &alice_pub).unwrap();
+ let recovered = unwrap_dek_with_provider_p256(&wrapped, &provider).unwrap();
+ assert_eq!(&recovered[..], dek.as_ref());
+ }
+
+ #[test]
+ fn p256_wrong_recipient_rejected() {
+ let alice = SoftwareP256Identity::generate();
+ let alice_pub = *alice.public_key_sec1();
+ let bob = SoftwareP256Identity::generate();
+ let provider_bob = SoftwareP256KeyProvider::new(bob);
+
+ let dek = random_dek();
+ let wrapped = wrap_dek_for_recipient_p256(dek.as_ref(), &alice_pub).unwrap();
+
+ let res = unwrap_dek_with_provider_p256(&wrapped, &provider_bob);
+ assert!(res.is_err(), "Bob's P-256 provider must not unwrap a DEK addressed to Alice");
+ }
+
+ #[test]
+ fn p256_unwrap_rejects_x25519_provider() {
+ // Cross-suite mismatch: an X25519 file provider must not be accepted
+ // for a P-256 envelope (silently producing garbage would be worse
+ // than refusing).
+ let alice_p256 = SoftwareP256Identity::generate();
+ let alice_pub = *alice_p256.public_key_sec1();
+ let dek = random_dek();
+ let wrapped = wrap_dek_for_recipient_p256(dek.as_ref(), &alice_pub).unwrap();
+
+ let bob_x25519 = FileKeyProvider::new(ClassicIdentity::generate());
+ let res = unwrap_dek_with_provider_p256(&wrapped, &bob_x25519);
+ assert!(res.is_err(), "X25519 provider must not be accepted for a P-256 envelope");
+ }
+
+ #[test]
+ fn p256_unwrap_rejects_wrong_ephemeral_length() {
+ let id = SoftwareP256Identity::generate();
+ let provider = SoftwareP256KeyProvider::new(id);
+ let err = provider.ecdh(&[0u8; 32]).unwrap_err();
+ match err {
+ CryptoError::InvalidKeyLength { expected, got } => {
+ assert_eq!(expected, P256_PUBLIC_KEY_LEN);
+ assert_eq!(got, 32);
+ }
+ other => panic!("expected InvalidKeyLength, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn p256_wrapped_dek_json_round_trip() {
+ let alice = SoftwareP256Identity::generate();
+ let alice_pub = *alice.public_key_sec1();
+ let dek = random_dek();
+ let wrapped = wrap_dek_for_recipient_p256(dek.as_ref(), &alice_pub).unwrap();
+ let json = wrapped.to_json_hex();
+ // Suite is recorded explicitly so a polymorphic envelope reader can
+ // dispatch without inspecting the ephemeral key length.
+ assert_eq!(json["suite"].as_str(), Some(SUITE_HW_P256_V1));
+ let parsed = WrappedDekP256::from_json_hex(&json).unwrap();
+ assert_eq!(wrapped, parsed);
+ }
+
+ #[test]
+ fn p256_unwrap_x25519_provider_classic_envelope_still_works() {
+ // Sanity: adding the P-256 suite must not regress the classic path.
+ let alice = ClassicIdentity::generate();
+ let alice_pub = alice.x25519_pub;
+ let provider = FileKeyProvider::new(alice);
+ let dek = random_dek();
+ let wrapped = wrap_dek_for_recipient(dek.as_ref(), &alice_pub).unwrap();
+ let recovered = unwrap_dek_with_provider(&wrapped, &provider).unwrap();
+ assert_eq!(&recovered[..], dek.as_ref());
+ }
}