From f84eaf92c445c5b858d732066695c68914917a87 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 3 Jun 2026 11:18:24 -0400 Subject: [PATCH 01/11] spec: scaffold for DSPX-3396 conformant hybrid pqt --- spec/DSPX-3396.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 spec/DSPX-3396.md diff --git a/spec/DSPX-3396.md b/spec/DSPX-3396.md new file mode 100644 index 0000000000..dc54cf5725 --- /dev/null +++ b/spec/DSPX-3396.md @@ -0,0 +1,106 @@ +--- +ticket: DSPX-3396 +title: PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing) +status: draft +authors: [dmihalcik@virtru.com] +branches: [opentdf/platform:DSPX-3396-conformant-hybrid-pqt] +prs: [] +created: 2026-06-03 +updated: 2026-06-03 +--- + +# PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing) + +## Summary +Contextlib/ocrypto currently represents hybrid post-quantum key material (X-Wing, P256+ML-KEM-768, P384+ML-KEM-1024) with custom PEM block headers and raw concatenated payloads — no ASN.1 envelope, no AlgorithmIdentifier OID, and (for the NIST hybrids) a combiner and byte ordering that diverge from draft-ietf-lamps-pq-composite-kem-14. This blocks interop with other PQ-aware tooling and means the IETF OIDs cannot honestly be advertised. +The pure ML-KEM PEM cleanup is being landed separately on DSPX-3383-post-quantum-kem. This task tracks the larger hybrid refactor, which should be branched off main and shipped as its own PR. +ScopeBring NIST composite-KEM hybrids into conformance with draft-ietf-lamps-pq-composite-kem-14, and the X-Wing hybrid into conformance with draft-connolly-cfrg-xwing-kem-10. Both move from custom PEM block headers to standard PUBLIC KEY / PRIVATE KEY headers with SPKI / PKCS#8 envelopes and OID-based dispatch. +NIST composite KEM (P256+ML-KEM-768, P384+ML-KEM-1024)Aspect +Current (lib/ocrypto/hybrid_nist.go) +Draft-14 conformance +Public key concat order +ecPoint ‖ mlkemPK +mlkemPK ‖ ecPoint +Private key concat order +ecScalar ‖ mlkemSeed +mlkemSeed ‖ ecPrivateKey(DER) +Ciphertext concat order +ecEphemeralPoint ‖ mlkemCT +mlkemCT ‖ ecEphemeralPoint +EC private encoding +raw 32/48-byte scalar +RFC 5915 ECPrivateKey DER +Combiner +HKDF-SHA256(ecdhSS ‖ mlkemSS, salt, info) +SHA3-256(mlkemSS ‖ tradSS ‖ tradCT ‖ tradPK ‖ Label) +Label bytes +n/a +ASCII MLKEM768-P256 / MLKEM1024-P384 +Wrap KDF +HKDF over combined secret +spec output (32 bytes) used directly as AES-256 key +Salt/info plumbing +accepted, used +remove from NIST hybrid public APIs +PEM block type +SECP256R1 MLKEM768 PUBLIC KEY etc. +standard PUBLIC KEY / PRIVATE KEY +Algorithm identifier +n/a (no envelope) +OID 1.3.6.1.5.5.7.6.59 (P256) / 1.3.6.1.5.5.7.6.63 (P384); params absent +The custom HybridNISTWrappedKey ASN.1 { HybridCiphertext, EncryptedDEK } envelope used at the TDF layer stays — the spec defines the KEM only, not DEK wrapping. +X-WingAspect +Current (lib/ocrypto/xwing.go) +Draft-10 conformance +KEM combiner +matches spec (cloudflare/circl primitive) +unchanged +PEM block type +XWING PUBLIC KEY / XWING PRIVATE KEY +standard PUBLIC KEY / PRIVATE KEY +Algorithm identifier +n/a +OID 1.3.6.1.4.1.62253.25722; params absent +Public key inside SPKI +raw 1216 bytes +unchanged, now wrapped in SPKI BIT STRING +Private key inside PKCS#8 +raw bytes +unchanged, now wrapped in OneAsymmetricKey OCTET STRING +DispatcherFromPublicPEMWithSalt (lib/ocrypto/asym_encryption.go:73) and FromPrivatePEMWithSalt (lib/ocrypto/asym_decryption.go:41) currently switch on block.Type. After this refactor the hybrid cases are removed; the dispatcher reads block.Type == "PUBLIC KEY" / "PRIVATE KEY", peeks the AlgorithmIdentifier OID, and routes to the appropriate constructor. +Helper module to add: lib/ocrypto/pq_oids.go (OID constants) and lib/ocrypto/pq_asn1.go (SPKI/PKCS#8 marshal/parse helpers). +External call sites to updatesdk/tdf_hybrid_test.go +sdk/experimental/tdf/{key_access,writer}_test.go +otdfctl/cmd/policy/kasKeys.go, otdfctl/cmd/policy/kasKeys_test.go +otdfctl/pkg/utils/pemvalidate.go, otdfctl/pkg/utils/pemvalidate_test.go +tests-bdd/cukes/utils/utils_genKeys.go +Any test fixture embedding a literal hybrid PEM string must be regenerated. NIST hybrid constructors (NewSaltedP256MLKEM768Decryptor etc.) lose their salt/info parameters — call sites in sdk/ and otdfctl/ need to drop those arguments. +Acceptance criteriaHybrid PEM artifacts use PUBLIC KEY / PRIVATE KEY headers; openssl asn1parse shows the expected OID. +go test ./lib/ocrypto/... ./sdk/... passes, including new conformance round-trips. +Test vectors taken from the draft appendices (or reference implementations) decrypt successfully under the new code path. +golangci-lint run clean across the diff. +Out of scopePure ML-KEM PEM cleanup — landing on DSPX-3383-post-quantum-kem. +Migration tooling for existing on-disk hybrid keys — not needed, no deployed material in the old format. +ML-DSA / SLH-DSA signature scheme PEM (separate work). +Referencesdraft-ietf-lamps-pq-composite-kem-14 — https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-kem/ +draft-connolly-cfrg-xwing-kem-10 — https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/ +RFC 5280 (SPKI), RFC 5958 (PKCS#8), RFC 5915 (ECPrivateKey) + +## Problem / Motivation +_Why does this work need to happen? What is the user/business pain?_ + +## Proposed Solution +_What will you build, at a functional level? Sketch the approach._ + +## Inputs / Outputs / Contracts +_Function signatures, data shapes, API contracts, CLI flags._ + +## Edge Cases & Constraints +_Boundary conditions, error states, performance limits, security considerations._ + +## Out of Scope +_What this work item explicitly does not cover._ + +## Acceptance Criteria +- [ ] _Clear, testable condition_ +- [ ] _…_ From a13c053cee892a0154167de8dd19499191daa4f3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 3 Jun 2026 11:47:25 -0400 Subject: [PATCH 02/11] spec: fix DSPX-3396 markdown formatting Rewrite the spec body from the Jira ticket as the source of truth so tables, headings, and bullet lists render correctly. The original import had collapsed all table rows into single lines and dropped the section headings. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Dave Mihalcik --- spec/DSPX-3396.md | 166 ++++++++++++++++++++-------------------------- 1 file changed, 72 insertions(+), 94 deletions(-) diff --git a/spec/DSPX-3396.md b/spec/DSPX-3396.md index dc54cf5725..058a21b1db 100644 --- a/spec/DSPX-3396.md +++ b/spec/DSPX-3396.md @@ -1,6 +1,6 @@ --- ticket: DSPX-3396 -title: PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing) +title: "PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing)" status: draft authors: [dmihalcik@virtru.com] branches: [opentdf/platform:DSPX-3396-conformant-hybrid-pqt] @@ -11,96 +11,74 @@ updated: 2026-06-03 # PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing) -## Summary -Contextlib/ocrypto currently represents hybrid post-quantum key material (X-Wing, P256+ML-KEM-768, P384+ML-KEM-1024) with custom PEM block headers and raw concatenated payloads — no ASN.1 envelope, no AlgorithmIdentifier OID, and (for the NIST hybrids) a combiner and byte ordering that diverge from draft-ietf-lamps-pq-composite-kem-14. This blocks interop with other PQ-aware tooling and means the IETF OIDs cannot honestly be advertised. -The pure ML-KEM PEM cleanup is being landed separately on DSPX-3383-post-quantum-kem. This task tracks the larger hybrid refactor, which should be branched off main and shipped as its own PR. -ScopeBring NIST composite-KEM hybrids into conformance with draft-ietf-lamps-pq-composite-kem-14, and the X-Wing hybrid into conformance with draft-connolly-cfrg-xwing-kem-10. Both move from custom PEM block headers to standard PUBLIC KEY / PRIVATE KEY headers with SPKI / PKCS#8 envelopes and OID-based dispatch. -NIST composite KEM (P256+ML-KEM-768, P384+ML-KEM-1024)Aspect -Current (lib/ocrypto/hybrid_nist.go) -Draft-14 conformance -Public key concat order -ecPoint ‖ mlkemPK -mlkemPK ‖ ecPoint -Private key concat order -ecScalar ‖ mlkemSeed -mlkemSeed ‖ ecPrivateKey(DER) -Ciphertext concat order -ecEphemeralPoint ‖ mlkemCT -mlkemCT ‖ ecEphemeralPoint -EC private encoding -raw 32/48-byte scalar -RFC 5915 ECPrivateKey DER -Combiner -HKDF-SHA256(ecdhSS ‖ mlkemSS, salt, info) -SHA3-256(mlkemSS ‖ tradSS ‖ tradCT ‖ tradPK ‖ Label) -Label bytes -n/a -ASCII MLKEM768-P256 / MLKEM1024-P384 -Wrap KDF -HKDF over combined secret -spec output (32 bytes) used directly as AES-256 key -Salt/info plumbing -accepted, used -remove from NIST hybrid public APIs -PEM block type -SECP256R1 MLKEM768 PUBLIC KEY etc. -standard PUBLIC KEY / PRIVATE KEY -Algorithm identifier -n/a (no envelope) -OID 1.3.6.1.5.5.7.6.59 (P256) / 1.3.6.1.5.5.7.6.63 (P384); params absent -The custom HybridNISTWrappedKey ASN.1 { HybridCiphertext, EncryptedDEK } envelope used at the TDF layer stays — the spec defines the KEM only, not DEK wrapping. -X-WingAspect -Current (lib/ocrypto/xwing.go) -Draft-10 conformance -KEM combiner -matches spec (cloudflare/circl primitive) -unchanged -PEM block type -XWING PUBLIC KEY / XWING PRIVATE KEY -standard PUBLIC KEY / PRIVATE KEY -Algorithm identifier -n/a -OID 1.3.6.1.4.1.62253.25722; params absent -Public key inside SPKI -raw 1216 bytes -unchanged, now wrapped in SPKI BIT STRING -Private key inside PKCS#8 -raw bytes -unchanged, now wrapped in OneAsymmetricKey OCTET STRING -DispatcherFromPublicPEMWithSalt (lib/ocrypto/asym_encryption.go:73) and FromPrivatePEMWithSalt (lib/ocrypto/asym_decryption.go:41) currently switch on block.Type. After this refactor the hybrid cases are removed; the dispatcher reads block.Type == "PUBLIC KEY" / "PRIVATE KEY", peeks the AlgorithmIdentifier OID, and routes to the appropriate constructor. -Helper module to add: lib/ocrypto/pq_oids.go (OID constants) and lib/ocrypto/pq_asn1.go (SPKI/PKCS#8 marshal/parse helpers). -External call sites to updatesdk/tdf_hybrid_test.go -sdk/experimental/tdf/{key_access,writer}_test.go -otdfctl/cmd/policy/kasKeys.go, otdfctl/cmd/policy/kasKeys_test.go -otdfctl/pkg/utils/pemvalidate.go, otdfctl/pkg/utils/pemvalidate_test.go -tests-bdd/cukes/utils/utils_genKeys.go -Any test fixture embedding a literal hybrid PEM string must be regenerated. NIST hybrid constructors (NewSaltedP256MLKEM768Decryptor etc.) lose their salt/info parameters — call sites in sdk/ and otdfctl/ need to drop those arguments. -Acceptance criteriaHybrid PEM artifacts use PUBLIC KEY / PRIVATE KEY headers; openssl asn1parse shows the expected OID. -go test ./lib/ocrypto/... ./sdk/... passes, including new conformance round-trips. -Test vectors taken from the draft appendices (or reference implementations) decrypt successfully under the new code path. -golangci-lint run clean across the diff. -Out of scopePure ML-KEM PEM cleanup — landing on DSPX-3383-post-quantum-kem. -Migration tooling for existing on-disk hybrid keys — not needed, no deployed material in the old format. -ML-DSA / SLH-DSA signature scheme PEM (separate work). -Referencesdraft-ietf-lamps-pq-composite-kem-14 — https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-kem/ -draft-connolly-cfrg-xwing-kem-10 — https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/ -RFC 5280 (SPKI), RFC 5958 (PKCS#8), RFC 5915 (ECPrivateKey) - -## Problem / Motivation -_Why does this work need to happen? What is the user/business pain?_ - -## Proposed Solution -_What will you build, at a functional level? Sketch the approach._ - -## Inputs / Outputs / Contracts -_Function signatures, data shapes, API contracts, CLI flags._ - -## Edge Cases & Constraints -_Boundary conditions, error states, performance limits, security considerations._ - -## Out of Scope -_What this work item explicitly does not cover._ - -## Acceptance Criteria -- [ ] _Clear, testable condition_ -- [ ] _…_ +## Context + +`lib/ocrypto` currently represents hybrid post-quantum key material (X-Wing, P256+ML-KEM-768, P384+ML-KEM-1024) with custom PEM block headers and raw concatenated payloads — no ASN.1 envelope, no `AlgorithmIdentifier` OID, and (for the NIST hybrids) a combiner and byte ordering that diverge from `draft-ietf-lamps-pq-composite-kem-14`. This blocks interop with other PQ-aware tooling and means the IETF OIDs cannot honestly be advertised. + +The pure ML-KEM PEM cleanup is being landed separately on `DSPX-3383-post-quantum-kem`. This task tracks the larger hybrid refactor, which should be branched off `main` and shipped as its own PR. + +## Scope + +Bring NIST composite-KEM hybrids into conformance with `draft-ietf-lamps-pq-composite-kem-14`, and the X-Wing hybrid into conformance with `draft-connolly-cfrg-xwing-kem-10`. Both move from custom PEM block headers to standard `PUBLIC KEY` / `PRIVATE KEY` headers with SPKI / PKCS#8 envelopes and OID-based dispatch. + +### NIST composite KEM (P256+ML-KEM-768, P384+ML-KEM-1024) + +| Aspect | Current (`lib/ocrypto/hybrid_nist.go`) | Draft-14 conformance | +| --- | --- | --- | +| Public key concat order | `ecPoint ‖ mlkemPK` | `mlkemPK ‖ ecPoint` | +| Private key concat order | `ecScalar ‖ mlkemSeed` | `mlkemSeed ‖ ecPrivateKey(DER)` | +| Ciphertext concat order | `ecEphemeralPoint ‖ mlkemCT` | `mlkemCT ‖ ecEphemeralPoint` | +| EC private encoding | raw 32/48-byte scalar | RFC 5915 `ECPrivateKey` DER | +| Combiner | `HKDF-SHA256(ecdhSS ‖ mlkemSS, salt, info)` | `SHA3-256(mlkemSS ‖ tradSS ‖ tradCT ‖ tradPK ‖ Label)` | +| Label bytes | n/a | ASCII `MLKEM768-P256` / `MLKEM1024-P384` | +| Wrap KDF | HKDF over combined secret | spec output (32 bytes) used directly as AES-256 key | +| Salt/info plumbing | accepted, used | **remove from NIST hybrid public APIs** | +| PEM block type | `SECP256R1 MLKEM768 PUBLIC KEY` etc. | standard `PUBLIC KEY` / `PRIVATE KEY` | +| Algorithm identifier | n/a (no envelope) | OID `1.3.6.1.5.5.7.6.59` (P256) / `1.3.6.1.5.5.7.6.63` (P384); params absent | + +The custom `HybridNISTWrappedKey ASN.1 { HybridCiphertext, EncryptedDEK }` envelope used at the TDF layer stays — the spec defines the KEM only, not DEK wrapping. + +### X-Wing + +| Aspect | Current (`lib/ocrypto/xwing.go`) | Draft-10 conformance | +| --- | --- | --- | +| KEM combiner | matches spec (cloudflare/circl primitive) | unchanged | +| PEM block type | `XWING PUBLIC KEY` / `XWING PRIVATE KEY` | standard `PUBLIC KEY` / `PRIVATE KEY` | +| Algorithm identifier | n/a | OID `1.3.6.1.4.1.62253.25722`; params absent | +| Public key inside SPKI | raw 1216 bytes | unchanged, now wrapped in SPKI BIT STRING | +| Private key inside PKCS#8 | raw bytes | unchanged, now wrapped in OneAsymmetricKey OCTET STRING | + +### Dispatcher + +`FromPublicPEMWithSalt` (`lib/ocrypto/asym_encryption.go:73`) and `FromPrivatePEMWithSalt` (`lib/ocrypto/asym_decryption.go:41`) currently switch on `block.Type`. After this refactor the hybrid cases are removed; the dispatcher reads `block.Type == "PUBLIC KEY"` / `"PRIVATE KEY"`, peeks the `AlgorithmIdentifier` OID, and routes to the appropriate constructor. + +Helper module to add: `lib/ocrypto/pq_oids.go` (OID constants) and `lib/ocrypto/pq_asn1.go` (SPKI/PKCS#8 marshal/parse helpers). + +## External call sites to update + +- `sdk/tdf_hybrid_test.go` +- `sdk/experimental/tdf/{key_access,writer}_test.go` +- `otdfctl/cmd/policy/kasKeys.go`, `otdfctl/cmd/policy/kasKeys_test.go` +- `otdfctl/pkg/utils/pemvalidate.go`, `otdfctl/pkg/utils/pemvalidate_test.go` +- `tests-bdd/cukes/utils/utils_genKeys.go` + +Any test fixture embedding a literal hybrid PEM string must be regenerated. NIST hybrid constructors (`NewSaltedP256MLKEM768Decryptor` etc.) lose their `salt`/`info` parameters — call sites in `sdk/` and `otdfctl/` need to drop those arguments. + +## Acceptance criteria + +- [ ] Hybrid PEM artifacts use `PUBLIC KEY` / `PRIVATE KEY` headers; `openssl asn1parse` shows the expected OID. +- [ ] `go test ./lib/ocrypto/... ./sdk/...` passes, including new conformance round-trips. +- [ ] Test vectors taken from the draft appendices (or reference implementations) decrypt successfully under the new code path. +- [ ] `golangci-lint run` clean across the diff. + +## Out of scope + +- Pure ML-KEM PEM cleanup — landing on `DSPX-3383-post-quantum-kem`. +- Migration tooling for existing on-disk hybrid keys — not needed, no deployed material in the old format. +- ML-DSA / SLH-DSA signature scheme PEM (separate work). + +## References + +- draft-ietf-lamps-pq-composite-kem-14 — https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-kem/ +- draft-connolly-cfrg-xwing-kem-10 — https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/ +- RFC 5280 (SPKI), RFC 5958 (PKCS#8), RFC 5915 (ECPrivateKey) From 6545741a400ccd553dabddcfc2d3223d20c938d9 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 3 Jun 2026 13:38:33 -0400 Subject: [PATCH 03/11] feat(ocrypto)!: conform hybrid PQ/T key formats to IETF drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the three hybrid PQ/T KEMs (X-Wing, P-256+ML-KEM-768, P-384+ML-KEM-1024) into interop with draft-ietf-lamps-pq-composite-kem-14 and draft-connolly-cfrg-xwing-kem-10 so we can honestly advertise the registered AlgorithmIdentifier OIDs. - Public/private keys now use standard `PUBLIC KEY` / `PRIVATE KEY` PEM blocks wrapped in SPKI / PKCS#8; the OID inside the AlgorithmIdentifier selects the scheme (no more custom block names). - NIST hybrids flip to draft-14 byte order: `mlkemPK || ecPoint` for public keys, `mlkemSeed || ECPrivateKey(RFC 5915 DER)` for private keys, `mlkemCT || ephemeralECPoint` for hybrid ciphertext. - NIST combiner is now `SHA3-256(mlkemSS || tradSS || tradCT || tradPK || Label)` per draft-14 §4.3 — the old HKDF-with-TDF-salt path is removed and `salt`/`info` parameters are dropped from the NIST public API. X-Wing's combiner is unchanged (delegated to circl). - `FromPublicPEM` / `FromPrivatePEM` dispatch hybrids by OID and fall through to the stdlib x509 path for RSA/EC. This is a wire-format break for hybrid keys. No on-disk material in the old format was deployed, so no migration tooling is needed. Signed-off-by: Dave Mihalcik --- lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md | 217 ++++---- lib/ocrypto/asym_decryption.go | 40 +- lib/ocrypto/asym_encryption.go | 42 +- lib/ocrypto/benchmark_test.go | 253 +--------- lib/ocrypto/hybrid_common.go | 65 +-- lib/ocrypto/hybrid_conformance_test.go | 123 +++++ lib/ocrypto/hybrid_nist.go | 495 ++++++++----------- lib/ocrypto/hybrid_nist_test.go | 101 ++-- lib/ocrypto/pem_blocks.go | 9 + lib/ocrypto/pq_asn1.go | 98 ++++ lib/ocrypto/pq_asn1_test.go | 77 +++ lib/ocrypto/pq_oids.go | 22 + lib/ocrypto/xwing.go | 46 +- lib/ocrypto/xwing_test.go | 42 +- sdk/experimental/tdf/key_access_test.go | 12 +- sdk/experimental/tdf/writer_test.go | 41 +- sdk/tdf_hybrid_test.go | 12 +- sdk/tdf_test.go | 266 +++++----- service/internal/security/basic_manager.go | 44 +- service/internal/security/standard_crypto.go | 39 +- 20 files changed, 1050 insertions(+), 994 deletions(-) create mode 100644 lib/ocrypto/hybrid_conformance_test.go create mode 100644 lib/ocrypto/pem_blocks.go create mode 100644 lib/ocrypto/pq_asn1.go create mode 100644 lib/ocrypto/pq_asn1_test.go create mode 100644 lib/ocrypto/pq_oids.go diff --git a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md index e6cbf4fe74..6d45beb75f 100644 --- a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md +++ b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md @@ -4,70 +4,89 @@ 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` +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`. ## 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 §3.2): ``` -[ EC Public Key (uncompressed point) | ML-KEM Public Key ] +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 §3.3): ``` -[ EC Private Key (raw scalar) | ML-KEM Private Key ] +OneAsymmetricKey { + version = v1, + AlgorithmIdentifier { oid = , parameters ABSENT }, + OCTET STRING [ mlkemSeed (64 bytes) || ECPrivateKey DER (RFC 5915) ] +} ``` -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 +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,58 +94,47 @@ 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) +(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 §4.3) -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 +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). - -### Step 5 - Key Derivation (HKDF) +Where: -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`: ``` encryptedDEK = AES-256-GCM.Encrypt(key=wrapKey, plaintext=splitKey) @@ -140,33 +148,35 @@ The output format is: 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 §3.4: ``` -[ ephemeral EC public key (65 or 97 bytes) | ML-KEM ciphertext (1088 or 1568 bytes) ] +[ 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. @@ -176,72 +186,62 @@ The base64-decoded DER blob is unmarshalled: ``` 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 +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: ``` -ecPrivBytes = privateKeyRaw[:ecPrivSize] // 32 or 48 bytes -mlkemPrivBytes = privateKeyRaw[ecPrivSize:] // 2400 or 3168 bytes +mlkemSeed = privateKeyRaw[:mlkemSeedSize] // 64 bytes +ecPrivDER = privateKeyRaw[mlkemSeedSize:] // RFC 5915 ECPrivateKey DER ``` +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) +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) +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: ``` -combinedSecret = ecdhSecret || mlkemSecret -``` - -### Step 7 - Key Derivation (HKDF) - -Identical to Wrap Step 5: - -``` -wrapKey = HKDF-SHA256( - IKM: combinedSecret, - salt: SHA256("TDF"), - info: -) -> 32 bytes +wrapKey = SHA3-256( mlkemSS || tradSS || tradCT || tradPK || Label ) ``` -Both sides derive the same `wrapKey` because both sides have the same `ecdhSecret` and `mlkemSecret`. +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). -### Step 8 - AES-GCM Decrypt the Split Key +### Step 7 - AES-GCM Decrypt the Split Key ``` splitKey = AES-256-GCM.Decrypt(key=wrapKey, ciphertext=encryptedDEK) @@ -255,26 +255,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 §4.3 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 +288,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 only | SHA3-256 with Label + tradCT + tradPK (draft-14 §4.3) | 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..6e0a999a8b 100644 --- a/lib/ocrypto/asym_decryption.go +++ b/lib/ocrypto/asym_decryption.go @@ -43,13 +43,14 @@ 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 + } } priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) @@ -202,6 +203,31 @@ func (e ECDecryptor) DecryptWithEphemeralKey(data, ephemeral []byte) ([]byte, er return plaintext, nil } +// hybridDecryptorFromPKCS8 mirrors hybridEncryptorFromSPKI for PKCS#8 private +// keys. Salt/info are honoured only for X-Wing; the NIST composite-KEM hybrids +// have no salt/info inputs. +func hybridDecryptorFromPKCS8(der, salt, info []byte) (PrivateKeyDecryptor, bool, error) { + oid, raw, err := parseHybridPKCS8(der) + if err != nil { + // Not a hybrid PKCS#8 envelope. Signal "not handled" so the caller can + // fall back to the standard x509 PKCS#8 path for RSA/EC keys. + return nil, false, nil //nolint:nilerr // intentional fall-through + } + 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 + default: + return nil, false, nil + } +} + 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..44eaad8907 100644 --- a/lib/ocrypto/asym_encryption.go +++ b/lib/ocrypto/asym_encryption.go @@ -74,13 +74,14 @@ 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 + } } pub, err := getPublicPart(publicKeyInPem) @@ -282,3 +283,30 @@ 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 second return value reports whether the OID +// matched a known hybrid scheme — when false, the caller should fall back to +// the standard x509 SPKI path. 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, err := parseHybridSPKI(der) + if err != nil { + // Not a hybrid SPKI envelope. Signal "not handled" so the caller can + // fall back to the standard x509 SPKI path for RSA/EC keys. + return nil, false, nil //nolint:nilerr // intentional fall-through + } + 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 + default: + return nil, false, nil + } +} 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/hybrid_common.go b/lib/ocrypto/hybrid_common.go index 4f4ee05417..68bd6951f4 100644 --- a/lib/ocrypto/hybrid_common.go +++ b/lib/ocrypto/hybrid_common.go @@ -2,70 +2,33 @@ 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: - 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.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 §4.3. 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_conformance_test.go b/lib/ocrypto/hybrid_conformance_test.go new file mode 100644 index 0000000000..a7f67d046d --- /dev/null +++ b/lib/ocrypto/hybrid_conformance_test.go @@ -0,0 +1,123 @@ +package ocrypto + +import ( + "crypto/ecdh" + "encoding/asn1" + "encoding/pem" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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 §3 (id-MLKEM768-ECDH-P256, id-MLKEM1024-ECDH-P384) +// - draft-connolly-cfrg-xwing-kem-10 §6 (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 §4.3, Table "Combiner Labels". +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 §3.2 (the order was flipped from earlier internal +// versions). +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") +} + +// TestHybridCrossSchemeDispatchRejection verifies that the dispatcher will not +// happily decrypt an X-Wing-wrapped DEK with a P-256+ML-KEM-768 private key +// (or any other mismatched pairing) — the OID inside the PKCS#8 envelope is +// the only thing routing the decryption path, and it must be authoritative. +func TestHybridCrossSchemeDispatchRejection(t *testing.T) { + xw, err := NewXWingKeyPair() + require.NoError(t, err) + nist, err := NewP256MLKEM768KeyPair() + require.NoError(t, err) + + xwPub, err := xw.PublicKeyInPemFormat() + require.NoError(t, err) + nistPriv, err := nist.PrivateKeyInPemFormat() + require.NoError(t, err) + + xwEnc, err := FromPublicPEM(xwPub) + require.NoError(t, err) + wrapped, err := xwEnc.Encrypt([]byte("cross-scheme-dek")) + require.NoError(t, err) + + nistDec, err := FromPrivatePEM(nistPriv) + require.NoError(t, err) + + _, err = nistDec.Decrypt(wrapped) + require.Error(t, err, "NIST decryptor must not accept an X-Wing wrapped envelope") +} diff --git a/lib/ocrypto/hybrid_nist.go b/lib/ocrypto/hybrid_nist.go index ee0a575ac5..f4c1b0b2eb 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,84 @@ 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) + P256MLKEM768MLKEMPubKeySize = 1184 + P256MLKEM768MLKEMCtSize = 1088 + + P384MLKEM1024ECPublicKeySize = 97 // uncompressed P-384 point (RFC 5480) + P384MLKEM1024MLKEMPubKeySize = 1568 + P384MLKEM1024MLKEMCtSize = 1568 + + // Raw public-key and ciphertext sizes after draft-14 ordering (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 §4.3 + oid asn1.ObjectIdentifier // AlgorithmIdentifier OID + keyType KeyType } 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 +127,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 newHybridNISTKeyPair(p *hybridNISTParams, genMLKEM func() (pub, priv []byte, err error)) (HybridNISTKeyPair, error) { - ecPriv, err := p.curve.GenerateKey(rand.Reader) +func generateMLKEM768() ([]byte, []byte, error) { + dk, err := mlkem.GenerateKey768() 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 generateMLKEM1024() ([]byte, []byte, error) { + dk, err := mlkem.GenerateKey1024() + if err != nil { + return nil, nil, err + } + return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil +} + +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 +186,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 NewP256MLKEM768Encryptor(publicKey []byte) (*HybridNISTEncryptor, error) { + return newHybridNISTEncryptor(&p256mlkem768Params, publicKey) } -func P256MLKEM768PrivateKeyFromPem(data []byte) ([]byte, error) { - return decodeSizedPEMBlock(data, PEMBlockP256MLKEM768PrivateKey, P256MLKEM768PrivateKeySize) +func NewP384MLKEM1024Encryptor(publicKey []byte) (*HybridNISTEncryptor, error) { + return newHybridNISTEncryptor(&p384mlkem1024Params, publicKey) } -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, salt, info []byte) (*HybridNISTEncryptor, error) { - return newHybridNISTEncryptor(&p256mlkem768Params, publicKey, salt, info) -} - -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 +245,135 @@ 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) + return newHybridNISTDecryptor(&p384mlkem1024Params, privateKey) } -func NewSaltedP384MLKEM1024Decryptor(privateKey, salt, info []byte) (*HybridNISTDecryptor, error) { - return newHybridNISTDecryptor(&p384mlkem1024Params, privateKey, salt, info) -} - -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) } 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) } 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 §4.3: // -// 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) - } - - 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) - } - ephemeral, err := p.curve.GenerateKey(rand.Reader) - if err != nil { - return nil, nil, fmt.Errorf("ECDH ephemeral key generation failed: %w", err) - } - ecdhSecret, err := ephemeral.ECDH(ecPub) - if err != nil { - return nil, nil, fmt.Errorf("ECDH failed: %w", err) - } - ephemeralPub := ephemeral.PublicKey().Bytes() - - // ML-KEM: encapsulate - var mlkemSecret, mlkemCt []byte +// 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). +func hybridNISTCombiner(p *hybridNISTParams, mlkemSS, tradSS, tradCT, tradPK []byte) []byte { + 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) +} + +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 +390,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 +409,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:] + mlkemCT := wrapped.HybridCiphertext[:p.mlkemCtSize] + ephemeralPubBytes := wrapped.HybridCiphertext[p.mlkemCtSize:] - // Split private key - ecPrivBytes := privateKeyRaw[:p.ecPrivSize] - mlkemPrivBytes := privateKeyRaw[p.ecPrivSize:] - - // ECDH: reconstruct shared secret - ecPriv, err := p.curve.NewPrivateKey(ecPrivBytes) + ecdsaPriv, err := x509.ParseECPrivateKey(ecPrivDER) + if err != nil { + 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("invalid EC private key: %w", err) + 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 +458,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..718e70cf2b --- /dev/null +++ b/lib/ocrypto/pem_blocks.go @@ -0,0 +1,9 @@ +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" +) diff --git a/lib/ocrypto/pq_asn1.go b/lib/ocrypto/pq_asn1.go new file mode 100644 index 0000000000..e82c449d2a --- /dev/null +++ b/lib/ocrypto/pq_asn1.go @@ -0,0 +1,98 @@ +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. +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") + } + 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) + } + 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..a0f0892b45 --- /dev/null +++ b/lib/ocrypto/pq_oids.go @@ -0,0 +1,22 @@ +package ocrypto + +import "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} +) + +// ASCII Labels mixed into the composite-KEM combiner per draft-14 §4.3 to +// domain-separate ciphertexts produced under different curve/ML-KEM pairings. +const ( + labelMLKEM768P256 = "MLKEM768-P256" + labelMLKEM1024P384 = "MLKEM1024-P384" +) diff --git a/lib/ocrypto/xwing.go b/lib/ocrypto/xwing.go index 1ea9d5ab25..12f0d0f7b5 100644 --- a/lib/ocrypto/xwing.go +++ b/lib/ocrypto/xwing.go @@ -18,11 +18,10 @@ const ( XWingPublicKeySize = xwing.PublicKeySize XWingPrivateKeySize = xwing.PrivateKeySize XWingCiphertextSize = xwing.CiphertextSize - - PEMBlockXWingPublicKey = "XWING PUBLIC KEY" - PEMBlockXWingPrivateKey = "XWING PRIVATE KEY" ) +// XWingWrappedKey is the ASN.1 envelope stored in wrapped_key. draft-10 defines +// only the KEM; this DEK wrapping envelope is local to OpenTDF and unchanged. type XWingWrappedKey struct { XWingCiphertext []byte `asn1:"tag:0"` EncryptedDEK []byte `asn1:"tag:1"` @@ -63,25 +62,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 +98,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 { @@ -243,18 +246,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..db3a658594 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), @@ -899,28 +903,11 @@ func hybridUnwrapForTest(t *testing.T, ktype ocrypto.KeyType, privatePEM, wrappe 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.NotEmpty(t, dek, "%s recovered DEK", ktype) } func testHybridXWingFlow(t *testing.T) { 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..ab6cf992a2 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,10 @@ 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") + 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..c5142d81bd 100644 --- a/service/internal/security/basic_manager.go +++ b/service/internal/security/basic_manager.go @@ -111,51 +111,13 @@ 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: - 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) - } - 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: + 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.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..f10f1e2a2d 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), ) @@ -247,7 +248,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 +506,11 @@ 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) + rawKey, err = dec.Decrypt(ciphertext) if err != nil { return nil, fmt.Errorf("failed to decrypt with X-Wing: %w", err) } @@ -519,27 +520,13 @@ 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) + } + rawKey, err = dec.Decrypt(ciphertext) + if err != nil { + return nil, fmt.Errorf("failed to decrypt with hybrid [%s]: %w", key.Algorithm, err) } default: From d23dc97d5ea390e54ca2572d7eb35d934bdf7e82 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 4 Jun 2026 08:26:42 -0400 Subject: [PATCH 04/11] fix(ocrypto): address PR review on hybrid PQ/T conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3563 review surfaced gaps at the edges of the IETF-draft conformance work. Tightens dispatch, adds spec-anchored KATs, and corrects citations that had drifted between draft revisions. - Dispatcher: distinguish unknown-hybrid-OID from non-envelope so unrecognised hybrid OIDs no longer silently retry as RSA/EC; reject CERTIFICATE PEM blocks carrying a hybrid SPKI. - KAS decrypt sites cross-check the OID-routed decryptor's KeyType against keyDetails.Algorithm before trusting it. - Combiner asserts input lengths and is anchored byte-for-byte to the lamps-wg/draft-composite-kem reference KATs for both NIST hybrids. - HybridNISTDecryptor parses and validates the EC DER tail at construction, mirroring the encryptor's strictness. - Six-way cross-scheme dispatch rejection test (table-driven). - pq_asn1: reject non-absent AlgorithmIdentifier.Parameters per draft-14 §6 / draft-10 §5.8. - X-Wing: inline TODO(DSPX-TBD) noting the draft-05 primitive vs. draft-10 wire-format split until cloudflare/circl ships draft-10. - Doc/spec citation sweep across hybrid_nist.go, pq_oids.go, hybrid_common.go, HYBRID_NIST_KEY_WRAPPING.md; trim stale BenchmarkHybridSubOps references from BENCHMARK_REPORT.md. Signed-off-by: Dave Mihalcik --- lib/ocrypto/BENCHMARK_REPORT.md | 40 +---- lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md | 14 +- lib/ocrypto/asym_decryption.go | 29 +++- lib/ocrypto/asym_encryption.go | 33 ++-- lib/ocrypto/hybrid_common.go | 2 +- lib/ocrypto/hybrid_conformance_test.go | 169 ++++++++++++++++--- lib/ocrypto/hybrid_nist.go | 49 +++++- lib/ocrypto/pem_blocks.go | 5 +- lib/ocrypto/pq_asn1.go | 11 ++ lib/ocrypto/pq_oids.go | 38 ++++- lib/ocrypto/xwing.go | 23 ++- service/internal/security/basic_manager.go | 7 + service/internal/security/standard_crypto.go | 19 +++ 13 files changed, 348 insertions(+), 91 deletions(-) diff --git a/lib/ocrypto/BENCHMARK_REPORT.md b/lib/ocrypto/BENCHMARK_REPORT.md index 8c01339a51..d695f9c613 100644 --- a/lib/ocrypto/BENCHMARK_REPORT.md +++ b/lib/ocrypto/BENCHMARK_REPORT.md @@ -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 @@ -93,39 +92,12 @@ These benchmarks follow the KAS unwrap paths: ## Analysis: Where Time Is Spent -The `BenchmarkHybridSubOps` benchmarks break down hybrid wrap operations into their constituent parts: - -### X-Wing Sub-Operations - -| 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% | - -### 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% | - -**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); +HKDF, AES-GCM, and ASN.1 marshaling are all sub-microsecond. 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 6d45beb75f..6c00420774 100644 --- a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md +++ b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md @@ -24,11 +24,13 @@ References: 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`. +> 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 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 §3.2): +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`): ``` SubjectPublicKeyInfo { @@ -46,7 +48,7 @@ The EC half is an uncompressed SEC1 point (leading `0x04` tag). The PEM block ty ### Combined Private Key -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 §3.3): +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`): ``` OneAsymmetricKey { @@ -112,7 +114,7 @@ ML-KEM is a KEM, not a key exchange. Encapsulation takes only the public key and 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 (draft-14 §4.3) +### Step 4 - Combine Secrets (draft-14 §3.4) 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: @@ -159,7 +161,7 @@ HybridNISTWrappedKey ::= SEQUENCE { } ``` -Where `hybridCiphertext` is laid out per draft-14 §3.4: +Where `hybridCiphertext` is laid out per draft-14 §4.3 (`SerializeCiphertext`): ``` [ ML-KEM ciphertext (1088 or 1568 bytes) | ephemeral EC public key (65 or 97 bytes) ] @@ -280,7 +282,7 @@ This means: - 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 static ML-KEM public key and the ML-KEM ciphertext are **not** in the combiner input. Draft-14 §4.3 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. +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 @@ -288,7 +290,7 @@ The static ML-KEM public key and the ML-KEM ciphertext are **not** in the combin |--------|-----------------|-------------------|-------------------------------|--------------------------| | 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 | SHA3-256 with Label + tradCT + tradPK (draft-14 §4.3) | SHA3-256 (X-Wing spec, inside circl) | +| 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 | Yes (`tradCT`, `tradPK`, `Label` in combiner) | Yes (public keys mixed into SHA3-256) | diff --git a/lib/ocrypto/asym_decryption.go b/lib/ocrypto/asym_decryption.go index 6e0a999a8b..28c31798a2 100644 --- a/lib/ocrypto/asym_decryption.go +++ b/lib/ocrypto/asym_decryption.go @@ -52,6 +52,12 @@ func FromPrivatePEMWithSalt(privateKeyInPem string, salt, info []byte) (PrivateK 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) switch { @@ -204,14 +210,16 @@ func (e ECDecryptor) DecryptWithEphemeralKey(data, ephemeral []byte) ([]byte, er } // hybridDecryptorFromPKCS8 mirrors hybridEncryptorFromSPKI for PKCS#8 private -// keys. Salt/info are honoured only for X-Wing; the NIST composite-KEM hybrids -// have no salt/info inputs. +// 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, err := parseHybridPKCS8(der) - if err != nil { - // Not a hybrid PKCS#8 envelope. Signal "not handled" so the caller can - // fall back to the standard x509 PKCS#8 path for RSA/EC keys. - return nil, false, nil //nolint:nilerr // intentional fall-through + 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): @@ -223,9 +231,14 @@ func hybridDecryptorFromPKCS8(der, salt, info []byte) (PrivateKeyDecryptor, bool case oid.Equal(oidCompositeMLKEM1024P384): dec, err := NewP384MLKEM1024Decryptor(raw) return dec, true, err - default: + } + // 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 { diff --git a/lib/ocrypto/asym_encryption.go b/lib/ocrypto/asym_encryption.go index 44eaad8907..2938108ecc 100644 --- a/lib/ocrypto/asym_encryption.go +++ b/lib/ocrypto/asym_encryption.go @@ -83,6 +83,11 @@ func FromPublicPEMWithSalt(publicKeyInPem string, salt, info []byte) (PublicKeyE 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) if err != nil { @@ -285,16 +290,18 @@ func (e ECEncryptor) PublicKeyInPemFormat() (string, error) { } // hybridEncryptorFromSPKI tries to decode `der` as a hybrid PQ/T -// SubjectPublicKeyInfo. The second return value reports whether the OID -// matched a known hybrid scheme — when false, the caller should fall back to -// the standard x509 SPKI path. Salt/info are honoured only for X-Wing (the -// NIST composite-KEM hybrids derive their wrap key without them). +// 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, err := parseHybridSPKI(der) - if err != nil { - // Not a hybrid SPKI envelope. Signal "not handled" so the caller can - // fall back to the standard x509 SPKI path for RSA/EC keys. - return nil, false, nil //nolint:nilerr // intentional fall-through + 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): @@ -306,7 +313,13 @@ func hybridEncryptorFromSPKI(der, salt, info []byte) (PublicKeyEncryptor, bool, case oid.Equal(oidCompositeMLKEM1024P384): enc, err := NewP384MLKEM1024Encryptor(raw) return enc, true, err - default: + } + // 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/hybrid_common.go b/lib/ocrypto/hybrid_common.go index 68bd6951f4..7b7db008bf 100644 --- a/lib/ocrypto/hybrid_common.go +++ b/lib/ocrypto/hybrid_common.go @@ -22,7 +22,7 @@ func HybridWrapDEK(ktype KeyType, kasPublicKeyPEM string, dek []byte) ([]byte, e // 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 §4.3. +// key without salt per draft-ietf-lamps-pq-composite-kem-14 §3.4 (combiner). func defaultTDFSalt() []byte { digest := sha256.New() digest.Write([]byte("TDF")) diff --git a/lib/ocrypto/hybrid_conformance_test.go b/lib/ocrypto/hybrid_conformance_test.go index a7f67d046d..44d4ea5220 100644 --- a/lib/ocrypto/hybrid_conformance_test.go +++ b/lib/ocrypto/hybrid_conformance_test.go @@ -3,6 +3,7 @@ package ocrypto import ( "crypto/ecdh" "encoding/asn1" + "encoding/hex" "encoding/pem" "testing" @@ -10,13 +11,22 @@ import ( "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 §3 (id-MLKEM768-ECDH-P256, id-MLKEM1024-ECDH-P384) -// - draft-connolly-cfrg-xwing-kem-10 §6 (id-Xwing) +// - 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}, @@ -36,7 +46,7 @@ func TestHybridOIDsMatchDrafts(t *testing.T) { // 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 §4.3, Table "Combiner Labels". +// 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) @@ -44,8 +54,7 @@ func TestHybridCombinerLabelsMatchDraft(t *testing.T) { // TestP256MLKEM768PublicKeyConcatOrder verifies that the raw public-key // material under our SPKI envelope is laid out as `mlkemPK || ecPoint`, in -// that order, per draft-14 §3.2 (the order was flipped from earlier internal -// versions). +// that order, per draft-14 §4.1 (SerializePublicKey). func TestP256MLKEM768PublicKeyConcatOrder(t *testing.T) { kp, err := NewP256MLKEM768KeyPair() require.NoError(t, err) @@ -95,29 +104,145 @@ func TestP384MLKEM1024PublicKeyConcatOrder(t *testing.T) { assert.NoError(t, err, "trailing bytes must parse as a P-384 ECDH public key") } -// TestHybridCrossSchemeDispatchRejection verifies that the dispatcher will not -// happily decrypt an X-Wing-wrapped DEK with a P-256+ML-KEM-768 private key -// (or any other mismatched pairing) — the OID inside the PKCS#8 envelope is -// the only thing routing the decryption path, and it must be authoritative. +// 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) { - xw, err := NewXWingKeyPair() - require.NoError(t, err) - nist, err := NewP256MLKEM768KeyPair() - require.NoError(t, err) + 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) + }) + } + } +} - xwPub, err := xw.PublicKeyInPemFormat() +// 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) - nistPriv, err := nist.PrivateKeyInPemFormat() + pubPEM, err := kp.PublicKeyInPemFormat() require.NoError(t, err) + block, _ := pem.Decode([]byte(pubPEM)) + require.NotNil(t, block) - xwEnc, err := FromPublicPEM(xwPub) - require.NoError(t, err) - wrapped, err := xwEnc.Encrypt([]byte("cross-scheme-dek")) - require.NoError(t, err) + fakeCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: block.Bytes}) - nistDec, err := FromPrivatePEM(nistPriv) + _, 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 = nistDec.Decrypt(wrapped) - require.Error(t, err, "NIST decryptor must not accept an X-Wing wrapped envelope") + _, _, 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 f4c1b0b2eb..0d6f359cda 100644 --- a/lib/ocrypto/hybrid_nist.go +++ b/lib/ocrypto/hybrid_nist.go @@ -31,7 +31,8 @@ const ( P384MLKEM1024MLKEMPubKeySize = 1568 P384MLKEM1024MLKEMCtSize = 1568 - // Raw public-key and ciphertext sizes after draft-14 ordering (mlkem || ec). + // 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 @@ -54,11 +55,15 @@ type hybridNISTParams struct { ecPubSize int // uncompressed point length mlkemPubSize int mlkemCtSize int - label string // ASCII domain-separator per draft-14 §4.3 - oid asn1.ObjectIdentifier // AlgorithmIdentifier OID + 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(), namedCurve: elliptic.P256(), @@ -256,6 +261,17 @@ func newHybridNISTDecryptor(p *hybridNISTParams, privateKey []byte) (*HybridNIST 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...), params: p, @@ -266,6 +282,12 @@ func (d *HybridNISTDecryptor) Decrypt(data []byte) ([]byte, error) { 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) } @@ -283,13 +305,32 @@ func P384MLKEM1024UnwrapDEK(privateKeyRaw, wrappedDER []byte) ([]byte, error) { } // hybridNISTCombiner returns the 32-byte SHA3-256 digest defined in -// draft-ietf-lamps-pq-composite-kem-14 §4.3: +// draft-ietf-lamps-pq-composite-kem-14 §3.4: // // 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)) + } + if len(tradSS) != expectedTradSS { + panic(fmt.Sprintf("hybridNISTCombiner[%s]: tradSS length %d, want %d", p.keyType, len(tradSS), expectedTradSS)) + } + if len(tradCT) != p.ecPubSize { + panic(fmt.Sprintf("hybridNISTCombiner[%s]: tradCT length %d, want %d", p.keyType, len(tradCT), p.ecPubSize)) + } + if len(tradPK) != p.ecPubSize { + panic(fmt.Sprintf("hybridNISTCombiner[%s]: tradPK length %d, want %d", p.keyType, len(tradPK), p.ecPubSize)) + } h := sha3.New256() // hash.Hash.Write never returns an error (documented in the stdlib). _, _ = h.Write(mlkemSS) diff --git a/lib/ocrypto/pem_blocks.go b/lib/ocrypto/pem_blocks.go index 718e70cf2b..234b6eaafa 100644 --- a/lib/ocrypto/pem_blocks.go +++ b/lib/ocrypto/pem_blocks.go @@ -4,6 +4,7 @@ package ocrypto // 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" + pemBlockPublicKey = "PUBLIC KEY" + pemBlockPrivateKey = "PRIVATE KEY" + pemBlockCertificate = "CERTIFICATE" ) diff --git a/lib/ocrypto/pq_asn1.go b/lib/ocrypto/pq_asn1.go index e82c449d2a..89d5f6d675 100644 --- a/lib/ocrypto/pq_asn1.go +++ b/lib/ocrypto/pq_asn1.go @@ -23,6 +23,11 @@ type subjectPublicKeyInfo struct { // 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 @@ -61,6 +66,9 @@ func parseHybridSPKI(der []byte) (asn1.ObjectIdentifier, []byte, error) { 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 } @@ -94,5 +102,8 @@ func parseHybridPKCS8(der []byte) (asn1.ObjectIdentifier, []byte, error) { 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_oids.go b/lib/ocrypto/pq_oids.go index a0f0892b45..92c8e5b639 100644 --- a/lib/ocrypto/pq_oids.go +++ b/lib/ocrypto/pq_oids.go @@ -1,6 +1,9 @@ package ocrypto -import "encoding/asn1" +import ( + "bytes" + "encoding/asn1" +) // OIDs assigned to the hybrid post-quantum/traditional KEMs we support. // @@ -14,8 +17,39 @@ var ( oidXWing = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62253, 25722} ) -// ASCII Labels mixed into the composite-KEM combiner per draft-14 §4.3 to +// 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/xwing.go b/lib/ocrypto/xwing.go index 12f0d0f7b5..b92c93fecb 100644 --- a/lib/ocrypto/xwing.go +++ b/lib/ocrypto/xwing.go @@ -20,8 +20,21 @@ const ( XWingCiphertextSize = xwing.CiphertextSize ) -// XWingWrappedKey is the ASN.1 envelope stored in wrapped_key. draft-10 defines -// only the KEM; this DEK wrapping envelope is local to OpenTDF and unchanged. +// 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"` @@ -141,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) } diff --git a/service/internal/security/basic_manager.go b/service/internal/security/basic_manager.go index c5142d81bd..12443eaa25 100644 --- a/service/internal/security/basic_manager.go +++ b/service/internal/security/basic_manager.go @@ -115,6 +115,13 @@ func (b *BasicManager) Decrypt(ctx context.Context, keyDetails trust.KeyDetails, if len(ephemeralPublicKey) > 0 { return nil, errors.New("ephemeral public key should not be provided for hybrid decryption") } + // 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 := decrypter.Decrypt(ciphertext) if err != nil { return nil, fmt.Errorf("failed to decrypt with hybrid [%s]: %w", keyDetails.Algorithm(), err) diff --git a/service/internal/security/standard_crypto.go b/service/internal/security/standard_crypto.go index f10f1e2a2d..27ad982431 100644 --- a/service/internal/security/standard_crypto.go +++ b/service/internal/security/standard_crypto.go @@ -510,6 +510,9 @@ func (s *StandardCrypto) Decrypt(_ context.Context, keyID trust.KeyIdentifier, c if err != nil { return nil, fmt.Errorf("failed to parse X-Wing 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 X-Wing: %w", err) @@ -524,6 +527,9 @@ func (s *StandardCrypto) Decrypt(_ context.Context, keyID trust.KeyIdentifier, c 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) @@ -535,3 +541,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 +} From 6058b4183d211cd24275f37025f306eea52ee6b7 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 4 Jun 2026 17:45:18 -0400 Subject: [PATCH 05/11] fixup enable pq/t on actions --- test/start-additional-kas/action.yaml | 6 ++++++ test/start-up-with-containers/action.yaml | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/test/start-additional-kas/action.yaml b/test/start-additional-kas/action.yaml index 94040f6ca5..3996fb2d1e 100644 --- a/test/start-additional-kas/action.yaml +++ b/test/start-additional-kas/action.yaml @@ -16,6 +16,10 @@ inputs: default: "false" description: 'Whether to enable ECC wrapping for TDFs' required: false + pqc-enabled: + default: "false" + description: 'Whether to enable post-quantum and hybrid PQ/T wrapping for TDFs' + required: false key-management: default: "false" description: 'Whether or not key_management is enabled for this KAS' @@ -95,6 +99,7 @@ runs: KAS_NAME: ${{ inputs.kas-name }} KAS_PORT: ${{ inputs.kas-port }} EC_TDF_ENABLED: ${{ inputs.ec-tdf-enabled }} + PQC_ENABLED: ${{ inputs.pqc-enabled }} KEY_MANAGEMENT: ${{ inputs.key-management }} ROOT_KEY: ${{ inputs.root-key }} LOG_LEVEL: ${{ inputs.log-level }} @@ -105,6 +110,7 @@ runs: (.server.port = env(KAS_PORT)) | (.mode = ["kas"]) | (.services.kas.preview.ec_tdf_enabled = env(EC_TDF_ENABLED)) + | (.services.kas.preview.hybrid_tdf_enabled = env(PQC_ENABLED)) | (.services.kas.preview.key_management = env(KEY_MANAGEMENT)) | (.services.kas.registered_kas_uri = "http://localhost:" + env(KAS_PORT)) | del(.services.kas.root_key) diff --git a/test/start-up-with-containers/action.yaml b/test/start-up-with-containers/action.yaml index ad206c9dbc..b675005b61 100644 --- a/test/start-up-with-containers/action.yaml +++ b/test/start-up-with-containers/action.yaml @@ -15,6 +15,10 @@ inputs: default: "false" description: 'Whether to enable ECC wrapping for TDFs' required: false + pqc-enabled: + default: "false" + description: 'Whether to enable post-quantum and hybrid PQ/T wrapping for TDFs' + required: false log-level: default: "debug" description: 'Log level for the platform (audit, debug, info, warn, error)' @@ -165,6 +169,12 @@ runs: run: | yq e '.services.kas.ec_tdf_enabled = true' -i opentdf.yaml working-directory: otdf-test-platform + - name: Enable PQ (mlkem, xwing, and hybrid) wrapping for TDFs + shell: bash + if: ${{ inputs.pqc-enabled }} + run: | + yq e '.services.kas.hybrid_tdf_enabled = true' -i opentdf.yaml + working-directory: otdf-test-platform - name: Validate logging inputs shell: bash env: From 2399243697d53408b2f84b78c8095c8ab8caaa9f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 5 Jun 2026 08:07:36 -0400 Subject: [PATCH 06/11] fix(ocrypto): address hybrid PR feedback --- lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md | 30 ++++---- lib/ocrypto/asym_encryption.go | 2 +- lib/ocrypto/ec_key_pair.go | 4 +- lib/ocrypto/hybrid_common.go | 7 ++ lib/ocrypto/hybrid_common_test.go | 20 +++++ lib/ocrypto/hybrid_conformance_test.go | 97 +++++++++++++++++++++++++ lib/ocrypto/hybrid_nist.go | 2 + lib/ocrypto/rsa_key_pair.go | 4 +- sdk/experimental/tdf/writer_test.go | 16 ++-- 9 files changed, 155 insertions(+), 27 deletions(-) create mode 100644 lib/ocrypto/hybrid_common_test.go diff --git a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md index 6c00420774..8eb7c54951 100644 --- a/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md +++ b/lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md @@ -32,7 +32,7 @@ Core implementation: `lib/ocrypto/hybrid_nist.go`. Shared SPKI/PKCS#8 helpers an 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`): -``` +```asn1 SubjectPublicKeyInfo { AlgorithmIdentifier { oid = , parameters ABSENT }, BIT STRING [ mlkemPublicKey || ecPublicKey (uncompressed SEC1 point) ] @@ -50,7 +50,7 @@ The EC half is an uncompressed SEC1 point (leading `0x04` tag). The PEM block ty 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 }, @@ -86,7 +86,7 @@ This is performed on the **client side** during TDF encryption. 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: -``` +```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 ``` @@ -105,7 +105,7 @@ The ephemeral key provides forward secrecy — even if the KAS static key is lat ML-KEM is a KEM, not a key exchange. Encapsulation takes only the public key and produces two outputs: -``` +```text (mlkemSS, mlkemCT) = ML-KEM.Encapsulate(KAS_mlkem_public) ``` @@ -118,7 +118,7 @@ No ephemeral ML-KEM key pair is generated by the client. The ciphertext itself s 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: -``` +```text wrapKey = SHA3-256( mlkemSS || tradSS || tradCT || tradPK || Label ) ``` @@ -138,13 +138,13 @@ The Label constants live in `lib/ocrypto/pq_oids.go` (`labelMLKEM768P256`, `labe 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) ] ``` @@ -163,7 +163,7 @@ HybridNISTWrappedKey ::= SEQUENCE { Where `hybridCiphertext` is laid out per draft-14 §4.3 (`SerializeCiphertext`): -``` +```text [ ML-KEM ciphertext (1088 or 1568 bytes) | ephemeral EC public key (65 or 97 bytes) ] ``` @@ -186,7 +186,7 @@ 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: [ mlkemCT | ephemeralECPub ] encryptedDEK: [ nonce | ciphertext | tag ] @@ -197,7 +197,7 @@ ASN.1 Unmarshal -> HybridNISTWrappedKey { The `hybridCiphertext` is split at the known ML-KEM ciphertext size boundary: -``` +```go mlkemCT = hybridCiphertext[:mlkemCtSize] // 1088 or 1568 bytes ephemeralECPub = hybridCiphertext[mlkemCtSize:] // 65 or 97 bytes ``` @@ -206,7 +206,7 @@ ephemeralECPub = hybridCiphertext[mlkemCtSize:] // 65 or 97 bytes 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 ``` @@ -217,7 +217,7 @@ The ML-KEM decapsulation key is reconstructed from the seed via `mlkem.NewDecaps KAS uses its static EC private key with the client's ephemeral EC public key: -``` +```text tradSS = ECDH(KAS_ec_private, ephemeralECPub) ``` @@ -227,7 +227,7 @@ This produces the same `tradSS` that the client computed in Wrap Step 2, because KAS uses its ML-KEM private key to decapsulate the ciphertext: -``` +```text mlkemSS = ML-KEM.Decapsulate(KAS_mlkem_private, mlkemCT) ``` @@ -237,7 +237,7 @@ Decapsulation recovers the same 32-byte shared secret the client obtained during Identical to Wrap Step 4: -``` +```text wrapKey = SHA3-256( mlkemSS || tradSS || tradCT || tradPK || Label ) ``` @@ -245,7 +245,7 @@ Both sides derive the same `wrapKey` because both sides have the same `mlkemSS`, ### Step 7 - AES-GCM Decrypt the Split Key -``` +```text splitKey = AES-256-GCM.Decrypt(key=wrapKey, ciphertext=encryptedDEK) ``` diff --git a/lib/ocrypto/asym_encryption.go b/lib/ocrypto/asym_encryption.go index 2938108ecc..860e799cbf 100644 --- a/lib/ocrypto/asym_encryption.go +++ b/lib/ocrypto/asym_encryption.go @@ -243,7 +243,7 @@ func publicKeyInPemFormat(pk any) (string, error) { publicKeyPem := pem.EncodeToMemory( &pem.Block{ - Type: "PUBLIC KEY", + Type: pemBlockPublicKey, Bytes: publicKeyBytes, }, ) 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 7b7db008bf..f7178d009c 100644 --- a/lib/ocrypto/hybrid_common.go +++ b/lib/ocrypto/hybrid_common.go @@ -10,10 +10,17 @@ import ( // and produces the ASN.1-encoded wrapped DEK envelope used in // `hybrid-wrapped` manifests. func HybridWrapDEK(ktype KeyType, kasPublicKeyPEM string, dek []byte) ([]byte, error) { + 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) } 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 index 44d4ea5220..4f1bfeb8d0 100644 --- a/lib/ocrypto/hybrid_conformance_test.go +++ b/lib/ocrypto/hybrid_conformance_test.go @@ -2,6 +2,7 @@ package ocrypto import ( "crypto/ecdh" + "crypto/x509" "encoding/asn1" "encoding/hex" "encoding/pem" @@ -104,6 +105,102 @@ func TestP384MLKEM1024PublicKeyConcatOrder(t *testing.T) { 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 diff --git a/lib/ocrypto/hybrid_nist.go b/lib/ocrypto/hybrid_nist.go index 0d6f359cda..c127e7093e 100644 --- a/lib/ocrypto/hybrid_nist.go +++ b/lib/ocrypto/hybrid_nist.go @@ -24,10 +24,12 @@ const mlkemSeedSize = 64 // Sizes for the elementary halves of the two NIST composite-KEM hybrids. const ( 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 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/sdk/experimental/tdf/writer_test.go b/sdk/experimental/tdf/writer_test.go index db3a658594..415927c4c8 100644 --- a/sdk/experimental/tdf/writer_test.go +++ b/sdk/experimental/tdf/writer_test.go @@ -896,9 +896,8 @@ 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") @@ -907,7 +906,7 @@ func hybridUnwrapForTest(t *testing.T, ktype ocrypto.KeyType, privatePEM, wrappe require.NoError(t, err, "FromPrivatePEM") dek, err := dec.Decrypt(wrappedDER) require.NoError(t, err, "hybrid Decrypt") - assert.NotEmpty(t, dek, "%s recovered DEK", ktype) + assert.Equal(t, expectedDEK, dek, "%s recovered DEK", ktype) } func testHybridXWingFlow(t *testing.T) { @@ -922,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) @@ -943,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) { @@ -958,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) @@ -979,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) { @@ -994,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) @@ -1015,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 From 492a55d9faaad4fa103cf60c549d446fbd6f2ec6 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 5 Jun 2026 08:34:02 -0400 Subject: [PATCH 07/11] fix(core): address follow-up hybrid review Signed-off-by: Dave Mihalcik --- lib/ocrypto/BENCHMARK_REPORT.md | 16 +++++++++------- sdk/tdf_test.go | 3 +++ service/internal/security/standard_crypto.go | 14 ++++++++++++++ .../internal/security/standard_crypto_test.go | 19 +++++++++++++++++++ test/start-up-with-containers/action.yaml | 2 +- 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/lib/ocrypto/BENCHMARK_REPORT.md b/lib/ocrypto/BENCHMARK_REPORT.md index d695f9c613..6d5ce4b2b5 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 @@ -48,7 +48,7 @@ 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 | |--------|-----:|-------------:|-----:|----------:|-------------| @@ -93,11 +93,13 @@ These benchmarks follow the KAS unwrap paths: ## Analysis: Where Time Is Spent KEM encapsulation dominates all hybrid schemes (~93-97% of total wrap time); -HKDF, AES-GCM, and ASN.1 marshaling are all sub-microsecond. 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. +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/sdk/tdf_test.go b/sdk/tdf_test.go index ab6cf992a2..de75b9a8d2 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -3102,6 +3102,9 @@ func (f *FakeKas) getRewrapResponse(rewrapRequest string, fulfillableObligations 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") diff --git a/service/internal/security/standard_crypto.go b/service/internal/security/standard_crypto.go index 27ad982431..cbc3d1cdf0 100644 --- a/service/internal/security/standard_crypto.go +++ b/service/internal/security/standard_crypto.go @@ -164,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), 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 b675005b61..1904155c04 100644 --- a/test/start-up-with-containers/action.yaml +++ b/test/start-up-with-containers/action.yaml @@ -171,7 +171,7 @@ runs: working-directory: otdf-test-platform - name: Enable PQ (mlkem, xwing, and hybrid) wrapping for TDFs shell: bash - if: ${{ inputs.pqc-enabled }} + if: ${{ inputs.pqc-enabled == 'true' }} run: | yq e '.services.kas.hybrid_tdf_enabled = true' -i opentdf.yaml working-directory: otdf-test-platform From 02b6bbd873adc4b008cffcb752a40f93584d7267 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 5 Jun 2026 09:36:12 -0400 Subject: [PATCH 08/11] fixup manual check of benchmark numbers --- lib/ocrypto/BENCHMARK_REPORT.md | 53 +++++++++++++-------------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/lib/ocrypto/BENCHMARK_REPORT.md b/lib/ocrypto/BENCHMARK_REPORT.md index 6d5ce4b2b5..afc279b460 100644 --- a/lib/ocrypto/BENCHMARK_REPORT.md +++ b/lib/ocrypto/BENCHMARK_REPORT.md @@ -34,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. @@ -52,14 +52,12 @@ These benchmarks follow the exact TDF wrapping paths: | 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 @@ -70,25 +68,14 @@ 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 | +| 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 | + +**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). ## Analysis: Where Time Is Spent From 0d398ab90b39ea038ef78290c80e20673a30c0a3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 5 Jun 2026 11:02:54 -0400 Subject: [PATCH 09/11] fixup revert action changes --- test/start-additional-kas/action.yaml | 6 ------ test/start-up-with-containers/action.yaml | 10 ---------- 2 files changed, 16 deletions(-) diff --git a/test/start-additional-kas/action.yaml b/test/start-additional-kas/action.yaml index 3996fb2d1e..94040f6ca5 100644 --- a/test/start-additional-kas/action.yaml +++ b/test/start-additional-kas/action.yaml @@ -16,10 +16,6 @@ inputs: default: "false" description: 'Whether to enable ECC wrapping for TDFs' required: false - pqc-enabled: - default: "false" - description: 'Whether to enable post-quantum and hybrid PQ/T wrapping for TDFs' - required: false key-management: default: "false" description: 'Whether or not key_management is enabled for this KAS' @@ -99,7 +95,6 @@ runs: KAS_NAME: ${{ inputs.kas-name }} KAS_PORT: ${{ inputs.kas-port }} EC_TDF_ENABLED: ${{ inputs.ec-tdf-enabled }} - PQC_ENABLED: ${{ inputs.pqc-enabled }} KEY_MANAGEMENT: ${{ inputs.key-management }} ROOT_KEY: ${{ inputs.root-key }} LOG_LEVEL: ${{ inputs.log-level }} @@ -110,7 +105,6 @@ runs: (.server.port = env(KAS_PORT)) | (.mode = ["kas"]) | (.services.kas.preview.ec_tdf_enabled = env(EC_TDF_ENABLED)) - | (.services.kas.preview.hybrid_tdf_enabled = env(PQC_ENABLED)) | (.services.kas.preview.key_management = env(KEY_MANAGEMENT)) | (.services.kas.registered_kas_uri = "http://localhost:" + env(KAS_PORT)) | del(.services.kas.root_key) diff --git a/test/start-up-with-containers/action.yaml b/test/start-up-with-containers/action.yaml index 1904155c04..ad206c9dbc 100644 --- a/test/start-up-with-containers/action.yaml +++ b/test/start-up-with-containers/action.yaml @@ -15,10 +15,6 @@ inputs: default: "false" description: 'Whether to enable ECC wrapping for TDFs' required: false - pqc-enabled: - default: "false" - description: 'Whether to enable post-quantum and hybrid PQ/T wrapping for TDFs' - required: false log-level: default: "debug" description: 'Log level for the platform (audit, debug, info, warn, error)' @@ -169,12 +165,6 @@ runs: run: | yq e '.services.kas.ec_tdf_enabled = true' -i opentdf.yaml working-directory: otdf-test-platform - - name: Enable PQ (mlkem, xwing, and hybrid) wrapping for TDFs - shell: bash - if: ${{ inputs.pqc-enabled == 'true' }} - run: | - yq e '.services.kas.hybrid_tdf_enabled = true' -i opentdf.yaml - working-directory: otdf-test-platform - name: Validate logging inputs shell: bash env: From 3a947f9edbf4215befc535559207a09dde06bf35 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 5 Jun 2026 11:04:01 -0400 Subject: [PATCH 10/11] fixup rm spec --- spec/DSPX-3396.md | 84 ----------------------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 spec/DSPX-3396.md diff --git a/spec/DSPX-3396.md b/spec/DSPX-3396.md deleted file mode 100644 index 058a21b1db..0000000000 --- a/spec/DSPX-3396.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -ticket: DSPX-3396 -title: "PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing)" -status: draft -authors: [dmihalcik@virtru.com] -branches: [opentdf/platform:DSPX-3396-conformant-hybrid-pqt] -prs: [] -created: 2026-06-03 -updated: 2026-06-03 ---- - -# PQC: Conform hybrid pq/t key formats to IETF drafts (composite-KEM, X-Wing) - -## Context - -`lib/ocrypto` currently represents hybrid post-quantum key material (X-Wing, P256+ML-KEM-768, P384+ML-KEM-1024) with custom PEM block headers and raw concatenated payloads — no ASN.1 envelope, no `AlgorithmIdentifier` OID, and (for the NIST hybrids) a combiner and byte ordering that diverge from `draft-ietf-lamps-pq-composite-kem-14`. This blocks interop with other PQ-aware tooling and means the IETF OIDs cannot honestly be advertised. - -The pure ML-KEM PEM cleanup is being landed separately on `DSPX-3383-post-quantum-kem`. This task tracks the larger hybrid refactor, which should be branched off `main` and shipped as its own PR. - -## Scope - -Bring NIST composite-KEM hybrids into conformance with `draft-ietf-lamps-pq-composite-kem-14`, and the X-Wing hybrid into conformance with `draft-connolly-cfrg-xwing-kem-10`. Both move from custom PEM block headers to standard `PUBLIC KEY` / `PRIVATE KEY` headers with SPKI / PKCS#8 envelopes and OID-based dispatch. - -### NIST composite KEM (P256+ML-KEM-768, P384+ML-KEM-1024) - -| Aspect | Current (`lib/ocrypto/hybrid_nist.go`) | Draft-14 conformance | -| --- | --- | --- | -| Public key concat order | `ecPoint ‖ mlkemPK` | `mlkemPK ‖ ecPoint` | -| Private key concat order | `ecScalar ‖ mlkemSeed` | `mlkemSeed ‖ ecPrivateKey(DER)` | -| Ciphertext concat order | `ecEphemeralPoint ‖ mlkemCT` | `mlkemCT ‖ ecEphemeralPoint` | -| EC private encoding | raw 32/48-byte scalar | RFC 5915 `ECPrivateKey` DER | -| Combiner | `HKDF-SHA256(ecdhSS ‖ mlkemSS, salt, info)` | `SHA3-256(mlkemSS ‖ tradSS ‖ tradCT ‖ tradPK ‖ Label)` | -| Label bytes | n/a | ASCII `MLKEM768-P256` / `MLKEM1024-P384` | -| Wrap KDF | HKDF over combined secret | spec output (32 bytes) used directly as AES-256 key | -| Salt/info plumbing | accepted, used | **remove from NIST hybrid public APIs** | -| PEM block type | `SECP256R1 MLKEM768 PUBLIC KEY` etc. | standard `PUBLIC KEY` / `PRIVATE KEY` | -| Algorithm identifier | n/a (no envelope) | OID `1.3.6.1.5.5.7.6.59` (P256) / `1.3.6.1.5.5.7.6.63` (P384); params absent | - -The custom `HybridNISTWrappedKey ASN.1 { HybridCiphertext, EncryptedDEK }` envelope used at the TDF layer stays — the spec defines the KEM only, not DEK wrapping. - -### X-Wing - -| Aspect | Current (`lib/ocrypto/xwing.go`) | Draft-10 conformance | -| --- | --- | --- | -| KEM combiner | matches spec (cloudflare/circl primitive) | unchanged | -| PEM block type | `XWING PUBLIC KEY` / `XWING PRIVATE KEY` | standard `PUBLIC KEY` / `PRIVATE KEY` | -| Algorithm identifier | n/a | OID `1.3.6.1.4.1.62253.25722`; params absent | -| Public key inside SPKI | raw 1216 bytes | unchanged, now wrapped in SPKI BIT STRING | -| Private key inside PKCS#8 | raw bytes | unchanged, now wrapped in OneAsymmetricKey OCTET STRING | - -### Dispatcher - -`FromPublicPEMWithSalt` (`lib/ocrypto/asym_encryption.go:73`) and `FromPrivatePEMWithSalt` (`lib/ocrypto/asym_decryption.go:41`) currently switch on `block.Type`. After this refactor the hybrid cases are removed; the dispatcher reads `block.Type == "PUBLIC KEY"` / `"PRIVATE KEY"`, peeks the `AlgorithmIdentifier` OID, and routes to the appropriate constructor. - -Helper module to add: `lib/ocrypto/pq_oids.go` (OID constants) and `lib/ocrypto/pq_asn1.go` (SPKI/PKCS#8 marshal/parse helpers). - -## External call sites to update - -- `sdk/tdf_hybrid_test.go` -- `sdk/experimental/tdf/{key_access,writer}_test.go` -- `otdfctl/cmd/policy/kasKeys.go`, `otdfctl/cmd/policy/kasKeys_test.go` -- `otdfctl/pkg/utils/pemvalidate.go`, `otdfctl/pkg/utils/pemvalidate_test.go` -- `tests-bdd/cukes/utils/utils_genKeys.go` - -Any test fixture embedding a literal hybrid PEM string must be regenerated. NIST hybrid constructors (`NewSaltedP256MLKEM768Decryptor` etc.) lose their `salt`/`info` parameters — call sites in `sdk/` and `otdfctl/` need to drop those arguments. - -## Acceptance criteria - -- [ ] Hybrid PEM artifacts use `PUBLIC KEY` / `PRIVATE KEY` headers; `openssl asn1parse` shows the expected OID. -- [ ] `go test ./lib/ocrypto/... ./sdk/...` passes, including new conformance round-trips. -- [ ] Test vectors taken from the draft appendices (or reference implementations) decrypt successfully under the new code path. -- [ ] `golangci-lint run` clean across the diff. - -## Out of scope - -- Pure ML-KEM PEM cleanup — landing on `DSPX-3383-post-quantum-kem`. -- Migration tooling for existing on-disk hybrid keys — not needed, no deployed material in the old format. -- ML-DSA / SLH-DSA signature scheme PEM (separate work). - -## References - -- draft-ietf-lamps-pq-composite-kem-14 — https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-kem/ -- draft-connolly-cfrg-xwing-kem-10 — https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/ -- RFC 5280 (SPKI), RFC 5958 (PKCS#8), RFC 5915 (ECPrivateKey) From c9ac7f6c4bc9fd17fc0f2358aecb4a33e8220f23 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 5 Jun 2026 13:57:59 -0400 Subject: [PATCH 11/11] fix(ci): update curl pin to pqc-enabled tag so PQC keys are generated The watch-sh-fix tag predates PQC support in init-temp-keys.sh, so kas-xwing-private.pem and related files were never generated. The pqc-enabled tag points to main HEAD which already runs `go run ./service/cmd/keygen` to produce the PQC key pairs needed by start-additional-kas when pqc-enabled is true. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- test/start-up-with-containers/action.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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