diff --git a/lib/ocrypto/BENCHMARK_REPORT.md b/lib/ocrypto/BENCHMARK_REPORT.md index 8c01339a51..afc279b460 100644 --- a/lib/ocrypto/BENCHMARK_REPORT.md +++ b/lib/ocrypto/BENCHMARK_REPORT.md @@ -8,7 +8,7 @@ > - **Wrap** follows `sdk/tdf.go` (`generateWrapKeyWithRSA`, `generateWrapKeyWithEC`, `generateWrapKeyWithHybrid`) > - **Unwrap** follows `service/internal/security/standard_crypto.go:Decrypt()` > -> This includes PEM parsing, ephemeral keygen, ECDH, HKDF, AES-GCM, and ASN.1 marshaling — not simplified library-level `WrapDEK()` / `UnwrapDEK()` calls. +> This includes PEM parsing, ephemeral keygen, ECDH, scheme-specific secret combining, AES-GCM, and ASN.1 marshaling — not simplified library-level `WrapDEK()` / `UnwrapDEK()` calls. ## How to Run @@ -23,7 +23,6 @@ cd lib/ocrypto && go test -bench=. -benchmem -count=1 -timeout=5m cd lib/ocrypto && go test -bench=BenchmarkKeyGeneration -benchmem cd lib/ocrypto && go test -bench=BenchmarkWrapDEK -benchmem cd lib/ocrypto && go test -bench=BenchmarkUnwrapDEK -benchmem -cd lib/ocrypto && go test -bench=BenchmarkHybridSubOps -benchmem # Wrapped key size comparison table cd lib/ocrypto && go test -v -run TestWrappedKeySizeComparison @@ -35,12 +34,12 @@ cd lib/ocrypto && go test -v -run TestWrappedKeySizeComparison | Scheme | Time | B/op | allocs/op | vs EC P-256 | |--------|-----:|-----:|----------:|-------------| -| RSA-2048 | 47.7 ms | 652 KB | 5,929 | ~6,400x slower | -| EC P-256 | 7.4 us | 984 B | 16 | baseline | -| EC P-384 | 71.3 us | 1.2 KB | 19 | ~9.6x slower | -| X-Wing | 43.8 us | 9.8 KB | 9 | ~5.9x slower | -| P256+ML-KEM-768 | 34.8 us | 11.4 KB | 13 | ~4.7x slower | -| P384+ML-KEM-1024 | 113.8 us | 17.9 KB | 16 | ~15x slower | +| RSA-2048 | 31.3 ms | 652 KB | 4,399 | 4,770x slower | +| EC P-256 | 6.6 µs | 984 B | 16 | baseline | +| EC P-384 | 61.2 µs | 1.2 KB | 19 | 9.5x slower | +| X-Wing | 39.1 µs | 9.8 KB | 9 | 6.0x slower | +| P256+ML-KEM-768 | 40.1 µs | 11.4 KB | 68 | 6.1x slower | +| P384+ML-KEM-1024 | 173.8 µs | 17.9 KB | 80 | 26.5x slower | **Takeaway:** RSA-2048 key generation is orders of magnitude slower than everything else (~48ms). All hybrid schemes generate keys in under 115us. EC P-256 is fastest at ~7us; EC P-384 keygen is ~10x slower than P-256 due to the larger field size. @@ -49,18 +48,16 @@ cd lib/ocrypto && go test -v -run TestWrappedKeySizeComparison These benchmarks follow the exact TDF wrapping paths: - **RSA:** `FromPublicPEM` -> `Encrypt` (OAEP) - **EC:** `NewECKeyPair` -> `ComputeECDHKey` -> `CalculateHKDF` -> `AES-GCM Encrypt` -- **Hybrid:** `PubKeyFromPem` -> `Encapsulate` -> `CalculateHKDF` -> `AES-GCM Encrypt` -> `ASN.1 Marshal` +- **Hybrid:** `PubKeyFromPem` -> `Encapsulate` -> scheme-specific combiner/KDF -> `AES-GCM Encrypt` -> `ASN.1 Marshal` | Scheme | Time | Wrapped Size | B/op | allocs/op | vs EC P-256 | |--------|-----:|-------------:|-----:|----------:|-------------| -| RSA-2048 | 25.5 us | 256 B | 4.1 KB | 33 | 0.5x (faster) | -| EC P-256 | 54.5 us | 60 B | 12.0 KB | 158 | baseline | -| EC P-384 | 449.3 us | 60 B | 14.3 KB | 189 | ~8.2x slower | -| X-Wing | 77.4 us | 1,190 B | 16.4 KB | 42 | ~1.4x slower | -| P256+ML-KEM-768 | 75.2 us | 1,223 B | 18.7 KB | 59 | ~1.4x slower | -| P384+ML-KEM-1024 | 369.9 us | 1,735 B | 27.0 KB | 68 | ~6.8x slower | - -**Takeaway:** P256+ML-KEM-768 wrapping (~75us) is only ~1.4x slower than EC P-256 (~55us) — the ephemeral EC keygen + ECDH in the EC path narrows the gap significantly. RSA wrap is fastest since it's just OAEP padding. The two P-384-based schemes are the slowest (EC P-384 ~449us, P384+ML-KEM-1024 ~370us) — the P-384 ECDH operation alone dominates EC P-384's wrap cost since each call re-generates an ephemeral key. +| RSA-2048 | 22.2 µs | 4.3 KB | 36 | 0.5x faster | +| EC P-256 | 49.1 µs | 12.8 KB | 177 | baseline | +| EC P-384 | 396.4 µs | 15.1 KB | 211 | 8.1x slower | +| X-Wing | 69.2 µs | 16.7 KB | 46 | 1.4x slower | +| P256+ML-KEM-768 | 59.9 µs | 17.9 KB | 47 | 1.2x slower | +| P384+ML-KEM-1024 | 308.5 µs | 26.0 KB | 56 | 6.3x slower | ### Unwrap DEK @@ -71,61 +68,25 @@ These benchmarks follow the KAS unwrap paths: | Scheme | Time | B/op | allocs/op | vs EC P-256 | |--------|-----:|-----:|----------:|-------------| -| RSA-2048 | 737.3 us | 560 B | 8 | ~26x slower | -| EC P-256 | 28.4 us | 4.1 KB | 40 | baseline | -| EC P-384 | 230.5 us | 4.6 KB | 55 | ~8.1x slower | -| X-Wing | 90.4 us | 12.4 KB | 37 | ~3.2x slower | -| P256+ML-KEM-768 | 96.3 us | 13.8 KB | 51 | ~3.4x slower | -| P384+ML-KEM-1024 | 400.2 us | 20.1 KB | 60 | ~14x slower | - -**Takeaway:** RSA unwrap is the slowest operation in the entire suite (~737us) due to private key exponentiation. P256+ML-KEM-768 unwraps in ~96us — fast enough for real-time use. EC P-384 unwrap (~231us) is ~8x slower than P-256 because of the more expensive curve operations. Hybrid unwraps include PEM parsing overhead that could be optimized by caching parsed keys (as EC already does). - -### Wrap + Unwrap Round-Trip Summary - -| Scheme | Wrap + Unwrap | Quantum Safe? | -|--------|-------------:|:-------------:| -| RSA-2048 | 763 us | No | -| EC P-256 | 83 us | No | -| EC P-384 | 680 us | No | -| X-Wing | 168 us | Yes | -| P256+ML-KEM-768 | 172 us | Yes | -| P384+ML-KEM-1024 | 770 us | Yes | - -## Analysis: Where Time Is Spent - -The `BenchmarkHybridSubOps` benchmarks break down hybrid wrap operations into their constituent parts: - -### X-Wing Sub-Operations +| RSA-2048 | 606.0 µs | 0.6 KB | 8 | 24.6x slower | +| EC P-256 | 24.6 µs | 3.9 KB | 39 | baseline | +| EC P-384 | 202.0 µs | 4.3 KB | 51 | 8.2x slower | +| X-Wing | 79.9 µs | 12.8 KB | 43 | 3.2x slower | +| P256+ML-KEM-768 | 91.4 µs | 16.0 KB | 97 | 3.7x slower | +| P384+ML-KEM-1024 | 463.0 µs | 23.0 KB | 121 | 18.8x slower | -| Operation | Time | % of Wrap | -|-----------|-----:|----------:| -| Encapsulate (X25519 + ML-KEM-768) | 71.6 us | 92.5% | -| HKDF key derivation | 0.49 us | 0.6% | -| AES-GCM encrypt (32B DEK) | 0.37 us | 0.5% | -| ASN.1 marshal | 0.52 us | 0.7% | -| PEM parsing + overhead | ~4.4 us | 5.7% | +**Takeaway:** RSA unwrap is the slowest operation in the entire suite due to private key exponentiation. P256+ML-KEM-768 unwraps are fast enough for real-time use. EC P-384 unwrap is much slower than P-256 because of the more expensive curve operations. Hybrid unwraps include PEM parsing overhead that could be optimized by caching parsed keys (as EC already does). -### P256+ML-KEM-768 Sub-Operations - -| Operation | Time | % of Wrap | -|-----------|-----:|----------:| -| Encapsulate (ECDH P-256 + ML-KEM-768) | 70.0 us | 93.1% | -| HKDF key derivation | 0.51 us | 0.7% | -| AES-GCM encrypt (32B DEK) | 0.37 us | 0.5% | -| ASN.1 marshal | 0.51 us | 0.7% | -| PEM parsing + overhead | ~3.8 us | 5.1% | - -### P384+ML-KEM-1024 Sub-Operations - -| Operation | Time | % of Wrap | -|-----------|-----:|----------:| -| Encapsulate (ECDH P-384 + ML-KEM-1024) | 359.9 us | 97.3% | -| HKDF key derivation | 0.51 us | 0.1% | -| AES-GCM encrypt (32B DEK) | 0.37 us | 0.1% | -| ASN.1 marshal | 0.54 us | 0.1% | -| PEM parsing + overhead | ~8.6 us | 2.3% | +## Analysis: Where Time Is Spent -**Conclusion:** KEM encapsulation dominates all hybrid schemes at 93-97% of total time. HKDF, AES-GCM, and ASN.1 marshaling are all sub-microsecond and negligible. The P-384 elliptic curve ECDH is ~5x slower than P-256, which is why P384+ML-KEM-1024 is significantly slower than P256+ML-KEM-768. +KEM encapsulation dominates all hybrid schemes (~93-97% of total wrap time); +post-encapsulation combining, AES-GCM, and ASN.1 marshaling are all +sub-microsecond. X-Wing still includes its HKDF-based TDF wrap path, while the +NIST composites use the draft-14 SHA3-256 combiner instead of HKDF. The P-384 +elliptic curve ECDH is ~5x slower than P-256, which is why P384+ML-KEM-1024 is +significantly slower than P256+ML-KEM-768. Per-sub-op figures were captured +under a one-off benchmark that has since been removed; re-introduce with +`pprof` if a more granular breakdown is needed. ## Manifest Size Impact diff --git a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md index e6cbf4fe74..8eb7c54951 100644 --- a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md +++ b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md @@ -4,70 +4,91 @@ This document describes the hybrid post-quantum key wrapping scheme used in TDF (Trusted Data Format) that combines classical elliptic curve cryptography (ECDH) with post-quantum lattice-based cryptography (ML-KEM) to protect data encryption keys (split keys). +The wire format and combiner conform to [draft-ietf-lamps-pq-composite-kem-14](https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-kem/14/). The composite KEM is wrapped in standard X.509 SubjectPublicKeyInfo (SPKI) for public keys and PKCS#8 (RFC 5958 OneAsymmetricKey) for private keys, with the AlgorithmIdentifier OID selecting the scheme. + Two variants are supported. Hybrid security is bounded by the stronger of the two underlying primitives against each adversary class, so the post-quantum strength is set by ML-KEM and the classical strength is set by ECDH. -| Variant | Classical (ECDH) | Post-quantum (ML-KEM) | -|---------|------------------|-----------------------| -| P-256 + ML-KEM-768 | NIST Category 1 | NIST Category 3 | -| P-384 + ML-KEM-1024 | NIST Category 3 | NIST Category 5 | +| Variant | Classical (ECDH) | Post-quantum (ML-KEM) | AlgorithmIdentifier OID | +|---------|------------------|-----------------------|-------------------------| +| P-256 + ML-KEM-768 | NIST Category 1 | NIST Category 3 | `1.3.6.1.5.5.7.6.59` (`id-MLKEM768-ECDH-P256`) | +| P-384 + ML-KEM-1024 | NIST Category 3 | NIST Category 5 | `1.3.6.1.5.5.7.6.63` (`id-MLKEM1024-ECDH-P384`) | References: +- draft-ietf-lamps-pq-composite-kem-14 (composite KEM construction, OIDs, combiner): https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-kem/14/ - NIST PQC Call for Proposals §4.A.5 (category definitions): https://csrc.nist.gov/csrc/media/projects/post-quantum-cryptography/documents/call-for-proposals-final-dec-2016.pdf - FIPS 203 (ML-KEM-768 = Cat 3, ML-KEM-1024 = Cat 5): https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.203.pdf - NIST SP 800-57 Part 1 Rev. 5 Table 2 (P-256 = 128-bit ≈ Cat 1, P-384 = 192-bit ≈ Cat 3): https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-57pt1r5.pdf +- RFC 5280 §4.1 (SubjectPublicKeyInfo): https://datatracker.ietf.org/doc/html/rfc5280 +- RFC 5958 (PKCS#8 / OneAsymmetricKey): https://datatracker.ietf.org/doc/html/rfc5958 +- RFC 5915 (ECPrivateKey DER): https://datatracker.ietf.org/doc/html/rfc5915 + +Core implementation: `lib/ocrypto/hybrid_nist.go`. Shared SPKI/PKCS#8 helpers and OID constants: `lib/ocrypto/pq_asn1.go`, `lib/ocrypto/pq_oids.go`. -Core implementation: `lib/ocrypto/hybrid_nist.go` +> All `§N.M` citations below refer to draft-ietf-lamps-pq-composite-kem-14 unless stated otherwise. If you bump to a later draft, re-verify each citation — section numbering has shifted between revisions. ## Key Format ### Combined Public Key -The KAS (Key Access Server) hosts a combined public key in PEM format. The raw bytes inside the PEM are a simple concatenation of the EC and ML-KEM public keys: +The KAS (Key Access Server) hosts a combined public key in PEM format. The PEM uses the standard `PUBLIC KEY` block type. Inside the SPKI envelope, the raw key bytes are a concatenation of the ML-KEM and EC public keys, in that order (draft-14 §4.1, `SerializePublicKey`): -``` -[ EC Public Key (uncompressed point) | ML-KEM Public Key ] +```asn1 +SubjectPublicKeyInfo { + AlgorithmIdentifier { oid = , parameters ABSENT }, + BIT STRING [ mlkemPublicKey || ecPublicKey (uncompressed SEC1 point) ] +} ``` -| Variant | EC Public Key Size | ML-KEM Public Key Size | Combined Size | PEM Block Type | -|---------|-------------------|----------------------|---------------|----------------| -| P-256 + ML-KEM-768 | 65 bytes | 1184 bytes | 1249 bytes | `SECP256R1 MLKEM768 PUBLIC KEY` | -| P-384 + ML-KEM-1024 | 97 bytes | 1568 bytes | 1665 bytes | `SECP384R1 MLKEM1024 PUBLIC KEY` | +| Variant | ML-KEM Public Key Size | EC Public Key Size | Raw Concat Size | +|---------|-----------------------|-------------------|-----------------| +| P-256 + ML-KEM-768 | 1184 bytes | 65 bytes | 1249 bytes | +| P-384 + ML-KEM-1024 | 1568 bytes | 97 bytes | 1665 bytes | + +The EC half is an uncompressed SEC1 point (leading `0x04` tag). The PEM block type is the standard `PUBLIC KEY` for both variants; routing is by the OID inside the AlgorithmIdentifier. ### Combined Private Key -The KAS holds a combined private key, also a concatenation: +The KAS holds a combined private key in PEM format using the standard `PRIVATE KEY` block type. Inside the PKCS#8 / OneAsymmetricKey envelope, the raw key bytes are a concatenation of the ML-KEM seed and the EC private key encoded as RFC 5915 `ECPrivateKey` DER, in that order (draft-14 §4.2, `SerializePrivateKey`): +```asn1 +OneAsymmetricKey { + version = v1, + AlgorithmIdentifier { oid = , parameters ABSENT }, + OCTET STRING [ mlkemSeed (64 bytes) || ECPrivateKey DER (RFC 5915) ] +} ``` -[ EC Private Key (raw scalar) | ML-KEM Private Key ] -``` -The ML-KEM portion is stored in the 64-byte seed form (`d || z`) defined by FIPS 203 §7.1, not the expanded ~2400/3168-byte decapsulation key. The seed is what `crypto/mlkem` (Go 1.25) emits via `Bytes()` and consumes via `NewDecapsulationKey768` / `NewDecapsulationKey1024`. The constant `mlkemSeedSize = 64` in `hybrid_nist.go` and the size checks in `decodeSizedPEMBlock` enforce this layout. +The ML-KEM portion is stored in the 64-byte seed form (`d || z`) defined by FIPS 203 §7.1, not the expanded ~2400/3168-byte decapsulation key. The seed is what `crypto/mlkem` (Go 1.25) emits via `Bytes()` and consumes via `NewDecapsulationKey768` / `NewDecapsulationKey1024`. The constant `mlkemSeedSize = 64` in `hybrid_nist.go` enforces this layout. + +The EC half is a full RFC 5915 `ECPrivateKey` DER blob (not the bare scalar), produced by `x509.MarshalECPrivateKey`. Its length varies slightly with the curve and ASN.1 lengths but is bounded; size validation lives in the parser. -| Variant | EC Private Key Size | ML-KEM Seed Size | Combined Size | PEM Block Type | -|---------|--------------------|------------------|---------------|----------------| -| P-256 + ML-KEM-768 | 32 bytes | 64 bytes | 96 bytes | `SECP256R1 MLKEM768 PRIVATE KEY` | -| P-384 + ML-KEM-1024 | 48 bytes | 64 bytes | 112 bytes | `SECP384R1 MLKEM1024 PRIVATE KEY` | +| Variant | ML-KEM Seed | EC Private Key (RFC 5915 DER, approx.) | +|---------|-------------|----------------------------------------| +| P-256 + ML-KEM-768 | 64 bytes | ~121 bytes | +| P-384 + ML-KEM-1024 | 64 bytes | ~167 bytes | ### How the Client Obtains the Public Key The client obtains the combined public key from KAS in one of two ways: -1. **Fetched at runtime (autoconfigure)**: The SDK calls the KAS `PublicKey` gRPC endpoint, specifying the hybrid algorithm. KAS returns the combined PEM. The response is cached for 5 minutes. +1. **Fetched at runtime (autoconfigure)**: The SDK calls the KAS `PublicKey` gRPC endpoint, specifying the hybrid algorithm. KAS returns the SPKI PEM. The response is cached for 5 minutes. 2. **Provided manually**: The caller supplies the public key PEM via `WithKasInformation(...)` when configuring the SDK. +In both cases, dispatch happens by parsing the SPKI envelope and matching the AlgorithmIdentifier OID against the known hybrid OIDs (`asym_encryption.go`). The PEM block type alone is not authoritative. + ## Wrap (Encrypt the Split Key) -Function: `hybridNISTWrapDEK` (`hybrid_nist.go`, line 339) +Function: `hybridNISTWrapDEK` (`hybrid_nist.go`). This is performed on the **client side** during TDF encryption. -### Step 1 - Split the Public Key +### Step 1 - Parse the Public Key -The combined public key bytes are split at the known EC public key size boundary: +The SPKI PEM is decoded via `parseHybridSPKI`. The returned OID selects the variant; the raw bytes are split at the ML-KEM public key size boundary: -``` -ecPubBytes = publicKeyRaw[:ecPubSize] // 65 bytes for P-256, 97 bytes for P-384 -mlkemPubBytes = publicKeyRaw[ecPubSize:] // 1184 bytes for ML-KEM-768, 1568 bytes for ML-KEM-1024 +```go +mlkemPubBytes = publicKeyRaw[:mlkemPubSize] // 1184 bytes for ML-KEM-768, 1568 bytes for ML-KEM-1024 +ecPubBytes = publicKeyRaw[mlkemPubSize:] // 65 bytes for P-256, 97 bytes for P-384 ``` ### Step 2 - ECDH (Classical Key Agreement) @@ -75,98 +96,89 @@ mlkemPubBytes = publicKeyRaw[ecPubSize:] // 1184 bytes for ML-KEM-768, 1568 An ephemeral EC key pair is generated on the same curve as the KAS static key: 1. Generate ephemeral EC key pair: `ephemeral_private, ephemeral_public` -2. Compute ECDH shared secret: `ecdhSecret = ECDH(ephemeral_private, KAS_ec_public)` -3. Retain `ephemeral_public` bytes for inclusion in the output +2. Compute ECDH shared secret: `tradSS = ECDH(ephemeral_private, KAS_ec_public)` +3. Retain `tradCT = ephemeral_public` (uncompressed SEC1 point) for inclusion in the output and the combiner -This is a standard elliptic curve Diffie-Hellman operation. The ephemeral key provides forward secrecy - even if the KAS static key is later compromised, past wrapped keys remain protected. +The ephemeral key provides forward secrecy — even if the KAS static key is later compromised, past wrapped keys remain protected. ### Step 3 - ML-KEM Encapsulate (Post-Quantum KEM) -ML-KEM (Module Lattice-based Key Encapsulation Mechanism, formerly known as Kyber) is a KEM, not a key exchange. The encapsulation operation takes only the public key and produces two outputs: +ML-KEM is a KEM, not a key exchange. Encapsulation takes only the public key and produces two outputs: -``` -(mlkemSecret, mlkemCiphertext) = ML-KEM.Encapsulate(KAS_mlkem_public) +```text +(mlkemSS, mlkemCT) = ML-KEM.Encapsulate(KAS_mlkem_public) ``` -- `mlkemSecret` (32 bytes): A shared secret known to the encapsulator -- `mlkemCiphertext` (1088 bytes for ML-KEM-768, 1568 bytes for ML-KEM-1024): An opaque ciphertext that only the ML-KEM private key holder can decapsulate to recover the same shared secret - -Internally, `EncapsulateTo`: -1. Generates random coins (entropy) -2. Uses the ML-KEM public key (a matrix over a polynomial ring) to encrypt those random coins into the ciphertext -3. Derives the shared secret from both the random coins and the ciphertext +- `mlkemSS` (32 bytes): shared secret known to the encapsulator +- `mlkemCT` (1088 bytes for ML-KEM-768, 1568 bytes for ML-KEM-1024): opaque ciphertext that only the ML-KEM private key holder can decapsulate to recover the same shared secret No ephemeral ML-KEM key pair is generated by the client. The ciphertext itself serves as the "ephemeral" artifact sent to KAS. -### Step 4 - Combine Secrets +### Step 4 - Combine Secrets (draft-14 §3.4) -The two shared secrets from the classical and post-quantum operations are concatenated: +Per draft-14, the wrapping key is derived directly from a SHA3-256 hash that binds both shared secrets together with the traditional ciphertext, the traditional static public key, and a domain-separation Label: -``` -combinedSecret = ecdhSecret || mlkemSecret +```text +wrapKey = SHA3-256( mlkemSS || tradSS || tradCT || tradPK || Label ) ``` -This is a simple byte concatenation. The security property is that an attacker must break **both** ECDH and ML-KEM to recover the combined secret. If quantum computers break ECDH but ML-KEM remains secure, the combined secret is still protected (and vice versa). +Where: -### Step 5 - Key Derivation (HKDF) - -A 32-byte AES-256 wrapping key is derived from the combined secret using HKDF-SHA256: - -``` -wrapKey = HKDF-SHA256( - IKM: combinedSecret, // ecdhSecret || mlkemSecret - salt: SHA256("TDF"), // 32-byte fixed salt - info: // empty by default -) -> 32 bytes -``` +- `mlkemSS` is the ML-KEM-768/1024 shared secret (32 bytes) +- `tradSS` is the ECDH shared secret (32 bytes for P-256, 48 bytes for P-384) +- `tradCT` is the ephemeral EC public key sent on the wire (uncompressed SEC1 point) +- `tradPK` is the KAS static EC public key (uncompressed SEC1 point) +- `Label` is the ASCII string `"MLKEM768-P256"` or `"MLKEM1024-P384"` (per draft-14 §6) -Function: `deriveHybridNISTWrapKey` (`hybrid_nist.go`, line 479) +The 32-byte SHA3-256 digest is used directly as the AES-256 wrapping key. **No HKDF, no salt, no `info` parameter.** The Label is the only domain separator; including `tradCT` and `tradPK` gives the combiner identity binding (an attacker cannot substitute either without changing the wrap key). -The salt is a hardcoded SHA-256 digest of the ASCII string `"TDF"`, shared across all TDF key wrapping schemes. +The Label constants live in `lib/ocrypto/pq_oids.go` (`labelMLKEM768P256`, `labelMLKEM1024P384`). The combiner is exercised by the conformance tests in `hybrid_conformance_test.go`. -### Step 6 - AES-GCM Encrypt the Split Key +### Step 5 - AES-GCM Encrypt the Split Key -The split key (the actual data encryption key shard) is encrypted using AES-256-GCM: +The split key (the actual data encryption key shard) is encrypted using AES-256-GCM with the SHA3-256-derived `wrapKey`: -``` +```text encryptedDEK = AES-256-GCM.Encrypt(key=wrapKey, plaintext=splitKey) ``` The output format is: -``` +```text [ nonce (12 bytes) | ciphertext | authentication tag (16 bytes) ] ``` The nonce is randomly generated. The authentication tag provides integrity verification. -### Step 7 - Package as ASN.1 DER +### Step 6 - Package as ASN.1 DER -The ephemeral EC public key, ML-KEM ciphertext, and encrypted DEK are packaged into an ASN.1 DER structure: +The ML-KEM ciphertext, ephemeral EC public key, and encrypted DEK are packaged into an ASN.1 DER structure: ```asn1 HybridNISTWrappedKey ::= SEQUENCE { - hybridCiphertext [0] OCTET STRING, -- ephemeralECPub || mlkemCiphertext + hybridCiphertext [0] OCTET STRING, -- mlkemCT || ephemeralECPub encryptedDEK [1] OCTET STRING -- AES-GCM nonce + ciphertext + tag } ``` -Where `hybridCiphertext` is: +Where `hybridCiphertext` is laid out per draft-14 §4.3 (`SerializeCiphertext`): -``` -[ ephemeral EC public key (65 or 97 bytes) | ML-KEM ciphertext (1088 or 1568 bytes) ] +```text +[ ML-KEM ciphertext (1088 or 1568 bytes) | ephemeral EC public key (65 or 97 bytes) ] ``` -| Variant | Ephemeral EC Pub | ML-KEM Ciphertext | hybridCiphertext Size | -|---------|-----------------|-------------------|----------------------| -| P-256 + ML-KEM-768 | 65 bytes | 1088 bytes | 1153 bytes | -| P-384 + ML-KEM-1024 | 97 bytes | 1568 bytes | 1665 bytes | +| Variant | ML-KEM Ciphertext | Ephemeral EC Pub | hybridCiphertext Size | +|---------|-------------------|------------------|----------------------| +| P-256 + ML-KEM-768 | 1088 bytes | 65 bytes | 1153 bytes | +| P-384 + ML-KEM-1024 | 1568 bytes | 97 bytes | 1665 bytes | This DER blob is then base64-encoded and stored as the `wrappedKey` field in the TDF manifest's Key Access Object, with `keyType` set to `"hybrid-wrapped"`. +The `HybridNISTWrappedKey` envelope is a TDF-level container for the DEK wrap and is **not** specified by the IETF draft; the draft covers only the KEM (combined public key, combined private key, hybrid ciphertext, combined shared secret). + ## Unwrap (Decrypt the Split Key) -Function: `hybridNISTUnwrapDEK` (`hybrid_nist.go`, line 406) +Function: `hybridNISTUnwrapDEK` (`hybrid_nist.go`). This is performed on the **KAS server side** when a client sends a rewrap request. KAS holds the combined private key on disk, loaded at startup. @@ -174,76 +186,66 @@ This is performed on the **KAS server side** when a client sends a rewrap reques The base64-decoded DER blob is unmarshalled: -``` +```text ASN.1 Unmarshal -> HybridNISTWrappedKey { - hybridCiphertext: [ephemeralECPub | mlkemCiphertext] - encryptedDEK: [nonce | ciphertext | tag] + hybridCiphertext: [ mlkemCT | ephemeralECPub ] + encryptedDEK: [ nonce | ciphertext | tag ] } ``` ### Step 2 - Split the Hybrid Ciphertext -The `hybridCiphertext` is split at the known EC public key size boundary: +The `hybridCiphertext` is split at the known ML-KEM ciphertext size boundary: -``` -ephemeralECPub = hybridCiphertext[:ecPubSize] // 65 or 97 bytes -mlkemCiphertext = hybridCiphertext[ecPubSize:] // 1088 or 1568 bytes +```go +mlkemCT = hybridCiphertext[:mlkemCtSize] // 1088 or 1568 bytes +ephemeralECPub = hybridCiphertext[mlkemCtSize:] // 65 or 97 bytes ``` -### Step 3 - Split the Private Key +### Step 3 - Parse the Private Key -The combined private key is split at the known EC private key size boundary: +The combined private key is decoded via `parseHybridPKCS8`. The returned OID is checked against the dispatcher's expectation; the raw bytes are split at the ML-KEM seed size boundary: +```go +mlkemSeed = privateKeyRaw[:mlkemSeedSize] // 64 bytes +ecPrivDER = privateKeyRaw[mlkemSeedSize:] // RFC 5915 ECPrivateKey DER ``` -ecPrivBytes = privateKeyRaw[:ecPrivSize] // 32 or 48 bytes -mlkemPrivBytes = privateKeyRaw[ecPrivSize:] // 2400 or 3168 bytes -``` + +The ML-KEM decapsulation key is reconstructed from the seed via `mlkem.NewDecapsulationKey768` / `NewDecapsulationKey1024`. The EC private key is recovered via `x509.ParseECPrivateKey(ecPrivDER)`. ### Step 4 - ECDH (Reconstruct Classical Shared Secret) KAS uses its static EC private key with the client's ephemeral EC public key: -``` -ecdhSecret = ECDH(KAS_ec_private, ephemeral_ec_public) +```text +tradSS = ECDH(KAS_ec_private, ephemeralECPub) ``` -This produces the same `ecdhSecret` that the client computed in Wrap Step 2, because `ECDH(a, g^b) == ECDH(b, g^a)`. +This produces the same `tradSS` that the client computed in Wrap Step 2, because `ECDH(a, g^b) == ECDH(b, g^a)`. ### Step 5 - ML-KEM Decapsulate (Reconstruct Post-Quantum Shared Secret) KAS uses its ML-KEM private key to decapsulate the ciphertext: -``` -mlkemSecret = ML-KEM.Decapsulate(KAS_mlkem_private, mlkemCiphertext) +```text +mlkemSS = ML-KEM.Decapsulate(KAS_mlkem_private, mlkemCT) ``` -The ML-KEM private key contains the secret trapdoor in the lattice structure. Decapsulation recovers the random coins from the ciphertext and derives the same 32-byte shared secret that the client obtained during encapsulation. +Decapsulation recovers the same 32-byte shared secret the client obtained during encapsulation. ### Step 6 - Combine Secrets Identical to Wrap Step 4: +```text +wrapKey = SHA3-256( mlkemSS || tradSS || tradCT || tradPK || Label ) ``` -combinedSecret = ecdhSecret || mlkemSecret -``` - -### Step 7 - Key Derivation (HKDF) - -Identical to Wrap Step 5: -``` -wrapKey = HKDF-SHA256( - IKM: combinedSecret, - salt: SHA256("TDF"), - info: -) -> 32 bytes -``` +Both sides derive the same `wrapKey` because both sides have the same `mlkemSS`, `tradSS`, `tradCT` (sent on the wire), `tradPK` (KAS static public key, derived from the private key), and `Label` (constant per variant). -Both sides derive the same `wrapKey` because both sides have the same `ecdhSecret` and `mlkemSecret`. +### Step 7 - AES-GCM Decrypt the Split Key -### Step 8 - AES-GCM Decrypt the Split Key - -``` +```text splitKey = AES-256-GCM.Decrypt(key=wrapKey, ciphertext=encryptedDEK) ``` @@ -255,26 +257,32 @@ KAS now has the original split key. It enforces policy checks, and if the reques ### Hybrid Security Guarantee -The combined secret is derived from both ECDH and ML-KEM. An attacker must break **both** to recover the wrap key: +The `wrapKey` is derived from both ECDH and ML-KEM shared secrets. An attacker must break **both** to recover the wrap key: -- If a quantum computer breaks ECDH (recovers `ecdhSecret` from the ephemeral public key), ML-KEM still protects the combined secret -- If a classical vulnerability is found in ML-KEM (recovers `mlkemSecret` from the ciphertext), ECDH still protects the combined secret +- If a quantum computer breaks ECDH (recovers `tradSS` from the ephemeral public key), ML-KEM still protects `mlkemSS` under the SHA3-256 mix +- If a classical vulnerability is found in ML-KEM (recovers `mlkemSS` from the ciphertext), ECDH still protects `tradSS` ### Forward Secrecy - The ephemeral EC key pair is generated fresh for each wrap operation, providing forward secrecy on the classical side - ML-KEM encapsulation generates fresh randomness for each operation, providing forward secrecy on the post-quantum side -### What is NOT in the Combiner +### Identity Binding in the Combiner + +The draft-14 combiner mixes both ciphertexts and the traditional static public key into the SHA3-256 input, plus a fixed Label for domain separation. Concretely the combiner inputs are: + +- `mlkemSS`, `tradSS` — the two shared secrets +- `tradCT` — the ephemeral EC public key sent on the wire +- `tradPK` — the KAS static EC public key +- `Label` — `"MLKEM768-P256"` or `"MLKEM1024-P384"` -Unlike the X-Wing KEM (which uses SHA3-256 and mixes in public keys, ciphertexts, and a domain label), this NIST hybrid implementation: +This means: -- Does **not** mix the ephemeral EC public key into the KDF -- Does **not** mix the ML-KEM ciphertext into the KDF -- Does **not** mix the static public keys into the KDF -- Does **not** use a domain separation label in the HKDF info +- The ephemeral EC public key is bound into the wrap key — an attacker cannot substitute a different `tradCT` without changing `wrapKey`. +- The KAS static EC public key is bound in — committing the wrap key to the intended recipient. +- A constant Label provides domain separation between schemes. -The two raw shared secrets are simply concatenated and passed through HKDF. This is a common and accepted pattern for hybrid KEM composition, though it provides less identity binding than the X-Wing combiner approach. +The static ML-KEM public key and the ML-KEM ciphertext are **not** in the combiner input. Draft-14 §3.4 takes the position that ML-KEM's IND-CCA2 already binds the ciphertext to its shared secret, so adding the ciphertext to the combiner does not strengthen the security argument. ## Comparison with Other Key Wrapping Schemes in TDF @@ -282,10 +290,11 @@ The two raw shared secrets are simply concatenated and passed through HKDF. This |--------|-----------------|-------------------|-------------------------------|--------------------------| | Classical | RSA-2048 | ECDH (P-256/384/521) | ECDH (P-256/384) | X25519 | | Post-Quantum | None | None | ML-KEM-768/1024 | ML-KEM-768 | -| Combiner | N/A | HKDF only | Concatenation + HKDF | SHA3-256 (spec-defined, inside circl library) + HKDF | +| Combiner | N/A | HKDF + AES-GCM | SHA3-256 with Label + tradCT + tradPK (draft-14 §3.4) | SHA3-256 (X-Wing spec, inside circl) | | Output Format | Base64(RSA ciphertext) | Base64(AES-GCM ciphertext) | Base64(ASN.1 DER) | Base64(ASN.1 DER) | | Ephemeral Key | None | EC ephemeral | EC ephemeral + ML-KEM ciphertext | X-Wing ciphertext (contains both) | -| Identity Binding | N/A | No | No | Yes (public keys mixed into SHA3-256) | +| Identity Binding | N/A | No | Yes (`tradCT`, `tradPK`, `Label` in combiner) | Yes (public keys mixed into SHA3-256) | +| Public Key PEM | `PUBLIC KEY` (SPKI, stdlib OID) | `PUBLIC KEY` (SPKI, stdlib OID) | `PUBLIC KEY` (SPKI, draft-14 OID) | `PUBLIC KEY` (SPKI, draft-10 OID) | ## Manifest Example diff --git a/lib/ocrypto/asym_decryption.go b/lib/ocrypto/asym_decryption.go index 1cbfbfc943..28c31798a2 100644 --- a/lib/ocrypto/asym_decryption.go +++ b/lib/ocrypto/asym_decryption.go @@ -43,13 +43,20 @@ func FromPrivatePEMWithSalt(privateKeyInPem string, salt, info []byte) (PrivateK if block == nil { return AsymDecryption{}, errors.New("failed to parse PEM formatted private key") } - switch block.Type { - case PEMBlockXWingPrivateKey: - return NewSaltedXWingDecryptor(block.Bytes, salt, info) - case PEMBlockP256MLKEM768PrivateKey: - return NewSaltedP256MLKEM768Decryptor(block.Bytes, salt, info) - case PEMBlockP384MLKEM1024PrivateKey: - return NewSaltedP384MLKEM1024Decryptor(block.Bytes, salt, info) + + // Hybrid PQ/T private keys are PKCS#8-wrapped under one of our known OIDs. + // Peek at the AlgorithmIdentifier and route hybrids to their constructors; + // everything else (RSA, EC, EC PRIVATE KEY) falls through to x509. + if block.Type == pemBlockPrivateKey { + if dec, matched, err := hybridDecryptorFromPKCS8(block.Bytes, salt, info); matched { + return dec, err + } + } + // Reject CERTIFICATE blocks containing a hybrid SPKI: certificates are not + // supported as a private-key transport, but operators sometimes paste them + // here by mistake. Symmetric with the public-key path. + if block.Type == pemBlockCertificate && containsHybridOID(block.Bytes) { + return AsymDecryption{}, errors.New("certificate-wrapped hybrid keys are not supported; provide a bare PKCS#8 PRIVATE KEY") } priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) @@ -202,6 +209,38 @@ func (e ECDecryptor) DecryptWithEphemeralKey(data, ephemeral []byte) ([]byte, er return plaintext, nil } +// hybridDecryptorFromPKCS8 mirrors hybridEncryptorFromSPKI for PKCS#8 private +// keys. The `matched` return reports whether the dispatcher owns the result: +// when true, the caller MUST return whatever this function returns. When +// false, the caller falls through to the legacy RSA/EC PKCS#8 / PKCS#1 path. +// Salt/info are honoured only for X-Wing. +func hybridDecryptorFromPKCS8(der, salt, info []byte) (PrivateKeyDecryptor, bool, error) { + oid, raw, parseErr := parseHybridPKCS8(der) + if parseErr != nil { + // Structurally not a PKCS#8 envelope (e.g. PKCS#1 RSA or EC PRIVATE + // KEY). Fall through to the legacy decoder. + return nil, false, nil //nolint:nilerr // intentional fall-through on non-envelope input + } + switch { + case oid.Equal(oidXWing): + dec, err := NewSaltedXWingDecryptor(raw, salt, info) + return dec, true, err + case oid.Equal(oidCompositeMLKEM768P256): + dec, err := NewP256MLKEM768Decryptor(raw) + return dec, true, err + case oid.Equal(oidCompositeMLKEM1024P384): + dec, err := NewP384MLKEM1024Decryptor(raw) + return dec, true, err + } + // Valid PKCS#8 envelope with a non-hybrid OID. If the stdlib recognises it, + // fall through. Otherwise surface a precise "unknown OID" error so the + // caller doesn't end up reporting a confusing PKCS#1/EC-Private-Key error. + if _, x509Err := x509.ParsePKCS8PrivateKey(der); x509Err == nil { + return nil, false, nil + } + return nil, true, fmt.Errorf("unsupported private-key algorithm OID %s: not a known hybrid scheme and not recognised by crypto/x509", oid) +} + func convCurve(c ecdh.Curve) elliptic.Curve { switch c { case ecdh.P256(): diff --git a/lib/ocrypto/asym_encryption.go b/lib/ocrypto/asym_encryption.go index 2a030c3f32..860e799cbf 100644 --- a/lib/ocrypto/asym_encryption.go +++ b/lib/ocrypto/asym_encryption.go @@ -74,13 +74,19 @@ func FromPublicPEMWithSalt(publicKeyInPem string, salt, info []byte) (PublicKeyE if block == nil { return nil, errors.New("failed to parse PEM formatted public key") } - switch block.Type { - case PEMBlockXWingPublicKey: - return NewXWingEncryptor(block.Bytes, salt, info) - case PEMBlockP256MLKEM768PublicKey: - return NewP256MLKEM768Encryptor(block.Bytes, salt, info) - case PEMBlockP384MLKEM1024PublicKey: - return NewP384MLKEM1024Encryptor(block.Bytes, salt, info) + + // Hybrid PQ/T public keys are SPKI-wrapped under one of our known OIDs. + // Peek at the AlgorithmIdentifier and route hybrids to their constructors; + // everything else (RSA, EC, CERTIFICATE) falls through to the x509 path. + if block.Type == pemBlockPublicKey { + if enc, matched, err := hybridEncryptorFromSPKI(block.Bytes, salt, info); matched { + return enc, err + } + } + // X.509 certificates carrying a hybrid SPKI are out of scope; reject them + // with a clear message so operators don't see a confusing x509 parse error. + if block.Type == pemBlockCertificate && containsHybridOID(block.Bytes) { + return nil, errors.New("certificate-wrapped hybrid keys are not supported; provide a bare SPKI PUBLIC KEY") } pub, err := getPublicPart(publicKeyInPem) @@ -237,7 +243,7 @@ func publicKeyInPemFormat(pk any) (string, error) { publicKeyPem := pem.EncodeToMemory( &pem.Block{ - Type: "PUBLIC KEY", + Type: pemBlockPublicKey, Bytes: publicKeyBytes, }, ) @@ -282,3 +288,38 @@ func (e ECEncryptor) Encrypt(data []byte) ([]byte, error) { func (e ECEncryptor) PublicKeyInPemFormat() (string, error) { return publicKeyInPemFormat(e.ek.Public()) } + +// hybridEncryptorFromSPKI tries to decode `der` as a hybrid PQ/T +// SubjectPublicKeyInfo. The `matched` return reports whether the dispatcher +// owns the result: when true, the caller MUST return whatever this function +// returns (encryptor or error) without trying the legacy x509 path. When +// false, the caller falls through to the standard RSA/EC handling. +// Salt/info are honoured only for X-Wing (the NIST composite-KEM hybrids +// derive their wrap key without them). +func hybridEncryptorFromSPKI(der, salt, info []byte) (PublicKeyEncryptor, bool, error) { + oid, raw, parseErr := parseHybridSPKI(der) + if parseErr != nil { + // Structurally not an SPKI envelope. Fall through to the legacy path, + // which handles PKCS#1 keys, certificates, and stdlib-recognised SPKI. + return nil, false, nil //nolint:nilerr // intentional fall-through on non-envelope input + } + switch { + case oid.Equal(oidXWing): + enc, err := NewXWingEncryptor(raw, salt, info) + return enc, true, err + case oid.Equal(oidCompositeMLKEM768P256): + enc, err := NewP256MLKEM768Encryptor(raw) + return enc, true, err + case oid.Equal(oidCompositeMLKEM1024P384): + enc, err := NewP384MLKEM1024Encryptor(raw) + return enc, true, err + } + // Valid SPKI envelope with a non-hybrid OID. If the stdlib recognises it, + // fall through so the legacy RSA/EC path can handle it. Otherwise, surface + // a precise error rather than letting x509 return its generic message — + // that prevents an unknown OID from being silently retried as RSA/EC. + if _, x509Err := x509.ParsePKIXPublicKey(der); x509Err == nil { + return nil, false, nil + } + return nil, true, fmt.Errorf("unsupported public-key algorithm OID %s: not a known hybrid scheme and not recognised by crypto/x509", oid) +} diff --git a/lib/ocrypto/benchmark_test.go b/lib/ocrypto/benchmark_test.go index b86b3075e7..b325672176 100644 --- a/lib/ocrypto/benchmark_test.go +++ b/lib/ocrypto/benchmark_test.go @@ -3,7 +3,6 @@ package ocrypto import ( "crypto/sha256" "crypto/x509" - "encoding/asn1" "fmt" "testing" ) @@ -57,14 +56,12 @@ func benchTDFSalt() []byte { return digest.Sum(nil) } -// BenchmarkWrapDEK mirrors the actual TDF key-wrapping paths in sdk/tdf.go: -// - RSA: FromPublicPEM -> Encrypt (generateWrapKeyWithRSA) -// - EC: NewECKeyPair -> ComputeECDHKey -> HKDF -> AES-GCM (generateWrapKeyWithEC) -// - Hybrid: PubKeyFromPem -> Encapsulate -> HKDF -> AES-GCM -> ASN.1 (generateWrapKeyWithHybrid) +// BenchmarkWrapDEK mirrors the actual TDF key-wrapping paths in sdk/tdf.go. +// Hybrid paths go through FromPublicPEM -> Encrypt, matching how the SDK now +// dispatches via OID after the draft-14 / draft-10 conformance refactor. func BenchmarkWrapDEK(b *testing.B) { salt := benchTDFSalt() - // RSA-2048: setup KAS public key rsaKP, err := NewRSAKeyPair(2048) if err != nil { b.Fatal(err) @@ -74,7 +71,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.Fatal(err) } - // EC P-256: setup KAS public key PEM ecKP, err := NewECKeyPair(ECCModeSecp256r1) if err != nil { b.Fatal(err) @@ -84,7 +80,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.Fatal(err) } - // EC P-384: setup KAS public key PEM ec384KP, err := NewECKeyPair(ECCModeSecp384r1) if err != nil { b.Fatal(err) @@ -94,7 +89,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.Fatal(err) } - // X-Wing: setup KAS public key PEM xwingKP, err := NewXWingKeyPair() if err != nil { b.Fatal(err) @@ -104,7 +98,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.Fatal(err) } - // P256+MLKEM768: setup KAS public key PEM p256KP, err := NewP256MLKEM768KeyPair() if err != nil { b.Fatal(err) @@ -114,7 +107,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.Fatal(err) } - // P384+MLKEM1024: setup KAS public key PEM p384KP, err := NewP384MLKEM1024KeyPair() if err != nil { b.Fatal(err) @@ -124,7 +116,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.Fatal(err) } - // RSA: tdf.go calls FromPublicPEM -> Encrypt b.Run("RSA-2048", func(b *testing.B) { for b.Loop() { enc, err := FromPublicPEM(rsaPubPEM) @@ -136,7 +127,6 @@ func BenchmarkWrapDEK(b *testing.B) { b.ReportMetric(float64(len(sinkBytes)), "wrapped-bytes") }) - // EC: tdf.go generates ephemeral EC keypair, computes ECDH, derives via HKDF, AES-GCM wraps b.Run("EC-P256", func(b *testing.B) { for b.Loop() { ephKP, err := NewECKeyPair(ECCModeSecp256r1) @@ -191,105 +181,43 @@ func BenchmarkWrapDEK(b *testing.B) { b.ReportMetric(float64(len(sinkBytes)), "wrapped-bytes") }) - // X-Wing: tdf.go parses PEM, calls Encapsulate, HKDF, AES-GCM, then ASN.1 marshal b.Run("XWing", func(b *testing.B) { for b.Loop() { - pubKey, err := XWingPubKeyFromPem([]byte(xwingPubPEM)) + enc, err := FromPublicPEM(xwingPubPEM) if err != nil { b.Fatal(err) } - ss, ct, err := XWingEncapsulate(pubKey) - if err != nil { - b.Fatal(err) - } - wrapKey, err := CalculateHKDF(salt, ss) - if err != nil { - b.Fatal(err) - } - gcm, err := NewAESGcm(wrapKey) - if err != nil { - b.Fatal(err) - } - encDEK, err := gcm.Encrypt(testDEK) - if err != nil { - b.Fatal(err) - } - sinkBytes, errSink = asn1.Marshal(HybridNISTWrappedKey{ - HybridCiphertext: ct, - EncryptedDEK: encDEK, - }) + sinkBytes, errSink = enc.Encrypt(testDEK) } b.ReportMetric(float64(len(sinkBytes)), "wrapped-bytes") }) - // P256+MLKEM768: same flow as X-Wing with different Encapsulate/PEM parse b.Run("P256_MLKEM768", func(b *testing.B) { for b.Loop() { - pubKey, err := P256MLKEM768PubKeyFromPem([]byte(p256PubPEM)) - if err != nil { - b.Fatal(err) - } - ss, ct, err := P256MLKEM768Encapsulate(pubKey) + enc, err := FromPublicPEM(p256PubPEM) if err != nil { b.Fatal(err) } - wrapKey, err := CalculateHKDF(salt, ss) - if err != nil { - b.Fatal(err) - } - gcm, err := NewAESGcm(wrapKey) - if err != nil { - b.Fatal(err) - } - encDEK, err := gcm.Encrypt(testDEK) - if err != nil { - b.Fatal(err) - } - sinkBytes, errSink = asn1.Marshal(HybridNISTWrappedKey{ - HybridCiphertext: ct, - EncryptedDEK: encDEK, - }) + sinkBytes, errSink = enc.Encrypt(testDEK) } b.ReportMetric(float64(len(sinkBytes)), "wrapped-bytes") }) - // P384+MLKEM1024: same flow with P384 variant b.Run("P384_MLKEM1024", func(b *testing.B) { for b.Loop() { - pubKey, err := P384MLKEM1024PubKeyFromPem([]byte(p384PubPEM)) - if err != nil { - b.Fatal(err) - } - ss, ct, err := P384MLKEM1024Encapsulate(pubKey) - if err != nil { - b.Fatal(err) - } - wrapKey, err := CalculateHKDF(salt, ss) + enc, err := FromPublicPEM(p384PubPEM) if err != nil { b.Fatal(err) } - gcm, err := NewAESGcm(wrapKey) - if err != nil { - b.Fatal(err) - } - encDEK, err := gcm.Encrypt(testDEK) - if err != nil { - b.Fatal(err) - } - sinkBytes, errSink = asn1.Marshal(HybridNISTWrappedKey{ - HybridCiphertext: ct, - EncryptedDEK: encDEK, - }) + sinkBytes, errSink = enc.Encrypt(testDEK) } b.ReportMetric(float64(len(sinkBytes)), "wrapped-bytes") }) } // BenchmarkUnwrapDEK mirrors the actual KAS unwrap paths in -// service/internal/security/standard_crypto.go:Decrypt(): -// - RSA: pre-loaded AsymDecryption.Decrypt (key already parsed) -// - EC: ECPrivateKeyFromPem (cached) -> NewSaltedECDecryptor(TDFSalt) -> DecryptWithEphemeralKey -// - Hybrid: PrivateKeyFromPem -> UnwrapDEK (PEM parsed each time in current KAS code) +// service/internal/security/standard_crypto.go:Decrypt(). Hybrid paths now go +// through FromPrivatePEM -> Decrypt to match the OID-routed dispatcher. func BenchmarkUnwrapDEK(b *testing.B) { salt := benchTDFSalt() @@ -319,7 +247,7 @@ func BenchmarkUnwrapDEK(b *testing.B) { b.Fatal(err) } - // EC P-256: KAS caches the parsed private key, creates decryptor per request + // EC P-256 ecKASKP, err := NewECKeyPair(ECCModeSecp256r1) if err != nil { b.Fatal(err) @@ -332,7 +260,6 @@ func BenchmarkUnwrapDEK(b *testing.B) { if err != nil { b.Fatal(err) } - // Wrap using the TDF path: ephemeral keygen + ECDH + HKDF + AES-GCM ecEphKP, err := NewECKeyPair(ECCModeSecp256r1) if err != nil { b.Fatal(err) @@ -361,8 +288,6 @@ func BenchmarkUnwrapDEK(b *testing.B) { if err != nil { b.Fatal(err) } - // KAS receives the ephemeral public key as DER (parsed from PEM in the manifest). - // DecryptWithEphemeralKey first tries x509.ParsePKIXPublicKey (DER), then compressed. ecEphPubECDH, err := ECPubKeyFromPem([]byte(ecEphPubPEM)) if err != nil { b.Fatal(err) @@ -371,13 +296,12 @@ func BenchmarkUnwrapDEK(b *testing.B) { if err != nil { b.Fatal(err) } - // KAS parses private key once (cached in StandardECCrypto) ecKASPrivKey, err := ECPrivateKeyFromPem([]byte(ecKASPrivPEM)) if err != nil { b.Fatal(err) } - // EC P-384: same flow as P-256, just on a different curve + // EC P-384 ec384KASKP, err := NewECKeyPair(ECCModeSecp384r1) if err != nil { b.Fatal(err) @@ -431,7 +355,7 @@ func BenchmarkUnwrapDEK(b *testing.B) { b.Fatal(err) } - // X-Wing: KAS parses PEM each call, then calls UnwrapDEK + // X-Wing xwingKP, err := NewXWingKeyPair() if err != nil { b.Fatal(err) @@ -445,7 +369,7 @@ func BenchmarkUnwrapDEK(b *testing.B) { b.Fatal(err) } - // P256+MLKEM768: KAS parses PEM each call, then calls UnwrapDEK + // P256+MLKEM768 p256KP, err := NewP256MLKEM768KeyPair() if err != nil { b.Fatal(err) @@ -459,7 +383,7 @@ func BenchmarkUnwrapDEK(b *testing.B) { b.Fatal(err) } - // P384+MLKEM1024: KAS parses PEM each call, then calls UnwrapDEK + // P384+MLKEM1024 p384KP, err := NewP384MLKEM1024KeyPair() if err != nil { b.Fatal(err) @@ -473,14 +397,12 @@ func BenchmarkUnwrapDEK(b *testing.B) { b.Fatal(err) } - // RSA: KAS has pre-loaded AsymDecryption, just calls Decrypt b.Run("RSA-2048", func(b *testing.B) { for b.Loop() { sinkBytes, errSink = rsaDec.Decrypt(rsaWrapped) } }) - // EC: KAS creates NewSaltedECDecryptor(cachedSK, TDFSalt, nil) -> DecryptWithEphemeralKey b.Run("EC-P256", func(b *testing.B) { for b.Loop() { dec, err := NewSaltedECDecryptor(ecKASPrivKey, salt, nil) @@ -501,164 +423,33 @@ func BenchmarkUnwrapDEK(b *testing.B) { } }) - // X-Wing: KAS parses PEM then calls UnwrapDEK b.Run("XWing", func(b *testing.B) { for b.Loop() { - privKey, err := XWingPrivateKeyFromPem([]byte(xwingPrivPEM)) + dec, err := FromPrivatePEM(xwingPrivPEM) if err != nil { b.Fatal(err) } - sinkBytes, errSink = XWingUnwrapDEK(privKey, xwingWrapped) + sinkBytes, errSink = dec.Decrypt(xwingWrapped) } }) - // P256+MLKEM768: KAS parses PEM then calls UnwrapDEK b.Run("P256_MLKEM768", func(b *testing.B) { for b.Loop() { - privKey, err := P256MLKEM768PrivateKeyFromPem([]byte(p256PrivPEM)) + dec, err := FromPrivatePEM(p256PrivPEM) if err != nil { b.Fatal(err) } - sinkBytes, errSink = P256MLKEM768UnwrapDEK(privKey, p256Wrapped) + sinkBytes, errSink = dec.Decrypt(p256Wrapped) } }) - // P384+MLKEM1024: KAS parses PEM then calls UnwrapDEK b.Run("P384_MLKEM1024", func(b *testing.B) { for b.Loop() { - privKey, err := P384MLKEM1024PrivateKeyFromPem([]byte(p384PrivPEM)) + dec, err := FromPrivatePEM(p384PrivPEM) if err != nil { b.Fatal(err) } - sinkBytes, errSink = P384MLKEM1024UnwrapDEK(privKey, p384Wrapped) - } - }) -} - -func BenchmarkHybridSubOps(b *testing.B) { - // Setup X-Wing - xwingKP, err := NewXWingKeyPair() - if err != nil { - b.Fatal(err) - } - xwingSS, xwingCt, err := XWingEncapsulate(xwingKP.publicKey) - if err != nil { - b.Fatal(err) - } - - // Setup P256+MLKEM768 - p256KP, err := NewP256MLKEM768KeyPair() - if err != nil { - b.Fatal(err) - } - p256SS, p256Ct, err := P256MLKEM768Encapsulate(p256KP.publicKey) - if err != nil { - b.Fatal(err) - } - - // Setup P384+MLKEM1024 - p384KP, err := NewP384MLKEM1024KeyPair() - if err != nil { - b.Fatal(err) - } - p384SS, p384Ct, err := P384MLKEM1024Encapsulate(p384KP.publicKey) - if err != nil { - b.Fatal(err) - } - - salt := defaultTDFSalt() - - // Pre-derive a wrap key for AES-GCM benchmarks - wrapKey, err := deriveXWingWrapKey(xwingSS, salt, nil) - if err != nil { - b.Fatal(err) - } - - b.Run("XWing/Encapsulate", func(b *testing.B) { - for b.Loop() { - sinkBytes, sinkBytes, errSink = XWingEncapsulate(xwingKP.publicKey) - } - }) - b.Run("XWing/HKDF", func(b *testing.B) { - for b.Loop() { - sinkBytes, errSink = deriveXWingWrapKey(xwingSS, salt, nil) - } - }) - b.Run("XWing/AES-GCM-Encrypt", func(b *testing.B) { - gcm, err := NewAESGcm(wrapKey) - if err != nil { - b.Fatal(err) - } - for b.Loop() { - sinkBytes, errSink = gcm.Encrypt(testDEK) - } - }) - b.Run("XWing/ASN1-Marshal", func(b *testing.B) { - wrapped := XWingWrappedKey{XWingCiphertext: xwingCt, EncryptedDEK: testDEK} - for b.Loop() { - sinkBytes, errSink = asn1.Marshal(wrapped) - } - }) - - // P256+MLKEM768 sub-ops - p256WrapKey, err := deriveHybridNISTWrapKey(p256SS, salt, nil) - if err != nil { - b.Fatal(err) - } - b.Run("P256_MLKEM768/Encapsulate", func(b *testing.B) { - for b.Loop() { - sinkBytes, sinkBytes, errSink = P256MLKEM768Encapsulate(p256KP.publicKey) - } - }) - b.Run("P256_MLKEM768/HKDF", func(b *testing.B) { - for b.Loop() { - sinkBytes, errSink = deriveHybridNISTWrapKey(p256SS, salt, nil) - } - }) - b.Run("P256_MLKEM768/AES-GCM-Encrypt", func(b *testing.B) { - gcm, err := NewAESGcm(p256WrapKey) - if err != nil { - b.Fatal(err) - } - for b.Loop() { - sinkBytes, errSink = gcm.Encrypt(testDEK) - } - }) - b.Run("P256_MLKEM768/ASN1-Marshal", func(b *testing.B) { - wrapped := HybridNISTWrappedKey{HybridCiphertext: p256Ct, EncryptedDEK: testDEK} - for b.Loop() { - sinkBytes, errSink = asn1.Marshal(wrapped) - } - }) - - // P384+MLKEM1024 sub-ops - p384WrapKey, err := deriveHybridNISTWrapKey(p384SS, salt, nil) - if err != nil { - b.Fatal(err) - } - b.Run("P384_MLKEM1024/Encapsulate", func(b *testing.B) { - for b.Loop() { - sinkBytes, sinkBytes, errSink = P384MLKEM1024Encapsulate(p384KP.publicKey) - } - }) - b.Run("P384_MLKEM1024/HKDF", func(b *testing.B) { - for b.Loop() { - sinkBytes, errSink = deriveHybridNISTWrapKey(p384SS, salt, nil) - } - }) - b.Run("P384_MLKEM1024/AES-GCM-Encrypt", func(b *testing.B) { - gcm, err := NewAESGcm(p384WrapKey) - if err != nil { - b.Fatal(err) - } - for b.Loop() { - sinkBytes, errSink = gcm.Encrypt(testDEK) - } - }) - b.Run("P384_MLKEM1024/ASN1-Marshal", func(b *testing.B) { - wrapped := HybridNISTWrappedKey{HybridCiphertext: p384Ct, EncryptedDEK: testDEK} - for b.Loop() { - sinkBytes, errSink = asn1.Marshal(wrapped) + sinkBytes, errSink = dec.Decrypt(p384Wrapped) } }) } diff --git a/lib/ocrypto/ec_key_pair.go b/lib/ocrypto/ec_key_pair.go index 70e30cc8df..c5e288373b 100644 --- a/lib/ocrypto/ec_key_pair.go +++ b/lib/ocrypto/ec_key_pair.go @@ -212,7 +212,7 @@ func (keyPair ECKeyPair) PrivateKeyInPemFormat() (string, error) { privateKeyPem := pem.EncodeToMemory( &pem.Block{ - Type: "PRIVATE KEY", + Type: pemBlockPrivateKey, Bytes: privateKeyBytes, }, ) @@ -232,7 +232,7 @@ func (keyPair ECKeyPair) PublicKeyInPemFormat() (string, error) { publicKeyPem := pem.EncodeToMemory( &pem.Block{ - Type: "PUBLIC KEY", + Type: pemBlockPublicKey, Bytes: publicKeyBytes, }, ) diff --git a/lib/ocrypto/hybrid_common.go b/lib/ocrypto/hybrid_common.go index 4f4ee05417..f7178d009c 100644 --- a/lib/ocrypto/hybrid_common.go +++ b/lib/ocrypto/hybrid_common.go @@ -2,70 +2,40 @@ package ocrypto import ( "crypto/sha256" - "encoding/pem" "fmt" ) -// HybridWrapDEK parses the recipient's hybrid public key PEM, encapsulates -// against it using the scheme implied by ktype, and returns the ASN.1-encoded -// wrapped DEK envelope used in `hybrid-wrapped` manifests. It dispatches across -// both the X-Wing and NIST EC + ML-KEM families so SDK call sites do not need -// to repeat the algorithm switch. -// -// The HKDF salt is the default TDF salt; callers that need a non-default salt -// should call the per-scheme `*WrapDEK` helpers directly. +// HybridWrapDEK parses the recipient's hybrid public key PEM via the +// OID-routed dispatcher, asserts the encryptor matches the requested ktype, +// and produces the ASN.1-encoded wrapped DEK envelope used in +// `hybrid-wrapped` manifests. func HybridWrapDEK(ktype KeyType, kasPublicKeyPEM string, dek []byte) ([]byte, error) { - switch ktype { //nolint:exhaustive // only handle hybrid types - case HybridXWingKey: - pubKey, err := XWingPubKeyFromPem([]byte(kasPublicKeyPEM)) - if err != nil { - return nil, fmt.Errorf("X-Wing public key: %w", err) - } - return XWingWrapDEK(pubKey, dek) - case HybridSecp256r1MLKEM768Key: - pubKey, err := P256MLKEM768PubKeyFromPem([]byte(kasPublicKeyPEM)) - if err != nil { - return nil, fmt.Errorf("P-256+ML-KEM-768 public key: %w", err) - } - return P256MLKEM768WrapDEK(pubKey, dek) - case HybridSecp384r1MLKEM1024Key: - pubKey, err := P384MLKEM1024PubKeyFromPem([]byte(kasPublicKeyPEM)) - if err != nil { - return nil, fmt.Errorf("P-384+ML-KEM-1024 public key: %w", err) - } - return P384MLKEM1024WrapDEK(pubKey, dek) - default: + if !IsHybridKeyType(ktype) { return nil, fmt.Errorf("unsupported hybrid key type: %s", ktype) } + + enc, err := FromPublicPEM(kasPublicKeyPEM) + if err != nil { + return nil, fmt.Errorf("hybrid public key: %w", err) + } + if enc.Type() != Hybrid { + return nil, fmt.Errorf("public key is not a hybrid scheme: %s", enc.KeyType()) + } + if enc.KeyType() != ktype { + return nil, fmt.Errorf("hybrid key type mismatch: PEM is %s, requested %s", enc.KeyType(), ktype) + } + return enc.Encrypt(dek) } -// defaultTDFSalt returns the salt used for HKDF derivation in all TDF hybrid -// key wrapping schemes (X-Wing and NIST EC + ML-KEM). Defined here rather than -// in a per-scheme file so that any change applies uniformly across schemes. +// defaultTDFSalt returns the salt used for HKDF derivation in the X-Wing +// hybrid wrapping scheme. The NIST composite-KEM hybrids derive their wrap +// key without salt per draft-ietf-lamps-pq-composite-kem-14 §3.4 (combiner). func defaultTDFSalt() []byte { digest := sha256.New() digest.Write([]byte("TDF")) return digest.Sum(nil) } -// rawToPEM wraps a fixed-size byte slice in a PEM block of the given type. Used -// by both X-Wing and NIST hybrid key serialization. -func rawToPEM(blockType string, raw []byte, expectedSize int) (string, error) { - if len(raw) != expectedSize { - return "", fmt.Errorf("invalid %s size: got %d want %d", blockType, len(raw), expectedSize) - } - - pemBytes := pem.EncodeToMemory(&pem.Block{ - Type: blockType, - Bytes: raw, - }) - if pemBytes == nil { - return "", fmt.Errorf("failed to encode %s to PEM", blockType) - } - - return string(pemBytes), nil -} - // cloneOrNil returns a copy of data, or nil if data is empty. func cloneOrNil(data []byte) []byte { if len(data) == 0 { diff --git a/lib/ocrypto/hybrid_common_test.go b/lib/ocrypto/hybrid_common_test.go new file mode 100644 index 0000000000..5fb6cc335b --- /dev/null +++ b/lib/ocrypto/hybrid_common_test.go @@ -0,0 +1,20 @@ +package ocrypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHybridWrapDEKRejectsNonHybridKeyType(t *testing.T) { + rsaKeyPair, err := NewRSAKeyPair(RSA2048Size) + require.NoError(t, err) + rsaPublicPEM, err := rsaKeyPair.PublicKeyInPemFormat() + require.NoError(t, err) + + wrapped, err := HybridWrapDEK(RSA2048Key, rsaPublicPEM, []byte("test-dek")) + require.Error(t, err) + require.Nil(t, wrapped) + assert.Contains(t, err.Error(), "unsupported hybrid key type") +} diff --git a/lib/ocrypto/hybrid_conformance_test.go b/lib/ocrypto/hybrid_conformance_test.go new file mode 100644 index 0000000000..4f1bfeb8d0 --- /dev/null +++ b/lib/ocrypto/hybrid_conformance_test.go @@ -0,0 +1,345 @@ +package ocrypto + +import ( + "crypto/ecdh" + "crypto/x509" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hexBytes decodes a hex string in a KAT vector. Tests fail fatally on bad +// hex — these are spec-pinned constants, not user input. +func hexBytes(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + return b +} + +// TestHybridOIDsMatchDrafts pins the AlgorithmIdentifier OIDs to the exact +// values registered by the IETF drafts. A mismatch here means we are +// advertising the wrong algorithm in SPKI/PKCS#8. +// +// Sources: +// - draft-ietf-lamps-pq-composite-kem-14 §6 (id-MLKEM768-ECDH-P256, id-MLKEM1024-ECDH-P384) +// - draft-connolly-cfrg-xwing-kem-10 §5.8 (id-XWing) +func TestHybridOIDsMatchDrafts(t *testing.T) { + assert.Equal(t, + asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 59}, + oidCompositeMLKEM768P256, + "P-256+ML-KEM-768 OID drift from draft-14") + assert.Equal(t, + asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 63}, + oidCompositeMLKEM1024P384, + "P-384+ML-KEM-1024 OID drift from draft-14") + assert.Equal(t, + asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62253, 25722}, + oidXWing, + "X-Wing OID drift from draft-10") +} + +// TestHybridCombinerLabelsMatchDraft pins the ASCII Label strings fed into the +// SHA3-256 combiner. The draft mandates these exact bytes; any drift would +// silently produce non-interop wrap keys. +// +// Source: draft-ietf-lamps-pq-composite-kem-14 §6 ("ParameterSet" Label column). +func TestHybridCombinerLabelsMatchDraft(t *testing.T) { + assert.Equal(t, "MLKEM768-P256", labelMLKEM768P256) + assert.Equal(t, "MLKEM1024-P384", labelMLKEM1024P384) +} + +// TestP256MLKEM768PublicKeyConcatOrder verifies that the raw public-key +// material under our SPKI envelope is laid out as `mlkemPK || ecPoint`, in +// that order, per draft-14 §4.1 (SerializePublicKey). +func TestP256MLKEM768PublicKeyConcatOrder(t *testing.T) { + kp, err := NewP256MLKEM768KeyPair() + require.NoError(t, err) + + pubPEM, err := kp.PublicKeyInPemFormat() + require.NoError(t, err) + block, _ := pem.Decode([]byte(pubPEM)) + require.NotNil(t, block) + + oid, raw, err := parseHybridSPKI(block.Bytes) + require.NoError(t, err) + require.True(t, oid.Equal(oidCompositeMLKEM768P256)) + require.Len(t, raw, P256MLKEM768PublicKeySize) + + // First P256MLKEM768MLKEMPubKeySize bytes are the ML-KEM-768 public key; + // trailing P256MLKEM768ECPublicKeySize bytes are the uncompressed P-256 + // SEC1 point (leading 0x04 tag). + ecPoint := raw[P256MLKEM768MLKEMPubKeySize:] + require.Len(t, ecPoint, P256MLKEM768ECPublicKeySize) + assert.Equal(t, byte(0x04), ecPoint[0], "EC half must be uncompressed SEC1 (0x04 tag)") + + // Round-trip the EC half through crypto/ecdh to prove it's a valid point. + _, err = ecdh.P256().NewPublicKey(ecPoint) + assert.NoError(t, err, "trailing bytes must parse as a P-256 ECDH public key") +} + +// TestP384MLKEM1024PublicKeyConcatOrder mirrors the P-256 case for the larger +// scheme. +func TestP384MLKEM1024PublicKeyConcatOrder(t *testing.T) { + kp, err := NewP384MLKEM1024KeyPair() + require.NoError(t, err) + + pubPEM, err := kp.PublicKeyInPemFormat() + require.NoError(t, err) + block, _ := pem.Decode([]byte(pubPEM)) + require.NotNil(t, block) + + oid, raw, err := parseHybridSPKI(block.Bytes) + require.NoError(t, err) + require.True(t, oid.Equal(oidCompositeMLKEM1024P384)) + require.Len(t, raw, P384MLKEM1024PublicKeySize) + + ecPoint := raw[P384MLKEM1024MLKEMPubKeySize:] + require.Len(t, ecPoint, P384MLKEM1024ECPublicKeySize) + assert.Equal(t, byte(0x04), ecPoint[0], "EC half must be uncompressed SEC1 (0x04 tag)") + _, err = ecdh.P384().NewPublicKey(ecPoint) + assert.NoError(t, err, "trailing bytes must parse as a P-384 ECDH public key") +} + +func TestHybridNISTPrivateKeyAndCiphertextConcatOrder(t *testing.T) { + tests := []struct { + name string + newKeyPair func(t *testing.T) HybridNISTKeyPair + params *hybridNISTParams + privateSize int + ciphertextSize int + }{ + { + name: "P256_MLKEM768", + newKeyPair: mustNewP256MLKEM768KeyPair, + params: &p256mlkem768Params, + privateSize: P256MLKEM768MLKEMSeedSize, + ciphertextSize: P256MLKEM768MLKEMCtSize, + }, + { + name: "P384_MLKEM1024", + newKeyPair: mustNewP384MLKEM1024KeyPair, + params: &p384mlkem1024Params, + privateSize: P384MLKEM1024MLKEMSeedSize, + ciphertextSize: P384MLKEM1024MLKEMCtSize, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyPair := tt.newKeyPair(t) + assertHybridNISTPrivateKeyLayout(t, keyPair, tt.params, tt.privateSize) + assertHybridNISTWrappedCiphertextLayout(t, keyPair, tt.params, tt.ciphertextSize) + }) + } +} + +func mustNewP256MLKEM768KeyPair(t *testing.T) HybridNISTKeyPair { + t.Helper() + keyPair, err := NewP256MLKEM768KeyPair() + require.NoError(t, err) + return keyPair +} + +func mustNewP384MLKEM1024KeyPair(t *testing.T) HybridNISTKeyPair { + t.Helper() + keyPair, err := NewP384MLKEM1024KeyPair() + require.NoError(t, err) + return keyPair +} + +func assertHybridNISTPrivateKeyLayout(t *testing.T, keyPair HybridNISTKeyPair, params *hybridNISTParams, mlkemSeedSize int) { + t.Helper() + + privPEM, err := keyPair.PrivateKeyInPemFormat() + require.NoError(t, err) + block, _ := pem.Decode([]byte(privPEM)) + require.NotNil(t, block) + + oid, raw, err := parseHybridPKCS8(block.Bytes) + require.NoError(t, err) + require.True(t, oid.Equal(params.oid)) + require.Greater(t, len(raw), mlkemSeedSize) + require.Len(t, raw[:mlkemSeedSize], mlkemSeedSize) + + ecPrivDER := raw[mlkemSeedSize:] + ecPriv, err := x509.ParseECPrivateKey(ecPrivDER) + require.NoError(t, err, "tail must parse as ECPrivateKey DER") + require.Same(t, params.namedCurve, ecPriv.Curve) + ecdhPriv, err := ecPriv.ECDH() + require.NoError(t, err) + require.Len(t, ecdhPriv.PublicKey().Bytes(), params.ecPubSize) +} + +func assertHybridNISTWrappedCiphertextLayout(t *testing.T, keyPair HybridNISTKeyPair, params *hybridNISTParams, mlkemCiphertextSize int) { + t.Helper() + + enc, err := NewP256MLKEM768Encryptor(keyPair.publicKey) + if params.keyType == HybridSecp384r1MLKEM1024Key { + enc, err = NewP384MLKEM1024Encryptor(keyPair.publicKey) + } + require.NoError(t, err) + + wrappedDER, err := enc.Encrypt([]byte("layout-test-dek")) + require.NoError(t, err) + + var wrapped HybridNISTWrappedKey + rest, err := asn1.Unmarshal(wrappedDER, &wrapped) + require.NoError(t, err) + require.Empty(t, rest) + require.Len(t, wrapped.HybridCiphertext, mlkemCiphertextSize+params.ecPubSize) + require.Len(t, wrapped.HybridCiphertext[:mlkemCiphertextSize], mlkemCiphertextSize) + + ephemeralECPub := wrapped.HybridCiphertext[mlkemCiphertextSize:] + require.Len(t, ephemeralECPub, params.ecPubSize) + require.Equal(t, byte(0x04), ephemeralECPub[0], "ephemeral EC point must be uncompressed SEC1") + _, err = params.curve.NewPublicKey(ephemeralECPub) + require.NoError(t, err, "tail must parse as ephemeral EC public key") +} + +// TestHybridCrossSchemeDispatchRejection verifies that across all six +// (encrypt-scheme, decrypt-scheme) cross-pairings of the three hybrid +// schemes, the decrypter rejects a ciphertext produced by a different +// scheme. The OID embedded in the PKCS#8 envelope authoritatively routes +// the decryption path; a wrap produced by a different scheme MUST NOT +// decapsulate cleanly. +func TestHybridCrossSchemeDispatchRejection(t *testing.T) { + schemes := []KeyType{HybridXWingKey, HybridSecp256r1MLKEM768Key, HybridSecp384r1MLKEM1024Key} + for _, enc := range schemes { + for _, dec := range schemes { + if enc == dec { + continue + } + t.Run(string(enc)+"_to_"+string(dec), func(t *testing.T) { + encKP, err := NewHybridKeyPair(enc) + require.NoError(t, err) + decKP, err := NewHybridKeyPair(dec) + require.NoError(t, err) + + encPub, err := encKP.PublicKeyInPemFormat() + require.NoError(t, err) + decPriv, err := decKP.PrivateKeyInPemFormat() + require.NoError(t, err) + + encryptor, err := FromPublicPEM(encPub) + require.NoError(t, err) + wrapped, err := encryptor.Encrypt([]byte("cross-scheme-dek")) + require.NoError(t, err) + + decryptor, err := FromPrivatePEM(decPriv) + require.NoError(t, err) + + _, err = decryptor.Decrypt(wrapped) + require.Error(t, err, "%s decryptor must not accept a %s wrapped envelope", dec, enc) + }) + } + } +} + +// TestHybridCertificatePEMRejected verifies that a hybrid SPKI wrapped in a +// CERTIFICATE PEM block surfaces a clear "not supported" error from both +// dispatchers, rather than a confusing x509 parse error. Defense against +// operators pasting in a cert by mistake. +func TestHybridCertificatePEMRejected(t *testing.T) { + kp, err := NewP256MLKEM768KeyPair() + require.NoError(t, err) + pubPEM, err := kp.PublicKeyInPemFormat() + require.NoError(t, err) + block, _ := pem.Decode([]byte(pubPEM)) + require.NotNil(t, block) + + fakeCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: block.Bytes}) + + _, err = FromPublicPEM(string(fakeCert)) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate-wrapped hybrid keys are not supported") + + _, err = FromPrivatePEM(string(fakeCert)) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate-wrapped hybrid keys are not supported") +} + +// TestHybridSPKIRejectsAlgorithmParameters verifies that any non-absent +// AlgorithmIdentifier `parameters` field on the hybrid SPKI/PKCS#8 envelope +// is rejected. Draft-14 §6 and draft-10 §5.8 both mandate parameters be +// absent for these schemes; carrying an explicit NULL would still violate. +func TestHybridSPKIRejectsAlgorithmParameters(t *testing.T) { + type algIDWithParams struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.RawValue + } + type spkiWithParams struct { + Algorithm algIDWithParams + SubjectPublicKey asn1.BitString + } + bogus := spkiWithParams{ + Algorithm: algIDWithParams{ + Algorithm: oidCompositeMLKEM768P256, + Parameters: asn1.RawValue{Tag: asn1.TagNull, Bytes: []byte{}}, + }, + SubjectPublicKey: asn1.BitString{Bytes: []byte{0x00}, BitLength: 8}, + } + der, err := asn1.Marshal(bogus) + require.NoError(t, err) + + _, _, err = parseHybridSPKI(der) + require.Error(t, err) + assert.Contains(t, err.Error(), "parameters must be absent") +} + +// TestCombinerKAT_MLKEM768_ECDH_P256 verifies hybridNISTCombiner byte-for-byte +// against the IETF-supplied combiner vector from lamps-wg/draft-composite-kem +// src/kemCombiner_MLKEM768_ECDH_P256_SHA3_256.md. Confirms that our SHA3-256 +// input ordering and Label encoding match the draft-14 §3.4 specification. +// +// Source: https://github.com/lamps-wg/draft-composite-kem/blob/main/src/kemCombiner_MLKEM768_ECDH_P256_SHA3_256.md +func TestCombinerKAT_MLKEM768_ECDH_P256(t *testing.T) { + mlkemSS := hexBytes(t, "ca48920ded22e063f98a79a4091508678b7042cab63f78c571ff392e82612d43") + tradSS := hexBytes(t, "ef1c92443aaf987000e3470d34332b4c53ff0cdd4554b6bf377bf7bdb677d3d0") + tradCT := hexBytes(t, + "041d155f6d3078d7e2cd4f9f758947029795dd9ab6d6e92d81d19171270cdefcd4"+ + "abb682edbb22faf961ce75fc688109931bfa24468f646b97eca4d57d5f5e7610") + tradPK := hexBytes(t, + "04ba2bfbf7b91182eb1fad54a2940c8b1dfd53de55fa3c02d199a3159ff73d38d2"+ + "9aa94f32e3e82bcc99b165320297149455997d7c3ea5ac97cd987d3e80396a3e") + expectedSS := hexBytes(t, "d6c69aa6e986b620a2777d8cf1fb6be1b2255d6efae0566deb34c882b38846ee") + + got := hybridNISTCombiner(&p256mlkem768Params, mlkemSS, tradSS, tradCT, tradPK) + assert.Equal(t, expectedSS, got, "combiner output diverged from draft-14 §3.4 KAT") +} + +// TestCombinerKAT_MLKEM1024_ECDH_P384 mirrors the above for the P-384 variant. +// +// Source: https://github.com/lamps-wg/draft-composite-kem/blob/main/src/kemCombiner_MLKEM1024_ECDH_P384_SHA3_256.md +func TestCombinerKAT_MLKEM1024_ECDH_P384(t *testing.T) { + mlkemSS := hexBytes(t, "c0f87f0c53fa8e2ba192a494694d37d1e3cf99c65e0dc5f69b2cc044b3fb205d") + tradSS := hexBytes(t, + "4d52b7ef430382f479603207c0b8f7aa5bc35d8758835007e39a2642ad65e635"+ + "d674db7a5513889657fb24e4e228a098") + tradCT := hexBytes(t, + "0401a5b81dcb51290a0eb142b9032d5a37503164b7a20ac0e3b52dc54f9b0b7c9f"+ + "dd2699a59563a0b9ad0e54478846faeab72b92275e1fbb8b963bcc6e80e30c089"+ + "fbe4ed8d47ec76951db94aede46e679d5692eeb1d1b150d5b2e6660dc67c469") + tradPK := hexBytes(t, + "0468cc4acc5dd85edbcbf25bae7ee7dcacec2968ea7ee57fc91311cb9c47d4a24c"+ + "3854e5ce3e5d0b309fda493224520f2870496eb16571108b3deafd72c1df17edc"+ + "302fbb8b60bae44d93177e6df5278e4667a090a2d59a2076f41d693975e8d19") + expectedSS := hexBytes(t, "eb60f6c80a309ad4158d7b02f2cf8c947faead96ebbd85c3f62a94868ffddca4") + + got := hybridNISTCombiner(&p384mlkem1024Params, mlkemSS, tradSS, tradCT, tradPK) + assert.Equal(t, expectedSS, got, "combiner output diverged from draft-14 §3.4 KAT") +} + +// TestCombinerLabelEncodingKAT pins the ASCII Label byte encoding to the +// draft's hex form. A drift here means our domain separator is wrong and +// would produce non-interop wrap keys. +// +// Sources: draft-ietf-lamps-pq-composite-kem-14 §6, vectors above. +func TestCombinerLabelEncodingKAT(t *testing.T) { + assert.Equal(t, hexBytes(t, "4d4c4b454d3736382d50323536"), []byte(labelMLKEM768P256)) + assert.Equal(t, hexBytes(t, "4d4c4b454d313032342d50333834"), []byte(labelMLKEM1024P384)) +} diff --git a/lib/ocrypto/hybrid_nist.go b/lib/ocrypto/hybrid_nist.go index ee0a575ac5..c127e7093e 100644 --- a/lib/ocrypto/hybrid_nist.go +++ b/lib/ocrypto/hybrid_nist.go @@ -2,14 +2,15 @@ package ocrypto import ( "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" "crypto/mlkem" "crypto/rand" - "crypto/sha256" + "crypto/sha3" + "crypto/x509" "encoding/asn1" + "encoding/pem" "fmt" - "io" - - "golang.org/x/crypto/hkdf" ) const ( @@ -20,104 +21,91 @@ const ( // ML-KEM seed size (d || z) used by crypto/mlkem for private key serialization. const mlkemSeedSize = 64 -// Sizes for P-256 + ML-KEM-768 hybrid. -const ( - P256MLKEM768ECPublicKeySize = 65 // uncompressed P-256 point - P256MLKEM768ECPrivateKeySize = 32 // P-256 scalar - P256MLKEM768MLKEMPubKeySize = 1184 // mlkem768 encapsulation key - P256MLKEM768MLKEMPrivKeySize = mlkemSeedSize - P256MLKEM768MLKEMCtSize = 1088 // mlkem768 ciphertext - - P256MLKEM768PublicKeySize = P256MLKEM768ECPublicKeySize + P256MLKEM768MLKEMPubKeySize // 1249 - P256MLKEM768PrivateKeySize = P256MLKEM768ECPrivateKeySize + P256MLKEM768MLKEMPrivKeySize // 96 - P256MLKEM768CiphertextSize = P256MLKEM768ECPublicKeySize + P256MLKEM768MLKEMCtSize // 1153 - - PEMBlockP256MLKEM768PublicKey = "SECP256R1 MLKEM768 PUBLIC KEY" - PEMBlockP256MLKEM768PrivateKey = "SECP256R1 MLKEM768 PRIVATE KEY" -) - -// Sizes for P-384 + ML-KEM-1024 hybrid. +// Sizes for the elementary halves of the two NIST composite-KEM hybrids. const ( - P384MLKEM1024ECPublicKeySize = 97 // uncompressed P-384 point - P384MLKEM1024ECPrivateKeySize = 48 // P-384 scalar - P384MLKEM1024MLKEMPubKeySize = 1568 // mlkem1024 encapsulation key - P384MLKEM1024MLKEMPrivKeySize = mlkemSeedSize - P384MLKEM1024MLKEMCtSize = 1568 // mlkem1024 ciphertext - - P384MLKEM1024PublicKeySize = P384MLKEM1024ECPublicKeySize + P384MLKEM1024MLKEMPubKeySize // 1665 - P384MLKEM1024PrivateKeySize = P384MLKEM1024ECPrivateKeySize + P384MLKEM1024MLKEMPrivKeySize // 112 - P384MLKEM1024CiphertextSize = P384MLKEM1024ECPublicKeySize + P384MLKEM1024MLKEMCtSize // 1665 - - PEMBlockP384MLKEM1024PublicKey = "SECP384R1 MLKEM1024 PUBLIC KEY" - PEMBlockP384MLKEM1024PrivateKey = "SECP384R1 MLKEM1024 PRIVATE KEY" + P256MLKEM768ECPublicKeySize = 65 // uncompressed P-256 point (RFC 5480) + P256MLKEM768MLKEMSeedSize = mlkemSeedSize + P256MLKEM768MLKEMPubKeySize = 1184 + P256MLKEM768MLKEMCtSize = 1088 + + P384MLKEM1024ECPublicKeySize = 97 // uncompressed P-384 point (RFC 5480) + P384MLKEM1024MLKEMSeedSize = mlkemSeedSize + P384MLKEM1024MLKEMPubKeySize = 1568 + P384MLKEM1024MLKEMCtSize = 1568 + + // Concatenated sizes: public key (draft-14 §4.1) and ciphertext (§4.3), + // both laid out as `mlkem || ec`. + P256MLKEM768PublicKeySize = P256MLKEM768MLKEMPubKeySize + P256MLKEM768ECPublicKeySize // 1249 + P256MLKEM768CiphertextSize = P256MLKEM768MLKEMCtSize + P256MLKEM768ECPublicKeySize // 1153 + P384MLKEM1024PublicKeySize = P384MLKEM1024MLKEMPubKeySize + P384MLKEM1024ECPublicKeySize // 1665 + P384MLKEM1024CiphertextSize = P384MLKEM1024MLKEMCtSize + P384MLKEM1024ECPublicKeySize // 1665 ) -// AES-256 key size used for wrap key derivation. -const hybridNISTWrapKeySize = 32 - -// HybridNISTWrappedKey is the ASN.1 envelope stored in wrapped_key. +// HybridNISTWrappedKey is the ASN.1 envelope stored in wrapped_key. The IETF +// composite-KEM draft defines only the KEM; this DEK wrapping envelope is +// kept identical to its pre-conformance shape so the TDF layer is unaffected. type HybridNISTWrappedKey struct { HybridCiphertext []byte `asn1:"tag:0"` EncryptedDEK []byte `asn1:"tag:1"` } -// hybridNISTParams captures the curve-specific parameters for a NIST hybrid scheme. +// hybridNISTParams captures the curve-specific parameters for one composite-KEM +// hybrid scheme. type hybridNISTParams struct { - curve ecdh.Curve - ecPubSize int - ecPrivSize int - mlkemPubSize int - mlkemPrivSize int - mlkemCtSize int - pubPEMBlock string - privPEMBlock string - keyType KeyType -} - + curve ecdh.Curve // for ECDH shared secret + namedCurve elliptic.Curve // for x509.MarshalECPrivateKey / RFC 5915 + ecPubSize int // uncompressed point length + mlkemPubSize int + mlkemCtSize int + label string // ASCII domain-separator per draft-14 §6 + oid asn1.ObjectIdentifier // AlgorithmIdentifier OID (draft-14 §6) + keyType KeyType +} + +// p256mlkem768Params and p384mlkem1024Params MUST stay structurally identical +// (same field set, same field order). If you add a field, add it to BOTH; if +// a third NIST composite-KEM hybrid lands, prefer consolidating these into a +// `map[asn1.ObjectIdentifier]hybridNISTParams` at that point. var p256mlkem768Params = hybridNISTParams{ - curve: ecdh.P256(), - ecPubSize: P256MLKEM768ECPublicKeySize, - ecPrivSize: P256MLKEM768ECPrivateKeySize, - mlkemPubSize: P256MLKEM768MLKEMPubKeySize, - mlkemPrivSize: P256MLKEM768MLKEMPrivKeySize, - mlkemCtSize: P256MLKEM768MLKEMCtSize, - pubPEMBlock: PEMBlockP256MLKEM768PublicKey, - privPEMBlock: PEMBlockP256MLKEM768PrivateKey, - keyType: HybridSecp256r1MLKEM768Key, + curve: ecdh.P256(), + namedCurve: elliptic.P256(), + ecPubSize: P256MLKEM768ECPublicKeySize, + mlkemPubSize: P256MLKEM768MLKEMPubKeySize, + mlkemCtSize: P256MLKEM768MLKEMCtSize, + label: labelMLKEM768P256, + oid: oidCompositeMLKEM768P256, + keyType: HybridSecp256r1MLKEM768Key, } var p384mlkem1024Params = hybridNISTParams{ - curve: ecdh.P384(), - ecPubSize: P384MLKEM1024ECPublicKeySize, - ecPrivSize: P384MLKEM1024ECPrivateKeySize, - mlkemPubSize: P384MLKEM1024MLKEMPubKeySize, - mlkemPrivSize: P384MLKEM1024MLKEMPrivKeySize, - mlkemCtSize: P384MLKEM1024MLKEMCtSize, - pubPEMBlock: PEMBlockP384MLKEM1024PublicKey, - privPEMBlock: PEMBlockP384MLKEM1024PrivateKey, - keyType: HybridSecp384r1MLKEM1024Key, -} - -// HybridNISTKeyPair holds a hybrid EC + ML-KEM keypair as raw bytes. + curve: ecdh.P384(), + namedCurve: elliptic.P384(), + ecPubSize: P384MLKEM1024ECPublicKeySize, + mlkemPubSize: P384MLKEM1024MLKEMPubKeySize, + mlkemCtSize: P384MLKEM1024MLKEMCtSize, + label: labelMLKEM1024P384, + oid: oidCompositeMLKEM1024P384, + keyType: HybridSecp384r1MLKEM1024Key, +} + +// HybridNISTKeyPair holds the raw byte form of a composite-KEM keypair: +// - publicKey = mlkemEncapsulationKey || uncompressedECPoint +// - privateKey = mlkemSeed || ECPrivateKey(DER, RFC 5915) type HybridNISTKeyPair struct { publicKey []byte privateKey []byte params *hybridNISTParams } -// HybridNISTEncryptor implements PublicKeyEncryptor for NIST hybrid schemes. +// HybridNISTEncryptor implements PublicKeyEncryptor for composite-KEM hybrids. type HybridNISTEncryptor struct { publicKey []byte - salt []byte - info []byte params *hybridNISTParams } -// HybridNISTDecryptor implements PrivateKeyDecryptor for NIST hybrid schemes. +// HybridNISTDecryptor implements PrivateKeyDecryptor for composite-KEM hybrids. type HybridNISTDecryptor struct { privateKey []byte - salt []byte - info []byte params *hybridNISTParams } @@ -146,45 +134,56 @@ func NewHybridKeyPair(kt KeyType) (KeyPair, error) { } func NewP256MLKEM768KeyPair() (HybridNISTKeyPair, error) { - return newHybridNISTKeyPair(&p256mlkem768Params, func() ([]byte, []byte, error) { - dk, err := mlkem.GenerateKey768() - if err != nil { - return nil, nil, err - } - return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil - }) + return newHybridNISTKeyPair(&p256mlkem768Params, generateMLKEM768) } func NewP384MLKEM1024KeyPair() (HybridNISTKeyPair, error) { - return newHybridNISTKeyPair(&p384mlkem1024Params, func() ([]byte, []byte, error) { - dk, err := mlkem.GenerateKey1024() - if err != nil { - return nil, nil, err - } - return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil - }) + return newHybridNISTKeyPair(&p384mlkem1024Params, generateMLKEM1024) +} + +func generateMLKEM768() ([]byte, []byte, error) { + dk, err := mlkem.GenerateKey768() + if err != nil { + return nil, nil, err + } + return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil } -func newHybridNISTKeyPair(p *hybridNISTParams, genMLKEM func() (pub, priv []byte, err error)) (HybridNISTKeyPair, error) { - ecPriv, err := p.curve.GenerateKey(rand.Reader) +func generateMLKEM1024() ([]byte, []byte, error) { + dk, err := mlkem.GenerateKey1024() if err != nil { - return HybridNISTKeyPair{}, fmt.Errorf("ECDH key generation failed: %w", err) + return nil, nil, err } - ecPub := ecPriv.PublicKey().Bytes() // uncompressed point - ecPrivBytes := ecPriv.Bytes() // raw scalar + return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil +} - mlkemPub, mlkemPriv, err := genMLKEM() +func newHybridNISTKeyPair(p *hybridNISTParams, genMLKEM func() ([]byte, []byte, error)) (HybridNISTKeyPair, error) { + ecPriv, err := ecdsa.GenerateKey(p.namedCurve, rand.Reader) + if err != nil { + return HybridNISTKeyPair{}, fmt.Errorf("EC key generation failed: %w", err) + } + ecPrivDER, err := x509.MarshalECPrivateKey(ecPriv) + if err != nil { + return HybridNISTKeyPair{}, fmt.Errorf("encode ECPrivateKey: %w", err) + } + ecdhPriv, err := ecPriv.ECDH() + if err != nil { + return HybridNISTKeyPair{}, fmt.Errorf("convert ECDSA to ECDH: %w", err) + } + ecPub := ecdhPriv.PublicKey().Bytes() + + mlkemPub, mlkemSeed, err := genMLKEM() if err != nil { return HybridNISTKeyPair{}, fmt.Errorf("ML-KEM key generation failed: %w", err) } - pubKey := make([]byte, 0, p.ecPubSize+p.mlkemPubSize) - pubKey = append(pubKey, ecPub...) + pubKey := make([]byte, 0, len(mlkemPub)+len(ecPub)) pubKey = append(pubKey, mlkemPub...) + pubKey = append(pubKey, ecPub...) - privKey := make([]byte, 0, p.ecPrivSize+p.mlkemPrivSize) - privKey = append(privKey, ecPrivBytes...) - privKey = append(privKey, mlkemPriv...) + privKey := make([]byte, 0, len(mlkemSeed)+len(ecPrivDER)) + privKey = append(privKey, mlkemSeed...) + privKey = append(privKey, ecPrivDER...) return HybridNISTKeyPair{ publicKey: pubKey, @@ -194,60 +193,54 @@ func newHybridNISTKeyPair(p *hybridNISTParams, genMLKEM func() (pub, priv []byte } func (k HybridNISTKeyPair) PublicKeyInPemFormat() (string, error) { - return rawToPEM(k.params.pubPEMBlock, k.publicKey, k.params.ecPubSize+k.params.mlkemPubSize) + der, err := marshalHybridSPKI(k.params.oid, k.publicKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: pemBlockPublicKey, Bytes: der})), nil } func (k HybridNISTKeyPair) PrivateKeyInPemFormat() (string, error) { - return rawToPEM(k.params.privPEMBlock, k.privateKey, k.params.ecPrivSize+k.params.mlkemPrivSize) + der, err := marshalHybridPKCS8(k.params.oid, k.privateKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: pemBlockPrivateKey, Bytes: der})), nil } func (k HybridNISTKeyPair) GetKeyType() KeyType { return k.params.keyType } -func P256MLKEM768PubKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockP256MLKEM768PublicKey, P256MLKEM768PublicKeySize) -} - -func P256MLKEM768PrivateKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockP256MLKEM768PrivateKey, P256MLKEM768PrivateKeySize) -} - -func P384MLKEM1024PubKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockP384MLKEM1024PublicKey, P384MLKEM1024PublicKeySize) -} - -func P384MLKEM1024PrivateKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockP384MLKEM1024PrivateKey, P384MLKEM1024PrivateKeySize) +func NewP256MLKEM768Encryptor(publicKey []byte) (*HybridNISTEncryptor, error) { + return newHybridNISTEncryptor(&p256mlkem768Params, publicKey) } -func NewP256MLKEM768Encryptor(publicKey, salt, info []byte) (*HybridNISTEncryptor, error) { - return newHybridNISTEncryptor(&p256mlkem768Params, publicKey, salt, info) +func NewP384MLKEM1024Encryptor(publicKey []byte) (*HybridNISTEncryptor, error) { + return newHybridNISTEncryptor(&p384mlkem1024Params, publicKey) } -func NewP384MLKEM1024Encryptor(publicKey, salt, info []byte) (*HybridNISTEncryptor, error) { - return newHybridNISTEncryptor(&p384mlkem1024Params, publicKey, salt, info) -} - -func newHybridNISTEncryptor(p *hybridNISTParams, publicKey, salt, info []byte) (*HybridNISTEncryptor, error) { - expectedSize := p.ecPubSize + p.mlkemPubSize +func newHybridNISTEncryptor(p *hybridNISTParams, publicKey []byte) (*HybridNISTEncryptor, error) { + expectedSize := p.mlkemPubSize + p.ecPubSize if len(publicKey) != expectedSize { return nil, fmt.Errorf("invalid %s public key size: got %d want %d", p.keyType, len(publicKey), expectedSize) } return &HybridNISTEncryptor{ publicKey: append([]byte(nil), publicKey...), - salt: cloneOrNil(salt), - info: cloneOrNil(info), params: p, }, nil } func (e *HybridNISTEncryptor) Encrypt(data []byte) ([]byte, error) { - return hybridNISTWrapDEK(e.params, e.publicKey, data, e.salt, e.info) + return hybridNISTWrapDEK(e.params, e.publicKey, data) } func (e *HybridNISTEncryptor) PublicKeyInPemFormat() (string, error) { - return rawToPEM(e.params.pubPEMBlock, e.publicKey, e.params.ecPubSize+e.params.mlkemPubSize) + der, err := marshalHybridSPKI(e.params.oid, e.publicKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: pemBlockPublicKey, Bytes: der})), nil } func (e *HybridNISTEncryptor) Type() SchemeType { return Hybrid } @@ -259,140 +252,171 @@ func (e *HybridNISTEncryptor) Metadata() (map[string]string, error) { } func NewP256MLKEM768Decryptor(privateKey []byte) (*HybridNISTDecryptor, error) { - return NewSaltedP256MLKEM768Decryptor(privateKey, defaultTDFSalt(), nil) -} - -func NewSaltedP256MLKEM768Decryptor(privateKey, salt, info []byte) (*HybridNISTDecryptor, error) { - return newHybridNISTDecryptor(&p256mlkem768Params, privateKey, salt, info) + return newHybridNISTDecryptor(&p256mlkem768Params, privateKey) } func NewP384MLKEM1024Decryptor(privateKey []byte) (*HybridNISTDecryptor, error) { - return NewSaltedP384MLKEM1024Decryptor(privateKey, defaultTDFSalt(), nil) -} - -func NewSaltedP384MLKEM1024Decryptor(privateKey, salt, info []byte) (*HybridNISTDecryptor, error) { - return newHybridNISTDecryptor(&p384mlkem1024Params, privateKey, salt, info) + return newHybridNISTDecryptor(&p384mlkem1024Params, privateKey) } -func newHybridNISTDecryptor(p *hybridNISTParams, privateKey, salt, info []byte) (*HybridNISTDecryptor, error) { - expectedSize := p.ecPrivSize + p.mlkemPrivSize - if len(privateKey) != expectedSize { - return nil, fmt.Errorf("invalid %s private key size: got %d want %d", p.keyType, len(privateKey), expectedSize) +func newHybridNISTDecryptor(p *hybridNISTParams, privateKey []byte) (*HybridNISTDecryptor, error) { + if len(privateKey) <= mlkemSeedSize { + return nil, fmt.Errorf("invalid %s private key: shorter than ML-KEM seed + ECPrivateKey", p.keyType) + } + // Parse the EC DER tail up front so a malformed key surfaces at + // construction time — mirrors newHybridNISTEncryptor's exact-size check + // on the public-key side. The parsed key itself is discarded; Decrypt + // re-parses (cheap relative to ML-KEM decapsulation) for code simplicity. + ecPriv, err := x509.ParseECPrivateKey(privateKey[mlkemSeedSize:]) + if err != nil { + return nil, fmt.Errorf("invalid %s private key: parse ECPrivateKey: %w", p.keyType, err) + } + if ecPriv.Curve != p.namedCurve { + return nil, fmt.Errorf("invalid %s private key: EC curve mismatch", p.keyType) } return &HybridNISTDecryptor{ privateKey: append([]byte(nil), privateKey...), - salt: cloneOrNil(salt), - info: cloneOrNil(info), params: p, }, nil } func (d *HybridNISTDecryptor) Decrypt(data []byte) ([]byte, error) { - return hybridNISTUnwrapDEK(d.params, d.privateKey, data, d.salt, d.info) + return hybridNISTUnwrapDEK(d.params, d.privateKey, data) +} + +// KeyType identifies the hybrid scheme so KAS-layer callers can cross-check +// the OID-routed decryptor against an asserted algorithm before trusting it. +func (d *HybridNISTDecryptor) KeyType() KeyType { + return d.params.keyType } func P256MLKEM768WrapDEK(publicKeyRaw, dek []byte) ([]byte, error) { - return hybridNISTWrapDEK(&p256mlkem768Params, publicKeyRaw, dek, defaultTDFSalt(), nil) + return hybridNISTWrapDEK(&p256mlkem768Params, publicKeyRaw, dek) } func P256MLKEM768UnwrapDEK(privateKeyRaw, wrappedDER []byte) ([]byte, error) { - return hybridNISTUnwrapDEK(&p256mlkem768Params, privateKeyRaw, wrappedDER, defaultTDFSalt(), nil) + return hybridNISTUnwrapDEK(&p256mlkem768Params, privateKeyRaw, wrappedDER) } func P384MLKEM1024WrapDEK(publicKeyRaw, dek []byte) ([]byte, error) { - return hybridNISTWrapDEK(&p384mlkem1024Params, publicKeyRaw, dek, defaultTDFSalt(), nil) + return hybridNISTWrapDEK(&p384mlkem1024Params, publicKeyRaw, dek) } func P384MLKEM1024UnwrapDEK(privateKeyRaw, wrappedDER []byte) ([]byte, error) { - return hybridNISTUnwrapDEK(&p384mlkem1024Params, privateKeyRaw, wrappedDER, defaultTDFSalt(), nil) + return hybridNISTUnwrapDEK(&p384mlkem1024Params, privateKeyRaw, wrappedDER) } -// hybridNISTEncapsulate performs hybrid encapsulation: -// 1. Generates an ephemeral EC key and computes ECDH shared secret -// 2. Encapsulates ML-KEM to produce a post-quantum shared secret -// 3. Combines both secrets (ECDH || ML-KEM) -// 4. Builds hybrid ciphertext (ephemeral EC point || ML-KEM ciphertext) +// hybridNISTCombiner returns the 32-byte SHA3-256 digest defined in +// draft-ietf-lamps-pq-composite-kem-14 §3.4: // -// Returns (combinedSecret, hybridCiphertext) without applying KDF or encryption. -func hybridNISTEncapsulate(p *hybridNISTParams, publicKeyRaw []byte) ([]byte, []byte, error) { - expectedPubSize := p.ecPubSize + p.mlkemPubSize - if len(publicKeyRaw) != expectedPubSize { - return nil, nil, fmt.Errorf("invalid %s public key size: got %d want %d", p.keyType, len(publicKeyRaw), expectedPubSize) +// SS = SHA3-256(mlkemSS || tradSS || tradCT || tradPK || Label) +// +// The 32-byte output is used directly as the AES-256 wrap key for our DEK +// envelope (no additional KDF step, per the draft). +// +// Input lengths are invariants of the call sites (hybridNISTWrapDEK / +// hybridNISTUnwrapDEK). A mismatch here means a programming bug, not bad +// user input — panicking is preferable to silently producing a +// wrong-but-valid-looking wrap key. +func hybridNISTCombiner(p *hybridNISTParams, mlkemSS, tradSS, tradCT, tradPK []byte) []byte { + const sec1UncompressedHalves = 2 // SEC1 uncompressed point = 0x04 || x || y (two equal halves) + expectedTradSS := (p.ecPubSize - 1) / sec1UncompressedHalves + if len(mlkemSS) != mlkem.SharedKeySize { + panic(fmt.Sprintf("hybridNISTCombiner: mlkemSS length %d, want %d", len(mlkemSS), mlkem.SharedKeySize)) } - - ecPubBytes := publicKeyRaw[:p.ecPubSize] - mlkemPubBytes := publicKeyRaw[p.ecPubSize:] - - // ECDH: generate ephemeral key, compute shared secret - ecPub, err := p.curve.NewPublicKey(ecPubBytes) - if err != nil { - return nil, nil, fmt.Errorf("invalid EC public key: %w", err) + if len(tradSS) != expectedTradSS { + panic(fmt.Sprintf("hybridNISTCombiner[%s]: tradSS length %d, want %d", p.keyType, len(tradSS), expectedTradSS)) } - ephemeral, err := p.curve.GenerateKey(rand.Reader) - if err != nil { - return nil, nil, fmt.Errorf("ECDH ephemeral key generation failed: %w", err) + if len(tradCT) != p.ecPubSize { + panic(fmt.Sprintf("hybridNISTCombiner[%s]: tradCT length %d, want %d", p.keyType, len(tradCT), p.ecPubSize)) } - ecdhSecret, err := ephemeral.ECDH(ecPub) - if err != nil { - return nil, nil, fmt.Errorf("ECDH failed: %w", err) + if len(tradPK) != p.ecPubSize { + panic(fmt.Sprintf("hybridNISTCombiner[%s]: tradPK length %d, want %d", p.keyType, len(tradPK), p.ecPubSize)) } - ephemeralPub := ephemeral.PublicKey().Bytes() + h := sha3.New256() + // hash.Hash.Write never returns an error (documented in the stdlib). + _, _ = h.Write(mlkemSS) + _, _ = h.Write(tradSS) + _, _ = h.Write(tradCT) + _, _ = h.Write(tradPK) + _, _ = h.Write([]byte(p.label)) + return h.Sum(nil) +} - // ML-KEM: encapsulate - var mlkemSecret, mlkemCt []byte +func mlkemEncapsulate(p *hybridNISTParams, mlkemPubBytes []byte) ([]byte, []byte, error) { switch p.keyType { //nolint:exhaustive // only NIST hybrid types case HybridSecp256r1MLKEM768Key: - ek, ekErr := mlkem.NewEncapsulationKey768(mlkemPubBytes) - if ekErr != nil { - return nil, nil, fmt.Errorf("mlkem768 encapsulation key: %w", ekErr) + ek, err := mlkem.NewEncapsulationKey768(mlkemPubBytes) + if err != nil { + return nil, nil, fmt.Errorf("mlkem768 encapsulation key: %w", err) } - mlkemSecret, mlkemCt = ek.Encapsulate() + ss, ct := ek.Encapsulate() + return ss, ct, nil case HybridSecp384r1MLKEM1024Key: - ek, ekErr := mlkem.NewEncapsulationKey1024(mlkemPubBytes) - if ekErr != nil { - return nil, nil, fmt.Errorf("mlkem1024 encapsulation key: %w", ekErr) + ek, err := mlkem.NewEncapsulationKey1024(mlkemPubBytes) + if err != nil { + return nil, nil, fmt.Errorf("mlkem1024 encapsulation key: %w", err) } - mlkemSecret, mlkemCt = ek.Encapsulate() + ss, ct := ek.Encapsulate() + return ss, ct, nil default: return nil, nil, fmt.Errorf("unsupported ML-KEM key type: %s", p.keyType) } - - // Combine secrets: ECDH || ML-KEM - combinedSecret := make([]byte, 0, len(ecdhSecret)+len(mlkemSecret)) - combinedSecret = append(combinedSecret, ecdhSecret...) - combinedSecret = append(combinedSecret, mlkemSecret...) - - // Build hybrid ciphertext: ephemeral EC point || ML-KEM ciphertext - hybridCt := make([]byte, 0, len(ephemeralPub)+len(mlkemCt)) - hybridCt = append(hybridCt, ephemeralPub...) - hybridCt = append(hybridCt, mlkemCt...) - - return combinedSecret, hybridCt, nil } -// P256MLKEM768Encapsulate performs P-256 ECDH + ML-KEM-768 hybrid encapsulation. -func P256MLKEM768Encapsulate(publicKeyRaw []byte) ([]byte, []byte, error) { - return hybridNISTEncapsulate(&p256mlkem768Params, publicKeyRaw) +func mlkemDecapsulate(p *hybridNISTParams, mlkemSeed, mlkemCT []byte) ([]byte, error) { + switch p.keyType { //nolint:exhaustive // only NIST hybrid types + case HybridSecp256r1MLKEM768Key: + dk, err := mlkem.NewDecapsulationKey768(mlkemSeed) + if err != nil { + return nil, fmt.Errorf("mlkem768 decapsulation key: %w", err) + } + return dk.Decapsulate(mlkemCT) + case HybridSecp384r1MLKEM1024Key: + dk, err := mlkem.NewDecapsulationKey1024(mlkemSeed) + if err != nil { + return nil, fmt.Errorf("mlkem1024 decapsulation key: %w", err) + } + return dk.Decapsulate(mlkemCT) + default: + return nil, fmt.Errorf("unsupported ML-KEM key type: %s", p.keyType) + } } -// P384MLKEM1024Encapsulate performs P-384 ECDH + ML-KEM-1024 hybrid encapsulation. -func P384MLKEM1024Encapsulate(publicKeyRaw []byte) ([]byte, []byte, error) { - return hybridNISTEncapsulate(&p384mlkem1024Params, publicKeyRaw) -} +func hybridNISTWrapDEK(p *hybridNISTParams, publicKeyRaw, dek []byte) ([]byte, error) { + expectedPubSize := p.mlkemPubSize + p.ecPubSize + if len(publicKeyRaw) != expectedPubSize { + return nil, fmt.Errorf("invalid %s public key size: got %d want %d", p.keyType, len(publicKeyRaw), expectedPubSize) + } -func hybridNISTWrapDEK(p *hybridNISTParams, publicKeyRaw, dek, salt, info []byte) ([]byte, error) { - combinedSecret, hybridCt, err := hybridNISTEncapsulate(p, publicKeyRaw) + mlkemPubBytes := publicKeyRaw[:p.mlkemPubSize] + ecPubBytes := publicKeyRaw[p.mlkemPubSize:] + + ecPub, err := p.curve.NewPublicKey(ecPubBytes) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid EC public key: %w", err) + } + ephemeral, err := p.curve.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("ECDH ephemeral key generation failed: %w", err) + } + tradSS, err := ephemeral.ECDH(ecPub) + if err != nil { + return nil, fmt.Errorf("ECDH failed: %w", err) } + ephemeralPub := ephemeral.PublicKey().Bytes() - // Derive AES-256 wrap key via HKDF - wrapKey, err := deriveHybridNISTWrapKey(combinedSecret, salt, info) + mlkemSS, mlkemCT, err := mlkemEncapsulate(p, mlkemPubBytes) if err != nil { return nil, err } - // AES-GCM encrypt DEK + wrapKey := hybridNISTCombiner(p, mlkemSS, tradSS, ephemeralPub, ecPubBytes) + + hybridCt := make([]byte, 0, len(mlkemCT)+len(ephemeralPub)) + hybridCt = append(hybridCt, mlkemCT...) + hybridCt = append(hybridCt, ephemeralPub...) + gcm, err := NewAESGcm(wrapKey) if err != nil { return nil, fmt.Errorf("NewAESGcm failed: %w", err) @@ -409,15 +433,15 @@ func hybridNISTWrapDEK(p *hybridNISTParams, publicKeyRaw, dek, salt, info []byte if err != nil { return nil, fmt.Errorf("asn1.Marshal failed: %w", err) } - return wrappedDER, nil } -func hybridNISTUnwrapDEK(p *hybridNISTParams, privateKeyRaw, wrappedDER, salt, info []byte) ([]byte, error) { - expectedPrivSize := p.ecPrivSize + p.mlkemPrivSize - if len(privateKeyRaw) != expectedPrivSize { - return nil, fmt.Errorf("invalid %s private key size: got %d want %d", p.keyType, len(privateKeyRaw), expectedPrivSize) +func hybridNISTUnwrapDEK(p *hybridNISTParams, privateKeyRaw, wrappedDER []byte) ([]byte, error) { + if len(privateKeyRaw) <= mlkemSeedSize { + return nil, fmt.Errorf("invalid %s private key: shorter than ML-KEM seed + ECPrivateKey", p.keyType) } + mlkemSeed := privateKeyRaw[:mlkemSeedSize] + ecPrivDER := privateKeyRaw[mlkemSeedSize:] var wrapped HybridNISTWrappedKey rest, err := asn1.Unmarshal(wrappedDER, &wrapped) @@ -428,70 +452,47 @@ func hybridNISTUnwrapDEK(p *hybridNISTParams, privateKeyRaw, wrappedDER, salt, i return nil, fmt.Errorf("asn1.Unmarshal left %d trailing bytes", len(rest)) } - expectedCtSize := p.ecPubSize + p.mlkemCtSize + expectedCtSize := p.mlkemCtSize + p.ecPubSize if len(wrapped.HybridCiphertext) != expectedCtSize { return nil, fmt.Errorf("invalid %s ciphertext size: got %d want %d", p.keyType, len(wrapped.HybridCiphertext), expectedCtSize) } - // Split hybrid ciphertext - ephemeralPubBytes := wrapped.HybridCiphertext[:p.ecPubSize] - mlkemCtBytes := wrapped.HybridCiphertext[p.ecPubSize:] - - // Split private key - ecPrivBytes := privateKeyRaw[:p.ecPrivSize] - mlkemPrivBytes := privateKeyRaw[p.ecPrivSize:] + mlkemCT := wrapped.HybridCiphertext[:p.mlkemCtSize] + ephemeralPubBytes := wrapped.HybridCiphertext[p.mlkemCtSize:] - // ECDH: reconstruct shared secret - ecPriv, err := p.curve.NewPrivateKey(ecPrivBytes) + ecdsaPriv, err := x509.ParseECPrivateKey(ecPrivDER) if err != nil { - return nil, fmt.Errorf("invalid EC private key: %w", err) + return nil, fmt.Errorf("parse ECPrivateKey: %w", err) } + if ecdsaPriv.Curve != p.namedCurve { + return nil, fmt.Errorf("EC private key curve mismatch for %s", p.keyType) + } + ecdhPriv, err := ecdsaPriv.ECDH() + if err != nil { + return nil, fmt.Errorf("convert ECDSA to ECDH: %w", err) + } + tradPK := ecdhPriv.PublicKey().Bytes() + ephemeralPub, err := p.curve.NewPublicKey(ephemeralPubBytes) if err != nil { return nil, fmt.Errorf("invalid ephemeral EC public key: %w", err) } - ecdhSecret, err := ecPriv.ECDH(ephemeralPub) + tradSS, err := ecdhPriv.ECDH(ephemeralPub) if err != nil { return nil, fmt.Errorf("ECDH failed: %w", err) } - // ML-KEM: decapsulate. Implicit rejection (FIPS 203 §6.3) means a wrong-key - // ciphertext yields a pseudorandom shared secret without an error here; - // authentication is enforced by the AES-GCM decrypt below. - var mlkemSecret []byte - switch p.keyType { //nolint:exhaustive // only NIST hybrid types - case HybridSecp256r1MLKEM768Key: - dk, dkErr := mlkem.NewDecapsulationKey768(mlkemPrivBytes) - if dkErr != nil { - return nil, fmt.Errorf("mlkem768 decapsulation key: %w", dkErr) - } - mlkemSecret, err = dk.Decapsulate(mlkemCtBytes) - case HybridSecp384r1MLKEM1024Key: - dk, dkErr := mlkem.NewDecapsulationKey1024(mlkemPrivBytes) - if dkErr != nil { - return nil, fmt.Errorf("mlkem1024 decapsulation key: %w", dkErr) - } - mlkemSecret, err = dk.Decapsulate(mlkemCtBytes) - default: - return nil, fmt.Errorf("unsupported ML-KEM key type: %s", p.keyType) - } + // ML-KEM implicit rejection (FIPS 203 §6.3) yields a pseudorandom shared + // secret on a wrong-key ciphertext rather than an error here; the AES-GCM + // decrypt below provides authentication. + mlkemSS, err := mlkemDecapsulate(p, mlkemSeed, mlkemCT) if err != nil { return nil, fmt.Errorf("ML-KEM decapsulate failed: %w", err) } - // Combine secrets: ECDH || ML-KEM - combinedSecret := make([]byte, 0, len(ecdhSecret)+len(mlkemSecret)) - combinedSecret = append(combinedSecret, ecdhSecret...) - combinedSecret = append(combinedSecret, mlkemSecret...) - - // Derive AES-256 wrap key via HKDF - wrapKey, err := deriveHybridNISTWrapKey(combinedSecret, salt, info) - if err != nil { - return nil, err - } + wrapKey := hybridNISTCombiner(p, mlkemSS, tradSS, ephemeralPubBytes, tradPK) - // AES-GCM decrypt DEK gcm, err := NewAESGcm(wrapKey) if err != nil { return nil, fmt.Errorf("NewAESGcm failed: %w", err) @@ -500,20 +501,5 @@ func hybridNISTUnwrapDEK(p *hybridNISTParams, privateKeyRaw, wrappedDER, salt, i if err != nil { return nil, fmt.Errorf("AES-GCM decrypt failed: %w", err) } - return plaintext, nil } - -func deriveHybridNISTWrapKey(combinedSecret, salt, info []byte) ([]byte, error) { - if len(salt) == 0 { - salt = defaultTDFSalt() - } - - hkdfObj := hkdf.New(sha256.New, combinedSecret, salt, info) - derivedKey := make([]byte, hybridNISTWrapKeySize) - if _, err := io.ReadFull(hkdfObj, derivedKey); err != nil { - return nil, fmt.Errorf("hkdf failure: %w", err) - } - - return derivedKey, nil -} diff --git a/lib/ocrypto/hybrid_nist_test.go b/lib/ocrypto/hybrid_nist_test.go index e7c9808aae..bc9e238f56 100644 --- a/lib/ocrypto/hybrid_nist_test.go +++ b/lib/ocrypto/hybrid_nist_test.go @@ -2,6 +2,7 @@ package ocrypto import ( "encoding/asn1" + "encoding/pem" "testing" "github.com/stretchr/testify/assert" @@ -17,13 +18,19 @@ func TestP256MLKEM768KeyPairAndPEM(t *testing.T) { privatePEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - publicKey, err := P256MLKEM768PubKeyFromPem([]byte(publicPEM)) + // Public/private PEMs round-trip via the OID-routed dispatcher. + enc, err := FromPublicPEM(publicPEM) require.NoError(t, err) - privateKey, err := P256MLKEM768PrivateKeyFromPem([]byte(privatePEM)) + dec, err := FromPrivatePEM(privatePEM) require.NoError(t, err) - assert.Len(t, publicKey, P256MLKEM768PublicKeySize) - assert.Len(t, privateKey, P256MLKEM768PrivateKeySize) + wrapped, err := enc.Encrypt([]byte("round-trip")) + require.NoError(t, err) + plaintext, err := dec.Decrypt(wrapped) + require.NoError(t, err) + assert.Equal(t, []byte("round-trip"), plaintext) + + assert.Len(t, keyPair.publicKey, P256MLKEM768PublicKeySize) assert.Equal(t, HybridSecp256r1MLKEM768Key, keyPair.GetKeyType()) } @@ -36,13 +43,18 @@ func TestP384MLKEM1024KeyPairAndPEM(t *testing.T) { privatePEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - publicKey, err := P384MLKEM1024PubKeyFromPem([]byte(publicPEM)) + enc, err := FromPublicPEM(publicPEM) require.NoError(t, err) - privateKey, err := P384MLKEM1024PrivateKeyFromPem([]byte(privatePEM)) + dec, err := FromPrivatePEM(privatePEM) require.NoError(t, err) - assert.Len(t, publicKey, P384MLKEM1024PublicKeySize) - assert.Len(t, privateKey, P384MLKEM1024PrivateKeySize) + wrapped, err := enc.Encrypt([]byte("round-trip-384")) + require.NoError(t, err) + plaintext, err := dec.Decrypt(wrapped) + require.NoError(t, err) + assert.Equal(t, []byte("round-trip-384"), plaintext) + + assert.Len(t, keyPair.publicKey, P384MLKEM1024PublicKeySize) assert.Equal(t, HybridSecp384r1MLKEM1024Key, keyPair.GetKeyType()) } @@ -140,10 +152,10 @@ func TestP256MLKEM768PEMDispatch(t *testing.T) { privatePEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - encryptor, err := FromPublicPEMWithSalt(publicPEM, []byte("salt"), []byte("info")) + encryptor, err := FromPublicPEM(publicPEM) require.NoError(t, err) - decryptor, err := FromPrivatePEMWithSalt(privatePEM, []byte("salt"), []byte("info")) + decryptor, err := FromPrivatePEM(privatePEM) require.NoError(t, err) nistEncryptor, ok := encryptor.(*HybridNISTEncryptor) @@ -176,10 +188,10 @@ func TestP384MLKEM1024PEMDispatch(t *testing.T) { privatePEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - encryptor, err := FromPublicPEMWithSalt(publicPEM, []byte("salt"), []byte("info")) + encryptor, err := FromPublicPEM(publicPEM) require.NoError(t, err) - decryptor, err := FromPrivatePEMWithSalt(privatePEM, []byte("salt"), []byte("info")) + decryptor, err := FromPrivatePEM(privatePEM) require.NoError(t, err) nistEncryptor, ok := encryptor.(*HybridNISTEncryptor) @@ -199,36 +211,41 @@ func TestP384MLKEM1024PEMDispatch(t *testing.T) { assert.Equal(t, []byte("dispatch-dek-384"), plaintext) } -func TestP256MLKEM768Encapsulate(t *testing.T) { - keyPair, err := NewP256MLKEM768KeyPair() - require.NoError(t, err) - - pubKey, err := keyPair.PublicKeyInPemFormat() - require.NoError(t, err) - - pubKeyRaw, err := P256MLKEM768PubKeyFromPem([]byte(pubKey)) - require.NoError(t, err) - - combinedSecret, hybridCt, err := P256MLKEM768Encapsulate(pubKeyRaw) - require.NoError(t, err) - assert.NotEmpty(t, combinedSecret) - assert.Len(t, hybridCt, P256MLKEM768CiphertextSize) -} - -func TestP384MLKEM1024Encapsulate(t *testing.T) { - keyPair, err := NewP384MLKEM1024KeyPair() - require.NoError(t, err) - - pubKey, err := keyPair.PublicKeyInPemFormat() - require.NoError(t, err) - - pubKeyRaw, err := P384MLKEM1024PubKeyFromPem([]byte(pubKey)) - require.NoError(t, err) - - combinedSecret, hybridCt, err := P384MLKEM1024Encapsulate(pubKeyRaw) - require.NoError(t, err) - assert.NotEmpty(t, combinedSecret) - assert.Len(t, hybridCt, P384MLKEM1024CiphertextSize) +// TestHybridNISTPEMShape verifies that the emitted PEM blocks carry the +// expected SPKI/PKCS#8 envelope and OID per draft-ietf-lamps-pq-composite-kem-14. +func TestHybridNISTPEMShape(t *testing.T) { + cases := []struct { + name string + gen func() (HybridNISTKeyPair, error) + oid asn1.ObjectIdentifier + }{ + {"P256+MLKEM768", NewP256MLKEM768KeyPair, oidCompositeMLKEM768P256}, + {"P384+MLKEM1024", NewP384MLKEM1024KeyPair, oidCompositeMLKEM1024P384}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + kp, err := tc.gen() + require.NoError(t, err) + + pubPEM, err := kp.PublicKeyInPemFormat() + require.NoError(t, err) + pubBlock, _ := pem.Decode([]byte(pubPEM)) + require.NotNil(t, pubBlock) + assert.Equal(t, "PUBLIC KEY", pubBlock.Type) + gotOID, _, err := parseHybridSPKI(pubBlock.Bytes) + require.NoError(t, err) + assert.True(t, gotOID.Equal(tc.oid), "SPKI OID mismatch: got %v want %v", gotOID, tc.oid) + + privPEM, err := kp.PrivateKeyInPemFormat() + require.NoError(t, err) + privBlock, _ := pem.Decode([]byte(privPEM)) + require.NotNil(t, privBlock) + assert.Equal(t, "PRIVATE KEY", privBlock.Type) + gotOID, _, err = parseHybridPKCS8(privBlock.Bytes) + require.NoError(t, err) + assert.True(t, gotOID.Equal(tc.oid), "PKCS#8 OID mismatch: got %v want %v", gotOID, tc.oid) + }) + } } func TestIsHybridKeyTypeIncludesNewTypes(t *testing.T) { diff --git a/lib/ocrypto/pem_blocks.go b/lib/ocrypto/pem_blocks.go new file mode 100644 index 0000000000..234b6eaafa --- /dev/null +++ b/lib/ocrypto/pem_blocks.go @@ -0,0 +1,10 @@ +package ocrypto + +// Standard RFC 7468 PEM block type labels. Used by hybrid (X-Wing, P-256+ML-KEM-768, +// P-384+ML-KEM-1024) key serialization; routing happens by the AlgorithmIdentifier +// OID inside the SPKI/PKCS#8 envelope, not by the PEM block type. +const ( + pemBlockPublicKey = "PUBLIC KEY" + pemBlockPrivateKey = "PRIVATE KEY" + pemBlockCertificate = "CERTIFICATE" +) diff --git a/lib/ocrypto/pq_asn1.go b/lib/ocrypto/pq_asn1.go new file mode 100644 index 0000000000..89d5f6d675 --- /dev/null +++ b/lib/ocrypto/pq_asn1.go @@ -0,0 +1,109 @@ +package ocrypto + +import ( + "encoding/asn1" + "errors" + "fmt" +) + +const bitsPerByte = 8 + +// pkixAlgorithmIdentifier matches the structure used by the SPKI/PKCS#8 envelopes +// in RFC 5280 and RFC 5958. For the hybrid PQ/T schemes covered here the +// parameters field is always absent, so it is modelled as an optional RawValue. +type pkixAlgorithmIdentifier struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.RawValue `asn1:"optional"` +} + +type subjectPublicKeyInfo struct { + Algorithm pkixAlgorithmIdentifier + SubjectPublicKey asn1.BitString +} + +// oneAsymmetricKey is the RFC 5958 PKCS#8 structure. Only v1 (Version = 0) with +// no attributes or publicKey is used here. +// +// We intentionally do NOT add the optional `Attributes [0] IMPLICIT ...` / +// `PublicKey [1] IMPLICIT ...` fields. PKCS#8 v2 inputs that carry them will +// leave trailing bytes after `asn1.Unmarshal`, which parseHybridPKCS8 rejects; +// the hybrid drafts use the bare v1 envelope. +type oneAsymmetricKey struct { + Version int + Algorithm pkixAlgorithmIdentifier + PrivateKey []byte +} + +// marshalHybridSPKI wraps a raw hybrid public key in a SubjectPublicKeyInfo +// whose AlgorithmIdentifier carries the supplied OID with no parameters. +func marshalHybridSPKI(oid asn1.ObjectIdentifier, raw []byte) ([]byte, error) { + spki := subjectPublicKeyInfo{ + Algorithm: pkixAlgorithmIdentifier{Algorithm: oid}, + SubjectPublicKey: asn1.BitString{ + Bytes: raw, + BitLength: len(raw) * bitsPerByte, + }, + } + der, err := asn1.Marshal(spki) + if err != nil { + return nil, fmt.Errorf("marshal SPKI: %w", err) + } + return der, nil +} + +// parseHybridSPKI returns the AlgorithmIdentifier OID and the raw BIT STRING +// payload of a hybrid SubjectPublicKeyInfo. It does not validate the OID — the +// caller decides whether the OID is one it understands. +func parseHybridSPKI(der []byte) (asn1.ObjectIdentifier, []byte, error) { + var spki subjectPublicKeyInfo + rest, err := asn1.Unmarshal(der, &spki) + if err != nil { + return nil, nil, fmt.Errorf("parse SPKI: %w", err) + } + if len(rest) != 0 { + return nil, nil, fmt.Errorf("parse SPKI: %d trailing bytes", len(rest)) + } + if spki.SubjectPublicKey.BitLength != len(spki.SubjectPublicKey.Bytes)*bitsPerByte { + return nil, nil, errors.New("parse SPKI: unexpected unused bits in BIT STRING") + } + if len(spki.Algorithm.Parameters.FullBytes) != 0 { + return nil, nil, errors.New("parse SPKI: AlgorithmIdentifier parameters must be absent for hybrid schemes") + } + return spki.Algorithm.Algorithm, spki.SubjectPublicKey.Bytes, nil +} + +// marshalHybridPKCS8 wraps a raw hybrid private key in a PKCS#8 OneAsymmetricKey +// envelope (v1) whose AlgorithmIdentifier carries the supplied OID with no +// parameters. +func marshalHybridPKCS8(oid asn1.ObjectIdentifier, raw []byte) ([]byte, error) { + pkcs8 := oneAsymmetricKey{ + Version: 0, + Algorithm: pkixAlgorithmIdentifier{Algorithm: oid}, + PrivateKey: raw, + } + der, err := asn1.Marshal(pkcs8) + if err != nil { + return nil, fmt.Errorf("marshal PKCS#8: %w", err) + } + return der, nil +} + +// parseHybridPKCS8 returns the AlgorithmIdentifier OID and the raw OCTET STRING +// payload of a hybrid PKCS#8 OneAsymmetricKey envelope. +func parseHybridPKCS8(der []byte) (asn1.ObjectIdentifier, []byte, error) { + var pkcs8 oneAsymmetricKey + rest, err := asn1.Unmarshal(der, &pkcs8) + if err != nil { + return nil, nil, fmt.Errorf("parse PKCS#8: %w", err) + } + if len(rest) != 0 { + return nil, nil, fmt.Errorf("parse PKCS#8: %d trailing bytes", len(rest)) + } + if pkcs8.Version != 0 { + return nil, nil, fmt.Errorf("parse PKCS#8: unsupported version %d", pkcs8.Version) + } + if len(pkcs8.Algorithm.Parameters.FullBytes) != 0 { + return nil, nil, errors.New("parse PKCS#8: AlgorithmIdentifier parameters must be absent for hybrid schemes") + } + return pkcs8.Algorithm.Algorithm, pkcs8.PrivateKey, nil +} diff --git a/lib/ocrypto/pq_asn1_test.go b/lib/ocrypto/pq_asn1_test.go new file mode 100644 index 0000000000..523e8dfcde --- /dev/null +++ b/lib/ocrypto/pq_asn1_test.go @@ -0,0 +1,77 @@ +package ocrypto + +import ( + "bytes" + "encoding/asn1" + "testing" +) + +func TestMarshalParseHybridSPKI_RoundTrip(t *testing.T) { + oid := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 59} + raw := bytes.Repeat([]byte{0xAB}, 1249) + + der, err := marshalHybridSPKI(oid, raw) + if err != nil { + t.Fatalf("marshalHybridSPKI: %v", err) + } + + gotOID, gotRaw, err := parseHybridSPKI(der) + if err != nil { + t.Fatalf("parseHybridSPKI: %v", err) + } + if !gotOID.Equal(oid) { + t.Fatalf("OID mismatch: got %v want %v", gotOID, oid) + } + if !bytes.Equal(gotRaw, raw) { + t.Fatalf("raw mismatch") + } +} + +func TestMarshalParseHybridPKCS8_RoundTrip(t *testing.T) { + oid := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62253, 25722} + raw := bytes.Repeat([]byte{0xCD}, 96) + + der, err := marshalHybridPKCS8(oid, raw) + if err != nil { + t.Fatalf("marshalHybridPKCS8: %v", err) + } + + gotOID, gotRaw, err := parseHybridPKCS8(der) + if err != nil { + t.Fatalf("parseHybridPKCS8: %v", err) + } + if !gotOID.Equal(oid) { + t.Fatalf("OID mismatch: got %v want %v", gotOID, oid) + } + if !bytes.Equal(gotRaw, raw) { + t.Fatalf("raw mismatch") + } +} + +func TestParseHybridSPKI_TrailingBytes(t *testing.T) { + oid := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 59} + der, err := marshalHybridSPKI(oid, []byte{1, 2, 3}) + if err != nil { + t.Fatalf("marshalHybridSPKI: %v", err) + } + der = append(der, 0x00) + if _, _, err := parseHybridSPKI(der); err == nil { + t.Fatalf("expected trailing-bytes error") + } +} + +func TestParseHybridPKCS8_Version(t *testing.T) { + // Hand-built PKCS#8 with version=1 should be rejected. + pkcs8 := oneAsymmetricKey{ + Version: 1, + Algorithm: pkixAlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62253, 25722}}, + PrivateKey: []byte{0xAA}, + } + der, err := asn1.Marshal(pkcs8) + if err != nil { + t.Fatalf("asn1.Marshal: %v", err) + } + if _, _, err := parseHybridPKCS8(der); err == nil { + t.Fatalf("expected version error") + } +} diff --git a/lib/ocrypto/pq_oids.go b/lib/ocrypto/pq_oids.go new file mode 100644 index 0000000000..92c8e5b639 --- /dev/null +++ b/lib/ocrypto/pq_oids.go @@ -0,0 +1,56 @@ +package ocrypto + +import ( + "bytes" + "encoding/asn1" +) + +// OIDs assigned to the hybrid post-quantum/traditional KEMs we support. +// +// The composite-KEM OIDs come from `draft-ietf-lamps-pq-composite-kem-14` +// (IANA SMI Security for PKIX Algorithms arc 1.3.6.1.5.5.7.6). The X-Wing OID +// is the one registered by `draft-connolly-cfrg-xwing-kem-10` under the +// Connolly private enterprise arc 1.3.6.1.4.1.62253. +var ( + oidCompositeMLKEM768P256 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 59} + oidCompositeMLKEM1024P384 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 63} + oidXWing = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62253, 25722} +) + +// hybridOIDDERs holds the DER (tag+length+content) byte forms of the hybrid +// OIDs, used by containsHybridOID for cheap detection inside a larger DER +// blob (e.g. a certificate's TBSCertificate). +var hybridOIDDERs = func() [][]byte { + oids := []asn1.ObjectIdentifier{oidXWing, oidCompositeMLKEM768P256, oidCompositeMLKEM1024P384} + out := make([][]byte, 0, len(oids)) + for _, oid := range oids { + der, err := asn1.Marshal(oid) + if err != nil { + panic("asn1.Marshal of hybrid OID failed: " + err.Error()) + } + out = append(out, der) + } + return out +}() + +// containsHybridOID reports whether der contains the DER encoding of any of +// our hybrid algorithm OIDs as a substring. Intended for the CERTIFICATE +// rejection path — false positives would require an unrelated OID to share +// the exact byte sequence, which is implausible given the specificity of the +// OIDs registered for these schemes. +func containsHybridOID(der []byte) bool { + for _, oidDER := range hybridOIDDERs { + if bytes.Contains(der, oidDER) { + return true + } + } + return false +} + +// ASCII Labels mixed into the composite-KEM combiner (draft-14 §3.4) to +// domain-separate ciphertexts produced under different curve/ML-KEM pairings. +// The label values themselves are registered in draft-14 §6 alongside the OIDs. +const ( + labelMLKEM768P256 = "MLKEM768-P256" + labelMLKEM1024P384 = "MLKEM1024-P384" +) diff --git a/lib/ocrypto/rsa_key_pair.go b/lib/ocrypto/rsa_key_pair.go index 914eb8f80c..5294e10c13 100644 --- a/lib/ocrypto/rsa_key_pair.go +++ b/lib/ocrypto/rsa_key_pair.go @@ -41,7 +41,7 @@ func (keyPair RsaKeyPair) PrivateKeyInPemFormat() (string, error) { privateKeyPem := pem.EncodeToMemory( &pem.Block{ - Type: "PRIVATE KEY", + Type: pemBlockPrivateKey, Bytes: privateKeyBytes, }, ) @@ -61,7 +61,7 @@ func (keyPair RsaKeyPair) PublicKeyInPemFormat() (string, error) { publicKeyPem := pem.EncodeToMemory( &pem.Block{ - Type: "PUBLIC KEY", + Type: pemBlockPublicKey, Bytes: publicKeyBytes, }, ) diff --git a/lib/ocrypto/xwing.go b/lib/ocrypto/xwing.go index 1ea9d5ab25..b92c93fecb 100644 --- a/lib/ocrypto/xwing.go +++ b/lib/ocrypto/xwing.go @@ -18,11 +18,23 @@ const ( XWingPublicKeySize = xwing.PublicKeySize XWingPrivateKeySize = xwing.PrivateKeySize XWingCiphertextSize = xwing.CiphertextSize - - PEMBlockXWingPublicKey = "XWING PUBLIC KEY" - PEMBlockXWingPrivateKey = "XWING PRIVATE KEY" ) +// X-Wing wire-format note: +// +// The KEM primitive comes from github.com/cloudflare/circl/kem/xwing, which +// currently implements draft-connolly-cfrg-xwing-kem-05. The SPKI/PKCS#8 +// envelope and AlgorithmIdentifier OID (id-XWing, draft-10 §5.8) follow +// draft-10. The two drafts differ in the internal labeling/KDF chain of the +// KEM itself, so wrapped keys produced here are NOT wire-compatible with a +// pure draft-10 implementation. +// +// TODO(DSPX-TBD): swap the primitive to a draft-10 implementation once one +// is available in Go (tracking: upgrade cloudflare/circl xwing to draft-10). + +// XWingWrappedKey is the ASN.1 envelope stored in wrapped_key. The X-Wing +// drafts define only the KEM; this DEK wrapping envelope is local to OpenTDF +// and unchanged across draft revisions. type XWingWrappedKey struct { XWingCiphertext []byte `asn1:"tag:0"` EncryptedDEK []byte `asn1:"tag:1"` @@ -63,25 +75,25 @@ func NewXWingKeyPair() (XWingKeyPair, error) { } func (k XWingKeyPair) PublicKeyInPemFormat() (string, error) { - return rawToPEM(PEMBlockXWingPublicKey, k.publicKey, XWingPublicKeySize) + der, err := marshalHybridSPKI(oidXWing, k.publicKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: pemBlockPublicKey, Bytes: der})), nil } func (k XWingKeyPair) PrivateKeyInPemFormat() (string, error) { - return rawToPEM(PEMBlockXWingPrivateKey, k.privateKey, XWingPrivateKeySize) + der, err := marshalHybridPKCS8(oidXWing, k.privateKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: pemBlockPrivateKey, Bytes: der})), nil } func (k XWingKeyPair) GetKeyType() KeyType { return HybridXWingKey } -func XWingPubKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockXWingPublicKey, XWingPublicKeySize) -} - -func XWingPrivateKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockXWingPrivateKey, XWingPrivateKeySize) -} - func NewXWingEncryptor(publicKey, salt, info []byte) (*XWingEncryptor, error) { if len(publicKey) != XWingPublicKeySize { return nil, fmt.Errorf("invalid X-Wing public key size: got %d want %d", len(publicKey), XWingPublicKeySize) @@ -99,7 +111,11 @@ func (e *XWingEncryptor) Encrypt(data []byte) ([]byte, error) { } func (e *XWingEncryptor) PublicKeyInPemFormat() (string, error) { - return rawToPEM(PEMBlockXWingPublicKey, e.publicKey, XWingPublicKeySize) + der, err := marshalHybridSPKI(oidXWing, e.publicKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: pemBlockPublicKey, Bytes: der})), nil } func (e *XWingEncryptor) Type() SchemeType { @@ -138,6 +154,12 @@ func (d *XWingDecryptor) Decrypt(data []byte) ([]byte, error) { return xwingUnwrapDEK(d.privateKey, data, d.salt, d.info) } +// KeyType identifies the hybrid scheme so KAS-layer callers can cross-check +// the OID-routed decryptor against an asserted algorithm before trusting it. +func (d *XWingDecryptor) KeyType() KeyType { + return HybridXWingKey +} + func XWingWrapDEK(publicKeyRaw, dek []byte) ([]byte, error) { return xwingWrapDEK(publicKeyRaw, dek, defaultTDFSalt(), nil) } @@ -243,18 +265,3 @@ func deriveXWingWrapKey(sharedSecret, salt, info []byte) ([]byte, error) { return derivedKey, nil } - -func decodeSizedPEMBlock(data []byte, blockType string, expectedSize int) ([]byte, error) { - block, _ := pem.Decode(data) - if block == nil { - return nil, fmt.Errorf("failed to parse PEM formatted %s", blockType) - } - if block.Type != blockType { - return nil, fmt.Errorf("unexpected PEM block type: got %s want %s", block.Type, blockType) - } - if len(block.Bytes) != expectedSize { - return nil, fmt.Errorf("invalid %s size: got %d want %d", blockType, len(block.Bytes), expectedSize) - } - - return append([]byte(nil), block.Bytes...), nil -} diff --git a/lib/ocrypto/xwing_test.go b/lib/ocrypto/xwing_test.go index c0bf783ba9..1eea9c4e8c 100644 --- a/lib/ocrypto/xwing_test.go +++ b/lib/ocrypto/xwing_test.go @@ -2,6 +2,7 @@ package ocrypto import ( "encoding/asn1" + "encoding/pem" "testing" "github.com/stretchr/testify/assert" @@ -17,13 +18,19 @@ func TestXWingKeyPairAndPEM(t *testing.T) { privatePEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - publicKey, err := XWingPubKeyFromPem([]byte(publicPEM)) + enc, err := FromPublicPEM(publicPEM) require.NoError(t, err) - privateKey, err := XWingPrivateKeyFromPem([]byte(privatePEM)) + dec, err := FromPrivatePEM(privatePEM) require.NoError(t, err) - assert.Len(t, publicKey, XWingPublicKeySize) - assert.Len(t, privateKey, XWingPrivateKeySize) + wrapped, err := enc.Encrypt([]byte("round-trip")) + require.NoError(t, err) + plaintext, err := dec.Decrypt(wrapped) + require.NoError(t, err) + assert.Equal(t, []byte("round-trip"), plaintext) + + assert.Len(t, keyPair.publicKey, XWingPublicKeySize) + assert.Len(t, keyPair.privateKey, XWingPrivateKeySize) assert.Equal(t, HybridXWingKey, keyPair.GetKeyType()) } @@ -112,6 +119,33 @@ func TestXWingPEMDispatch(t *testing.T) { assert.Equal(t, []byte("dispatch-dek"), plaintext) } +// TestXWingPEMShape verifies that the emitted PEM blocks carry the X-Wing +// OID inside standard SPKI/PKCS#8 envelopes per draft-connolly-cfrg-xwing-kem-10. +func TestXWingPEMShape(t *testing.T) { + kp, err := NewXWingKeyPair() + require.NoError(t, err) + + pubPEM, err := kp.PublicKeyInPemFormat() + require.NoError(t, err) + pubBlock, _ := pem.Decode([]byte(pubPEM)) + require.NotNil(t, pubBlock) + assert.Equal(t, "PUBLIC KEY", pubBlock.Type) + gotOID, raw, err := parseHybridSPKI(pubBlock.Bytes) + require.NoError(t, err) + assert.True(t, gotOID.Equal(oidXWing)) + assert.Len(t, raw, XWingPublicKeySize) + + privPEM, err := kp.PrivateKeyInPemFormat() + require.NoError(t, err) + privBlock, _ := pem.Decode([]byte(privPEM)) + require.NotNil(t, privBlock) + assert.Equal(t, "PRIVATE KEY", privBlock.Type) + gotOID, raw, err = parseHybridPKCS8(privBlock.Bytes) + require.NoError(t, err) + assert.True(t, gotOID.Equal(oidXWing)) + assert.Len(t, raw, XWingPrivateKeySize) +} + func TestXWingEncapsulate(t *testing.T) { keyPair, err := NewXWingKeyPair() require.NoError(t, err) diff --git a/sdk/experimental/tdf/key_access_test.go b/sdk/experimental/tdf/key_access_test.go index daea79b69c..6deac12f27 100644 --- a/sdk/experimental/tdf/key_access_test.go +++ b/sdk/experimental/tdf/key_access_test.go @@ -517,10 +517,10 @@ func TestWrapKeyWithPublicKey(t *testing.T) { privateKeyPEM, err := xwingKeyPair.PrivateKeyInPemFormat() require.NoError(t, err) - privateKey, err := ocrypto.XWingPrivateKeyFromPem([]byte(privateKeyPEM)) + dec, err := ocrypto.FromPrivatePEM(privateKeyPEM) require.NoError(t, err) - plaintext, err := ocrypto.XWingUnwrapDEK(privateKey, decodedWrappedKey) + plaintext, err := dec.Decrypt(decodedWrappedKey) require.NoError(t, err) assert.Equal(t, symKey, plaintext) }) @@ -555,10 +555,10 @@ func TestWrapKeyWithPublicKey(t *testing.T) { privateKeyPEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - privateKey, err := ocrypto.P256MLKEM768PrivateKeyFromPem([]byte(privateKeyPEM)) + dec, err := ocrypto.FromPrivatePEM(privateKeyPEM) require.NoError(t, err) - plaintext, err := ocrypto.P256MLKEM768UnwrapDEK(privateKey, decodedWrappedKey) + plaintext, err := dec.Decrypt(decodedWrappedKey) require.NoError(t, err) assert.Equal(t, symKey, plaintext) }) @@ -593,10 +593,10 @@ func TestWrapKeyWithPublicKey(t *testing.T) { privateKeyPEM, err := keyPair.PrivateKeyInPemFormat() require.NoError(t, err) - privateKey, err := ocrypto.P384MLKEM1024PrivateKeyFromPem([]byte(privateKeyPEM)) + dec, err := ocrypto.FromPrivatePEM(privateKeyPEM) require.NoError(t, err) - plaintext, err := ocrypto.P384MLKEM1024UnwrapDEK(privateKey, decodedWrappedKey) + plaintext, err := dec.Decrypt(decodedWrappedKey) require.NoError(t, err) assert.Equal(t, symKey, plaintext) }) diff --git a/sdk/experimental/tdf/writer_test.go b/sdk/experimental/tdf/writer_test.go index 08f35cfcc8..415927c4c8 100644 --- a/sdk/experimental/tdf/writer_test.go +++ b/sdk/experimental/tdf/writer_test.go @@ -202,7 +202,8 @@ func testInitialAttributesOnWriter(t *testing.T) { } initKAS := &policy.SimpleKasKey{KasUri: testKAS1, PublicKey: &policy.SimpleKasPublicKey{Algorithm: policy.Algorithm_ALGORITHM_RSA_2048, Kid: "kid1", Pem: mockRSAPublicKey1}} - writer, err := NewWriter(ctx, + writer, err := NewWriter( + ctx, WithInitialAttributes(initAttrs), WithDefaultKASForWriter(initKAS), ) @@ -230,7 +231,8 @@ func testInitialAttributesOnWriter(t *testing.T) { assert.True(t, found, "policy should include initial attribute") // Overrides at Finalize should take precedence - writer2, err := NewWriter(ctx, + writer2, err := NewWriter( + ctx, WithInitialAttributes(initAttrs), WithDefaultKASForWriter(initKAS), ) @@ -487,7 +489,8 @@ func testManifestGeneration(t *testing.T) { customMimeType := "application/json" encryptedMetadata := "Custom metadata content" - finalizeResult, err := writer.Finalize(ctx, + finalizeResult, err := writer.Finalize( + ctx, WithAttributeValues(attributes), WithPayloadMimeType(customMimeType), WithEncryptedMetadata(encryptedMetadata), @@ -525,7 +528,7 @@ func testManifestGeneration(t *testing.T) { // Verify policy content policyBytes, err := ocrypto.Base64Decode([]byte(encInfo.Policy)) - require.NoError(t, (err)) + require.NoError(t, err) // Policy bytes are now raw JSON, not base64 encoded var policy Policy err = json.Unmarshal(policyBytes, &policy) @@ -564,7 +567,8 @@ func testAssertionsAndMetadata(t *testing.T) { attributes := []*policy.Value{ createTestAttribute("https://example.com/attr/Sensitivity/value/Restricted", testKAS1, "kid1"), } - finalizeResult, err := writer.Finalize(ctx, + finalizeResult, err := writer.Finalize( + ctx, WithAttributeValues(attributes), WithEncryptedMetadata("Sensitive metadata content"), WithAssertions(testAssertion), @@ -892,35 +896,17 @@ func createTestAttributeWithAlgorithm(t *testing.T, fqn, kasURL, kid string, alg // hybridUnwrapForTest base64-decodes the wrappedKey from a manifest KAO and // unwraps it with the matching hybrid private key, asserting the recovered DEK -// is non-empty. This proves the writer's `hybrid-wrapped` output is consumable -// by the corresponding ocrypto unwrap path. -func hybridUnwrapForTest(t *testing.T, ktype ocrypto.KeyType, privatePEM, wrappedKeyB64 string) { +// exactly matches the writer DEK. +func hybridUnwrapForTest(t *testing.T, ktype ocrypto.KeyType, privatePEM, wrappedKeyB64 string, expectedDEK []byte) { t.Helper() wrappedDER, err := ocrypto.Base64Decode([]byte(wrappedKeyB64)) require.NoError(t, err, "Base64Decode wrapped key") - switch ktype { //nolint:exhaustive // only handle hybrid types - case ocrypto.HybridXWingKey: - raw, err := ocrypto.XWingPrivateKeyFromPem([]byte(privatePEM)) - require.NoError(t, err) - dek, err := ocrypto.XWingUnwrapDEK(raw, wrappedDER) - require.NoError(t, err, "XWingUnwrapDEK") - assert.NotEmpty(t, dek, "X-Wing recovered DEK") - case ocrypto.HybridSecp256r1MLKEM768Key: - raw, err := ocrypto.P256MLKEM768PrivateKeyFromPem([]byte(privatePEM)) - require.NoError(t, err) - dek, err := ocrypto.P256MLKEM768UnwrapDEK(raw, wrappedDER) - require.NoError(t, err, "P256MLKEM768UnwrapDEK") - assert.NotEmpty(t, dek, "P-256+ML-KEM-768 recovered DEK") - case ocrypto.HybridSecp384r1MLKEM1024Key: - raw, err := ocrypto.P384MLKEM1024PrivateKeyFromPem([]byte(privatePEM)) - require.NoError(t, err) - dek, err := ocrypto.P384MLKEM1024UnwrapDEK(raw, wrappedDER) - require.NoError(t, err, "P384MLKEM1024UnwrapDEK") - assert.NotEmpty(t, dek, "P-384+ML-KEM-1024 recovered DEK") - default: - t.Fatalf("unsupported hybrid key type for round-trip: %s", ktype) - } + dec, err := ocrypto.FromPrivatePEM(privatePEM) + require.NoError(t, err, "FromPrivatePEM") + dek, err := dec.Decrypt(wrappedDER) + require.NoError(t, err, "hybrid Decrypt") + assert.Equal(t, expectedDEK, dek, "%s recovered DEK", ktype) } func testHybridXWingFlow(t *testing.T) { @@ -935,6 +921,7 @@ func testHybridXWingFlow(t *testing.T) { writer, err := NewWriter(ctx) require.NoError(t, err) + expectedDEK := append([]byte(nil), writer.dek...) _, err = writer.WriteSegment(ctx, 0, []byte("hybrid xwing test data")) require.NoError(t, err) @@ -956,7 +943,7 @@ func testHybridXWingFlow(t *testing.T) { assert.NotEmpty(t, keyAccess.WrappedKey) validateManifestSchema(t, result.Manifest) - hybridUnwrapForTest(t, ocrypto.HybridXWingKey, privPEM, keyAccess.WrappedKey) + hybridUnwrapForTest(t, ocrypto.HybridXWingKey, privPEM, keyAccess.WrappedKey, expectedDEK) } func testHybridP256MLKEM768Flow(t *testing.T) { @@ -971,6 +958,7 @@ func testHybridP256MLKEM768Flow(t *testing.T) { writer, err := NewWriter(ctx) require.NoError(t, err) + expectedDEK := append([]byte(nil), writer.dek...) _, err = writer.WriteSegment(ctx, 0, []byte("hybrid p256 mlkem768 test data")) require.NoError(t, err) @@ -992,7 +980,7 @@ func testHybridP256MLKEM768Flow(t *testing.T) { assert.NotEmpty(t, keyAccess.WrappedKey) validateManifestSchema(t, result.Manifest) - hybridUnwrapForTest(t, ocrypto.HybridSecp256r1MLKEM768Key, privPEM, keyAccess.WrappedKey) + hybridUnwrapForTest(t, ocrypto.HybridSecp256r1MLKEM768Key, privPEM, keyAccess.WrappedKey, expectedDEK) } func testHybridP384MLKEM1024Flow(t *testing.T) { @@ -1007,6 +995,7 @@ func testHybridP384MLKEM1024Flow(t *testing.T) { writer, err := NewWriter(ctx) require.NoError(t, err) + expectedDEK := append([]byte(nil), writer.dek...) _, err = writer.WriteSegment(ctx, 0, []byte("hybrid p384 mlkem1024 test data")) require.NoError(t, err) @@ -1028,7 +1017,7 @@ func testHybridP384MLKEM1024Flow(t *testing.T) { assert.NotEmpty(t, keyAccess.WrappedKey) validateManifestSchema(t, result.Manifest) - hybridUnwrapForTest(t, ocrypto.HybridSecp384r1MLKEM1024Key, privPEM, keyAccess.WrappedKey) + hybridUnwrapForTest(t, ocrypto.HybridSecp384r1MLKEM1024Key, privPEM, keyAccess.WrappedKey, expectedDEK) } // validateManifestSchema validates a TDF manifest against the JSON schema diff --git a/sdk/tdf_hybrid_test.go b/sdk/tdf_hybrid_test.go index 86eb1e98e5..c415bb421d 100644 --- a/sdk/tdf_hybrid_test.go +++ b/sdk/tdf_hybrid_test.go @@ -22,13 +22,13 @@ func TestCreateKeyAccessWithXWingKey(t *testing.T) { assert.Empty(t, keyAccess.EphemeralPublicKey) assert.NotEmpty(t, keyAccess.WrappedKey) - privateKey, err := ocrypto.XWingPrivateKeyFromPem([]byte(mockHybridXWingPrivateKey)) + dec, err := ocrypto.FromPrivatePEM(mockHybridXWingPrivateKey) require.NoError(t, err) wrappedKey, err := ocrypto.Base64Decode([]byte(keyAccess.WrappedKey)) require.NoError(t, err) - plaintext, err := ocrypto.XWingUnwrapDEK(privateKey, wrappedKey) + plaintext, err := dec.Decrypt(wrappedKey) require.NoError(t, err) assert.Equal(t, symKey, plaintext) } @@ -47,13 +47,13 @@ func TestCreateKeyAccessWithP256MLKEM768Key(t *testing.T) { assert.Empty(t, keyAccess.EphemeralPublicKey) assert.NotEmpty(t, keyAccess.WrappedKey) - privateKey, err := ocrypto.P256MLKEM768PrivateKeyFromPem([]byte(mockHybridP256MLKEM768PrivateKey)) + dec, err := ocrypto.FromPrivatePEM(mockHybridP256MLKEM768PrivateKey) require.NoError(t, err) wrappedKey, err := ocrypto.Base64Decode([]byte(keyAccess.WrappedKey)) require.NoError(t, err) - plaintext, err := ocrypto.P256MLKEM768UnwrapDEK(privateKey, wrappedKey) + plaintext, err := dec.Decrypt(wrappedKey) require.NoError(t, err) assert.Equal(t, symKey, plaintext) } @@ -72,13 +72,13 @@ func TestCreateKeyAccessWithP384MLKEM1024Key(t *testing.T) { assert.Empty(t, keyAccess.EphemeralPublicKey) assert.NotEmpty(t, keyAccess.WrappedKey) - privateKey, err := ocrypto.P384MLKEM1024PrivateKeyFromPem([]byte(mockHybridP384MLKEM1024PrivateKey)) + dec, err := ocrypto.FromPrivatePEM(mockHybridP384MLKEM1024PrivateKey) require.NoError(t, err) wrappedKey, err := ocrypto.Base64Decode([]byte(keyAccess.WrappedKey)) require.NoError(t, err) - plaintext, err := ocrypto.P384MLKEM1024UnwrapDEK(privateKey, wrappedKey) + plaintext, err := dec.Decrypt(wrappedKey) require.NoError(t, err) assert.Equal(t, symKey, plaintext) } diff --git a/sdk/tdf_test.go b/sdk/tdf_test.go index 650fce17b1..de75b9a8d2 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -260,119 +260,127 @@ CgYIKoZIzj0EAwIDSAAwRQIhALYXC70t37RlmIkRDlUTehiVEHpSQXz04wQ9Ivw+ 4h4hAiBNR3rD3KieiJaiJrCfM6TPJL7TIch7pAhMHdG6IPJMoQ== -----END CERTIFICATE-----` - mockHybridXWingPublicKey = `-----BEGIN XWING PUBLIC KEY----- -4pKOW2Z+TqohuAO7Z8m1J9Ik5jbLOZgpfKKTdEXD4mqLgXa8D5RNaYhtkfJn4js6 -MWO35ZJqlfVuCoBnruillfoEY2VD6OVB7AQqBJyzhLeAFsOYgwLIF9UBXixSo6ym -eAA6GhYvzViUx/oQSHCI6JWhKhINXqar7usmFlxC/da4rolS9AGAkpx3S8F1VRyN -tBahkbcLDptIqzRgOJWaXuYdtRqiuqahJyEWLjal/FQa0+dTA5oWn4IWUZRGvVut -T/AFXMHMPdY7JmmmhNJAJTqcblHJ+FJ1YTBi0bEQE7F4XlqpdRxwISJSAGCZN6WD -eTVF9CmpM0iJbnlN5cvAcwCFGamL5II0wwEhftMDIoCb+IiR8NQcQfJnFus9DvhR -k5k7HVtjoumWWksL+tOudUtmYaOnV4CwyAunc8W+vsQ6edoCQWy8peRMvVcoaDUE -tVuACiiwIWADLbsmGyiBR6x4/ashJLs46XsB4pmB1BfIf4yis+B5VvLCnZIsMvKH -jLl81PMH4AsIytN0H/GOkJI/y1iGZsJkCZN15WJ2tAuwyMqz0yooh1lZIedgTheE -lBKYx1YlFvmSCZBfLnKOIWFHpqkQ8OTGXWgBfvhwyhEui3ZhJbhmgBQrYAeN8ZcP -s2isMmmzKVRcMzVFXaynv+UIiWVAH5BeXUbL0BVIkJulgXJptEyXs6JPGbEu1/ug -uvaWx6xWQ3NiR+BOA5eIigwjoQEtyYRczlg0kUcivca+/TiE3yh2gkU2xhLNFXdL -mHw1jNIbD5O5UPoGEdJ2XRLLVZJhhdmSWpN51FoW/OA6tJaMedtaNwjKD9wjJYEM -izoy9cSNuWZNKMlNdNc9aPfIcQoD2xKKKZR7iHdVNpRQEYiWFrxqT8aY3JN8UjEy -hdqeS7VAbQc28Ts7kxNa2xRmBQaOn3pb/eO+5LV/Y0Y4E7U7TXt3eBwbo5luf6Sg -dnBjypqJAmUzpEl6MrOs7Hh7RxByyKDIgYiNAaNUnwOI7USjxlN5sKhpj+iKvCYF -oCmRBMQ8w+p2s3GiXtB/whh1gwtIzTGkBpyaJrpjrFm67Bp88aUG3GJ5QKy/YPct -tVKRPcNRhlsrOsEwB0qE/+Qj2gcPDAydGIp3hxiJxFgun1RQp/BkiEJW22xN2Vim -n+tyUcRRvQCEoSU0+NXHddQvkzwHJVpdvsgRfgxHIlpTGXBReaQnRGZl2zEDVZGb -vva6ubOu8XhMJAs1n9M41aVlP4bDmwmNv/oTKwi2kSdObkZv2INLucxtiJZ3BhbC -NMiEXVU9DdS/USqZFpcQHqYB63hAMdmRsaM1wVW7HbRumglJY+A/hqgaV2wGtjNR -5qNHS4FTT7euxsOs0PIxE3sOgDmseTd0UditGXd3O5xx7LkBe4GdNTuXq+rMgiZf -P0i2ugDCV4McS4zKfpamYPQKqBufsiNkbMSVu+Mb7kQcgZZBY8gTnEyEY2k9oiJ5 -v6dH/oNa5ohGvHJjEcm4/3y3C7er0OKKM8JLkuIuUYsP7yqPY0V8LPjKI/wS0wxS -yv8kgAJe+QrgMAOzPsw5fAeK5Lrz+OhSRsF/hRJnlAgbv/UAHZQR7ueSrL6Tj8tY -Ubd0E5EZlOEecFd/41z5QA== ------END XWING PUBLIC KEY-----` - - // X-Wing private key is a 32-byte seed; the full X25519 + ML-KEM-768 - // components are derived from it at runtime by circl. - mockHybridXWingPrivateKey = `-----BEGIN XWING PRIVATE KEY----- -7uCvk28wGoVrwW5nU2huW28UXWa5tZMom6Zds8uohrA= ------END XWING PRIVATE KEY-----` - - mockHybridP256MLKEM768PublicKey = `-----BEGIN SECP256R1 MLKEM768 PUBLIC KEY----- -BGtgB2txkSmwez5qXBEmetZijnkCuYWdhPvcPoAHr5Aiv1Zk895ARtcgbO1KIye8 -52Wnc48CY2baUfSHptVKmfsnRbmsDEdZoTWyaaaAmieMws9Z9yZuwVNB2sx7AQES -d7ozp2t+nAftGc0WhUYxXDaup0usxA0QkpR7VqDa/ATqek+XlQm3TJ4pzMMIPK9H -FY6iUJchpAZ7+nQesKpDErrzQxeoGbCOOIqxAyaHNVgbxAxVIj8c23iwdMZKwMPC -LDb22Do61X7vFV0WCTTeVRJJ6lXVA6TkyVpXmb2A4gpUtSTGssINSSxxrA/wklA8 -gpqb4lurdYylQxBwArs0wnzhmThm2T/gOW89Mw6XnLbNqosqlcESwjfKXCKt63ZB -ZDVR2Wlc2RxWu0jysaoqUkYIZREceoFjSUWwagI0o8TVeAiVUBJD0AgtNcouJjad -pJvB6MYQlpe2F2sr1gvOlZph1IBkgpO8dRlKZ1yEgzUchG1tummcs3/lq2ois8fl -ZABkxVs9fMkO5G3vOgzucYjQe698sLTzaRygiKHnVS5GsWhBOq6eWVfOiwt+Klvv -KI6AfBQ2UFXsrBqXW8sjApWQ1xZVICtSBsksBhuf8GDh3AlNYxAJw0AAO6vgc7is -SRVZKCOsCKby+LxlFiEabGYbES07iHBvqx5ZCGHl+RB5SqJzWV/nPIrHrBmNiFvq -EYTnnLJ1hyqa7ABjGCeisoU6Js3yw1L6QMwtZS4sjMQdSWCoVqIeBimFtbFacVKR -OhR2YUVesQ8hgp21CjtJRMF8917OCbc1xrMj4by5tr5Aq0LjRlL+EcIcNgkjJrz9 -sXrXK2GvxZcf/FknshQ4pme+AJIvp771I3SLVEvdhbf35aRJCIQ3xQ5Y6Ijxa5Ag -wls8WMkAwQw3IBjNeYVcYn6GiTOet7yAoU+Z9Jx06SK/kqQ7HMJDChkJtLJLNJH/ -a7HrRZlZ4TA92kLP+R/ym6Kle8rN1iNiUXLxmKUR51vuNKnPVh4AGKQb0MYQurjo -YWcVg6I6MYV2XFIJAiqTBdC8kWUyZFYrw5M4E3RM+0M/slEWyQAf1L26FQodUKce -dqpo94trsCjTpbRQ+aTSNZdAHMwxkrR02TzUmoWTl0TUE8nS6yxTwUcd9wy+5KnX -mzHD42I6eyTgnAhLiZOOEpBXBX6VM4Zq1z6GO25id7kju2kgEREFnFVf7G3JWrZz -yXJ1SBLVlina4kmexJTTAyzi9IQqcAewm6nsc7byBpBenBA6aAjYZMQYtaSK+IRE -6norOKeJ0w+ZpAPdC43Mw7GBhD4eI3FQcJOce2L9yXgPYQCEaEJvDJLmJKo26iq5 -WmYEQwe1abKouruKNyaON2TEWLQBSqujca8MFKBOsXisvApkdm+fK3CLVjgSCAM4 -7LfBDFSHA7wj+KC2oXqUgwinBSgYkkJ6Vahny6GadZdpZAmYQCNLhgDDMAOcVxUx -aSQeIECzocR08QslRXNV542FlXlVx2b/ZC5yaFwAUYOACbyYq1azeTOIWyFoKGKa -uRpK+2jglo7wB7kU5LcIB8ZTFwLjGDZsEKdbmgzV+yVDqxXU1llgvKfhFmOfSALF -S39eOBaIhT1WOWpxinZRaagtmi8UQ0uE0uGhIV7fe+lLGuLxYKa/KYQe7wL6j0sL -wQ== ------END SECP256R1 MLKEM768 PUBLIC KEY-----` - - mockHybridP256MLKEM768PrivateKey = `-----BEGIN SECP256R1 MLKEM768 PRIVATE KEY----- -j60Fn/LBoCGnWihNB3Q6lXJaO4EkMCrEnOT3/z0zZ6Kdr+6m7ww3N6lLMP+Rb2O1 -3gjrOyiNXWbaTwl1mI2vi03PoW2bJbE1+wJuiT/2njjrBRyIxLqRD/4zbiscmhOp ------END SECP256R1 MLKEM768 PRIVATE KEY-----` - - mockHybridP384MLKEM1024PublicKey = `-----BEGIN SECP384R1 MLKEM1024 PUBLIC KEY----- -BFFAmpsfvYsT+TgT6noyJJ/0402OEfhJCdG9cXJDQPo5Q7VGRTr14roVHy8VP6Me -p4aiDnR2RaL+K7SmqsGRzP/vOSNz3ApEl24pAI8CnLMHDhkM7fKyAQrDje4aqQQv -2/a7h0LFLy27wBaSzpKmCzVrDtoFrVY8G9smtWoxyGucRpT5qxvhNcoHxpt3fyaV -mn0yXgyHZNiyxxsrPtjWA4x3bATWl5+bMRZ8rlFcmVbYFuGmLcnqXzf3ESyaCxxi -nCRGnIqWSPrlB56kS+6xXIykOsXSLgjaA1QUG/3XSNjXcKlayMa4g6qalMQ0v5jR -M2Z3FZzKnIpzJT1HejYJOC1JavnsOgWGkjGyIRZwfgpExdkCaMOQn9vVBbF7axDI -pY48aFNJEUooKYUpfKPypksbnRIJjNd2Wek2F7H2wyYrfx62MalHmOCheNoGaTGW -diokDS81m8jIUVGGuzGlbaPWLz9wBKgofzGrlPnispojJu2ZRK0aHAM6eOz5N1Zn -zh6SVgTmVD9GGo3FgVMcBxmICxroLEEUuov8vkq1dkfwDHXZDmoTRTHrefzbyjNj -K1xUIdlbWWjmUokmZ4OqAck0nRwImL13qYvWMpcWYWUxpxHzPozXBBKIxTnAI9UM -VU85htoJIh5iMXZoy+/bu3TawdaMHNWbtOlcjyE2m3m2F0Z7v7RVHuP2mYfErW/H -NRCEKhsmUulJRRDnVvdQCwrpE+rDCCf6zcybaDzhSKMkZlP8V0UDGr4Ghgg7KfYD -hg1ppiVlFhljvMn8c2fGrWqFu1KjJ2MHbpIBIPu6pnOjjyuJp7xFtsh4ReqMbNsX -WMcJF83UvJvxd6+AgGiVbckRXUz5PEF6GeLxACKzMnwDqu9VWOSXifYbhuCWbv6M -diYVqg7zjafUU1Rpwolbc2rpGiwciYX7APrnM7jnVQk6kY7GyyxoXFjhk+vGf0+r -OAHKei4ldZnLObukFdOQiIhWIdCSoh47MutmsS7RLCmVOPzozkMzg+GgoeMxgt4U -sgWMhtP2dmi2VVY0PD0HeN1LCGJqqCPyop15QkNxYTiXx5sgwggppfkZrcbJLCuT -l5q5NRzwF3a3wZerjJd1L3QXM+QprnUIlwGCg25ZWnAzZB0FvxExIQ1wsusJZ24x -uPiBACs2sSvlZoPAN0imG6EWFvX8SfNzpTnpQP6jR2iAQea0EJPqSYv0kmtYIux3 -shtacT0DH/5TaiG6DITyzTzUV1OTY/mpBqBkzVfwLN9BCzyINHmSDjsmExCKY3CY -Kd3RLuLDdM0AkjHHcl9zo7RypdAGIKnScqASeoR3yOUaP4LKysCUB6kBDg3ZftQR -IHiLOC6aMMKMdZbpKDuECBYDxblTOrcBhnAlX7eokZk1IEMSbmWngwHTfVRbfx+A -A5pUOCjxkYanl234p4KZYLRFMonalmkLb3msgY81ctzZjOxIXAb4XkirAiGDZe2V -T32HKlVjK2hzZpwRjZZ5rKS5IuRUiF6KZnnqDDzaI4dSfd4Sy1+FtmaAMwvidjf7 -FaPxCaJAccnVObwwjYNFuN1otiK6d860ot9AEUbInOOFEpTqzUUChY7Yr9Y4H/L3 -iYRbiH6LLfzjzIiGUEIkWCcar6jyZoKqwD7sMgMVGBOQjRqGx79KVgbSt1uakCBM -UaS2AEwsmFVVD8HCBGkFW5n1kAtrtmpAVZQiSSALW1BDl8csWz+SwGFpVqmHXHBp -EAYQb4HpGOE5X4sIIj1iY/B0vqpZHCUWbTUzJ7TLfVunawHVbrhKfwcIxuU2BHb3 -fg8giZ6GzNrUwN8YLeejct4iOv7IPF1XUJiqH5Qmb5USx96jtaJJLY+QDwtxfDcc -acsJKxn1MpRgrlS4vx8HOIqUhYlAnc2gmoU5BvZAXhXSUaXTLCpBGJN0uhc0Lyo3 -d2BKQzJEqmEHtB8cU544AKdjwgbWsJhFtukXYg6rSYDZjTlKSztyV1omJ+9EUIN5 -v7CCLVfKunF0Yx1LAL8JjQUhaB3cGHicmq9lC8pSBkAjKn3ZIrO8zuCDt6FTtzYZ -QVLjh0Hwh2hYanBLTDpLCpPxOdjYtWWWTRMnZ1wLwFQ0dPhbY8d6hQMrH5bBgg37 -dG0Gc/66ok9LqouBxAbrPBiooEhENhm4j2uiUSSKxY/bwlNHkgBle/VImlJ8Gt08 -f+Pe1WdvKDHXa4HUtLeScODyYJAVXQF6Ww1pLvCRy/gd ------END SECP384R1 MLKEM1024 PUBLIC KEY-----` - - mockHybridP384MLKEM1024PrivateKey = `-----BEGIN SECP384R1 MLKEM1024 PRIVATE KEY----- -0Tu+86kOEFj2BP8fRnWqaPq6d+E1Ufhl7/OJqmg1zLoYgPtEW4QBfLgxUM8Q0nUj -bh9BKtvBsaDd3GXyKpjIM0zXv8mzSf+bZQQ9zfaaSMJ3RsPoPhViUXLYnvbDrKfM -cql24SfEHKvj8gifFuV/eg== ------END SECP384R1 MLKEM1024 PRIVATE KEY-----` + // Hybrid fixtures use draft-conformant SPKI/PKCS#8 envelopes; the + // AlgorithmIdentifier OID inside selects the scheme. + mockHybridXWingPublicKey = `-----BEGIN PUBLIC KEY----- +MIIE1DANBgsrBgEEAYPmLYHIegOCBMEA5Xp+IGgwFPYa/POk5vSNSUkJzFFAkBhu +myyFhyMA8Xh3+1MWoXkyHDgTkXZ6HBeKlZllDQk5R1m1tElztoh4zrIJACpTBdi1 +wYE6DHJFm3m6bNKdWGNblwxOJmJkZPEdW9NFzAE21buSB9M5zfdJzWZJAJmKvhey +xItgArh43ekDAXVgN3gR7VSp0QZpkNCVQ8ZU+aluKZU4Z8yjn5sjc5s9k0ljvRrI +H6lR7rNqd9wGIkgxxpAj9xCEa8WTqZcHQmJ3mgJAmqKSJnqSAnBsOmk7sdoBBVxf +yVZZCxNHnQS2EHO/5WmzyJhokcwNiweHOSaqRaGEHrMK2NBJKHCzc6SyMdhBrHVW +nlM5+WlFSSWs+manYuEEL8QNVKACR0gmRLVl1qUzVCQgLKIw/qvNPCceTQADnfpU +kDTCbRu8AltC0WjFq6pYNAgB44rIINKjC/nPZJENkaMKUNIJljZQejUX+7Y2dnCl +FnEemVlFjFUDKfXKjtBotxZmdByl8gqfKlBMY4xi2ip+SNV46bPA8al7jlZZmZdS +pIou7ryWjchAkRpB0wiXZxce06d8oSB4sDogFmjD6XVmnfDGrIhyFEbEAUySFGuA +x/x8HAGt56V2YyoqT9h5uZc0O5cFdyymhbCeU+Grnft7UaLAOxRHzZirSZK5wmOz +QUxEvqHH+AcrrtyedeghKFGPA/VIZbFaiGxTXQuLK0tkAbsOz4pgOwdpjRZIZGI5 +WodKcngC5YutUkvKR4twOotseZiSIQJXp4mK1vtzzwZaH0bLiyQ82SQ00LstSYo6 +kRmwTapOzhwplwGpSTDEm8u1WPYKVOZx3xsvAPMiUEW4ugGSoIq/EZXOZbKP9BJd +f2CzTHG28egFD7G+hJA+M8q/v4Ix0aE91+BomAcX8DJqPeFN+fdDO/W45UF0KVt6 +nMFW4FwOAINADIh5aaBfiYKgzrhzmsUnrfYlt0isbdlxU8YDBvaPyCcNT9ihqpJI +FRuuKdV/n+rJRok27+Sp/dkVNYIAe3cgolul+FIo4JUpqPC3fiAViBy2kztffzm3 +t2tjADMbnhqE43p5YjRob+bJcWy8t1TFtgW99gGvumXPDDhg7McvLpWWB+K1yqxv +ZxxXc9rMsIZFsnksqXSuBCo6Iji7/wO5e3E73vCuYTmnPLQ4TNweP8JHczJIejV2 +zKUTkuafOILIdYWTq+q3gYIABnqhWge+QPa+a2TPKLAUMfOR0FGE9hYlFJWeyKY7 +EcGskeoWHuuZEkkYdyLPB4iYf9kqtZCrV7u5ePO9POaoIpp5sIKvmeS6kBcIj7PI +01s75tisRtmMlByG0SeVQ1ks6Vy5KAot/xaVVWVin8qGDyKjahoSG7lA1EtEHjqP +oHQYS9OQQPN3bwyohphP6OFDiZQJtIMgVlwBUlMkOYIIppxeabizK2WlUbcteuya +Jtgxigg4VHorrdAO5Vs294lcxoByc6vNuui7XshKi5Rw/DWQJZEsr4OIz3ew70zP +e9upCAS6SSZwYgfGpHkyXZhrVoxRuLuJvRbU8KXFlEqg8ljqSrwFfp45HljxUGZD +NbkBs5eEoflYBOsbTvaVsv+HHxp0Uf0nWLNMAKrM33nWFwf1AquYTQ== +-----END PUBLIC KEY-----` + + mockHybridXWingPrivateKey = `-----BEGIN PRIVATE KEY----- +MDQCAQAwDQYLKwYBBAGD5i2ByHoEICDfofZU3VTH8Q1a9aHROFy3+lmOoLZMJH8p +1JpbISFo +-----END PRIVATE KEY-----` + + mockHybridP256MLKEM768PublicKey = `-----BEGIN PUBLIC KEY----- +MIIE8jAKBggrBgEFBQcGOwOCBOIAoLwah7dVaYyMsSSEZ2A8gEWgH4ErZgg6TxME +c6QaWfZXy/qqkNAK6SqekNxcipzL/yhzyolOv9FYtPS7OnuwfMMt/WU/CboZX3h2 +TrmPE5cr9tuSx9IKS9SCqRVLaAtJTJxUEZB/+usvcHrLEGFhSKOpiFFmOqSf4jHH +2zsj7/IdXVWKqItpqtkQD8CZRDxjPpJuSOm87mwjTPBYNtVq0UkokDkn13AD1ag3 +JBCukusrWogKGgAbE7uo6XywwqOk2tNQtqeWa/qLn7nFVscvhDO6GIJfy/IuVBjB +koK47YqHOSELusRYkbkBQ7fNnlVa92a54BWVRzEiP8ZIXMpuuWlQIcam5FYZbaEy +VyWTbyrE7otfodVBcqOcHiScmtGu6ZxF7QvCvropBQYkutBqfJnMeUa9MRhP3LM3 +CDaCBPijTIkjMaCkhGkgULVF+6SNMVwdx1a/eVyatgQoZwFvm9peHuCWCOizxrOL +nrQz/Rw5WeU12kAf9pgljlVqJ6i14UYZR2a2kBgrbjti/uhL2Gcc7HhfjoG8hhN7 +3So29dUI55qXCkJQtFawA+VoR/UMTvClQFSunsyG7UYWwPFaRIWcfVLMRHBN+WjB +zpFO/cMf2Xl/XQkNCQUvE2Uol6yofwxB7sUx3vELePE0UMd8nUmKikeoxOluUdR1 +tYZSjiaOHgBTxTouAfUBWkoNd5R38PiMR7Ibpga1nrxkOTaa7hNTS4WHWRFEytuR +1lbKcnJN40dNDYiwLdQxl/mqerWk0ad+npp6KPtyhHMPP0vNf7ReieM014N5TDGK +/8WGmWp51aQjbiVeufyRGtum8MeX4GqVMRC7IUNO8YdvrMhPU0eW5pxYSoSLvkW6 +yPtVpRuKb8K7ldO8rojPEbuKsLiwxBISQfq/OvUrstNrN3LIE6vB9PdevYkR+kFu +ktKlnAWZoAwAK4x7yBgMwnGlJ4K+mnIkQGK1acOPhDyIq7ICywN3kwd2WlxMlfCU +q7k57ErMQcJfH7jKCTmvoABtQYWj3bnIDmeNdxQ+M3g92qSBUcdcz5k9VCCKEKAp +ZEZun7wnY9AVp0sZTDqQU5F7GPVdFdYXsZYOSshqKuY23MYWEqOJSDrAcOVk99MQ +uGg79EwX60QdizFeO0mGcjgUqmlKfbGwG7optbOlHDSDZ8NEW+SoaFrLFtkY3uR1 +1CBm87wxPUyAaLA5H9CGobB6RPZuJMvMxSsq6/RhfpI5phCY9Dgw1RV2waiE/fci +tnanjhsoslE2H7gIh9Jl+DUfe4lJoWfNrQYqGHExmjuBuOM4FgAtmKKjHBUvv6CO +pmGqwYEGxiG2MRQNzIt/UCsyhtU/b5yMtdmteYhZN4kZ6oK3k8eXvWN9GqOs8RC0 +ZsqGTaNhINSgTNIfx8AkLjk/w0VvuNZCubMWSemVLRlUihNl+hJ9pCsaTGS95rWz +RFKAGhfGK0OXbnK7OSopNBO3DszD1EWBl+sj3ZxN+ixceca6rlRYMNVGX/nAVLl/ +KlR0FEi8JNEHn/aye9kIYtUumyesvIniX1wkejL5Jl5DVEOeoILYFgKlgbz2GPCm +jm5yzO8Ec8iylqHWzcISmAAmM+m/HzxeG4q8SWVKOX13fcGfzefrmuFlhdebLRk7 +SqJ8NgNk4+OKYhSmR7Qkht1d+D/wjA== +-----END PUBLIC KEY-----` + + mockHybridP256MLKEM768PrivateKey = `-----BEGIN PRIVATE KEY----- +MIHLAgEAMAoGCCsGAQUFBwY7BIG5hcInRjjCXDDko9wJHhg2DE/3724DfxIFmOib +tXGwDWsz+sG/jR0kRZoeTuQ5zhAOlqlg2lUMPOT9Bp7HMN6zBDB3AgEBBCDScuOG +Sq9sarjdnXOHdNC215Y7YpFjaql0feyc3Q9luqAKBggqhkjOPQMBB6FEA0IABHPI +spah1s3CEpgAJjPpvx88XhuKvEllSjl9d33Bn83n65rhZYXXmy0ZO0qifDYDZOPj +imIUpke0JIbdXfg/8Iw= +-----END PRIVATE KEY-----` + + mockHybridP384MLKEM1024PublicKey = `-----BEGIN PUBLIC KEY----- +MIIGkjAKBggrBgEFBQcGPwOCBoIAb3pSMJMwX0S80CkMFKk5xzh5YZx4bsFaLzNF +I4V5zYqTxZF+r6Key0EJ6xmasQCrsUOrTrio57Zi8jMRgWxgxTd/67siPglc8iSL +w2MN3PgDVbNfV2tjjtLKCdyzV9gH8Jl3c3WeMkJX+kkSh5Mic/QG72K1PuVf3SNc +MQRjTqaQHXjMdAPBD3gkh3OZbGm6z5qAMMU+n/SmUAIll2a5l7UawsK3MQgIizYo +5WIN93C2jOshCpLMTOebERlStXJbcdmrLGKUJaKPeJIbCDmn6MLG16sWZjscilIl +gbuEHneaJ1NFd/enuWJ4rJEzlHJULtaO1gd/n/QQdyO0a9lTsdknQVIPgCC+yeBX +Dce5tChYbTdiruZ0aiA4gwytSJLPTgMICFV5l0oXvVEjCieFGdwsxyuEoDGkaqoA +4LejmnvNpxR4tzo2TKQUJjkxDnSBqRNoRoSZrHC5DtWdwYPM9fx/k0NmBYzIudyo +tNoOGTO/VVmXRWxAsDCMJVSScHERWWlF28AzQDJxidANlma/r7ClkSbAE1oYbWtt +aTfBTTFbBhZAsjYKkdyl94mqz7CejMJc+VJuz6CW4SCJzJtYWcyedQtNeyWZWYwp +jylP0wfAKeU3YPZzdvrHR3RL2JpmlhOunafGSTlaZAS+xnhksXIditeICDAS/kMV +Y5KvhrdBUcCSJeuZhiHFTmQdrgAPo+gHegoQi1ZinRijXPZicBSGAzceOrMJRhBB +TlsDxYitGet+1SdIcCMwDFEXumFDsAh9UvWX7JOiyuxmP9MyUwok0gqpDHShtyWr +vPqduyN2h2FwxcE8H3zLK1oQpxw6MtKS2FAICBaTWca2OrNkOAWt3YVVDSSNxuzP +1mem6Wgf40gJiHZo7+x4YIgF6RCnZ7xB0cNy1DwT7iEPfFys5iBdh+V43nt6UtTK +nRYK+FmxIfCwbaEWfSfMiaNohlLJGOW6GQC7J4ao8ClJ3QRNlqkkZVZZdNxya7h8 +C3vCkGydkkGBzKwxbZt9ebxun3Ya3yXIICFY9msQNIx65uukkdo9n2mtD9d134Ri +UVMFVQuVh/J2vBxgg4orj0YecLeY3gOf3almDIcMxBFVpeV5uSNTB8ykxVq/kaHK +uuBqT1pNCddAv5t4MkqJ81Ybmxoabta9JGMjU5UlfLx39KqVlmUoZHJ5wkK7dESj +APsVlttwpvNf2EQ96QSdW5FUUZqbP9IyV2CAUCkvcvVvTjYtW4IUWUAhSvGLKzhp +c6owObSomlVR2+UO+KAhMllBfrvBvVRVJCIs/ExDIXIDeeS+fYAnO7mPaGa5pJdG +36ap5viI+Cms32KGZ4dTHkwlPmyms6CJWOt+cYWUpZaeqqSJ+gGehlVXDTlAwaAc +d8Y0Gjqxb3Yfb1QXKclhz+QX6nspWJPFlKsWsxOE6vLP6/ZBFjdO09MgSHq4hLgN +wBtHsikQDTu9J6ZxQERantg3J+q09zsT/Ja18Rk2lgnNWCMahLqZFSGS8ZsaBogZ +86WLCrm2xLetqfENFKkLoQSimqnN+chcVSF+5gNyPThBqEtuI5pS5daWwnZjopZb +LyVa3yhmm6Ze9nVObQMMffQgwtpMKRqYEfpxK1kv/qJavjZWpppLalaM/yurOCeH +1rlLEAUD2evDnqGSHPUDMyh3efN8wthZJBh/vBxKS+IaxwROtACPfDAXxAOAqAgO +GkFiy/YWCdGXL1aLpgh/ylRW4IzI+kR+LdA5BTOYQBLM+uSpQRxXCRiLdXKDuQmd +0MiV42AeXQRM73BZ8zeTYqqjXfQtdBZyUiJxpnYwguxNkSFmfjmaPvc/E9WDBkOE +odyatQArpPRoRmRLdWHH1NRMySSwTJbICFh+WVwddtSCCXqDaXwa0UOWQkqBMnZk +JrU4ZAFRIjUyBoupm1wVmnZNbDQ2R0lPYThePXSTTcXDggIN7bYX5aHLzZxPZzjF ++FVQJ9yrogWNTPy3vfSN6mG8U8SZ2AG/dVALrwAOElI8l0aBDlTDBOwHQgms37Ql +qvO3k5W79yeQDHEfViVtO8eUY1enAsyBzNwMNjycHPJRb8zLScvjpAo86fS0/FD3 +kqmdtyUEEd4xKBvpxj5cWThUgJ/Eo6Cv/1UWNmcNSoSixrP0RjJnOYE2zyIDzoyd +y/HUUp5fpBjZCt343M3sfglouBkBzcxD9qKYuQsUafi9Vea6gVw46HPJJxpfRL4Q +2hzLNMaM +-----END PUBLIC KEY-----` + + mockHybridP384MLKEM1024PrivateKey = `-----BEGIN PRIVATE KEY----- +MIH5AgEAMAoGCCsGAQUFBwY/BIHnfScTvewzChFWiX30GHpd1ukmokPA6ay6CWAh +cug754hacNevVKE9vcaVMV1nESyVPQFLC1ffm5rxzA7WOcBDbTCBpAIBAQQwMHnD +CvoaxknaDTS4un1XewOzbUfWOsPEbR/EXECstStq9ZZuGkNgPOysvH86/ZbCoAcG +BSuBBAAioWQDYgAEEd4xKBvpxj5cWThUgJ/Eo6Cv/1UWNmcNSoSixrP0RjJnOYE2 +zyIDzoydy/HUUp5fpBjZCt343M3sfglouBkBzcxD9qKYuQsUafi9Vea6gVw46HPJ +JxpfRL4Q2hzLNMaM +-----END PRIVATE KEY-----` ) type TestReadAt struct { @@ -2738,7 +2746,7 @@ func (s *TDFSuite) testDecryptWithReader(sdk *SDK, tdfFile, decryptedTdfFileName resultBuf := bytes.Repeat([]byte{char}, int(bufSize)) // read last 5 bytes - n, err := r.ReadAt(buf, test.fileSize-(bufSize)) + n, err := r.ReadAt(buf, test.fileSize-bufSize) if err != nil { s.Require().ErrorIs(err, io.EOF) } @@ -2858,7 +2866,8 @@ func (s *TDFSuite) startBackend() { ats := getTokenSource(s.T()) - sdk, err := New(sdkPlatformURL, + sdk, err := New( + sdkPlatformURL, WithClientCredentials("test", "test", nil), withCustomAccessTokenSource(&ats), WithTokenEndpoint("http://localhost:65432/auth/token"), @@ -2894,7 +2903,8 @@ func (f *FakeAttributes) GetAttributeValuesByFqns(_ context.Context, in *connect for _, fqn := range in.Msg.GetFqns() { av, err := NewAttributeValueFQN(fqn) if err != nil { - slog.Error("invalid fqn", + slog.Error( + "invalid fqn", slog.String("fqn", fqn), slog.Any("error", err), ) @@ -3090,26 +3100,13 @@ func (f *FakeKas) getRewrapResponse(rewrapRequest string, fulfillableObligations kasPrivateKey = strings.ReplaceAll(lk.private, "\n\t", "\n") } - var symmetricKey []byte - switch ocrypto.KeyType(f.Algorithm) { //nolint:exhaustive // only handle hybrid types - case ocrypto.HybridSecp256r1MLKEM768Key: - privateKey, err := ocrypto.P256MLKEM768PrivateKeyFromPem([]byte(kasPrivateKey)) - f.s.Require().NoError(err, "failed to extract P256+ML-KEM-768 private key from PEM") - symmetricKey, err = ocrypto.P256MLKEM768UnwrapDEK(privateKey, wrappedKey) - f.s.Require().NoError(err, "failed to unwrap P256+ML-KEM-768 wrapped key") - case ocrypto.HybridSecp384r1MLKEM1024Key: - privateKey, err := ocrypto.P384MLKEM1024PrivateKeyFromPem([]byte(kasPrivateKey)) - f.s.Require().NoError(err, "failed to extract P384+ML-KEM-1024 private key from PEM") - symmetricKey, err = ocrypto.P384MLKEM1024UnwrapDEK(privateKey, wrappedKey) - f.s.Require().NoError(err, "failed to unwrap P384+ML-KEM-1024 wrapped key") - case ocrypto.HybridXWingKey: - privateKey, err := ocrypto.XWingPrivateKeyFromPem([]byte(kasPrivateKey)) - f.s.Require().NoError(err, "failed to extract X-Wing private key from PEM") - symmetricKey, err = ocrypto.XWingUnwrapDEK(privateKey, wrappedKey) - f.s.Require().NoError(err, "failed to unwrap X-Wing wrapped key") - default: - f.s.Require().Failf("unsupported hybrid algorithm", "algorithm: %s", f.Algorithm) - } + dec, err := ocrypto.FromPrivatePEM(kasPrivateKey) + f.s.Require().NoError(err, "failed to parse hybrid private key PEM") + kt, ok := dec.(interface{ KeyType() ocrypto.KeyType }) + f.s.Require().True(ok, "hybrid private key decryptor must expose KeyType") + f.s.Equal(f.Algorithm, string(kt.KeyType()), "hybrid private key algorithm mismatch") + symmetricKey, err := dec.Decrypt(wrappedKey) + f.s.Require().NoError(err, "failed to unwrap hybrid wrapped key") asymEncrypt, err := ocrypto.FromPublicPEMWithSalt(bodyData.GetClientPublicKey(), tdfSalt(), nil) f.s.Require().NoError(err, "ocrypto.FromPublicPEMWithSalt failed") diff --git a/service/internal/security/basic_manager.go b/service/internal/security/basic_manager.go index a33ce486f7..12443eaa25 100644 --- a/service/internal/security/basic_manager.go +++ b/service/internal/security/basic_manager.go @@ -111,51 +111,20 @@ func (b *BasicManager) Decrypt(ctx context.Context, keyDetails trust.KeyDetails, return nil, fmt.Errorf("failed to create protected key: %w", err) } return protectedKey, nil - case ocrypto.HybridXWingKey: - if len(ephemeralPublicKey) > 0 { - return nil, errors.New("ephemeral public key should not be provided for X-Wing decryption") - } - xwingPrivKey, err := ocrypto.XWingPrivateKeyFromPem(privKey) - if err != nil { - return nil, fmt.Errorf("failed to create X-Wing private key from PEM: %w", err) - } - plaintext, err := ocrypto.XWingUnwrapDEK(xwingPrivKey, ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to decrypt with X-Wing: %w", err) - } - protectedKey, err := ocrypto.NewAESProtectedKey(plaintext) - if err != nil { - return nil, fmt.Errorf("failed to create protected key: %w", err) - } - return protectedKey, nil - case ocrypto.HybridSecp256r1MLKEM768Key: + case ocrypto.HybridXWingKey, ocrypto.HybridSecp256r1MLKEM768Key, ocrypto.HybridSecp384r1MLKEM1024Key: if len(ephemeralPublicKey) > 0 { return nil, errors.New("ephemeral public key should not be provided for hybrid decryption") } - privKeyBytes, err := ocrypto.P256MLKEM768PrivateKeyFromPem(privKey) - if err != nil { - return nil, fmt.Errorf("failed to parse P256-MLKEM768 private key from PEM: %w", err) + // FromPrivatePEM routes by the OID inside the PKCS#8 envelope. Cross- + // check the routed decryptor against the algorithm the key record + // claims; a mismatch means the stored PEM does not match its metadata. + kt, ok := decrypter.(interface{ KeyType() ocrypto.KeyType }) + if !ok || kt.KeyType() != keyDetails.Algorithm() { + return nil, fmt.Errorf("hybrid key %s algorithm mismatch: PEM dispatched away from %s", keyDetails.ID(), keyDetails.Algorithm()) } - plaintext, err := ocrypto.P256MLKEM768UnwrapDEK(privKeyBytes, ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to decrypt with P256-MLKEM768: %w", err) - } - protectedKey, err := ocrypto.NewAESProtectedKey(plaintext) - if err != nil { - return nil, fmt.Errorf("failed to create protected key: %w", err) - } - return protectedKey, nil - case ocrypto.HybridSecp384r1MLKEM1024Key: - if len(ephemeralPublicKey) > 0 { - return nil, errors.New("ephemeral public key should not be provided for hybrid decryption") - } - privKeyBytes, err := ocrypto.P384MLKEM1024PrivateKeyFromPem(privKey) - if err != nil { - return nil, fmt.Errorf("failed to parse P384-MLKEM1024 private key from PEM: %w", err) - } - plaintext, err := ocrypto.P384MLKEM1024UnwrapDEK(privKeyBytes, ciphertext) + plaintext, err := decrypter.Decrypt(ciphertext) if err != nil { - return nil, fmt.Errorf("failed to decrypt with P384-MLKEM1024: %w", err) + return nil, fmt.Errorf("failed to decrypt with hybrid [%s]: %w", keyDetails.Algorithm(), err) } protectedKey, err := ocrypto.NewAESProtectedKey(plaintext) if err != nil { diff --git a/service/internal/security/standard_crypto.go b/service/internal/security/standard_crypto.go index 59953a8523..cbc3d1cdf0 100644 --- a/service/internal/security/standard_crypto.go +++ b/service/internal/security/standard_crypto.go @@ -120,7 +120,8 @@ func loadKeys(ks []KeyPairInfo) (*StandardCrypto, error) { keysByAlg := make(map[string]keylist) keysByID := make(keylist) for _, k := range ks { - slog.Info("crypto cfg loading", + slog.Info( + "crypto cfg loading", slog.Any("id", k.KID), slog.Any("alg", k.Algorithm), ) @@ -163,12 +164,26 @@ func loadKey(k KeyPairInfo) (any, error) { ecCertificatePEM: string(certPEM), }, nil case AlgorithmHPQTXWing: + dec, err := ocrypto.FromPrivatePEM(string(privatePEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse X-Wing private key: %w", err) + } + if err := assertDecryptorAlgorithm(dec, k.Algorithm, k.KID); err != nil { + return nil, err + } return StandardXWingCrypto{ KeyPairInfo: k, xwingPrivateKeyPem: string(privatePEM), xwingPublicKeyPem: string(certPEM), }, nil case AlgorithmHPQTSecp256r1MLKEM768, AlgorithmHPQTSecp384r1MLKEM1024: + dec, err := ocrypto.FromPrivatePEM(string(privatePEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse hybrid private key: %w", err) + } + if err := assertDecryptorAlgorithm(dec, k.Algorithm, k.KID); err != nil { + return nil, err + } return StandardHybridCrypto{ KeyPairInfo: k, hybridPrivateKeyPem: string(privatePEM), @@ -247,7 +262,8 @@ func loadDeprecatedKeys(rsaKeys map[string]StandardKeyInfo, ecKeys map[string]St keysByID[id] = k } for id, kasInfo := range ecKeys { - slog.Info("cfg.ECKeys", + slog.Info( + "cfg.ECKeys", slog.String("id", id), slog.Any("kasInfo", kasInfo), ) @@ -504,12 +520,14 @@ func (s *StandardCrypto) Decrypt(_ context.Context, keyID trust.KeyIdentifier, c return nil, errors.New("ephemeral public key should not be provided for X-Wing decryption") } - privateKey, err := ocrypto.XWingPrivateKeyFromPem([]byte(key.xwingPrivateKeyPem)) + dec, err := ocrypto.FromPrivatePEM(key.xwingPrivateKeyPem) if err != nil { return nil, fmt.Errorf("failed to parse X-Wing private key: %w", err) } - - rawKey, err = ocrypto.XWingUnwrapDEK(privateKey, ciphertext) + if err := assertDecryptorAlgorithm(dec, key.Algorithm, kid); err != nil { + return nil, err + } + rawKey, err = dec.Decrypt(ciphertext) if err != nil { return nil, fmt.Errorf("failed to decrypt with X-Wing: %w", err) } @@ -519,27 +537,16 @@ func (s *StandardCrypto) Decrypt(_ context.Context, keyID trust.KeyIdentifier, c return nil, errors.New("ephemeral public key should not be provided for hybrid decryption") } - switch key.Algorithm { - case AlgorithmHPQTSecp256r1MLKEM768: - privateKey, err := ocrypto.P256MLKEM768PrivateKeyFromPem([]byte(key.hybridPrivateKeyPem)) - if err != nil { - return nil, fmt.Errorf("failed to parse P256-MLKEM768 private key: %w", err) - } - rawKey, err = ocrypto.P256MLKEM768UnwrapDEK(privateKey, ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to decrypt with P256-MLKEM768: %w", err) - } - case AlgorithmHPQTSecp384r1MLKEM1024: - privateKey, err := ocrypto.P384MLKEM1024PrivateKeyFromPem([]byte(key.hybridPrivateKeyPem)) - if err != nil { - return nil, fmt.Errorf("failed to parse P384-MLKEM1024 private key: %w", err) - } - rawKey, err = ocrypto.P384MLKEM1024UnwrapDEK(privateKey, ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to decrypt with P384-MLKEM1024: %w", err) - } - default: - return nil, fmt.Errorf("unsupported hybrid algorithm [%s]", key.Algorithm) + dec, err := ocrypto.FromPrivatePEM(key.hybridPrivateKeyPem) + if err != nil { + return nil, fmt.Errorf("failed to parse hybrid private key: %w", err) + } + if err := assertDecryptorAlgorithm(dec, key.Algorithm, kid); err != nil { + return nil, err + } + rawKey, err = dec.Decrypt(ciphertext) + if err != nil { + return nil, fmt.Errorf("failed to decrypt with hybrid [%s]: %w", key.Algorithm, err) } default: @@ -548,3 +555,16 @@ func (s *StandardCrypto) Decrypt(_ context.Context, keyID trust.KeyIdentifier, c return ocrypto.NewAESProtectedKey(rawKey) } + +// assertDecryptorAlgorithm cross-checks an OID-routed decryptor (from +// ocrypto.FromPrivatePEM) against the algorithm the key record claims. Hybrid +// decryptors expose a KeyType() method; if the routed decryptor lacks that +// method or reports a different scheme, the stored PEM does not match its +// metadata and we must refuse to decrypt under the asserted algorithm. +func assertDecryptorAlgorithm(dec ocrypto.PrivateKeyDecryptor, expected, kid string) error { + kt, ok := dec.(interface{ KeyType() ocrypto.KeyType }) + if !ok || string(kt.KeyType()) != expected { + return fmt.Errorf("hybrid key %s algorithm mismatch: PEM dispatched away from %s", kid, expected) + } + return nil +} diff --git a/service/internal/security/standard_crypto_test.go b/service/internal/security/standard_crypto_test.go index 8f92b857ac..de4ee05be3 100644 --- a/service/internal/security/standard_crypto_test.go +++ b/service/internal/security/standard_crypto_test.go @@ -141,6 +141,25 @@ func TestStandardCryptoLoadErrors(t *testing.T) { _, err := NewStandardCrypto(cfg) require.Error(t, err) }) + + t.Run("rejects mislabeled hybrid key on load", func(t *testing.T) { + keyPair, err := ocrypto.NewP256MLKEM768KeyPair() + require.NoError(t, err) + privatePEM, err := keyPair.PrivateKeyInPemFormat() + require.NoError(t, err) + hybridPath := writeTempFile(t, dir, "hybrid-private.pem", privatePEM) + + cfg := StandardConfig{ + Keys: []KeyPairInfo{{ + Algorithm: AlgorithmHPQTSecp384r1MLKEM1024, + KID: "mislabeled-hybrid", + Private: hybridPath, + }}, + } + _, err = NewStandardCrypto(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "algorithm mismatch") + }) } func TestStandardCryptoDeprecatedKeys(t *testing.T) { diff --git a/test/start-up-with-containers/action.yaml b/test/start-up-with-containers/action.yaml index bc0e420f2e..c7a68caf11 100644 --- a/test/start-up-with-containers/action.yaml +++ b/test/start-up-with-containers/action.yaml @@ -126,9 +126,9 @@ runs: - name: Download latest init-temp-keys.sh, docker-compose.yaml, and watch.sh shell: bash run: | - curl https://raw.githubusercontent.com/opentdf/platform/refs/tags/watch-sh-fix/.github/scripts/init-temp-keys.sh > otdf-test-platform/.github/scripts/init-temp-keys.sh - curl https://raw.githubusercontent.com/opentdf/platform/refs/tags/watch-sh-fix/docker-compose.yaml > otdf-test-platform/docker-compose.yaml - curl https://raw.githubusercontent.com/opentdf/platform/refs/tags/watch-sh-fix/.github/scripts/watch.sh > otdf-test-platform/.github/scripts/watch.sh + curl https://raw.githubusercontent.com/opentdf/platform/refs/tags/pqc-enabled/.github/scripts/init-temp-keys.sh > otdf-test-platform/.github/scripts/init-temp-keys.sh + curl https://raw.githubusercontent.com/opentdf/platform/refs/tags/pqc-enabled/docker-compose.yaml > otdf-test-platform/docker-compose.yaml + curl https://raw.githubusercontent.com/opentdf/platform/refs/tags/pqc-enabled/.github/scripts/watch.sh > otdf-test-platform/.github/scripts/watch.sh - name: Set up go (platform's go version) id: setup-go uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0