From b75fb556c346981ec6933e98beebe4cf5e014755 Mon Sep 17 00:00:00 2001 From: sujan kota Date: Mon, 18 May 2026 15:20:07 -0400 Subject: [PATCH 1/3] feat(sdk): DSPX-3309 add hybrid post-quantum key wrapping for KAS (X-Wing, ECDH+ML-KEM) --- .../io/opentdf/platform/sdk/HybridCrypto.java | 162 ++++++++++ .../platform/sdk/HybridNISTKeyPair.java | 296 ++++++++++++++++++ .../java/io/opentdf/platform/sdk/KeyType.java | 16 +- .../java/io/opentdf/platform/sdk/TDF.java | 9 +- .../io/opentdf/platform/sdk/XWingKeyPair.java | 92 ++++++ .../platform/sdk/HybridCryptoTest.java | 166 ++++++++++ .../opentdf/platform/sdk/TDFHybridTest.java | 157 ++++++++++ 7 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java b/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java new file mode 100644 index 00000000..057b22fe --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java @@ -0,0 +1,162 @@ +package io.opentdf.platform.sdk; + +import org.bouncycastle.asn1.ASN1EncodableVector; +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.ASN1TaggedObject; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.DERTaggedObject; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.HKDFParameters; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Dispatcher and shared helpers for hybrid post-quantum key wrapping + * (X-Wing and NIST EC + ML-KEM). Mirrors the lib/ocrypto Go package. + * + * Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs + * SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK } + * + * Derived AES-256 wrap key: HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), info=empty). + * EncryptedDEK: AES-256-GCM(wrapKey).encrypt(DEK) with 12-byte IV prefix + 16-byte tag. + */ +final class HybridCrypto { + + static final int WRAP_KEY_SIZE = 32; + + private HybridCrypto() {} + + /** + * Wrap a DEK against a hybrid public-key PEM. Dispatches across X-Wing and NIST hybrid types. + * Returns the ASN.1-encoded envelope used in {@code wrappedKey} for {@code hybrid-wrapped} key access. + */ + static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) { + switch (keyType) { + case HybridXWingKey: + return XWingKeyPair.wrapDEK(XWingKeyPair.pubKeyFromPem(publicKeyPEM), dek); + case HybridSecp256r1MLKEM768Key: + return HybridNISTKeyPair.P256_MLKEM768.wrapDEK( + HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(publicKeyPEM), dek); + case HybridSecp384r1MLKEM1024Key: + return HybridNISTKeyPair.P384_MLKEM1024.wrapDEK( + HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(publicKeyPEM), dek); + default: + throw new SDKException("unsupported hybrid key type: " + keyType); + } + } + + /** + * Build the ASN.1 envelope from a hybrid KEM ciphertext and the AES-GCM(iv||ct) encrypted DEK. + */ + static byte[] marshalEnvelope(byte[] hybridCiphertext, byte[] encryptedDEK) { + ASN1EncodableVector v = new ASN1EncodableVector(); + v.add(new DERTaggedObject(false, 0, new DEROctetString(hybridCiphertext))); + v.add(new DERTaggedObject(false, 1, new DEROctetString(encryptedDEK))); + try { + return new DERSequence(v).getEncoded("DER"); + } catch (IOException e) { + throw new SDKException("failed to encode hybrid wrapped key envelope", e); + } + } + + /** + * Parse the ASN.1 envelope. Returns {@code [hybridCiphertext, encryptedDEK]}. + * Rejects trailing bytes (matches the Go {@code asn1.Unmarshal} strict behaviour). + */ + static byte[][] unmarshalEnvelope(byte[] der) { + try (ASN1InputStream in = new ASN1InputStream(new ByteArrayInputStream(der))) { + ASN1Primitive prim = in.readObject(); + if (prim == null) { + throw new SDKException("hybrid wrapped key envelope is empty"); + } + if (in.readObject() != null) { + throw new SDKException("hybrid wrapped key envelope has trailing bytes"); + } + ASN1Sequence seq = ASN1Sequence.getInstance(prim); + if (seq.size() != 2) { + throw new SDKException("hybrid wrapped key envelope must have 2 elements, got " + seq.size()); + } + byte[] hybridCt = readImplicitOctetString(seq.getObjectAt(0), 0); + byte[] encDek = readImplicitOctetString(seq.getObjectAt(1), 1); + return new byte[][] { hybridCt, encDek }; + } catch (IOException e) { + throw new SDKException("failed to decode hybrid wrapped key envelope", e); + } + } + + private static byte[] readImplicitOctetString(org.bouncycastle.asn1.ASN1Encodable enc, int expectedTag) { + ASN1TaggedObject tagged = ASN1TaggedObject.getInstance(enc); + if (tagged.getTagNo() != expectedTag) { + throw new SDKException("expected context tag " + expectedTag + " but got " + tagged.getTagNo()); + } + return org.bouncycastle.asn1.ASN1OctetString.getInstance(tagged, false).getOctets(); + } + + /** + * HKDF-SHA256 → 32-byte AES wrap key. {@code salt=null} substitutes the default TDF salt. + */ + static byte[] deriveWrapKey(byte[] combinedSecret, byte[] salt, byte[] info) { + byte[] effSalt = (salt == null || salt.length == 0) ? defaultTDFSalt() : salt; + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + hkdf.init(new HKDFParameters(combinedSecret, effSalt, info)); + byte[] out = new byte[WRAP_KEY_SIZE]; + hkdf.generateBytes(out, 0, out.length); + return out; + } + + /** + * SHA-256("TDF") — matches the Go {@code defaultTDFSalt()} and Java {@code TDF.GLOBAL_KEY_SALT}. + */ + static byte[] defaultTDFSalt() { + try { + MessageDigest d = MessageDigest.getInstance("SHA-256"); + d.update("TDF".getBytes()); + return d.digest(); + } catch (NoSuchAlgorithmException e) { + throw new SDKException("SHA-256 not available", e); + } + } + + /** + * Encode a raw key into a PEM block with the given header type. + */ + static String rawToPem(String blockType, byte[] raw, int expectedSize) { + if (raw.length != expectedSize) { + throw new SDKException("invalid " + blockType + " size: got " + raw.length + " want " + expectedSize); + } + String b64 = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(raw); + return "-----BEGIN " + blockType + "-----\n" + b64 + "\n-----END " + blockType + "-----\n"; + } + + /** + * Decode a PEM block of the expected type and content size. Strict on header type and size. + */ + static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) { + String header = "-----BEGIN " + expectedType + "-----"; + String footer = "-----END " + expectedType + "-----"; + int headerIdx = pem.indexOf(header); + int footerIdx = pem.indexOf(footer); + if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) { + throw new SDKException("failed to parse PEM formatted " + expectedType); + } + String body = pem.substring(headerIdx + header.length(), footerIdx).replaceAll("\\s", ""); + byte[] raw; + try { + raw = Base64.getDecoder().decode(body); + } catch (IllegalArgumentException e) { + throw new SDKException("failed to base64-decode " + expectedType + " PEM body", e); + } + if (raw.length != expectedSize) { + throw new SDKException("invalid " + expectedType + " size: got " + raw.length + " want " + expectedSize); + } + return raw; + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java b/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java new file mode 100644 index 00000000..602e16a0 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java @@ -0,0 +1,296 @@ +package io.opentdf.platform.sdk; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.SecretWithEncapsulation; +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; +import org.bouncycastle.jce.spec.ECPrivateKeySpec; +import org.bouncycastle.jce.spec.ECPublicKeySpec; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMExtractor; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMParameters; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mlkem.MLKEMPublicKeyParameters; + +import javax.crypto.KeyAgreement; +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.util.Arrays; + +/** + * NIST hybrid post-quantum key wrapping (P-256 + ML-KEM-768 and P-384 + ML-KEM-1024). + * Mirrors {@code lib/ocrypto/hybrid_nist.go}. + * + * Wire layout of the wrapped DEK: + *
+ *   SEQUENCE {
+ *     [0] IMPLICIT OCTET STRING hybridCiphertext  -- ephemeralECPoint || mlkemCiphertext
+ *     [1] IMPLICIT OCTET STRING encryptedDEK      -- AES-256-GCM(iv||ct||tag)
+ *   }
+ * 
+ * with {@code wrapKey = HKDF-SHA256(ecdhSecret || mlkemSecret, salt = SHA-256("TDF"))}. + * + * Raw key encoding: + * + */ +final class HybridNISTKeyPair { + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + static final HybridNISTKeyPair P256_MLKEM768 = new HybridNISTKeyPair( + "secp256r1", + /* ecPubSize */ 65, + /* ecPrivSize */ 32, + /* mlkemPubSize */ 1184, + /* mlkemCtSize */ 1088, + MLKEMParameters.ml_kem_768, + "SECP256R1 MLKEM768 PUBLIC KEY", + "SECP256R1 MLKEM768 PRIVATE KEY", + KeyType.HybridSecp256r1MLKEM768Key); + + static final HybridNISTKeyPair P384_MLKEM1024 = new HybridNISTKeyPair( + "secp384r1", + /* ecPubSize */ 97, + /* ecPrivSize */ 48, + /* mlkemPubSize */ 1568, + /* mlkemCtSize */ 1568, + MLKEMParameters.ml_kem_1024, + "SECP384R1 MLKEM1024 PUBLIC KEY", + "SECP384R1 MLKEM1024 PRIVATE KEY", + KeyType.HybridSecp384r1MLKEM1024Key); + + /** Fixed 64-byte ML-KEM seed (d || z) per FIPS 203. */ + static final int MLKEM_SEED_SIZE = 64; + + private final String curveName; + private final int ecPubSize; + private final int ecPrivSize; + private final int mlkemPubSize; + private final int mlkemCtSize; + private final MLKEMParameters mlkemParams; + private final String pubPemBlock; + private final String privPemBlock; + private final KeyType keyType; + private final ECNamedCurveParameterSpec curveSpec; + + private final byte[] publicKey; + private final byte[] privateKey; + + private HybridNISTKeyPair(String curveName, int ecPubSize, int ecPrivSize, int mlkemPubSize, int mlkemCtSize, + MLKEMParameters mlkemParams, String pubPemBlock, String privPemBlock, KeyType keyType) { + this.curveName = curveName; + this.ecPubSize = ecPubSize; + this.ecPrivSize = ecPrivSize; + this.mlkemPubSize = mlkemPubSize; + this.mlkemCtSize = mlkemCtSize; + this.mlkemParams = mlkemParams; + this.pubPemBlock = pubPemBlock; + this.privPemBlock = privPemBlock; + this.keyType = keyType; + this.curveSpec = ECNamedCurveTable.getParameterSpec(curveName); + this.publicKey = null; + this.privateKey = null; + } + + private HybridNISTKeyPair(HybridNISTKeyPair params, byte[] publicKey, byte[] privateKey) { + this.curveName = params.curveName; + this.ecPubSize = params.ecPubSize; + this.ecPrivSize = params.ecPrivSize; + this.mlkemPubSize = params.mlkemPubSize; + this.mlkemCtSize = params.mlkemCtSize; + this.mlkemParams = params.mlkemParams; + this.pubPemBlock = params.pubPemBlock; + this.privPemBlock = params.privPemBlock; + this.keyType = params.keyType; + this.curveSpec = params.curveSpec; + this.publicKey = publicKey; + this.privateKey = privateKey; + } + + int publicKeySize() { return ecPubSize + mlkemPubSize; } + int privateKeySize() { return ecPrivSize + MLKEM_SEED_SIZE; } + int ciphertextSize() { return ecPubSize + mlkemCtSize; } + KeyType keyType() { return keyType; } + + HybridNISTKeyPair generate() { + SecureRandom random = new SecureRandom(); + + // EC half — generate ephemeral scalar, derive uncompressed point. + BigInteger ecScalar = generateEcScalar(random); + ECPoint ecPoint = curveSpec.getG().multiply(ecScalar).normalize(); + byte[] ecPubBytes = ecPoint.getEncoded(/* compressed */ false); + byte[] ecPrivBytes = leftPad(ecScalar.toByteArray(), ecPrivSize); + + // ML-KEM half. + MLKEMKeyPairGenerator mlGen = new MLKEMKeyPairGenerator(); + mlGen.init(new MLKEMKeyGenerationParameters(random, mlkemParams)); + AsymmetricCipherKeyPair mkp = mlGen.generateKeyPair(); + byte[] mlPubBytes = ((MLKEMPublicKeyParameters) mkp.getPublic()).getEncoded(); + byte[] mlSeed = ((MLKEMPrivateKeyParameters) mkp.getPrivate()).getSeed(); + + if (ecPubBytes.length != ecPubSize) { + throw new SDKException("EC public key size " + ecPubBytes.length + " != expected " + ecPubSize); + } + if (mlPubBytes.length != mlkemPubSize) { + throw new SDKException("ML-KEM public key size " + mlPubBytes.length + " != expected " + mlkemPubSize); + } + if (mlSeed.length != MLKEM_SEED_SIZE) { + throw new SDKException("ML-KEM seed size " + mlSeed.length + " != expected " + MLKEM_SEED_SIZE); + } + + byte[] pub = concat(ecPubBytes, mlPubBytes); + byte[] priv = concat(ecPrivBytes, mlSeed); + return new HybridNISTKeyPair(this, pub, priv); + } + + String publicKeyInPemFormat() { + return HybridCrypto.rawToPem(pubPemBlock, publicKey, publicKeySize()); + } + + String privateKeyInPemFormat() { + return HybridCrypto.rawToPem(privPemBlock, privateKey, privateKeySize()); + } + + byte[] getPublicKey() { return publicKey == null ? null : publicKey.clone(); } + byte[] getPrivateKey() { return privateKey == null ? null : privateKey.clone(); } + + byte[] pubKeyFromPem(String pem) { + return HybridCrypto.decodeSizedPemBlock(pem, pubPemBlock, publicKeySize()); + } + + byte[] privateKeyFromPem(String pem) { + return HybridCrypto.decodeSizedPemBlock(pem, privPemBlock, privateKeySize()); + } + + byte[] wrapDEK(byte[] rawPub, byte[] dek) { + if (rawPub.length != publicKeySize()) { + throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize()); + } + byte[] recipientEcPub = Arrays.copyOfRange(rawPub, 0, ecPubSize); + byte[] recipientMlPub = Arrays.copyOfRange(rawPub, ecPubSize, rawPub.length); + + SecureRandom random = new SecureRandom(); + + // ECDH: generate ephemeral, compute shared secret, capture ephemeral point. + BigInteger ephemeralScalar = generateEcScalar(random); + byte[] ephemeralEcPub = curveSpec.getG().multiply(ephemeralScalar).normalize().getEncoded(false); + byte[] ecdhSecret = computeEcdhSecret(ephemeralScalar, recipientEcPub); + + // ML-KEM encapsulate. + MLKEMPublicKeyParameters mlPub = new MLKEMPublicKeyParameters(mlkemParams, recipientMlPub); + SecretWithEncapsulation kemEnc = new MLKEMGenerator(random).generateEncapsulated(mlPub); + byte[] mlSecret = kemEnc.getSecret(); + byte[] mlCiphertext = kemEnc.getEncapsulation(); + if (mlCiphertext.length != mlkemCtSize) { + throw new SDKException("ML-KEM ciphertext size " + mlCiphertext.length + " != expected " + mlkemCtSize); + } + + byte[] combinedSecret = concat(ecdhSecret, mlSecret); + byte[] hybridCt = concat(ephemeralEcPub, mlCiphertext); + byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); + byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); + return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek); + } + + byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) { + if (rawPriv.length != privateKeySize()) { + throw new SDKException("invalid " + keyType + " private key size: got " + rawPriv.length + " want " + privateKeySize()); + } + byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer); + byte[] hybridCt = parts[0]; + byte[] encryptedDek = parts[1]; + if (hybridCt.length != ciphertextSize()) { + throw new SDKException("invalid " + keyType + " ciphertext size: got " + hybridCt.length + " want " + ciphertextSize()); + } + + byte[] ephemeralEcPub = Arrays.copyOfRange(hybridCt, 0, ecPubSize); + byte[] mlCiphertext = Arrays.copyOfRange(hybridCt, ecPubSize, hybridCt.length); + + byte[] ecScalarBytes = Arrays.copyOfRange(rawPriv, 0, ecPrivSize); + byte[] mlSeed = Arrays.copyOfRange(rawPriv, ecPrivSize, rawPriv.length); + + BigInteger ecScalar = new BigInteger(1, ecScalarBytes); + byte[] ecdhSecret = computeEcdhSecret(ecScalar, ephemeralEcPub); + + MLKEMPrivateKeyParameters mlPriv = new MLKEMPrivateKeyParameters(mlkemParams, mlSeed); + byte[] mlSecret = new MLKEMExtractor(mlPriv).extractSecret(mlCiphertext); + + byte[] combinedSecret = concat(ecdhSecret, mlSecret); + byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); + return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); + } + + /** Generate a uniformly random scalar in [1, n-1] using rejection sampling. */ + private BigInteger generateEcScalar(SecureRandom random) { + BigInteger n = curveSpec.getN(); + int nBitLength = n.bitLength(); + BigInteger d; + do { + d = new BigInteger(nBitLength, random); + } while (d.signum() <= 0 || d.compareTo(n) >= 0); + return d; + } + + /** Standard ECDH: x-coordinate of {@code scalar * peerPoint}, fixed-size big-endian. */ + private byte[] computeEcdhSecret(BigInteger scalar, byte[] peerUncompressedPoint) { + try { + ECPoint peer = curveSpec.getCurve().decodePoint(peerUncompressedPoint); + ECPublicKeySpec peerSpec = new ECPublicKeySpec(peer, curveSpec); + ECPrivateKeySpec mySpec = new ECPrivateKeySpec(scalar, curveSpec); + KeyFactory kf = KeyFactory.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME); + PublicKey peerPub = kf.generatePublic(peerSpec); + PrivateKey myPriv = kf.generatePrivate(mySpec); + + KeyAgreement ka = KeyAgreement.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME); + ka.init(myPriv); + ka.doPhase(peerPub, /* lastPhase */ true); + byte[] raw = ka.generateSecret(); + // JCA may strip leading zeros; left-pad to the field size to match Go's crypto/ecdh ECDH output. + if (raw.length != ecPrivSize) { + raw = leftPad(raw, ecPrivSize); + } + return raw; + } catch (Exception e) { + throw new SDKException("ECDH failed for " + curveName, e); + } + } + + private static byte[] leftPad(byte[] src, int width) { + if (src.length == width) return src; + if (src.length > width) { + // Strip leading 0x00 sign byte from BigInteger.toByteArray() if present. + int excess = src.length - width; + for (int i = 0; i < excess; i++) { + if (src[i] != 0) { + throw new SDKException("scalar/secret too large for width " + width); + } + } + return Arrays.copyOfRange(src, excess, src.length); + } + byte[] out = new byte[width]; + System.arraycopy(src, 0, out, width - src.length, src.length); + return out; + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java index 06be4cf6..2b0e607e 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java @@ -14,7 +14,10 @@ public enum KeyType { RSA4096Key("rsa:4096"), EC256Key("ec:secp256r1", SECP256R1), EC384Key("ec:secp384r1", SECP384R1), - EC521Key("ec:secp521r1", SECP521R1); + EC521Key("ec:secp521r1", SECP521R1), + HybridXWingKey("hpqt:xwing"), + HybridSecp256r1MLKEM768Key("hpqt:secp256r1-mlkem768"), + HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024"); private final String keyType; private final ECCurve curve; @@ -93,4 +96,15 @@ public static KeyType fromPublicKeyAlgorithm(KasPublicKeyAlgEnum algorithm) { public boolean isEc() { return this.curve != null; } + + public boolean isHybrid() { + switch (this) { + case HybridXWingKey: + case HybridSecp256r1MLKEM768Key: + case HybridSecp384r1MLKEM1024Key: + return true; + default: + return false; + } + } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java index b30460eb..cb97c901 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -87,6 +87,7 @@ private static byte[] tdfECKeySaltCompute() { private static final String kSplitKeyType = "split"; private static final String kWrapped = "wrapped"; private static final String kECWrapped = "ec-wrapped"; + private static final String kHybridWrapped = "hybrid-wrapped"; private static final String kKasProtocol = "kas"; private static final int kGcmIvSize = 12; private static final int kAesBlockSize = 16; @@ -226,7 +227,13 @@ private Manifest.KeyAccess createKeyAccess(Config.TDFConfig tdfConfig, Config.KA : kasInfo.Algorithm; var keyType = KeyType.fromString(algorithm); - if (keyType.isEc()) { + if (keyType.isHybrid()) { + byte[] wrapped = HybridCrypto.wrapDEK(keyType, kasInfo.PublicKey, symKey); + keyAccess.wrappedKey = Base64.getEncoder().encodeToString(wrapped); + keyAccess.keyType = kHybridWrapped; + // ephemeralPublicKey intentionally left null — the ephemeral material is + // carried inside the ASN.1 envelope in wrappedKey. + } else if (keyType.isEc()) { var ecKeyWrappedKeyInfo = createECWrappedKey(kasInfo, symKey, keyType); keyAccess.wrappedKey = ecKeyWrappedKeyInfo.wrappedKey; keyAccess.ephemeralPublicKey = ecKeyWrappedKeyInfo.publicKey; diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java b/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java new file mode 100644 index 00000000..ba7b3901 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java @@ -0,0 +1,92 @@ +package io.opentdf.platform.sdk; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.SecretWithEncapsulation; +import org.bouncycastle.pqc.crypto.xwing.XWingKEMExtractor; +import org.bouncycastle.pqc.crypto.xwing.XWingKEMGenerator; +import org.bouncycastle.pqc.crypto.xwing.XWingKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.xwing.XWingKeyPairGenerator; +import org.bouncycastle.pqc.crypto.xwing.XWingPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.xwing.XWingPublicKeyParameters; + +import java.security.SecureRandom; + +/** + * X-Wing (X25519 + ML-KEM-768) KEM with the ASN.1 envelope format used by TDF + * {@code hybrid-wrapped} key access objects. Mirrors {@code lib/ocrypto/xwing.go}. + */ +final class XWingKeyPair { + + static final String PEM_BLOCK_PUBLIC_KEY = "XWING PUBLIC KEY"; + static final String PEM_BLOCK_PRIVATE_KEY = "XWING PRIVATE KEY"; + + static final int PUBLIC_KEY_SIZE = 1216; + /** X-Wing private key is a 32-byte seed; full X25519 + ML-KEM-768 components are derived at runtime. */ + static final int PRIVATE_KEY_SIZE = 32; + static final int CIPHERTEXT_SIZE = 1120; + static final int SHARED_SECRET_SIZE = 32; + + private final byte[] publicKey; + private final byte[] privateKey; + + private XWingKeyPair(byte[] publicKey, byte[] privateKey) { + this.publicKey = publicKey; + this.privateKey = privateKey; + } + + static XWingKeyPair generate() { + XWingKeyPairGenerator gen = new XWingKeyPairGenerator(); + gen.init(new XWingKeyGenerationParameters(new SecureRandom())); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + XWingPublicKeyParameters pub = (XWingPublicKeyParameters) kp.getPublic(); + XWingPrivateKeyParameters priv = (XWingPrivateKeyParameters) kp.getPrivate(); + return new XWingKeyPair(pub.getEncoded(), priv.getEncoded()); + } + + String publicKeyInPemFormat() { + return HybridCrypto.rawToPem(PEM_BLOCK_PUBLIC_KEY, publicKey, PUBLIC_KEY_SIZE); + } + + String privateKeyInPemFormat() { + return HybridCrypto.rawToPem(PEM_BLOCK_PRIVATE_KEY, privateKey, PRIVATE_KEY_SIZE); + } + + static byte[] pubKeyFromPem(String pem) { + return HybridCrypto.decodeSizedPemBlock(pem, PEM_BLOCK_PUBLIC_KEY, PUBLIC_KEY_SIZE); + } + + static byte[] privateKeyFromPem(String pem) { + return HybridCrypto.decodeSizedPemBlock(pem, PEM_BLOCK_PRIVATE_KEY, PRIVATE_KEY_SIZE); + } + + static byte[] wrapDEK(byte[] rawPub, byte[] dek) { + if (rawPub.length != PUBLIC_KEY_SIZE) { + throw new SDKException("invalid X-Wing public key size: got " + rawPub.length + " want " + PUBLIC_KEY_SIZE); + } + XWingPublicKeyParameters pub = new XWingPublicKeyParameters(rawPub); + SecretWithEncapsulation enc = new XWingKEMGenerator(new SecureRandom()).generateEncapsulated(pub); + byte[] sharedSecret = enc.getSecret(); + byte[] ciphertext = enc.getEncapsulation(); + + byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); + byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); + return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek); + } + + static byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) { + if (rawPriv.length != PRIVATE_KEY_SIZE) { + throw new SDKException("invalid X-Wing private key size: got " + rawPriv.length + " want " + PRIVATE_KEY_SIZE); + } + byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer); + byte[] ciphertext = parts[0]; + byte[] encryptedDek = parts[1]; + if (ciphertext.length != CIPHERTEXT_SIZE) { + throw new SDKException("invalid X-Wing ciphertext size: got " + ciphertext.length + " want " + CIPHERTEXT_SIZE); + } + + XWingPrivateKeyParameters priv = new XWingPrivateKeyParameters(rawPriv); + byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext); + byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); + return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java new file mode 100644 index 00000000..44afc618 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java @@ -0,0 +1,166 @@ +package io.opentdf.platform.sdk; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for hybrid post-quantum key wrapping. Mirrors + * {@code lib/ocrypto/xwing_test.go} and {@code lib/ocrypto/hybrid_nist_test.go}. + * + * Each scheme is exercised through a full round-trip: generate keypair → PEM + * round-trip → wrap DEK → unwrap DEK → assert equal. The unwrap path is + * also used as a wire-format guard: if marshal/unmarshal drift, the round-trip + * fails. + */ +class HybridCryptoTest { + + private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes(); + + @Test + void xwingRoundTrip() { + XWingKeyPair kp = XWingKeyPair.generate(); + + String pubPem = kp.publicKeyInPemFormat(); + String privPem = kp.privateKeyInPemFormat(); + assertTrue(pubPem.startsWith("-----BEGIN XWING PUBLIC KEY-----"), "public PEM header"); + assertTrue(privPem.contains("XWING PRIVATE KEY"), "private PEM header"); + + byte[] rawPub = XWingKeyPair.pubKeyFromPem(pubPem); + byte[] rawPriv = XWingKeyPair.privateKeyFromPem(privPem); + assertEquals(XWingKeyPair.PUBLIC_KEY_SIZE, rawPub.length); + assertEquals(XWingKeyPair.PRIVATE_KEY_SIZE, rawPriv.length); + + byte[] wrapped = XWingKeyPair.wrapDEK(rawPub, DEK); + assertNotNull(wrapped); + // ASN.1 SEQUENCE header byte + assertEquals((byte) 0x30, wrapped[0]); + + byte[] unwrapped = XWingKeyPair.unwrapDEK(rawPriv, wrapped); + assertArrayEquals(DEK, unwrapped); + } + + @Test + void p256mlkem768RoundTrip() { + HybridNISTKeyPair kp = HybridNISTKeyPair.P256_MLKEM768.generate(); + + String pubPem = kp.publicKeyInPemFormat(); + String privPem = kp.privateKeyInPemFormat(); + assertTrue(pubPem.contains("SECP256R1 MLKEM768 PUBLIC KEY"), "public PEM header"); + assertTrue(privPem.contains("SECP256R1 MLKEM768 PRIVATE KEY"), "private PEM header"); + + byte[] rawPub = HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(pubPem); + byte[] rawPriv = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(privPem); + assertEquals(65 + 1184, rawPub.length); + assertEquals(32 + 64, rawPriv.length); + + byte[] wrapped = HybridNISTKeyPair.P256_MLKEM768.wrapDEK(rawPub, DEK); + byte[] unwrapped = HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(rawPriv, wrapped); + assertArrayEquals(DEK, unwrapped); + } + + @Test + void p384mlkem1024RoundTrip() { + HybridNISTKeyPair kp = HybridNISTKeyPair.P384_MLKEM1024.generate(); + + String pubPem = kp.publicKeyInPemFormat(); + String privPem = kp.privateKeyInPemFormat(); + assertTrue(pubPem.contains("SECP384R1 MLKEM1024 PUBLIC KEY"), "public PEM header"); + assertTrue(privPem.contains("SECP384R1 MLKEM1024 PRIVATE KEY"), "private PEM header"); + + byte[] rawPub = HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(pubPem); + byte[] rawPriv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(privPem); + assertEquals(97 + 1568, rawPub.length); + assertEquals(48 + 64, rawPriv.length); + + byte[] wrapped = HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(rawPub, DEK); + byte[] unwrapped = HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(rawPriv, wrapped); + assertArrayEquals(DEK, unwrapped); + } + + @Test + void wrapProducesDifferentCiphertextEachCall() { + XWingKeyPair kp = XWingKeyPair.generate(); + byte[] rawPub = XWingKeyPair.pubKeyFromPem(kp.publicKeyInPemFormat()); + byte[] w1 = XWingKeyPair.wrapDEK(rawPub, DEK); + byte[] w2 = XWingKeyPair.wrapDEK(rawPub, DEK); + assertNotEquals(Arrays.toString(w1), Arrays.toString(w2), + "wrap must be randomised (fresh ephemeral + GCM IV) — two calls produced identical ciphertext"); + } + + @Test + void crossSchemePrivateKeyFails() { + HybridNISTKeyPair p256 = HybridNISTKeyPair.P256_MLKEM768.generate(); + HybridNISTKeyPair p384 = HybridNISTKeyPair.P384_MLKEM1024.generate(); + + byte[] p256Pub = HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(p256.publicKeyInPemFormat()); + byte[] wrapped = HybridNISTKeyPair.P256_MLKEM768.wrapDEK(p256Pub, DEK); + + byte[] p384Priv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(p384.privateKeyInPemFormat()); + assertThrows(SDKException.class, + () -> HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(p384Priv, wrapped), + "P-384 private key must reject a P-256 wrapped envelope"); + } + + @Test + void pemBlockTypeMismatchRejected() { + XWingKeyPair kp = XWingKeyPair.generate(); + String pem = kp.publicKeyInPemFormat(); + String mangled = pem.replace("XWING PUBLIC KEY", "WRONG PUBLIC KEY"); + assertThrows(SDKException.class, () -> XWingKeyPair.pubKeyFromPem(mangled)); + } + + @Test + void pemBodySizeMismatchRejected() { + XWingKeyPair kp = XWingKeyPair.generate(); + String pem = kp.publicKeyInPemFormat(); + // Truncate one base64 char inside the body — yields wrong byte length after decode. + int headerEnd = pem.indexOf('\n') + 1; + String truncated = pem.substring(0, headerEnd) + pem.substring(headerEnd + 4); + assertThrows(SDKException.class, () -> XWingKeyPair.pubKeyFromPem(truncated)); + } + + @Test + void dispatcherSelectsCorrectScheme() { + // Round-trip via the public HybridCrypto.wrapDEK dispatcher for each key type. + XWingKeyPair xw = XWingKeyPair.generate(); + byte[] xwWrapped = HybridCrypto.wrapDEK(KeyType.HybridXWingKey, xw.publicKeyInPemFormat(), DEK); + byte[] xwPriv = XWingKeyPair.privateKeyFromPem(xw.privateKeyInPemFormat()); + assertArrayEquals(DEK, XWingKeyPair.unwrapDEK(xwPriv, xwWrapped)); + + HybridNISTKeyPair p256 = HybridNISTKeyPair.P256_MLKEM768.generate(); + byte[] p256Wrapped = HybridCrypto.wrapDEK(KeyType.HybridSecp256r1MLKEM768Key, + p256.publicKeyInPemFormat(), DEK); + byte[] p256Priv = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(p256.privateKeyInPemFormat()); + assertArrayEquals(DEK, HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(p256Priv, p256Wrapped)); + + HybridNISTKeyPair p384 = HybridNISTKeyPair.P384_MLKEM1024.generate(); + byte[] p384Wrapped = HybridCrypto.wrapDEK(KeyType.HybridSecp384r1MLKEM1024Key, + p384.publicKeyInPemFormat(), DEK); + byte[] p384Priv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(p384.privateKeyInPemFormat()); + assertArrayEquals(DEK, HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(p384Priv, p384Wrapped)); + } + + @Test + void dispatcherRejectsNonHybridKeyType() { + assertThrows(SDKException.class, + () -> HybridCrypto.wrapDEK(KeyType.RSA2048Key, "not-a-real-pem", DEK)); + } + + @Test + void truncatedEnvelopeRejected() { + XWingKeyPair kp = XWingKeyPair.generate(); + byte[] rawPub = XWingKeyPair.pubKeyFromPem(kp.publicKeyInPemFormat()); + byte[] rawPriv = XWingKeyPair.privateKeyFromPem(kp.privateKeyInPemFormat()); + byte[] wrapped = XWingKeyPair.wrapDEK(rawPub, DEK); + byte[] truncated = Arrays.copyOf(wrapped, wrapped.length - 10); + assertThrows(SDKException.class, () -> XWingKeyPair.unwrapDEK(rawPriv, truncated)); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java new file mode 100644 index 00000000..c5966a3c --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java @@ -0,0 +1,157 @@ +package io.opentdf.platform.sdk; + +import io.opentdf.platform.policy.KeyAccessServer; +import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClient; +import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest; +import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersResponse; +import com.connectrpc.ResponseMessage; +import com.connectrpc.UnaryBlockingCall; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Mirrors {@code sdk/tdf_hybrid_test.go}. Creates a TDF using each hybrid KAS key type, + * then asserts the resulting manifest's KeyAccess object has: + * + */ +class TDFHybridTest { + + private static KeyAccessServerRegistryServiceClient kasRegistryService; + + @BeforeAll + static void setupMocks() { + kasRegistryService = mock(KeyAccessServerRegistryServiceClient.class); + ListKeyAccessServersResponse mockResponse = ListKeyAccessServersResponse.newBuilder() + .addKeyAccessServers(KeyAccessServer.newBuilder().setUri("https://kas.example.com").build()) + .build(); + when(kasRegistryService.listKeyAccessServersBlocking(any(ListKeyAccessServersRequest.class), any())) + .thenReturn(new UnaryBlockingCall<>() { + @Override + public ResponseMessage execute() { + return new ResponseMessage.Success<>(mockResponse, + Collections.emptyMap(), Collections.emptyMap()); + } + + @Override + public void cancel() {} + }); + } + + @Test + void createKeyAccessWithXWingKey() throws Exception { + XWingKeyPair kp = XWingKeyPair.generate(); + Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess( + KeyType.HybridXWingKey, kp.publicKeyInPemFormat(), "xwing-kid"); + assertThat(ka.keyType).isEqualTo("hybrid-wrapped"); + assertThat(ka.ephemeralPublicKey).isNull(); + assertThat(ka.wrappedKey).isNotEmpty(); + + // Round-trip: unwrap with the matching private key — confirms wire format is valid. + byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey); + byte[] privRaw = XWingKeyPair.privateKeyFromPem(kp.privateKeyInPemFormat()); + byte[] symKey = XWingKeyPair.unwrapDEK(privRaw, wrappedDer); + assertThat(symKey).hasSize(32); + } + + @Test + void createKeyAccessWithP256MLKEM768Key() throws Exception { + HybridNISTKeyPair kp = HybridNISTKeyPair.P256_MLKEM768.generate(); + Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess( + KeyType.HybridSecp256r1MLKEM768Key, kp.publicKeyInPemFormat(), "p256mlkem768-kid"); + assertThat(ka.keyType).isEqualTo("hybrid-wrapped"); + assertThat(ka.ephemeralPublicKey).isNull(); + assertThat(ka.wrappedKey).isNotEmpty(); + + byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey); + byte[] privRaw = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(kp.privateKeyInPemFormat()); + byte[] symKey = HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(privRaw, wrappedDer); + assertThat(symKey).hasSize(32); + } + + @Test + void createKeyAccessWithP384MLKEM1024Key() throws Exception { + HybridNISTKeyPair kp = HybridNISTKeyPair.P384_MLKEM1024.generate(); + Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess( + KeyType.HybridSecp384r1MLKEM1024Key, kp.publicKeyInPemFormat(), "p384mlkem1024-kid"); + assertThat(ka.keyType).isEqualTo("hybrid-wrapped"); + assertThat(ka.ephemeralPublicKey).isNull(); + assertThat(ka.wrappedKey).isNotEmpty(); + + byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey); + byte[] privRaw = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(kp.privateKeyInPemFormat()); + byte[] symKey = HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(privRaw, wrappedDer); + assertThat(symKey).hasSize(32); + } + + /** + * Build a fake KAS that returns {@code (algorithm, publicKeyPem)} as its public key, then + * call {@code TDF.createTDF} on a 32-byte plaintext and return the single KeyAccess produced + * in the manifest. + */ + private Manifest.KeyAccess createTDFAndGetFirstKeyAccess(KeyType keyType, String publicKeyPem, String kid) + throws Exception { + Config.KASInfo kasInfo = new Config.KASInfo(); + kasInfo.URL = "https://kas.example.com"; + kasInfo.KID = kid; + kasInfo.Algorithm = keyType.toString(); + kasInfo.PublicKey = publicKeyPem; + + SDK.KAS fakeKas = new SDK.KAS() { + @Override + public void close() {} + + @Override + public Config.KASInfo getPublicKey(Config.KASInfo info) { + Config.KASInfo copy = info.clone(); + copy.Algorithm = keyType.toString(); + copy.PublicKey = publicKeyPem; + copy.KID = kid; + return copy; + } + + @Override + public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) { + throw new UnsupportedOperationException("KAS unwrap is not exercised by hybrid TDF creation tests"); + } + + @Override + public KASKeyCache getKeyCache() { + return new KASKeyCache(); + } + }; + + Config.TDFConfig config = Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(kasInfo)); + + InputStream plaintext = new ByteArrayInputStream("hybrid hello".getBytes()); + ByteArrayOutputStream tdfOut = new ByteArrayOutputStream(); + + TDF tdf = new TDF(new FakeServicesBuilder() + .setKas(fakeKas) + .setKeyAccessServerRegistryService(kasRegistryService) + .build()); + + Manifest manifest = tdf.createTDF(plaintext, tdfOut, config).getManifest(); + List kaos = manifest.encryptionInformation.keyAccessObj; + assertThat(kaos).hasSize(1); + return kaos.get(0); + } +} From 8f046da21496f73580de7b1d76c52ec8eeb85328 Mon Sep 17 00:00:00 2001 From: sujan kota Date: Tue, 19 May 2026 14:57:49 -0400 Subject: [PATCH 2/3] Test scripts --- scripts/README.md | 113 +++++++++++++++++ scripts/test-hybrid-pqc.sh | 241 +++++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 scripts/README.md create mode 100755 scripts/test-hybrid-pqc.sh diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..58b1b29c --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,113 @@ +# scripts/ + +Developer scripts for the OpenTDF Java SDK. Not bundled with the published +artifacts. + +## `test-hybrid-pqc.sh` + +End-to-end test of the Java SDK's hybrid post-quantum key wrapping +(`hpqt:xwing`, `hpqt:secp256r1-mlkem768`, `hpqt:secp384r1-mlkem1024`) against +a locally running OpenTDF platform. Per algorithm it: + +1. Confirms the KAS publishes a hybrid PEM for that algorithm (`grpcurl` + pre-flight, optional). +2. Encrypts a small payload via the `cmdline` jar using + `--encap-key-type=`. +3. Asserts the resulting TDF manifest has: + - `keyAccess[0].type == "hybrid-wrapped"` + - `keyAccess[0].ephemeralPublicKey` empty (the ephemeral material is + carried inside the ASN.1 envelope in `wrappedKey`) + - `keyAccess[0].wrappedKey` starts with the ASN.1 SEQUENCE byte `0x30` +4. Decrypts the TDF (this is the step that actually exercises hybrid + decapsulation on the KAS rewrap path). +5. Diffs the decrypted payload against the original. + +On success the script also prints the plaintext, the full `keyAccess[0]` +(KAO), and the decrypted output for each algorithm so you can eyeball the +artifacts. + +### Prerequisites + +| Requirement | Notes | +|---|---| +| **JDK 17** | The project's Kotlin compiler can't parse newer JDK version strings. Use Corretto/Temurin/etc. 17. On macOS: `export JAVA_HOME=$(/usr/libexec/java_home -v 17)`. | +| **Maven 3.9+** | Project uses standard `mvn clean install`. | +| **Buf token** | Proto generation requires auth. Either `buf registry login` once, or export `BUF_INPUT_HTTPS_USERNAME` / `BUF_INPUT_HTTPS_PASSWORD`. | +| **Local platform with PQC support** | `opentdf/platform` checked out on a branch that implements `hpqt:*` KAS keys + the `hybrid-wrapped` rewrap path. See the platform repo for bring-up (`docker compose` / `make start`). | +| **Hybrid KAS keys registered** | The local platform must have a KAS key registered for each `hpqt:*` algorithm you intend to test. Use `otdfctl` (or platform tooling) to register them. | +| **CLI tools** | `java`, `mvn`, `unzip`, `jq` on `PATH`. `grpcurl` optional but recommended (drives the pre-flight check). | + +### Run it + +From the repo root: + +```bash +# Full run — builds cmdline, pre-flight check, all 3 algorithms +PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-hybrid-pqc.sh + +# Reuse an already-built cmdline jar (much faster on iterative runs) +scripts/test-hybrid-pqc.sh --skip-build + +# One algorithm only +scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey + +# Multiple specific algorithms (comma-separated) +scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey,HybridSecp256r1MLKEM768Key + +# Skip the grpcurl pre-flight (use when grpcurl isn't installed) +scripts/test-hybrid-pqc.sh --skip-kas-check +``` + +### Configuration + +All defaults match the existing CI workflow (`.github/workflows/checks.yaml`). +Override via flag or env var: + +| Flag / Env | Default | Description | +|---|---|---| +| `--platform-endpoint` / `PLATFORM_ENDPOINT` | `http://localhost:8080` | Platform base URL | +| `--kas-url` / `KAS_URL` | same as platform endpoint | KAS URL passed to cmdline `encrypt` | +| `--client-id` / `CLIENT_ID` | `opentdf-sdk` | OIDC client id | +| `--client-secret` / `CLIENT_SECRET` | `secret` | OIDC client secret | +| `--attr` / `DATA_ATTR` | `https://example.com/attr/attr1/value/value1` | Attribute FQN attached to encrypt | +| `--algorithms` | all three | Comma-separated subset of `KeyType` enum names | +| `--skip-build` | (off) | Reuse `cmdline/target/cmdline.jar` | +| `--skip-kas-check` | (off) | Skip the `grpcurl` pre-flight | + +### Expected output + +``` +[OK] hpqt:xwing: KAS returns hybrid PEM (-----BEGIN XWING PUBLIC KEY-----) +[OK] hpqt:secp256r1-mlkem768: KAS returns hybrid PEM (-----BEGIN SECP256R1 MLKEM768 PUBLIC KEY-----) +[OK] hpqt:secp384r1-mlkem1024: KAS returns hybrid PEM (-----BEGIN SECP384R1 MLKEM1024 PUBLIC KEY-----) +... +[OK] HybridXWingKey: manifest OK (hybrid-wrapped, ASN.1 envelope, no ephemeralPublicKey) +[OK] HybridXWingKey: round-trip OK +... +All 3 hybrid algorithm(s) passed round-trip. +``` + +Exit code is 0 on success, 1 on any algorithm failure (other algorithms still +attempted), 2 on misuse. + +### Troubleshooting + +| Symptom | Likely cause / fix | +|---|---| +| `Maven build failed ... Buf API token` | Run `buf registry login`, or export `BUF_INPUT_HTTPS_USERNAME` and `BUF_INPUT_HTTPS_PASSWORD`. | +| `Maven build failed ... Kotlin ... isAtLeastJava9` (stack trace) | JDK too new. `export JAVA_HOME=$(/usr/libexec/java_home -v 17)` and rerun. | +| `KAS returned no publicKey` | Platform isn't running, or isn't reachable at `$PLATFORM_ENDPOINT`. | +| `KAS returned a non-hybrid PEM` | The platform is up but no hybrid KAS key is registered for that algorithm. Register one and rerun. | +| `keyType='null'` (manifest assertion) | You're on an old branch where `TDF.java` doesn't yet route hybrid algorithms. Pull the latest branch HEAD. | +| `decrypt failed` after manifest passes | KAS-side rewrap doesn't yet support the `hybrid-wrapped` keyType. Check the platform branch has the matching server change. | + +### Known SDK gap + +`KeyType.fromAlgorithm` and `KeyType.fromPublicKeyAlgorithm` +(`sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java`) don't yet map the +hybrid algorithm protobuf enums. Auto-discovery via the KAS registry +(`Config.KASInfo.fromKeyAccessServer`) will throw `IllegalArgumentException` +once the platform's proto definitions include `KAS_PUBLIC_KEY_ALG_ENUM_HPQT_*` +values. This script bypasses that path by using `--encap-key-type` explicitly; +extending the script to also exercise registry-discovery should wait until the +mapping is added. diff --git a/scripts/test-hybrid-pqc.sh b/scripts/test-hybrid-pqc.sh new file mode 100755 index 00000000..9ec76875 --- /dev/null +++ b/scripts/test-hybrid-pqc.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# +# test-hybrid-pqc.sh — round-trip the Java SDK's hybrid post-quantum key +# wrapping against a locally running OpenTDF platform. +# +# Per algorithm: encrypt → assert manifest → KAS rewrap → decrypt → diff. +# +# Prereqs: +# * Local platform up at $PLATFORM_ENDPOINT with hybrid KAS keys registered +# for hpqt:xwing, hpqt:secp256r1-mlkem768, hpqt:secp384r1-mlkem1024 +# * java, mvn (JDK 17), unzip, jq on PATH +# * grpcurl optional (used only for the pre-flight key-publication check) +# +# Usage: +# scripts/test-hybrid-pqc.sh # full run, all 3 algorithms +# scripts/test-hybrid-pqc.sh --skip-build # reuse existing jar +# scripts/test-hybrid-pqc.sh --skip-kas-check # skip grpcurl pre-flight +# scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey # subset +# PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-hybrid-pqc.sh +# +# See scripts/README.md for a full prereq + troubleshooting guide. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +JAR="$REPO_ROOT/cmdline/target/cmdline.jar" + +PLATFORM_ENDPOINT="${PLATFORM_ENDPOINT:-http://localhost:8080}" +KAS_URL="${KAS_URL:-$PLATFORM_ENDPOINT}" +CLIENT_ID="${CLIENT_ID:-opentdf-sdk}" +CLIENT_SECRET="${CLIENT_SECRET:-secret}" +DATA_ATTR="${DATA_ATTR:-https://example.com/attr/attr1/value/value1}" +ALGORITHMS=(HybridXWingKey HybridSecp256r1MLKEM768Key HybridSecp384r1MLKEM1024Key) +SKIP_BUILD=0 +SKIP_KAS_CHECK=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) SKIP_BUILD=1; shift ;; + --skip-kas-check) SKIP_KAS_CHECK=1; shift ;; + --algorithms) IFS=, read -r -a ALGORITHMS <<< "$2"; shift 2 ;; + --platform-endpoint) PLATFORM_ENDPOINT="$2"; shift 2 ;; + --kas-url) KAS_URL="$2"; shift 2 ;; + --attr) DATA_ATTR="$2"; shift 2 ;; + --client-id) CLIENT_ID="$2"; shift 2 ;; + --client-secret) CLIENT_SECRET="$2"; shift 2 ;; + -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +# Map KeyType enum name → the hpqt:* algorithm string the KAS expects. +# Function form (instead of `declare -A`) so this works on macOS bash 3.2. +alg_to_string() { + case "$1" in + HybridXWingKey) echo "hpqt:xwing" ;; + HybridSecp256r1MLKEM768Key) echo "hpqt:secp256r1-mlkem768" ;; + HybridSecp384r1MLKEM1024Key) echo "hpqt:secp384r1-mlkem1024" ;; + *) return 1 ;; + esac +} + +WORK_DIR="$(mktemp -d -t hybrid-pqc-XXXXXX)" +trap 'rm -rf "$WORK_DIR"' EXIT + +if [[ -t 1 ]]; then + GREEN=$'\033[0;32m'; RED=$'\033[0;31m'; YELLOW=$'\033[0;33m'; RESET=$'\033[0m' +else + GREEN=''; RED=''; YELLOW=''; RESET='' +fi +pass() { echo "${GREEN}[OK]${RESET} $*"; } +fail() { echo "${RED}[FAIL]${RESET} $*"; } +info() { echo "${YELLOW}[..]${RESET} $*"; } + +require() { command -v "$1" >/dev/null 2>&1 || { fail "missing required tool: $1"; exit 2; }; } +require java; require unzip; require jq +[[ $SKIP_BUILD -eq 1 ]] || require mvn + +run_cmdline() { + java -jar "$JAR" \ + --client-id="$CLIENT_ID" \ + --client-secret="$CLIENT_SECRET" \ + --platform-endpoint="$PLATFORM_ENDPOINT" \ + -h "$@" +} + +##### 1. Build +if [[ $SKIP_BUILD -eq 0 ]]; then + info "Building cmdline (mvn clean install -DskipTests)" + build_log="$WORK_DIR/build.log" + if ! (cd "$REPO_ROOT" && mvn --batch-mode clean install -DskipTests) > "$build_log" 2>&1; then + fail "Maven build failed. Tail of build log:" + tail -40 "$build_log" | sed 's/^/ /' + if grep -q "Buf API token" "$build_log" 2>/dev/null; then + fail "Hint: run 'buf registry login' or export BUF_INPUT_HTTPS_USERNAME / BUF_INPUT_HTTPS_PASSWORD before retrying." + fi + exit 1 + fi + pass "Build complete" +else + info "Skipping build (--skip-build)" +fi +[[ -f "$JAR" ]] || { fail "jar not found at $JAR — run without --skip-build"; exit 1; } + +##### 2. Pre-flight: confirm KAS publishes hybrid keys +if [[ $SKIP_KAS_CHECK -eq 0 ]] && command -v grpcurl >/dev/null 2>&1; then + info "Pre-flight: querying KAS for hybrid public keys" + host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}" + for alg_name in "${ALGORITHMS[@]}"; do + if ! alg=$(alg_to_string "$alg_name"); then + fail "unknown algorithm: $alg_name"; exit 2 + fi + resp=$(grpcurl -plaintext -d "{\"algorithm\":\"$alg\"}" \ + "$host" kas.AccessService/PublicKey 2>&1 || true) + pem=$(jq -r '.publicKey // empty' <<<"$resp" 2>/dev/null || true) + if [[ -z "$pem" ]]; then + fail "$alg: KAS returned no publicKey. Response was:" + echo "$resp" | head -5 | sed 's/^/ /' + fail "Is the platform running with the PQC-capable KAS branch and the key registered?" + exit 1 + fi + # Hybrid PEMs have XWING or MLKEM markers; RSA/EC PEMs don't. + first_line=$(echo "$pem" | head -1) + if [[ "$first_line" != *"XWING"* && "$first_line" != *"MLKEM"* && "$first_line" != *"HPQT"* && "$first_line" != *"HYBRID"* ]]; then + fail "$alg: KAS returned a non-hybrid PEM (first line: $first_line)" + fail "The KAS doesn't appear to have a hybrid key registered for $alg" + exit 1 + fi + pass "$alg: KAS returns hybrid PEM ($first_line)" + done +else + info "Skipping KAS pre-flight check" +fi + +##### 3. Round-trip each algorithm +PAYLOAD="$WORK_DIR/payload" +printf 'hybrid pqc round-trip payload @ %s\n' "$(date)" > "$PAYLOAD" +PAYLOAD_BYTES=$(wc -c < "$PAYLOAD" | tr -d ' ') +info "Test payload: $PAYLOAD_BYTES bytes" +echo " --- plaintext ---" +sed 's/^/ /' < "$PAYLOAD" +echo " --- end plaintext ---" + +failures=() +for alg_name in "${ALGORITHMS[@]}"; do + tdf="$WORK_DIR/test-${alg_name}.tdf" + out="$WORK_DIR/out-${alg_name}" + enc_log="$WORK_DIR/encrypt-${alg_name}.log" + dec_log="$WORK_DIR/decrypt-${alg_name}.log" + + info "[$alg_name] encrypt" + if ! run_cmdline encrypt \ + --kas-url="$KAS_URL" \ + --mime-type=text/plain \ + --attr="$DATA_ATTR" \ + --autoconfigure=false \ + --encap-key-type="$alg_name" \ + -f "$PAYLOAD" > "$tdf" 2> "$enc_log"; then + fail "$alg_name: encrypt failed" + sed 's/^/ /' < "$enc_log" + failures+=("$alg_name (encrypt)") + continue + fi + + info "[$alg_name] verify manifest" + manifest_entry=$(unzip -l "$tdf" 2>/dev/null | awk '/manifest\.json$/ {print $NF; exit}') + if [[ -z "$manifest_entry" ]]; then + fail "$alg_name: no manifest.json entry inside $tdf" + failures+=("$alg_name (manifest entry missing)") + continue + fi + manifest=$(unzip -p "$tdf" "$manifest_entry") + # In Manifest.java, the Java field `keyType` is annotated with + # @SerializedName("type"), so the JSON key is "type" (not "keyType"). + keyType=$(jq -r '.encryptionInformation.keyAccess[0].type' <<<"$manifest") + ephem=$(jq -r '.encryptionInformation.keyAccess[0].ephemeralPublicKey // ""' <<<"$manifest") + wrapped=$(jq -r '.encryptionInformation.keyAccess[0].wrappedKey // ""' <<<"$manifest") + if [[ "$keyType" != "hybrid-wrapped" ]]; then + fail "$alg_name: type='$keyType' (expected 'hybrid-wrapped')" + echo " keyAccess[0]:" + jq '.encryptionInformation.keyAccess[0]' <<<"$manifest" 2>/dev/null | sed 's/^/ /' + failures+=("$alg_name (bad type: $keyType)") + continue + fi + if [[ -n "$ephem" ]]; then + fail "$alg_name: ephemeralPublicKey unexpectedly set ('$ephem')" + failures+=("$alg_name (stray ephemeralPublicKey)") + continue + fi + if [[ -z "$wrapped" ]]; then + fail "$alg_name: wrappedKey is empty" + failures+=("$alg_name (empty wrappedKey)") + continue + fi + # ASN.1 SEQUENCE always starts with 0x30 — same invariant HybridCryptoTest checks. + first_byte=$(base64 -d <<<"$wrapped" 2>/dev/null | xxd -p -l 1 || true) + if [[ "$first_byte" != "30" ]]; then + fail "$alg_name: wrappedKey does not start with ASN.1 SEQUENCE (got 0x$first_byte)" + failures+=("$alg_name (bad envelope)") + continue + fi + pass "$alg_name: manifest OK (hybrid-wrapped, ASN.1 envelope, no ephemeralPublicKey)" + echo " --- keyAccess[0] (KAO) ---" + jq '.encryptionInformation.keyAccess[0]' <<<"$manifest" | sed 's/^/ /' + echo " --- end keyAccess[0] ---" + + info "[$alg_name] decrypt (rewrap via KAS)" + if ! run_cmdline decrypt -f "$tdf" > "$out" 2> "$dec_log"; then + fail "$alg_name: decrypt failed" + sed 's/^/ /' < "$dec_log" + failures+=("$alg_name (decrypt)") + continue + fi + if ! diff -q "$PAYLOAD" "$out" >/dev/null; then + fail "$alg_name: decrypted payload differs from original" + echo " --- expected (first 200 bytes) ---" + head -c 200 "$PAYLOAD" | sed 's/^/ /' + echo + echo " --- got (first 200 bytes) ---" + head -c 200 "$out" | sed 's/^/ /' + echo + failures+=("$alg_name (payload mismatch)") + continue + fi + pass "$alg_name: round-trip OK" + out_bytes=$(wc -c < "$out" | tr -d ' ') + echo " --- decrypted ($out_bytes bytes) ---" + sed 's/^/ /' < "$out" + echo " --- end decrypted ---" +done + +echo +if [[ ${#failures[@]} -eq 0 ]]; then + echo "${GREEN}All ${#ALGORITHMS[@]} hybrid algorithm(s) passed round-trip.${RESET}" + exit 0 +else + echo "${RED}FAILURES (${#failures[@]}):${RESET}" + printf ' - %s\n' "${failures[@]}" + exit 1 +fi From d666c0788da28cec69d26662e78c68e67c1d1443 Mon Sep 17 00:00:00 2001 From: sujan kota Date: Thu, 21 May 2026 19:41:26 -0400 Subject: [PATCH 3/3] make hybrid PQC provider-agnostic --- scripts/README.md | 1 + sdk/pom.xml | 7 + .../io/opentdf/platform/sdk/HybridCrypto.java | 169 +++++++++++----- .../platform/sdk/HybridNISTKeyPair.java | 182 +++++++++++------- .../io/opentdf/platform/sdk/XWingKeyPair.java | 4 +- 5 files changed, 242 insertions(+), 121 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 58b1b29c..1090340d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -33,6 +33,7 @@ artifacts. | **JDK 17** | The project's Kotlin compiler can't parse newer JDK version strings. Use Corretto/Temurin/etc. 17. On macOS: `export JAVA_HOME=$(/usr/libexec/java_home -v 17)`. | | **Maven 3.9+** | Project uses standard `mvn clean install`. | | **Buf token** | Proto generation requires auth. Either `buf registry login` once, or export `BUF_INPUT_HTTPS_USERNAME` / `BUF_INPUT_HTTPS_PASSWORD`. | +| **`non-fips` Maven profile (default)** | Hybrid PQC needs `bcprov-jdk18on` at compile/runtime scope for the ML-KEM and X-Wing primitives (no JDK 11 stdlib equivalent). The default `non-fips` profile pulls it in automatically. The `fips` profile does not yet support hybrid PQC — follow-up. | | **Local platform with PQC support** | `opentdf/platform` checked out on a branch that implements `hpqt:*` KAS keys + the `hybrid-wrapped` rewrap path. See the platform repo for bring-up (`docker compose` / `make start`). | | **Hybrid KAS keys registered** | The local platform must have a KAS key registered for each `hpqt:*` algorithm you intend to test. Use `otdfctl` (or platform tooling) to register them. | | **CLI tools** | `java`, `mvn`, `unzip`, `jq` on `PATH`. `grpcurl` optional but recommended (drives the pre-flight check). | diff --git a/sdk/pom.xml b/sdk/pom.xml index de6b7618..7f35e454 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -483,6 +483,13 @@ true + + + org.bouncycastle + bcprov-jdk18on + org.bouncycastle bcpkix-jdk18on diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java b/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java index 057b22fe..f2b776f6 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java @@ -1,26 +1,12 @@ package io.opentdf.platform.sdk; -import org.bouncycastle.asn1.ASN1EncodableVector; -import org.bouncycastle.asn1.ASN1InputStream; -import org.bouncycastle.asn1.ASN1Primitive; -import org.bouncycastle.asn1.ASN1Sequence; -import org.bouncycastle.asn1.ASN1TaggedObject; -import org.bouncycastle.asn1.DEROctetString; -import org.bouncycastle.asn1.DERSequence; -import org.bouncycastle.asn1.DERTaggedObject; -import org.bouncycastle.crypto.digests.SHA256Digest; -import org.bouncycastle.crypto.generators.HKDFBytesGenerator; -import org.bouncycastle.crypto.params.HKDFParameters; - -import java.io.ByteArrayInputStream; -import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Base64; /** * Dispatcher and shared helpers for hybrid post-quantum key wrapping - * (X-Wing and NIST EC + ML-KEM). Mirrors the lib/ocrypto Go package. + * (X-Wing and NIST EC + ML-KEM). * * Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs * SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK } @@ -32,6 +18,11 @@ final class HybridCrypto { static final int WRAP_KEY_SIZE = 32; + // ASN.1 tag bytes used by the envelope. + private static final int TAG_SEQUENCE = 0x30; + private static final int TAG_CONTEXT_PRIMITIVE_0 = 0x80; + private static final int TAG_CONTEXT_PRIMITIVE_1 = 0x81; + private HybridCrypto() {} /** @@ -57,14 +48,10 @@ static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) { * Build the ASN.1 envelope from a hybrid KEM ciphertext and the AES-GCM(iv||ct) encrypted DEK. */ static byte[] marshalEnvelope(byte[] hybridCiphertext, byte[] encryptedDEK) { - ASN1EncodableVector v = new ASN1EncodableVector(); - v.add(new DERTaggedObject(false, 0, new DEROctetString(hybridCiphertext))); - v.add(new DERTaggedObject(false, 1, new DEROctetString(encryptedDEK))); - try { - return new DERSequence(v).getEncoded("DER"); - } catch (IOException e) { - throw new SDKException("failed to encode hybrid wrapped key envelope", e); - } + byte[] body = concat( + encodeTLV(TAG_CONTEXT_PRIMITIVE_0, hybridCiphertext), + encodeTLV(TAG_CONTEXT_PRIMITIVE_1, encryptedDEK)); + return encodeTLV(TAG_SEQUENCE, body); } /** @@ -72,44 +59,120 @@ static byte[] marshalEnvelope(byte[] hybridCiphertext, byte[] encryptedDEK) { * Rejects trailing bytes (matches the Go {@code asn1.Unmarshal} strict behaviour). */ static byte[][] unmarshalEnvelope(byte[] der) { - try (ASN1InputStream in = new ASN1InputStream(new ByteArrayInputStream(der))) { - ASN1Primitive prim = in.readObject(); - if (prim == null) { - throw new SDKException("hybrid wrapped key envelope is empty"); - } - if (in.readObject() != null) { - throw new SDKException("hybrid wrapped key envelope has trailing bytes"); - } - ASN1Sequence seq = ASN1Sequence.getInstance(prim); - if (seq.size() != 2) { - throw new SDKException("hybrid wrapped key envelope must have 2 elements, got " + seq.size()); - } - byte[] hybridCt = readImplicitOctetString(seq.getObjectAt(0), 0); - byte[] encDek = readImplicitOctetString(seq.getObjectAt(1), 1); - return new byte[][] { hybridCt, encDek }; - } catch (IOException e) { - throw new SDKException("failed to decode hybrid wrapped key envelope", e); + Cursor c = new Cursor(der, 0); + int tag = c.readByte(); + if (tag != TAG_SEQUENCE) { + throw new SDKException("expected ASN.1 SEQUENCE (0x30), got 0x" + Integer.toHexString(tag)); + } + int seqLen = readLength(c); + int seqEnd = c.pos + seqLen; + if (seqEnd > der.length) { + throw new SDKException("hybrid wrapped key envelope length exceeds buffer"); + } + if (seqEnd != der.length) { + throw new SDKException("hybrid wrapped key envelope has trailing bytes"); + } + byte[] hybridCt = readImplicitOctetString(c, 0); + byte[] encDek = readImplicitOctetString(c, 1); + if (c.pos != seqEnd) { + throw new SDKException("hybrid wrapped key envelope SEQUENCE has trailing bytes"); + } + return new byte[][] { hybridCt, encDek }; + } + + private static byte[] readImplicitOctetString(Cursor c, int expectedTagNo) { + int expectedTag = TAG_CONTEXT_PRIMITIVE_0 | expectedTagNo; + int tag = c.readByte(); + if (tag != expectedTag) { + throw new SDKException("expected context tag " + expectedTagNo + + " (0x" + Integer.toHexString(expectedTag) + ") but got 0x" + Integer.toHexString(tag)); + } + int len = readLength(c); + if (c.pos + len > c.buf.length) { + throw new SDKException("context-tagged element length exceeds buffer"); + } + byte[] out = new byte[len]; + System.arraycopy(c.buf, c.pos, out, 0, len); + c.pos += len; + return out; + } + + private static byte[] encodeTLV(int tag, byte[] content) { + byte[] lenBytes = encodeLength(content.length); + byte[] out = new byte[1 + lenBytes.length + content.length]; + out[0] = (byte) tag; + System.arraycopy(lenBytes, 0, out, 1, lenBytes.length); + System.arraycopy(content, 0, out, 1 + lenBytes.length, content.length); + return out; + } + + private static byte[] encodeLength(int len) { + if (len < 0) { + throw new SDKException("negative ASN.1 length: " + len); + } + if (len < 0x80) { + return new byte[] { (byte) len }; + } + // Long form: 0x80 | numBytes, then big-endian length bytes. + int numBytes = 0; + int tmp = len; + while (tmp > 0) { numBytes++; tmp >>>= 8; } + byte[] out = new byte[1 + numBytes]; + out[0] = (byte) (0x80 | numBytes); + for (int i = numBytes; i > 0; i--) { + out[i] = (byte) (len & 0xFF); + len >>>= 8; } + return out; } - private static byte[] readImplicitOctetString(org.bouncycastle.asn1.ASN1Encodable enc, int expectedTag) { - ASN1TaggedObject tagged = ASN1TaggedObject.getInstance(enc); - if (tagged.getTagNo() != expectedTag) { - throw new SDKException("expected context tag " + expectedTag + " but got " + tagged.getTagNo()); + private static int readLength(Cursor c) { + int first = c.readByte(); + if ((first & 0x80) == 0) { + return first; + } + int numBytes = first & 0x7F; + if (numBytes == 0 || numBytes > 4) { + // indefinite-length (numBytes == 0) is BER-only; DER rejects it. + // > 4 would overflow a positive 32-bit int and is implausible for our envelope. + throw new SDKException("invalid ASN.1 length encoding: numBytes=" + numBytes); + } + int len = 0; + for (int i = 0; i < numBytes; i++) { + len = (len << 8) | c.readByte(); + } + if (len < 0) { + throw new SDKException("ASN.1 length overflowed signed int"); } - return org.bouncycastle.asn1.ASN1OctetString.getInstance(tagged, false).getOctets(); + return len; + } + + private static final class Cursor { + final byte[] buf; + int pos; + Cursor(byte[] buf, int pos) { this.buf = buf; this.pos = pos; } + int readByte() { + if (pos >= buf.length) { + throw new SDKException("unexpected end of ASN.1 input at offset " + pos); + } + return buf[pos++] & 0xFF; + } + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; } /** - * HKDF-SHA256 → 32-byte AES wrap key. {@code salt=null} substitutes the default TDF salt. + * HKDF-SHA256 → 32-byte AES wrap key. Delegates to + * {@link ECKeyPair#calculateHKDF(byte[], byte[])} (HKDF-Extract + Expand, + * empty info, L = 32 — the parameters all three hybrid algorithms use). */ - static byte[] deriveWrapKey(byte[] combinedSecret, byte[] salt, byte[] info) { - byte[] effSalt = (salt == null || salt.length == 0) ? defaultTDFSalt() : salt; - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); - hkdf.init(new HKDFParameters(combinedSecret, effSalt, info)); - byte[] out = new byte[WRAP_KEY_SIZE]; - hkdf.generateBytes(out, 0, out.length); - return out; + static byte[] deriveWrapKey(byte[] combinedSecret) { + return ECKeyPair.calculateHKDF(defaultTDFSalt(), combinedSecret); } /** diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java b/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java index 602e16a0..2b83eea5 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java @@ -2,12 +2,6 @@ import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.SecretWithEncapsulation; -import org.bouncycastle.jce.ECNamedCurveTable; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; -import org.bouncycastle.jce.spec.ECPrivateKeySpec; -import org.bouncycastle.jce.spec.ECPublicKeySpec; -import org.bouncycastle.math.ec.ECPoint; import org.bouncycastle.pqc.crypto.mlkem.MLKEMExtractor; import org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator; import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyGenerationParameters; @@ -18,16 +12,22 @@ import javax.crypto.KeyAgreement; import java.math.BigInteger; +import java.security.AlgorithmParameters; import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.PublicKey; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.security.SecureRandom; -import java.security.Security; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPrivateKeySpec; +import java.security.spec.ECPublicKeySpec; import java.util.Arrays; /** * NIST hybrid post-quantum key wrapping (P-256 + ML-KEM-768 and P-384 + ML-KEM-1024). - * Mirrors {@code lib/ocrypto/hybrid_nist.go}. * * Wire layout of the wrapped DEK: *
@@ -43,15 +43,12 @@
  *   
  • Public key: {@code uncompressedECPoint || mlkemEncapsulationKey}
  • *
  • Private key: {@code paddedECScalar || mlkemSeed(64B)}
  • * + * + * EC operations use only stdlib JCA. ML-KEM operations use BouncyCastle's + * low-level API because no JDK 11 stdlib KEM API exists (added in JDK 21). */ final class HybridNISTKeyPair { - static { - if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { - Security.addProvider(new BouncyCastleProvider()); - } - } - static final HybridNISTKeyPair P256_MLKEM768 = new HybridNISTKeyPair( "secp256r1", /* ecPubSize */ 65, @@ -86,7 +83,8 @@ final class HybridNISTKeyPair { private final String pubPemBlock; private final String privPemBlock; private final KeyType keyType; - private final ECNamedCurveParameterSpec curveSpec; + private final ECParameterSpec ecParams; + private final int ecFieldByteSize; private final byte[] publicKey; private final byte[] privateKey; @@ -102,7 +100,8 @@ private HybridNISTKeyPair(String curveName, int ecPubSize, int ecPrivSize, int m this.pubPemBlock = pubPemBlock; this.privPemBlock = privPemBlock; this.keyType = keyType; - this.curveSpec = ECNamedCurveTable.getParameterSpec(curveName); + this.ecParams = ecParamsFor(curveName); + this.ecFieldByteSize = (this.ecParams.getCurve().getField().getFieldSize() + 7) / 8; this.publicKey = null; this.privateKey = null; } @@ -117,7 +116,8 @@ private HybridNISTKeyPair(HybridNISTKeyPair params, byte[] publicKey, byte[] pri this.pubPemBlock = params.pubPemBlock; this.privPemBlock = params.privPemBlock; this.keyType = params.keyType; - this.curveSpec = params.curveSpec; + this.ecParams = params.ecParams; + this.ecFieldByteSize = params.ecFieldByteSize; this.publicKey = publicKey; this.privateKey = privateKey; } @@ -130,22 +130,16 @@ private HybridNISTKeyPair(HybridNISTKeyPair params, byte[] publicKey, byte[] pri HybridNISTKeyPair generate() { SecureRandom random = new SecureRandom(); - // EC half — generate ephemeral scalar, derive uncompressed point. - BigInteger ecScalar = generateEcScalar(random); - ECPoint ecPoint = curveSpec.getG().multiply(ecScalar).normalize(); - byte[] ecPubBytes = ecPoint.getEncoded(/* compressed */ false); - byte[] ecPrivBytes = leftPad(ecScalar.toByteArray(), ecPrivSize); + // EC half — stdlib KeyPairGenerator gives us scalar + point in one call. + EcKeypairBytes ec = generateEcKeypairBytes(random); - // ML-KEM half. + // ML-KEM half — BC's low-level API; no JDK 11 stdlib alternative. MLKEMKeyPairGenerator mlGen = new MLKEMKeyPairGenerator(); mlGen.init(new MLKEMKeyGenerationParameters(random, mlkemParams)); AsymmetricCipherKeyPair mkp = mlGen.generateKeyPair(); byte[] mlPubBytes = ((MLKEMPublicKeyParameters) mkp.getPublic()).getEncoded(); byte[] mlSeed = ((MLKEMPrivateKeyParameters) mkp.getPrivate()).getSeed(); - if (ecPubBytes.length != ecPubSize) { - throw new SDKException("EC public key size " + ecPubBytes.length + " != expected " + ecPubSize); - } if (mlPubBytes.length != mlkemPubSize) { throw new SDKException("ML-KEM public key size " + mlPubBytes.length + " != expected " + mlkemPubSize); } @@ -153,8 +147,8 @@ HybridNISTKeyPair generate() { throw new SDKException("ML-KEM seed size " + mlSeed.length + " != expected " + MLKEM_SEED_SIZE); } - byte[] pub = concat(ecPubBytes, mlPubBytes); - byte[] priv = concat(ecPrivBytes, mlSeed); + byte[] pub = concat(ec.publicPoint, mlPubBytes); + byte[] priv = concat(ec.scalar, mlSeed); return new HybridNISTKeyPair(this, pub, priv); } @@ -186,9 +180,9 @@ byte[] wrapDEK(byte[] rawPub, byte[] dek) { SecureRandom random = new SecureRandom(); - // ECDH: generate ephemeral, compute shared secret, capture ephemeral point. - BigInteger ephemeralScalar = generateEcScalar(random); - byte[] ephemeralEcPub = curveSpec.getG().multiply(ephemeralScalar).normalize().getEncoded(false); + // ECDH: generate ephemeral keypair, compute shared secret, ship the ephemeral point. + EcKeypairBytes ephemeral = generateEcKeypairBytes(random); + BigInteger ephemeralScalar = new BigInteger(1, ephemeral.scalar); byte[] ecdhSecret = computeEcdhSecret(ephemeralScalar, recipientEcPub); // ML-KEM encapsulate. @@ -201,8 +195,8 @@ byte[] wrapDEK(byte[] rawPub, byte[] dek) { } byte[] combinedSecret = concat(ecdhSecret, mlSecret); - byte[] hybridCt = concat(ephemeralEcPub, mlCiphertext); - byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); + byte[] hybridCt = concat(ephemeral.publicPoint, mlCiphertext); + byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret); byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek); } @@ -231,38 +225,57 @@ byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) { byte[] mlSecret = new MLKEMExtractor(mlPriv).extractSecret(mlCiphertext); byte[] combinedSecret = concat(ecdhSecret, mlSecret); - byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); + byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret); return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); } - /** Generate a uniformly random scalar in [1, n-1] using rejection sampling. */ - private BigInteger generateEcScalar(SecureRandom random) { - BigInteger n = curveSpec.getN(); - int nBitLength = n.bitLength(); - BigInteger d; - do { - d = new BigInteger(nBitLength, random); - } while (d.signum() <= 0 || d.compareTo(n) >= 0); - return d; + /** Resolve a named-curve {@link ECParameterSpec} via stdlib JCA. */ + private static ECParameterSpec ecParamsFor(String curveName) { + try { + AlgorithmParameters ap = AlgorithmParameters.getInstance("EC"); + ap.init(new ECGenParameterSpec(curveName)); + return ap.getParameterSpec(ECParameterSpec.class); + } catch (Exception e) { + throw new SDKException("EC parameters not available for curve " + curveName, e); + } + } + + /** + * Generate an EC keypair via stdlib and return scalar (padded) and uncompressed-point bytes. + */ + private EcKeypairBytes generateEcKeypairBytes(SecureRandom random) { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec(curveName), random); + KeyPair kp = kpg.generateKeyPair(); + ECPrivateKey priv = (ECPrivateKey) kp.getPrivate(); + ECPublicKey pub = (ECPublicKey) kp.getPublic(); + byte[] scalar = toFixedLength(priv.getS(), ecPrivSize); + byte[] point = encodeUncompressedPoint(pub.getW(), ecFieldByteSize); + if (point.length != ecPubSize) { + throw new SDKException("encoded EC point size " + point.length + " != expected " + ecPubSize); + } + return new EcKeypairBytes(scalar, point); + } catch (Exception e) { + throw new SDKException("failed to generate EC keypair on " + curveName, e); + } } - /** Standard ECDH: x-coordinate of {@code scalar * peerPoint}, fixed-size big-endian. */ + /** Standard ECDH via JCA: x-coordinate of {@code scalar * peerPoint}, fixed-size big-endian. */ private byte[] computeEcdhSecret(BigInteger scalar, byte[] peerUncompressedPoint) { try { - ECPoint peer = curveSpec.getCurve().decodePoint(peerUncompressedPoint); - ECPublicKeySpec peerSpec = new ECPublicKeySpec(peer, curveSpec); - ECPrivateKeySpec mySpec = new ECPrivateKeySpec(scalar, curveSpec); - KeyFactory kf = KeyFactory.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME); - PublicKey peerPub = kf.generatePublic(peerSpec); - PrivateKey myPriv = kf.generatePrivate(mySpec); - - KeyAgreement ka = KeyAgreement.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME); - ka.init(myPriv); - ka.doPhase(peerPub, /* lastPhase */ true); + ECPoint peerPoint = decodeUncompressedPoint(peerUncompressedPoint, ecFieldByteSize); + ECPublicKeySpec peerSpec = new ECPublicKeySpec(peerPoint, ecParams); + ECPrivateKeySpec mySpec = new ECPrivateKeySpec(scalar, ecParams); + KeyFactory kf = KeyFactory.getInstance("EC"); + + KeyAgreement ka = KeyAgreement.getInstance("ECDH"); + ka.init(kf.generatePrivate(mySpec)); + ka.doPhase(kf.generatePublic(peerSpec), /* lastPhase */ true); byte[] raw = ka.generateSecret(); // JCA may strip leading zeros; left-pad to the field size to match Go's crypto/ecdh ECDH output. - if (raw.length != ecPrivSize) { - raw = leftPad(raw, ecPrivSize); + if (raw.length != ecFieldByteSize) { + raw = leftPad(raw, ecFieldByteSize); } return raw; } catch (Exception e) { @@ -270,18 +283,46 @@ private byte[] computeEcdhSecret(BigInteger scalar, byte[] peerUncompressedPoint } } - private static byte[] leftPad(byte[] src, int width) { - if (src.length == width) return src; - if (src.length > width) { - // Strip leading 0x00 sign byte from BigInteger.toByteArray() if present. - int excess = src.length - width; + private static byte[] encodeUncompressedPoint(ECPoint w, int byteSize) { + byte[] x = toFixedLength(w.getAffineX(), byteSize); + byte[] y = toFixedLength(w.getAffineY(), byteSize); + byte[] out = new byte[1 + 2 * byteSize]; + out[0] = 0x04; + System.arraycopy(x, 0, out, 1, byteSize); + System.arraycopy(y, 0, out, 1 + byteSize, byteSize); + return out; + } + + private static ECPoint decodeUncompressedPoint(byte[] encoded, int byteSize) { + if (encoded.length != 1 + 2 * byteSize || encoded[0] != 0x04) { + throw new SDKException("invalid uncompressed EC point encoding (length=" + encoded.length + + ", lead=0x" + Integer.toHexString(encoded[0] & 0xFF) + ")"); + } + BigInteger x = new BigInteger(1, Arrays.copyOfRange(encoded, 1, 1 + byteSize)); + BigInteger y = new BigInteger(1, Arrays.copyOfRange(encoded, 1 + byteSize, 1 + 2 * byteSize)); + return new ECPoint(x, y); + } + + /** Convert a non-negative {@link BigInteger} to a fixed-length big-endian byte array. */ + private static byte[] toFixedLength(BigInteger value, int length) { + byte[] bytes = value.toByteArray(); + if (bytes.length == length) return bytes; + if (bytes.length > length) { + int excess = bytes.length - length; for (int i = 0; i < excess; i++) { - if (src[i] != 0) { - throw new SDKException("scalar/secret too large for width " + width); + if (bytes[i] != 0) { + throw new SDKException("value too large for width " + length); } } - return Arrays.copyOfRange(src, excess, src.length); + return Arrays.copyOfRange(bytes, excess, bytes.length); } + byte[] out = new byte[length]; + System.arraycopy(bytes, 0, out, length - bytes.length, bytes.length); + return out; + } + + private static byte[] leftPad(byte[] src, int width) { + if (src.length >= width) return src; byte[] out = new byte[width]; System.arraycopy(src, 0, out, width - src.length, src.length); return out; @@ -293,4 +334,13 @@ private static byte[] concat(byte[] a, byte[] b) { System.arraycopy(b, 0, out, a.length, b.length); return out; } + + private static final class EcKeypairBytes { + final byte[] scalar; + final byte[] publicPoint; + EcKeypairBytes(byte[] scalar, byte[] publicPoint) { + this.scalar = scalar; + this.publicPoint = publicPoint; + } + } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java b/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java index ba7b3901..7eed601e 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java @@ -68,7 +68,7 @@ static byte[] wrapDEK(byte[] rawPub, byte[] dek) { byte[] sharedSecret = enc.getSecret(); byte[] ciphertext = enc.getEncapsulation(); - byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); + byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret); byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek); } @@ -86,7 +86,7 @@ static byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) { XWingPrivateKeyParameters priv = new XWingPrivateKeyParameters(rawPriv); byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext); - byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); + byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret); return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); } }