From ae06320253f60618f5ef3021831cb3dbe6373636 Mon Sep 17 00:00:00 2001 From: Freeman Fang Date: Fri, 21 Aug 2026 09:49:42 -0400 Subject: [PATCH 1/4] Add ML-KEM (FIPS 203) post-quantum key transport support Adds ML-KEM-512/768/1024 key transport via the W3C "XML Security: Generic Hybrid Cipher" structure, on both the DOM and STAX encryption paths. Part of the post-quantum work tracked under SANTUARIO-634 (originally proposed in SANTUARIO-633 / apache/santuario-xml-security-java#645), split out here as the encryption-only half per community request. Adds negative-test coverage on both the DOM and STAX paths: decryption with the wrong recipient's ML-KEM private key fails cleanly, and a truncated EncryptedKey CipherValue is rejected via the existing length check in KeyUtils#kemDecapsulate. Added per Arpan0995's review feedback on #645. --- .../xml/security/algorithms/JCEMapper.java | 17 + .../security/encryption/EncryptionMethod.java | 65 ++++ .../xml/security/encryption/XMLCipher.java | 292 +++++++++++++- .../security/encryption/XMLCipherUtil.java | 50 +++ .../params/KeyEncapsulationParameters.java | 69 ++++ .../keys/content/DEREncodedKeyValue.java | 1 + .../stax/ext/XMLSecurityConstants.java | 19 + .../stax/ext/XMLSecurityProperties.java | 45 +++ .../input/XMLEncryptedKeyInputHandler.java | 122 +++++- .../output/XMLEncryptOutputProcessor.java | 134 ++++++- .../AbstractInboundSecurityToken.java | 10 +- .../security/utils/EncryptionConstants.java | 49 +++ .../apache/xml/security/utils/KeyUtils.java | 181 +++++++++ .../bindings/schemas/xenc-schema-11.xsd | 2 +- .../bindings/schemas/xenc-schema.xsd | 2 +- src/main/resources/security-config.xml | 34 ++ .../encryption/XMLEncryptionMLKEMTest.java | 353 +++++++++++++++++ .../encryption/StaxMLKEMEncryptionTest.java | 358 ++++++++++++++++++ 18 files changed, 1778 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/apache/xml/security/encryption/params/KeyEncapsulationParameters.java create mode 100644 src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java create mode 100644 src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java diff --git a/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java b/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java index 1dc09759d..536f8df9d 100644 --- a/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java +++ b/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java @@ -25,6 +25,7 @@ import org.apache.xml.security.encryption.XMLCipher; import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.utils.EncryptionConstants; import org.apache.xml.security.utils.JavaUtils; import org.w3c.dom.Element; @@ -318,6 +319,22 @@ public static void registerDefaultAlgorithms() { XMLCipher.RSA_OAEP_11, new Algorithm("RSA", "RSA/ECB/OAEPPadding", "KeyTransport") ); + algorithmsMap.put( + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512, + new Algorithm("ML-KEM-512", "ML-KEM-512", "KeyTransport") + ); + algorithmsMap.put( + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768, + new Algorithm("ML-KEM-768", "ML-KEM-768", "KeyTransport") + ); + algorithmsMap.put( + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024, + new Algorithm("ML-KEM-1024", "ML-KEM-1024", "KeyTransport") + ); + algorithmsMap.put( + EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID, + new Algorithm("", "", "KeyTransport") + ); algorithmsMap.put( XMLCipher.DIFFIE_HELLMAN, new Algorithm("", "", "KeyAgreement") diff --git a/src/main/java/org/apache/xml/security/encryption/EncryptionMethod.java b/src/main/java/org/apache/xml/security/encryption/EncryptionMethod.java index 4b512e7d9..a6f835977 100644 --- a/src/main/java/org/apache/xml/security/encryption/EncryptionMethod.java +++ b/src/main/java/org/apache/xml/security/encryption/EncryptionMethod.java @@ -102,6 +102,71 @@ public interface EncryptionMethod { */ String getMGFAlgorithm(); + /** + * Returns the Key Encapsulation Method algorithm URI used for KEM-based key transport + * (W3C "XML Security: Generic Hybrid Cipher", https://www.w3.org/TR/xmlsec-generic-hybrid/), + * i.e. the {@code Algorithm} attribute of the {@code ghc:KeyEncapsulationMethod} element nested + * inside {@code ghc:GenericHybridCipherMethod}. + * + * @return the key encapsulation algorithm, or {@code null} if this is not a Generic Hybrid + * Cipher {@code EncryptionMethod}. + */ + String getKeyEncapsulationAlgorithm(); + + /** + * Sets the Key Encapsulation Method algorithm URI. See {@link #getKeyEncapsulationAlgorithm()}. + * + * @param algorithm the key encapsulation algorithm. + */ + void setKeyEncapsulationAlgorithm(String algorithm); + + /** + * Returns the {@code xenc11:KeyDerivationMethod} nested inside {@code ghc:KeyEncapsulationMethod}, + * used to derive the data-encapsulation (AES key-wrap) key from the KEM shared secret. + * + * @return the key derivation method, or {@code null} if not set. + */ + KeyDerivationMethod getKeyEncapsulationKeyDerivationMethod(); + + /** + * Sets the key derivation method. See {@link #getKeyEncapsulationKeyDerivationMethod()}. + * + * @param keyDerivationMethod the key derivation method. + */ + void setKeyEncapsulationKeyDerivationMethod(KeyDerivationMethod keyDerivationMethod); + + /** + * Returns the {@code ghc:KeyLen} value nested inside {@code ghc:KeyEncapsulationMethod}: the + * length, in bytes, of the derived data-encapsulation key. + * + * @return the key length in bytes, or a non-positive value if not set. + */ + int getKeyEncapsulationKeyLength(); + + /** + * Sets the derived key length in bytes. See {@link #getKeyEncapsulationKeyLength()}. + * + * @param keyLength the key length in bytes. + */ + void setKeyEncapsulationKeyLength(int keyLength); + + /** + * Returns the Data Encapsulation Method algorithm URI, i.e. the {@code Algorithm} attribute of + * the {@code ghc:DataEncapsulationMethod} element nested inside {@code ghc:GenericHybridCipherMethod} + * (typically an AES-KeyWrap algorithm URI). + * + * @return the data encapsulation algorithm, or {@code null} if this is not a Generic Hybrid + * Cipher {@code EncryptionMethod}. + */ + String getDataEncapsulationAlgorithm(); + + /** + * Sets the Data Encapsulation Method algorithm URI. See {@link #getDataEncapsulationAlgorithm()}. + * + * @param algorithm the data encapsulation algorithm. + */ + void setDataEncapsulationAlgorithm(String algorithm); + /** * Returns an iterator over all the additional elements contained in the * EncryptionMethod. diff --git a/src/main/java/org/apache/xml/security/encryption/XMLCipher.java b/src/main/java/org/apache/xml/security/encryption/XMLCipher.java index de85efb5a..ca91219e5 100644 --- a/src/main/java/org/apache/xml/security/encryption/XMLCipher.java +++ b/src/main/java/org/apache/xml/security/encryption/XMLCipher.java @@ -55,9 +55,12 @@ import org.apache.xml.security.c14n.InvalidCanonicalizerException; import org.apache.xml.security.encryption.keys.KeyInfoEnc; import org.apache.xml.security.encryption.params.KeyAgreementParameters; +import org.apache.xml.security.encryption.params.KeyDerivationParameters; +import org.apache.xml.security.encryption.params.KeyEncapsulationParameters; import org.apache.xml.security.exceptions.XMLSecurityException; import org.apache.xml.security.keys.KeyInfo; import org.apache.xml.security.encryption.keys.content.AgreementMethodImpl; +import org.apache.xml.security.encryption.keys.content.derivedKey.KeyDerivationMethodImpl; import org.apache.xml.security.keys.keyresolver.KeyResolverException; import org.apache.xml.security.keys.keyresolver.KeyResolverSpi; import org.apache.xml.security.keys.keyresolver.implementations.EncryptedKeyResolver; @@ -1382,6 +1385,7 @@ public EncryptedKey encryptKey( AlgorithmParameterSpec cipherSpec = null; Key wrapKey = this.key; + byte[] kemEncapsulation = null; if (params instanceof OAEPParameterSpec) { cipherSpec = params; } else if (params instanceof KeyAgreementParameters) { @@ -1389,6 +1393,16 @@ public EncryptedKey encryptKey( validateAndUpdateKeyAgreementParameterKeys(keyAgreementParameter); // Generate a key using the key Agreement Parameters for the wrap algorithm wrapKey = KeyUtils.aesWrapKeyWithDHGeneratedKey(keyAgreementParameter); + } else if (params instanceof KeyEncapsulationParameters) { + KeyEncapsulationParameters keyEncapsulationParameter = (KeyEncapsulationParameters) params; + validateAndUpdateKeyEncapsulationParameterKeys(keyEncapsulationParameter); + // Encapsulate a shared secret to the recipient's KEM public key and derive the wrap key from it + KeyUtils.KemEncapsulation kemResult = KeyUtils.kemEncapsulate( + keyEncapsulationParameter.getRecipientPublicKey(), + keyEncapsulationParameter.getKeyEncapsulationAlgorithm(), + keyEncapsulationParameter.getKeyDerivationParameter()); + wrapKey = kemResult.getWrapKey(); + kemEncapsulation = kemResult.getEncapsulation(); } else if (params != null) { throw new XMLEncryptionException("encryption.UnsupportedAlgorithmParameterSpec", params.getClass().getName()); } @@ -1413,6 +1427,15 @@ public EncryptedKey encryptKey( throw new XMLEncryptionException(e); } + if (kemEncapsulation != null) { + // Per the W3C Generic Hybrid Cipher spec, CipherValue holds the concatenation of the + // KEM encapsulation (C0) and the AES-wrapped CEK (C1) + byte[] combined = new byte[kemEncapsulation.length + encryptedBytes.length]; + System.arraycopy(kemEncapsulation, 0, combined, 0, kemEncapsulation.length); + System.arraycopy(encryptedBytes, 0, combined, kemEncapsulation.length, encryptedBytes.length); + encryptedBytes = combined; + } + String base64EncodedEncryptedOctets = XMLUtils.encodeToString(encryptedBytes); LOG.log(Level.DEBUG, "Encrypted key octets:\n{0}", base64EncodedEncryptedOctets); LOG.log(Level.DEBUG, "Encrypted key octets length = {0}", base64EncodedEncryptedOctets.length()); @@ -1421,7 +1444,9 @@ public EncryptedKey encryptKey( cv.setValue(base64EncodedEncryptedOctets); try { - EncryptionMethod method = factory.newEncryptionMethod(new URI(algorithm).toString()); + String encryptionMethodAlgorithm = params instanceof KeyEncapsulationParameters + ? EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID : algorithm; + EncryptionMethod method = factory.newEncryptionMethod(new URI(encryptionMethodAlgorithm).toString()); method.setDigestAlgorithm(digestAlg); ek.setEncryptionMethod(method); if (params instanceof OAEPParameterSpec) { @@ -1439,6 +1464,14 @@ public EncryptedKey encryptKey( KeyInfoEnc keyInfo = new KeyInfoEnc(contextDocument); keyInfo.add(agreementMethod); ek.setKeyInfo(keyInfo); + } else if (params instanceof KeyEncapsulationParameters) { + KeyEncapsulationParameters keyEncapsulationParameter = (KeyEncapsulationParameters) params; + KeyDerivationParameters kdp = keyEncapsulationParameter.getKeyDerivationParameter(); + method.setKeyEncapsulationAlgorithm(keyEncapsulationParameter.getKeyEncapsulationAlgorithm()); + method.setKeyEncapsulationKeyDerivationMethod( + XMLCipherUtil.constructKeyDerivationMethod(contextDocument, kdp)); + method.setKeyEncapsulationKeyLength(kdp.getKeyLength()); + method.setDataEncapsulationAlgorithm(algorithm); } } catch (URISyntaxException ex) { @@ -1447,6 +1480,35 @@ public EncryptedKey encryptKey( return ek; } + /** + * Method validates and updates if needed the KeyEncapsulationParameters with the required keys. + * + * @param keyEncapsulationParameter KeyEncapsulationParameters to be validated and updated + * with the required key if needed + */ + public void validateAndUpdateKeyEncapsulationParameterKeys(KeyEncapsulationParameters keyEncapsulationParameter) + throws XMLEncryptionException { + if (keyEncapsulationParameter == null) { + return; + } + // check if the recipient's public key is set, if not, use the recipient's public key + // specified in the XMLCipher instance init method. + if (keyEncapsulationParameter.getRecipientPublicKey() == null && this.key != null) { + if (this.key instanceof PublicKey) { + LOG.log(Level.DEBUG, "Recipient's public key is not set in keyEncapsulationParameter, " + + "use the recipient's public key specified in XMLCipher instance init method."); + keyEncapsulationParameter.setRecipientPublicKey((PublicKey) this.key); + } else { + throw new XMLEncryptionException("algorithms.WrongKeyForThisOperation", + this.key.getClass().getName(), "java.security.PublicKey"); + } + } + if (keyEncapsulationParameter.getRecipientPublicKey() == null) { + // recipient's public key is mandatory for key encapsulation. + throw new XMLEncryptionException("encryption.nokey"); + } + } + /** * Decrypt a key from a passed in EncryptedKey structure * @@ -1479,7 +1541,9 @@ public Key decryptKey(EncryptedKey encryptedKey, String algorithm) try { String keyWrapAlg = encryptedKey.getEncryptionMethod().getAlgorithm(); String keyType = JCEMapper.getJCEKeyAlgorithmFromURI(keyWrapAlg); - if ( "RSA".equals(keyType) || "EC".equals(keyType)) { + if ("RSA".equals(keyType) || "EC".equals(keyType) + || (keyType != null && keyType.startsWith("ML-KEM")) + || EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID.equals(keyWrapAlg)) { key = ki.getPrivateKey(); } else { key = ki.getSecretKey(); @@ -1503,14 +1567,28 @@ public Key decryptKey(EncryptedKey encryptedKey, String algorithm) String jceKeyAlgorithm = JCEMapper.getJCEKeyAlgorithmFromURI(algorithm); LOG.log(Level.DEBUG, "JCE Key Algorithm: {0}", jceKeyAlgorithm); + boolean genericHybrid = EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID.equals( + encryptedKey.getEncryptionMethod().getAlgorithm()); + Cipher c; if (contextCipher == null) { - // Now create the working cipher - c = - constructCipher( - encryptedKey.getEncryptionMethod().getAlgorithm(), - encryptedKey.getEncryptionMethod().getDigestAlgorithm() - ); + // Now create the working cipher. For Generic Hybrid Cipher (KEM) key transport, the + // top-level EncryptionMethod algorithm is the generic "generic-hybrid" URI, not a JCE + // cipher algorithm - the actual data-encapsulation (AES-KeyWrap) algorithm is nested + // inside GenericHybridCipherMethod/DataEncapsulationMethod. + String cipherAlgorithm = genericHybrid + ? encryptedKey.getEncryptionMethod().getDataEncapsulationAlgorithm() + : encryptedKey.getEncryptionMethod().getAlgorithm(); + if (genericHybrid) { + // dataEncapsulationAlgorithm comes straight from the untrusted input XML + // (ghc:DataEncapsulationMethod/@Algorithm); restrict it to an AES-KeyWrap algorithm + // before it is used to construct the unwrap Cipher below, rather than accepting + // whatever cipher JCEMapper happens to resolve the URI to (e.g. legacy TripleDES + // key-wrap). This mirrors the check XMLEncryptedKeyInputHandler already performs + // for the STAX path. + KeyUtils.getAESKeyBitSizeForWrapAlgorithm(cipherAlgorithm); + } + c = constructCipher(cipherAlgorithm, encryptedKey.getEncryptionMethod().getDigestAlgorithm()); } else { c = contextCipher; } @@ -1534,6 +1612,17 @@ public Key decryptKey(EncryptedKey encryptedKey, String algorithm) if (params instanceof KeyAgreementParameters) { Key wrapKey = KeyUtils.aesWrapKeyWithDHGeneratedKey((KeyAgreementParameters) params); c.init(Cipher.UNWRAP_MODE, wrapKey); + } else if (params instanceof KeyEncapsulationParameters) { + // Split the leading KEM encapsulation (C0) off the combined ciphertext, decapsulate + // it to derive the wrap key, and continue unwrapping only the remaining AES-wrapped + // CEK bytes (C1) + KeyUtils.KemDecapsulation kemResult = KeyUtils.kemDecapsulate( + ((KeyEncapsulationParameters) params).getRecipientPrivateKey(), + ((KeyEncapsulationParameters) params).getKeyEncapsulationAlgorithm(), + encryptedBytes, + ((KeyEncapsulationParameters) params).getKeyDerivationParameter()); + c.init(Cipher.UNWRAP_MODE, kemResult.getWrapKey()); + encryptedBytes = kemResult.getWrappedKey(); } ret = c.unwrap(encryptedBytes, jceKeyAlgorithm, Cipher.SECRET_KEY); } catch (InvalidKeyException | NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { @@ -1608,10 +1697,49 @@ private AlgorithmParameterSpec getAlgorithmParameters(EncryptedKey encryptedKey) encMethod.getMGFAlgorithm(), encMethod.getOAEPparams()); } + if (EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID.equals(encryptionAlgorithm)) { + LOG.log(Level.DEBUG,"EncryptedKey key algorithm is Generic Hybrid Cipher (KEM) key transport"); + return constructKeyEncapsulationParameters(encMethod); + } + KeyInfoEnc keyInfo = encryptedKey.getKeyInfo() instanceof KeyInfoEnc ? (KeyInfoEnc) encryptedKey.getKeyInfo(): null; return constructKeyAgreementParameters(keyInfo, encryptionAlgorithm); } + /** + * The method validates whether the provided key is of type PrivateKey and the EncryptionMethod + * carries the Generic Hybrid Cipher key-encapsulation data. If both conditions are met, it + * proceeds to extract the KeyEncapsulationParameters for key derivation; otherwise, it returns null. + * + * @param encMethod the EncryptionMethod containing the Generic Hybrid Cipher key encapsulation data + * @return KeyEncapsulationParameters object containing the key encapsulation data + * or null if the provided key is not a PrivateKey + */ + private KeyEncapsulationParameters constructKeyEncapsulationParameters(EncryptionMethod encMethod) + throws XMLSecurityException { + + if (!(this.key instanceof PrivateKey)) { + LOG.log(Level.INFO,"The EncryptedKey key is using Generic Hybrid Cipher key encapsulation data, " + + "but provided key is not a PrivateKey. Skipping Key Encapsulation data processing."); + return null; + } + + String kemAlgorithm = encMethod.getKeyEncapsulationAlgorithm(); + KeyDerivationMethod keyDerivationMethod = encMethod.getKeyEncapsulationKeyDerivationMethod(); + if (kemAlgorithm == null || keyDerivationMethod == null) { + throw new XMLEncryptionException("Key Encapsulation Algorithm or Key Derivation Method is not specified"); + } + + int keyLength = encMethod.getKeyEncapsulationKeyLength() > 0 + ? encMethod.getKeyEncapsulationKeyLength() * 8 + : KeyUtils.getAESKeyBitSizeForWrapAlgorithm(encMethod.getDataEncapsulationAlgorithm()); + KeyDerivationParameters kdp = XMLCipherUtil.constructKeyDerivationParameter(keyDerivationMethod, keyLength); + + KeyEncapsulationParameters keyEncapsulationParameters = new KeyEncapsulationParameters(kemAlgorithm, kdp); + keyEncapsulationParameters.setRecipientPrivateKey((PrivateKey) this.key); + return keyEncapsulationParameters; + } + /** * The method validates whether key agreement data is present and checks if * the provided key is of type PrivateKey. If both conditions are met, it @@ -1920,7 +2048,11 @@ public byte[] decryptToByteArray(Element element) throws XMLEncryptionException } private void validateEncryptionMethodAlgorithm(String encryptionMethodAlgorithm) throws XMLEncryptionException { - if (algorithm != null && !algorithm.equals(encryptionMethodAlgorithm)) { + // Generic Hybrid Cipher (KEM) key transport always uses the "generic-hybrid" URI as the + // top-level EncryptionMethod algorithm, regardless of the AES-KeyWrap algorithm the + // XMLCipher instance was initialised with (which is nested as DataEncapsulationMethod). + if (algorithm != null && !algorithm.equals(encryptionMethodAlgorithm) + && !EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID.equals(encryptionMethodAlgorithm)) { throw new XMLEncryptionException("empty", "EncryptionMethod algorithm \"" + encryptionMethodAlgorithm + "\" does not match the algorithm this XMLCipher was initialised with: \"" @@ -2514,6 +2646,52 @@ EncryptionMethod newEncryptionMethod(Element element) { result.setMGFAlgorithm(mgfAlgorithm); } + Element genericHybridCipherMethodElement = + (Element) element.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecGHCNS, + EncryptionConstants._TAG_GENERICHYBRIDCIPHERMETHOD).item(0); + if (genericHybridCipherMethodElement != null) { + Element keyEncapsulationMethodElement = + (Element) genericHybridCipherMethodElement.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecGHCNS, + EncryptionConstants._TAG_KEYENCAPSULATIONMETHOD).item(0); + if (keyEncapsulationMethodElement != null) { + result.setKeyEncapsulationAlgorithm( + keyEncapsulationMethodElement.getAttributeNS(null, "Algorithm")); + + Element keyDerivationMethodElement = + (Element) keyEncapsulationMethodElement.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpec11NS, + EncryptionConstants._TAG_KEYDERIVATIONMETHOD).item(0); + if (keyDerivationMethodElement != null) { + try { + result.setKeyEncapsulationKeyDerivationMethod( + new KeyDerivationMethodImpl(keyDerivationMethodElement, null)); + } catch (XMLSecurityException xse) { + throw new RuntimeException(xse); + } + } + + Element keyLenElement = + (Element) keyEncapsulationMethodElement.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecGHCNS, + EncryptionConstants._TAG_KEYLEN).item(0); + if (keyLenElement != null) { + result.setKeyEncapsulationKeyLength( + Integer.parseInt(keyLenElement.getFirstChild().getNodeValue())); + } + } + + Element dataEncapsulationMethodElement = + (Element) genericHybridCipherMethodElement.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecGHCNS, + EncryptionConstants._TAG_DATAENCAPSULATIONMETHOD).item(0); + if (dataEncapsulationMethodElement != null) { + result.setDataEncapsulationAlgorithm( + dataEncapsulationMethodElement.getAttributeNS(null, "Algorithm")); + } + } + // TODO: Make this mess work // @@ -3120,6 +3298,10 @@ private class EncryptionMethodImpl implements EncryptionMethod { private List encryptionMethodInformation; private String digestAlgorithm; private String mgfAlgorithm; + private String keyEncapsulationAlgorithm; + private KeyDerivationMethod keyEncapsulationKeyDerivationMethod; + private int keyEncapsulationKeyLength = Integer.MIN_VALUE; + private String dataEncapsulationAlgorithm; /** * Constructor. @@ -3191,6 +3373,54 @@ public String getMGFAlgorithm() { return mgfAlgorithm; } + /** {@inheritDoc} */ + @Override + public String getKeyEncapsulationAlgorithm() { + return keyEncapsulationAlgorithm; + } + + /** {@inheritDoc} */ + @Override + public void setKeyEncapsulationAlgorithm(String algorithm) { + keyEncapsulationAlgorithm = algorithm; + } + + /** {@inheritDoc} */ + @Override + public KeyDerivationMethod getKeyEncapsulationKeyDerivationMethod() { + return keyEncapsulationKeyDerivationMethod; + } + + /** {@inheritDoc} */ + @Override + public void setKeyEncapsulationKeyDerivationMethod(KeyDerivationMethod keyDerivationMethod) { + keyEncapsulationKeyDerivationMethod = keyDerivationMethod; + } + + /** {@inheritDoc} */ + @Override + public int getKeyEncapsulationKeyLength() { + return keyEncapsulationKeyLength; + } + + /** {@inheritDoc} */ + @Override + public void setKeyEncapsulationKeyLength(int keyLength) { + keyEncapsulationKeyLength = keyLength; + } + + /** {@inheritDoc} */ + @Override + public String getDataEncapsulationAlgorithm() { + return dataEncapsulationAlgorithm; + } + + /** {@inheritDoc} */ + @Override + public void setDataEncapsulationAlgorithm(String algorithm) { + dataEncapsulationAlgorithm = algorithm; + } + /** {@inheritDoc} */ @Override public Iterator getEncryptionMethodInformation() { @@ -3255,6 +3485,50 @@ Element toElement() { ); result.appendChild(mgfElement); } + if (keyEncapsulationAlgorithm != null) { + Element genericHybridCipherMethodElement = + contextDocument.createElementNS( + EncryptionConstants.EncryptionSpecGHCNS, + "ghc:" + EncryptionConstants._TAG_GENERICHYBRIDCIPHERMETHOD + ); + genericHybridCipherMethodElement.setAttributeNS( + Constants.NamespaceSpecNS, "xmlns:ghc", EncryptionConstants.EncryptionSpecGHCNS + ); + + Element keyEncapsulationMethodElement = + contextDocument.createElementNS( + EncryptionConstants.EncryptionSpecGHCNS, + "ghc:" + EncryptionConstants._TAG_KEYENCAPSULATIONMETHOD + ); + keyEncapsulationMethodElement.setAttributeNS(null, "Algorithm", keyEncapsulationAlgorithm); + if (keyEncapsulationKeyDerivationMethod instanceof ElementProxy) { + keyEncapsulationMethodElement.appendChild( + ((ElementProxy) keyEncapsulationKeyDerivationMethod).getElement() + ); + } + if (keyEncapsulationKeyLength > 0) { + Element keyLenElement = + contextDocument.createElementNS( + EncryptionConstants.EncryptionSpecGHCNS, + "ghc:" + EncryptionConstants._TAG_KEYLEN + ); + keyLenElement.appendChild( + contextDocument.createTextNode(String.valueOf(keyEncapsulationKeyLength)) + ); + keyEncapsulationMethodElement.appendChild(keyLenElement); + } + genericHybridCipherMethodElement.appendChild(keyEncapsulationMethodElement); + + Element dataEncapsulationMethodElement = + contextDocument.createElementNS( + EncryptionConstants.EncryptionSpecGHCNS, + "ghc:" + EncryptionConstants._TAG_DATAENCAPSULATIONMETHOD + ); + dataEncapsulationMethodElement.setAttributeNS(null, "Algorithm", dataEncapsulationAlgorithm); + genericHybridCipherMethodElement.appendChild(dataEncapsulationMethodElement); + + result.appendChild(genericHybridCipherMethodElement); + } for (Element element : encryptionMethodInformation) { result.appendChild(element); } diff --git a/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java b/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java index 95842b139..31d95fb51 100644 --- a/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java +++ b/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java @@ -22,6 +22,7 @@ import org.apache.xml.security.encryption.keys.content.derivedKey.ConcatKDFParamsImpl; import org.apache.xml.security.encryption.keys.content.derivedKey.HKDFParamsImpl; import org.apache.xml.security.encryption.keys.content.derivedKey.KDFParams; +import org.apache.xml.security.encryption.keys.content.derivedKey.KeyDerivationMethodImpl; import org.apache.xml.security.encryption.params.ConcatKDFParams; import org.apache.xml.security.encryption.params.HKDFParams; import org.apache.xml.security.encryption.params.KeyAgreementParameters; @@ -29,6 +30,7 @@ import org.apache.xml.security.exceptions.XMLSecurityException; import org.apache.xml.security.utils.EncryptionConstants; import org.apache.xml.security.utils.KeyUtils; +import org.w3c.dom.Document; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.IvParameterSpec; @@ -284,6 +286,54 @@ public static KeyDerivationParameters constructKeyDerivationParameter(KeyDerivat throw new XMLEncryptionException("unknownAlgorithm", keyDerivationAlgorithm); } + /** + * Construct a {@code KeyDerivationMethod} DOM element from the given {@link KeyDerivationParameters}. + * The inverse of {@link #constructKeyDerivationParameter(KeyDerivationMethod, int)}. Supports the same + * two key derivation functions as the ECDH-ES/X25519/X448 key-agreement path: ConcatKDF and HKDF. + * + * @param doc the {@link Document} in which the {@code KeyDerivationMethod} element will be created + * @param keyDerivationParameter the key derivation parameters (e.g. {@link HKDFParams} or {@link ConcatKDFParams}) + * @return the constructed {@code KeyDerivationMethod} + * @throws XMLEncryptionException if the key derivation algorithm is not supported + */ + public static KeyDerivationMethod constructKeyDerivationMethod(Document doc, KeyDerivationParameters keyDerivationParameter) + throws XMLEncryptionException { + KeyDerivationMethodImpl keyDerivationMethod = new KeyDerivationMethodImpl(doc); + keyDerivationMethod.setAlgorithm(keyDerivationParameter.getAlgorithm()); + + KDFParams kdfParams; + if (keyDerivationParameter instanceof ConcatKDFParams) { + ConcatKDFParams kdfParameters = (ConcatKDFParams) keyDerivationParameter; + ConcatKDFParamsImpl concatKDFParams = new ConcatKDFParamsImpl(doc); + concatKDFParams.setDigestMethod(kdfParameters.getDigestAlgorithm()); + concatKDFParams.setAlgorithmId(kdfParameters.getAlgorithmID()); + concatKDFParams.setPartyUInfo(kdfParameters.getPartyUInfo()); + concatKDFParams.setPartyVInfo(kdfParameters.getPartyVInfo()); + concatKDFParams.setSuppPubInfo(kdfParameters.getSuppPubInfo()); + concatKDFParams.setSuppPrivInfo(kdfParameters.getSuppPrivInfo()); + kdfParams = concatKDFParams; + } else if (keyDerivationParameter instanceof HKDFParams) { + HKDFParams kdfParameters = (HKDFParams) keyDerivationParameter; + HKDFParamsImpl hkdfParams = new HKDFParamsImpl(doc); + hkdfParams.setPRFAlgorithm(kdfParameters.getHmacHashAlgorithm()); + Base64.Encoder base64Encoder = Base64.getEncoder(); + if (kdfParameters.getSalt() != null) { + hkdfParams.setSalt(base64Encoder.encodeToString(kdfParameters.getSalt())); + } + if (kdfParameters.getInfo() != null) { + hkdfParams.setInfo(base64Encoder.encodeToString(kdfParameters.getInfo())); + } + hkdfParams.setKeyLength(kdfParameters.getKeyBitLength() / 8); + kdfParams = hkdfParams; + } else { + throw new XMLEncryptionException("KeyDerivation.UnsupportedAlgorithm", + keyDerivationParameter.getAlgorithm(), keyDerivationParameter.getClass().getName()); + } + + keyDerivationMethod.setKDFParams(kdfParams); + return keyDerivationMethod; + } + /** * Method hexStringToByteArray converts hex string to byte array. * diff --git a/src/main/java/org/apache/xml/security/encryption/params/KeyEncapsulationParameters.java b/src/main/java/org/apache/xml/security/encryption/params/KeyEncapsulationParameters.java new file mode 100644 index 000000000..6592968fa --- /dev/null +++ b/src/main/java/org/apache/xml/security/encryption/params/KeyEncapsulationParameters.java @@ -0,0 +1,69 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.encryption.params; + +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.AlgorithmParameterSpec; + +/** + * This class is used to pass parameters to the Key Encapsulation Mechanism (KEM) based key + * transport, as specified in the W3C "XML Security: Generic Hybrid Cipher" note + * (https://www.w3.org/TR/xmlsec-generic-hybrid/). Unlike Diffie-Hellman key agreement + * ({@link KeyAgreementParameters}), a KEM has no ephemeral originator key pair: the + * encapsulating party only needs the recipient's public key, and the decapsulating party + * only needs the recipient's private key. + */ +public class KeyEncapsulationParameters implements AlgorithmParameterSpec { + + private final String keyEncapsulationAlgorithm; + private final KeyDerivationParameters keyDerivationParameter; + + private PublicKey recipientPublicKey; + private PrivateKey recipientPrivateKey; + + public KeyEncapsulationParameters(String keyEncapsulationAlgorithm, KeyDerivationParameters keyDerivationParameter) { + this.keyEncapsulationAlgorithm = keyEncapsulationAlgorithm; + this.keyDerivationParameter = keyDerivationParameter; + } + + public String getKeyEncapsulationAlgorithm() { + return keyEncapsulationAlgorithm; + } + + public KeyDerivationParameters getKeyDerivationParameter() { + return keyDerivationParameter; + } + + public PublicKey getRecipientPublicKey() { + return recipientPublicKey; + } + + public void setRecipientPublicKey(PublicKey recipientPublicKey) { + this.recipientPublicKey = recipientPublicKey; + } + + public PrivateKey getRecipientPrivateKey() { + return recipientPrivateKey; + } + + public void setRecipientPrivateKey(PrivateKey recipientPrivateKey) { + this.recipientPrivateKey = recipientPrivateKey; + } +} diff --git a/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java b/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java index a5c402578..6d4717cff 100644 --- a/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java +++ b/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java @@ -40,6 +40,7 @@ public class DEREncodedKeyValue extends Signature11ElementProxy implements KeyIn private static final String[] supportedKeyTypes = { "RSA", "DSA", "EC", "DiffieHellman", "DH", "XDH", "X25519", "X448", "EdDSA", "Ed25519", "Ed448", + "ML-KEM-512", "ML-KEM-768", "ML-KEM-1024", "RSASSA-PSS"}; /** diff --git a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java index 3368c97a3..dc6308926 100644 --- a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java +++ b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java @@ -144,9 +144,12 @@ public enum DIRECTION { public static final String NS_DSIG = "http://www.w3.org/2000/09/xmldsig#"; public static final String NS_DSIG_MORE ="http://www.w3.org/2001/04/xmldsig-more#"; public static final String NS_DSIG_MORE_2007_05 = "http://www.w3.org/2007/05/xmldsig-more#"; + public static final String NS_DSIG_MORE_2021_04 = "http://www.w3.org/2021/04/xmldsig-more#"; public static final String NS_DSIG11 = "http://www.w3.org/2009/xmldsig11#"; public static final String NS_WSSE11 = "http://docs.oasis-open.org/wss/oasis-wss-wssecurity-secext-1.1.xsd"; public static final String NS_XOP = "http://www.w3.org/2004/08/xop/include"; + /** W3C "XML Security: Generic Hybrid Cipher" namespace (https://www.w3.org/TR/xmlsec-generic-hybrid/) */ + public static final String NS_GHC = "http://www.w3.org/2010/xmlsec-ghc#"; public static final String PREFIX_XENC = "xenc"; public static final String PREFIX_XENC11 = "xenc11"; @@ -162,6 +165,22 @@ public enum DIRECTION { public static final QName TAG_xenc_OAEPparams = new QName(NS_XMLENC, "OAEPparams", PREFIX_XENC); public static final QName TAG_xenc11_MGF = new QName(NS_XMLENC11, "MGF", PREFIX_XENC11); + public static final QName TAG_xenc11_KeyDerivationMethod = new QName(NS_XMLENC11, "KeyDerivationMethod", PREFIX_XENC11); + + public static final String PREFIX_GHC = "ghc"; + public static final QName TAG_ghc_GenericHybridCipherMethod = new QName(NS_GHC, "GenericHybridCipherMethod", PREFIX_GHC); + public static final QName TAG_ghc_KeyEncapsulationMethod = new QName(NS_GHC, "KeyEncapsulationMethod", PREFIX_GHC); + public static final QName TAG_ghc_DataEncapsulationMethod = new QName(NS_GHC, "DataEncapsulationMethod", PREFIX_GHC); + public static final QName TAG_ghc_KeyLen = new QName(NS_GHC, "KeyLen", PREFIX_GHC); + + public static final String PREFIX_HKDF = "hkdf"; + public static final QName TAG_hkdf_HKDFParams = new QName(NS_DSIG_MORE_2021_04, "HKDFParams", PREFIX_HKDF); + public static final QName TAG_hkdf_PRF = new QName(NS_DSIG_MORE_2021_04, "PRF", PREFIX_HKDF); + public static final QName TAG_hkdf_Salt = new QName(NS_DSIG_MORE_2021_04, "Salt", PREFIX_HKDF); + public static final QName TAG_hkdf_Info = new QName(NS_DSIG_MORE_2021_04, "Info", PREFIX_HKDF); + public static final QName TAG_hkdf_KeyLength = new QName(NS_DSIG_MORE_2021_04, "KeyLength", PREFIX_HKDF); + /** HKDF key derivation algorithm URI (RFC 9231 provisional naming pattern) */ + public static final String NS_HKDF = NS_DSIG_MORE_2021_04 + "hkdf"; public static final String PREFIX_DSIG = "dsig"; public static final String PREFIX_DSIG_MORE_PSS = "pss"; diff --git a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityProperties.java b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityProperties.java index f04ac09aa..42de29640 100644 --- a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityProperties.java +++ b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityProperties.java @@ -52,6 +52,11 @@ public class XMLSecurityProperties { private String encryptionKeyTransportDigestAlgorithm; private String encryptionKeyTransportMGFAlgorithm; private byte[] encryptionKeyTransportOAEPParams; + // Generic Hybrid Cipher (W3C xmlsec-generic-hybrid) KEM-based key transport, e.g. ML-KEM (SANTUARIO-633). + // Used when encryptionKeyTransportAlgorithm is EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID. + private String encryptionKeyEncapsulationAlgorithm; + private String encryptionDataEncapsulationAlgorithm; + private String encryptionKeyEncapsulationHmacAlgorithm; private final List encryptionParts = new LinkedList<>(); private Key encryptionKey; private Key encryptionTransportKey; @@ -100,6 +105,9 @@ protected XMLSecurityProperties(XMLSecurityProperties xmlSecurityProperties) { this.encryptionKeyTransportDigestAlgorithm = xmlSecurityProperties.encryptionKeyTransportDigestAlgorithm; this.encryptionKeyTransportMGFAlgorithm = xmlSecurityProperties.encryptionKeyTransportMGFAlgorithm; this.encryptionKeyTransportOAEPParams = xmlSecurityProperties.encryptionKeyTransportOAEPParams; + this.encryptionKeyEncapsulationAlgorithm = xmlSecurityProperties.encryptionKeyEncapsulationAlgorithm; + this.encryptionDataEncapsulationAlgorithm = xmlSecurityProperties.encryptionDataEncapsulationAlgorithm; + this.encryptionKeyEncapsulationHmacAlgorithm = xmlSecurityProperties.encryptionKeyEncapsulationHmacAlgorithm; this.encryptionParts.addAll(xmlSecurityProperties.encryptionParts); this.encryptionKey = xmlSecurityProperties.encryptionKey; this.encryptionTransportKey = xmlSecurityProperties.encryptionTransportKey; @@ -333,6 +341,43 @@ public void setEncryptionKeyTransportOAEPParams(byte[] encryptionKeyTransportOAE this.encryptionKeyTransportOAEPParams = encryptionKeyTransportOAEPParams; } + /** + * Returns the Key Encapsulation Method algorithm URI (e.g. an ML-KEM algorithm URI) used when + * {@link #getEncryptionKeyTransportAlgorithm()} is the Generic Hybrid Cipher algorithm + * (see {@code EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID}, SANTUARIO-633). + */ + public String getEncryptionKeyEncapsulationAlgorithm() { + return encryptionKeyEncapsulationAlgorithm; + } + + public void setEncryptionKeyEncapsulationAlgorithm(String encryptionKeyEncapsulationAlgorithm) { + this.encryptionKeyEncapsulationAlgorithm = encryptionKeyEncapsulationAlgorithm; + } + + /** + * Returns the Data Encapsulation Method algorithm URI (an AES-KeyWrap algorithm) used when + * {@link #getEncryptionKeyTransportAlgorithm()} is the Generic Hybrid Cipher algorithm. + */ + public String getEncryptionDataEncapsulationAlgorithm() { + return encryptionDataEncapsulationAlgorithm; + } + + public void setEncryptionDataEncapsulationAlgorithm(String encryptionDataEncapsulationAlgorithm) { + this.encryptionDataEncapsulationAlgorithm = encryptionDataEncapsulationAlgorithm; + } + + /** + * Returns the HMAC hash algorithm URI used as the HKDF PRF when deriving the data-encapsulation + * (AES-KeyWrap) key from the KEM shared secret. Defaults to HMAC-SHA256 if unset. + */ + public String getEncryptionKeyEncapsulationHmacAlgorithm() { + return encryptionKeyEncapsulationHmacAlgorithm; + } + + public void setEncryptionKeyEncapsulationHmacAlgorithm(String encryptionKeyEncapsulationHmacAlgorithm) { + this.encryptionKeyEncapsulationHmacAlgorithm = encryptionKeyEncapsulationHmacAlgorithm; + } + public X509Certificate getEncryptionUseThisCertificate() { return encryptionUseThisCertificate; } diff --git a/src/main/java/org/apache/xml/security/stax/impl/processor/input/XMLEncryptedKeyInputHandler.java b/src/main/java/org/apache/xml/security/stax/impl/processor/input/XMLEncryptedKeyInputHandler.java index 1bcf1bf5e..ac21e0c95 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/processor/input/XMLEncryptedKeyInputHandler.java +++ b/src/main/java/org/apache/xml/security/stax/impl/processor/input/XMLEncryptedKeyInputHandler.java @@ -28,9 +28,11 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.security.PrivateKey; import java.security.spec.MGF1ParameterSpec; import java.util.Base64; import java.util.Deque; +import java.util.List; import javax.crypto.Cipher; import javax.crypto.NoSuchPaddingException; @@ -45,6 +47,9 @@ import org.apache.xml.security.binding.xmlenc.EncryptedKeyType; import org.apache.xml.security.binding.xmlenc11.MGFType; import org.apache.xml.security.binding.xop.Include; +import org.apache.xml.security.encryption.XMLCipherUtil; +import org.apache.xml.security.encryption.keys.content.derivedKey.KeyDerivationMethodImpl; +import org.apache.xml.security.encryption.params.KeyDerivationParameters; import org.apache.xml.security.exceptions.XMLSecurityException; import org.apache.xml.security.stax.ext.AbstractInputSecurityHeaderHandler; import org.apache.xml.security.stax.ext.InboundSecurityContext; @@ -61,7 +66,10 @@ import org.apache.xml.security.stax.securityToken.SecurityTokenConstants; import org.apache.xml.security.stax.securityToken.SecurityTokenFactory; import org.apache.xml.security.stax.securityToken.SecurityTokenProvider; +import org.apache.xml.security.utils.EncryptionConstants; +import org.apache.xml.security.utils.KeyUtils; import org.apache.xml.security.utils.XMLUtils; +import org.w3c.dom.Element; /** * An input handler for the EncryptedKey XML Structure @@ -170,6 +178,17 @@ private byte[] getSecret(InboundSecurityToken wrappedSecurityToken, String corre if (algorithmURI == null) { throw new XMLSecurityException("stax.encryption.noEncAlgo"); } + + final InboundSecurityToken wrappingSecurityToken = getWrappingSecurityToken(wrappedSecurityToken); + XMLSecurityConstants.AlgorithmUsage algorithmUsage = + wrappingSecurityToken.isAsymmetric() + ? XMLSecurityConstants.Asym_Key_Wrap : XMLSecurityConstants.Sym_Key_Wrap; + + if (EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID.equals(algorithmURI)) { + return this.decryptedKey = getGenericHybridSecret( + wrappingSecurityToken, correlationID, algorithmUsage, symmetricAlgorithmURI); + } + String jceName = JCEMapper.translateURItoJCEID(algorithmURI); String jceProvider = JCEMapper.getJCEProviderFromURI(algorithmURI); if (jceName == null) { @@ -177,17 +196,8 @@ private byte[] getSecret(InboundSecurityToken wrappedSecurityToken, String corre new Object[] {algorithmURI}); } - final InboundSecurityToken wrappingSecurityToken = getWrappingSecurityToken(wrappedSecurityToken); - Cipher cipher; try { - XMLSecurityConstants.AlgorithmUsage algorithmUsage; - if (wrappingSecurityToken.isAsymmetric()) { - algorithmUsage = XMLSecurityConstants.Asym_Key_Wrap; - } else { - algorithmUsage = XMLSecurityConstants.Sym_Key_Wrap; - } - if (jceProvider == null) { cipher = Cipher.getInstance(jceName); } else { @@ -260,6 +270,100 @@ private byte[] getSecret(InboundSecurityToken wrappedSecurityToken, String corre return this.decryptedKey; } } + + /** + * Decapsulate/unwrap a CEK transported via KEM-based (Generic Hybrid Cipher) key + * transport, per https://www.w3.org/TR/xmlsec-generic-hybrid/ (see SANTUARIO-633). + * The {@code ghc:GenericHybridCipherMethod} element has no JAXB binding, so it is + * read back as a raw DOM {@link Element} from the wildcard EncryptionMethod content + * and parsed with the same DOM classes ({@link KeyDerivationMethodImpl}, + * {@link XMLCipherUtil}) used by the DOM {@code XMLCipher} API for the same structure. + */ + private byte[] getGenericHybridSecret(InboundSecurityToken wrappingSecurityToken, String correlationID, + XMLSecurityConstants.AlgorithmUsage algorithmUsage, + String symmetricAlgorithmURI) throws XMLSecurityException { + try { + Element genericHybridCipherMethodElement = findAnyElement( + encryptedKeyType.getEncryptionMethod().getContent(), + XMLSecurityConstants.TAG_ghc_GenericHybridCipherMethod); + if (genericHybridCipherMethodElement == null) { + throw new XMLSecurityException("stax.unsupportedKeyTransp"); + } + + Element keyEncapsulationMethodElement = (Element) genericHybridCipherMethodElement + .getElementsByTagNameNS(XMLSecurityConstants.NS_GHC, "KeyEncapsulationMethod").item(0); + Element dataEncapsulationMethodElement = (Element) genericHybridCipherMethodElement + .getElementsByTagNameNS(XMLSecurityConstants.NS_GHC, "DataEncapsulationMethod").item(0); + if (keyEncapsulationMethodElement == null || dataEncapsulationMethodElement == null) { + throw new XMLSecurityException("stax.unsupportedKeyTransp"); + } + + String kemAlgorithm = keyEncapsulationMethodElement.getAttributeNS(null, "Algorithm"); + String dataEncapsulationAlgorithm = dataEncapsulationMethodElement.getAttributeNS(null, "Algorithm"); + + Element keyDerivationMethodElement = (Element) keyEncapsulationMethodElement + .getElementsByTagNameNS(XMLSecurityConstants.NS_XMLENC11, "KeyDerivationMethod").item(0); + if (keyDerivationMethodElement == null) { + throw new XMLSecurityException("stax.unsupportedKeyTransp"); + } + + int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(dataEncapsulationAlgorithm); + KeyDerivationMethodImpl keyDerivationMethod = new KeyDerivationMethodImpl(keyDerivationMethodElement, null); + KeyDerivationParameters kdp = XMLCipherUtil.constructKeyDerivationParameter(keyDerivationMethod, wrapKeyBitLength); + + Key wrapKeyToken = wrappingSecurityToken.getSecretKey(kemAlgorithm, algorithmUsage, correlationID); + if (!(wrapKeyToken instanceof PrivateKey)) { + throw new XMLSecurityException("stax.unsupportedKeyTransp"); + } + + if (encryptedKeyType.getCipherData() == null + || encryptedKeyType.getCipherData().getCipherValue() == null + || encryptedKeyType.getCipherData().getCipherValue().getContent() == null + || encryptedKeyType.getCipherData().getCipherValue().getContent().isEmpty()) { + throw new XMLSecurityException("stax.encryption.noCipherValue"); + } + + byte[] encryptedBytes = getEncryptedBytes(encryptedKeyType.getCipherData().getCipherValue()); + byte[] sha1Bytes = generateDigest(encryptedBytes); + String sha1Identifier = XMLUtils.encodeToString(sha1Bytes); + super.setSha1Identifier(sha1Identifier); + + try { + KeyUtils.KemDecapsulation kemResult = KeyUtils.kemDecapsulate( + (PrivateKey) wrapKeyToken, kemAlgorithm, encryptedBytes, kdp); + String jceWrapId = JCEMapper.translateURItoJCEID(dataEncapsulationAlgorithm); + Cipher cipher = Cipher.getInstance(jceWrapId); + cipher.init(Cipher.UNWRAP_MODE, kemResult.getWrapKey()); + Key key = cipher.unwrap(kemResult.getWrappedKey(), "AES", Cipher.SECRET_KEY); + return key.getEncoded(); + } catch (IllegalStateException e) { + throw new XMLSecurityException(e); + } catch (Exception e) { + LOG.log(Level.WARNING, "Unwrapping of the encrypted key failed with error: " + + e.getMessage() + ". Generating a faked one to mitigate timing attacks."); + + int keyLength = JCEMapper.getKeyLengthFromURI(symmetricAlgorithmURI); + return XMLSecurityConstants.generateBytes(keyLength / 8); + } + } catch (XMLSecurityException e) { + throw e; + } catch (Exception e) { + throw new XMLSecurityException(e); + } + } + + private Element findAnyElement(List content, javax.xml.namespace.QName qname) { + for (Object o : content) { + if (o instanceof Element) { + Element el = (Element) o; + if (qname.getNamespaceURI().equals(el.getNamespaceURI()) + && qname.getLocalPart().equals(el.getLocalName())) { + return el; + } + } + } + return null; + } }; this.securityToken.setElementPath(responsibleXMLSecStartXMLEvent.getElementPath()); this.securityToken.setXMLSecEvent(responsibleXMLSecStartXMLEvent); diff --git a/src/main/java/org/apache/xml/security/stax/impl/processor/output/XMLEncryptOutputProcessor.java b/src/main/java/org/apache/xml/security/stax/impl/processor/output/XMLEncryptOutputProcessor.java index f6ccd8ce9..2e6d32e77 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/processor/output/XMLEncryptOutputProcessor.java +++ b/src/main/java/org/apache/xml/security/stax/impl/processor/output/XMLEncryptOutputProcessor.java @@ -40,6 +40,8 @@ import javax.xml.stream.XMLStreamException; import org.apache.xml.security.algorithms.JCEMapper; +import org.apache.xml.security.encryption.params.HKDFParams; +import org.apache.xml.security.encryption.params.KeyDerivationParameters; import org.apache.xml.security.exceptions.XMLSecurityException; import org.apache.xml.security.stax.ext.OutputProcessorChain; import org.apache.xml.security.stax.ext.SecurePart; @@ -53,6 +55,8 @@ import org.apache.xml.security.stax.securityToken.OutboundSecurityToken; import org.apache.xml.security.stax.securityToken.SecurityTokenConstants; import org.apache.xml.security.stax.securityToken.SecurityTokenProvider; +import org.apache.xml.security.utils.EncryptionConstants; +import org.apache.xml.security.utils.KeyUtils; import org.apache.xml.security.utils.XMLUtils; /** @@ -149,7 +153,30 @@ protected void createKeyInfoStructure(OutputProcessorChain outputProcessorChain) attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Id, keyId)); createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_EncryptedKey, true, attributes); - attributes = new ArrayList<>(1); + if (EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID.equals(encryptionKeyTransportAlgorithm)) { + if (pubKey == null) { + throw new XMLSecurityException("stax.unsupportedKeyTransp"); + } + createGenericHybridCipherKeyInfoStructure(outputProcessorChain, pubKey); + } else { + createFlatKeyTransportKeyInfoStructure( + outputProcessorChain, encryptionKeyTransportAlgorithm, pubKey, secretKey); + } + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_EncryptedKey); + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig_KeyInfo); + } + + /** + * Writes the EncryptionMethod/KeyInfo/CipherData for a flat (RSA-OAEP-style) key + * transport algorithm, unchanged from before Generic Hybrid Cipher support was added. + */ + private void createFlatKeyTransportKeyInfoStructure( + OutputProcessorChain outputProcessorChain, String encryptionKeyTransportAlgorithm, + PublicKey pubKey, Key secretKey) throws XMLStreamException, XMLSecurityException { + + List attributes = new ArrayList<>(1); attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Algorithm, encryptionKeyTransportAlgorithm)); createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_EncryptionMethod, false, attributes); @@ -265,10 +292,111 @@ protected void createKeyInfoStructure(OutputProcessorChain outputProcessorChain) createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_CipherValue); createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_CipherData); + } - createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_EncryptedKey); + /** + * Writes the EncryptionMethod/KeyInfo/CipherData for KEM-based (Generic Hybrid Cipher) + * key transport, per https://www.w3.org/TR/xmlsec-generic-hybrid/ section 6.1 + * "Key Transport Example" (see SANTUARIO-633). + */ + private void createGenericHybridCipherKeyInfoStructure( + OutputProcessorChain outputProcessorChain, PublicKey pubKey) + throws XMLStreamException, XMLSecurityException { - createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig_KeyInfo); + String kemAlgorithm = getSecurityProperties().getEncryptionKeyEncapsulationAlgorithm(); + String dataEncapsulationAlgorithm = getSecurityProperties().getEncryptionDataEncapsulationAlgorithm(); + if (kemAlgorithm == null || dataEncapsulationAlgorithm == null) { + throw new XMLSecurityException("stax.unsupportedKeyTransp"); + } + String hmacAlgorithm = getSecurityProperties().getEncryptionKeyEncapsulationHmacAlgorithm(); + if (hmacAlgorithm == null) { + hmacAlgorithm = XMLSecurityConstants.NS_XMLDSIG_HMACSHA256; + } + + int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(dataEncapsulationAlgorithm); + KeyDerivationParameters kdfParams = HKDFParams.createBuilder(wrapKeyBitLength, hmacAlgorithm).build(); + + // EncryptionMethod/ghc:GenericHybridCipherMethod/ghc:KeyEncapsulationMethod/... + List attributes = new ArrayList<>(1); + attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Algorithm, + EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID)); + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_EncryptionMethod, false, attributes); + + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_GenericHybridCipherMethod, true, null); + + attributes = new ArrayList<>(1); + attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Algorithm, kemAlgorithm)); + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_KeyEncapsulationMethod, true, attributes); + + attributes = new ArrayList<>(1); + attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Algorithm, XMLSecurityConstants.NS_HKDF)); + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc11_KeyDerivationMethod, true, attributes); + + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_hkdf_HKDFParams, true, null); + + attributes = new ArrayList<>(1); + attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Algorithm, hmacAlgorithm)); + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_hkdf_PRF, true, attributes); + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_hkdf_PRF); + + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_hkdf_KeyLength, false, null); + createCharactersAndOutputAsEvent(outputProcessorChain, String.valueOf(wrapKeyBitLength / 8)); + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_hkdf_KeyLength); + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_hkdf_HKDFParams); + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc11_KeyDerivationMethod); + + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_KeyLen, false, null); + createCharactersAndOutputAsEvent(outputProcessorChain, String.valueOf(wrapKeyBitLength / 8)); + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_KeyLen); + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_KeyEncapsulationMethod); + + attributes = new ArrayList<>(1); + attributes.add(createAttribute(XMLSecurityConstants.ATT_NULL_Algorithm, dataEncapsulationAlgorithm)); + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_DataEncapsulationMethod, true, attributes); + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_DataEncapsulationMethod); + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_ghc_GenericHybridCipherMethod); + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_EncryptionMethod); + + createKeyInfoStructureForEncryptedKey(outputProcessorChain, keyWrappingToken); + + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_CipherData, false, null); + createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_CipherValue, false, null); + + String tokenId = outputProcessorChain.getSecurityContext().get( + XMLSecurityConstants.PROP_USE_THIS_TOKEN_ID_FOR_ENCRYPTION); + SecurityTokenProvider securityTokenProvider = + outputProcessorChain.getSecurityContext().getSecurityTokenProvider(tokenId); + final OutboundSecurityToken securityToken = securityTokenProvider.getSecurityToken(); + Key sessionKey = securityToken.getSecretKey(getSecurityProperties().getEncryptionSymAlgorithm()); + + // Encapsulate a shared secret to the recipient's KEM public key, derive the + // AES key-wrap key from it, and wrap the CEK with that key. CipherValue holds + // the concatenation of the KEM encapsulation (C0) and the wrapped CEK (C1). + KeyUtils.KemEncapsulation kemResult = KeyUtils.kemEncapsulate(pubKey, kemAlgorithm, kdfParams); + try { + String jceWrapId = JCEMapper.translateURItoJCEID(dataEncapsulationAlgorithm); + Cipher wrapCipher = Cipher.getInstance(jceWrapId); + wrapCipher.init(Cipher.WRAP_MODE, kemResult.getWrapKey()); + byte[] wrappedKey = wrapCipher.wrap(sessionKey); + + byte[] encapsulation = kemResult.getEncapsulation(); + byte[] combined = new byte[encapsulation.length + wrappedKey.length]; + System.arraycopy(encapsulation, 0, combined, 0, encapsulation.length); + System.arraycopy(wrappedKey, 0, combined, encapsulation.length, wrappedKey.length); + + createCharactersAndOutputAsEvent(outputProcessorChain, XMLUtils.encodeToString(combined)); + } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException + | IllegalBlockSizeException e) { + throw new XMLSecurityException(e); + } + + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_CipherValue); + createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_xenc_CipherData); } protected void createKeyInfoStructureForEncryptedKey( diff --git a/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java b/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java index 9aacd281d..66d737093 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java +++ b/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java @@ -19,6 +19,7 @@ package org.apache.xml.security.stax.impl.securityToken; import java.security.Key; +import java.security.PrivateKey; import java.security.PublicKey; import java.security.interfaces.DSAKey; import java.security.interfaces.ECKey; @@ -139,6 +140,10 @@ public final Key getSecretKey(String algorithmURI, XMLSecurityConstants.Algorith algorithmSuiteSecurityEvent.setKeyLength(((ECKey) key).getParams().getOrder().bitLength()); } else if (key instanceof SecretKey) { algorithmSuiteSecurityEvent.setKeyLength(key.getEncoded().length * 8); + } else if (key instanceof PrivateKey) { + // PQC or other asymmetric key types (e.g. ML-KEM): key length not classically defined + byte[] encoded = key.getEncoded(); + algorithmSuiteSecurityEvent.setKeyLength(encoded != null ? encoded.length * 8 : 0); } else { throw new XMLSecurityException("java.security.UnknownKeyType", new Object[] {key.getClass().getName()}); @@ -174,8 +179,9 @@ public final PublicKey getPublicKey(String algorithmURI, XMLSecurityConstants.Al } else if (publicKey instanceof ECKey) { algorithmSuiteSecurityEvent.setKeyLength(((ECKey) publicKey).getParams().getOrder().bitLength()); } else { - throw new XMLSecurityException("java.security.UnknownKeyType", - new Object[] {publicKey.getClass().getName()}); + // PQC or other asymmetric public key types (e.g. ML-DSA, ML-KEM): key length not classically defined + byte[] encoded = publicKey.getEncoded(); + algorithmSuiteSecurityEvent.setKeyLength(encoded != null ? encoded.length * 8 : 0); } inboundSecurityContext.registerSecurityEvent(algorithmSuiteSecurityEvent); } diff --git a/src/main/java/org/apache/xml/security/utils/EncryptionConstants.java b/src/main/java/org/apache/xml/security/utils/EncryptionConstants.java index c752c0427..15a93cee4 100644 --- a/src/main/java/org/apache/xml/security/utils/EncryptionConstants.java +++ b/src/main/java/org/apache/xml/security/utils/EncryptionConstants.java @@ -138,6 +138,18 @@ public final class EncryptionConstants { /** Tag of Element KEY LENGTH **/ public static final String _TAG_KEYLENGTH = "KeyLength"; + /** Tag of Element GenericHybridCipherMethod **/ + public static final String _TAG_GENERICHYBRIDCIPHERMETHOD = "GenericHybridCipherMethod"; + + /** Tag of Element KeyEncapsulationMethod **/ + public static final String _TAG_KEYENCAPSULATIONMETHOD = "KeyEncapsulationMethod"; + + /** Tag of Element DataEncapsulationMethod **/ + public static final String _TAG_DATAENCAPSULATIONMETHOD = "DataEncapsulationMethod"; + + /** Tag of Element KeyLen **/ + public static final String _TAG_KEYLEN = "KeyLen"; + /** Field ENCRYPTIONSPECIFICATION_URL */ public static final String ENCRYPTIONSPECIFICATION_URL = "http://www.w3.org/TR/2001/WD-xmlenc-core-20010626/"; @@ -154,6 +166,13 @@ public final class EncryptionConstants { public static final String EncryptionSpec11NS = "http://www.w3.org/2009/xmlenc11#"; + /** + * The namespace of the W3C XML Security: Generic Hybrid Cipher specification + * (https://www.w3.org/TR/xmlsec-generic-hybrid/) + */ + public static final String EncryptionSpecGHCNS = + "http://www.w3.org/2010/xmlsec-ghc#"; + /** URI for content*/ public static final String TYPE_CONTENT = EncryptionSpecNS + "Content"; @@ -220,6 +239,36 @@ public final class EncryptionConstants { public static final String ALGO_ID_KEYTRANSPORT_RSAOAEP_11 = EncryptionConstants.EncryptionSpec11NS + "rsa-oaep"; + /** + * Key Transport - Generic Hybrid Cipher (W3C xmlsec-generic-hybrid). Used as the + * top-level {@code xenc:EncryptionMethod} algorithm for KEM-based key transport (e.g. + * ML-KEM, see SANTUARIO-633); the actual key encapsulation algorithm is identified by + * the {@code KeyEncapsulationMethod/@Algorithm} attribute of the nested + * {@code ghc:GenericHybridCipherMethod} element (see {@link #ALGO_ID_KEYTRANSPORT_MLKEM_512} + * and friends below). + */ + public static final String ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID = + EncryptionConstants.EncryptionSpecGHCNS + "generic-hybrid"; + + // Provisional URIs for ML-KEM (FIPS 203) key encapsulation, per + // draft-eastlake-rfc9231bis-xmlsec-uris section 3.6.9. These use the draft's "tbd" + // placeholder namespace; no official W3C URI has been assigned yet, update once + // standardised (see SANTUARIO-634). Used as the value of + // ghc:GenericHybridCipherMethod/ghc:KeyEncapsulationMethod/@Algorithm, not as a + // top-level xenc:EncryptionMethod algorithm (see SANTUARIO-633, ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID). + + /** Key Encapsulation - ML-KEM-512 (FIPS 203, NIST security level 1) */ + public static final String ALGO_ID_KEYTRANSPORT_MLKEM_512 = + "http://www.w3.org/tbd#ml-kem-512"; + + /** Key Encapsulation - ML-KEM-768 (FIPS 203, NIST security level 3) */ + public static final String ALGO_ID_KEYTRANSPORT_MLKEM_768 = + "http://www.w3.org/tbd#ml-kem-768"; + + /** Key Encapsulation - ML-KEM-1024 (FIPS 203, NIST security level 5) */ + public static final String ALGO_ID_KEYTRANSPORT_MLKEM_1024 = + "http://www.w3.org/tbd#ml-kem-1024"; + /** Key Agreement - OPTIONAL Diffie-Hellman */ public static final String ALGO_ID_KEYAGREEMENT_DH = EncryptionConstants.EncryptionSpecNS + "dh"; diff --git a/src/main/java/org/apache/xml/security/utils/KeyUtils.java b/src/main/java/org/apache/xml/security/utils/KeyUtils.java index 7481597a9..295466679 100644 --- a/src/main/java/org/apache/xml/security/utils/KeyUtils.java +++ b/src/main/java/org/apache/xml/security/utils/KeyUtils.java @@ -18,6 +18,7 @@ */ package org.apache.xml.security.utils; +import org.apache.xml.security.algorithms.JCEMapper; import org.apache.xml.security.algorithms.implementations.ECDSAUtils; import org.apache.xml.security.encryption.XMLEncryptionException; import org.apache.xml.security.encryption.keys.content.derivedKey.ConcatKDF; @@ -33,10 +34,12 @@ import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.lang.System.Logger.Level; +import java.lang.reflect.Method; import java.security.*; import java.security.interfaces.ECPublicKey; import java.security.spec.ECGenParameterSpec; import java.util.Arrays; +import java.util.Set; /** * A set of utility methods to handle keys. @@ -312,4 +315,182 @@ public static byte[] deriveKeyWithConcatKDF(byte[] sharedSecret, ConcatKDFParams ConcatKDF concatKDF = new ConcatKDF(); return concatKDF.deriveKey(sharedSecret, ckdfParameter); } + + // The only Key Encapsulation Method algorithms this library registers (see JCEMapper / + // security-config.xml). kemEncapsulate/kemDecapsulate are reachable with a KEM algorithm URI + // taken directly from parsed XML (ghc:KeyEncapsulationMethod/@Algorithm), so this whitelist is + // enforced explicitly rather than relying on javax.crypto.KEM#getInstance to reject anything + // else JCEMapper might resolve the URI to. + private static final Set SUPPORTED_KEM_ALGORITHMS = Set.of( + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512, + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768, + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024); + + // Fully-qualified javax.crypto.KEM class names. This module targets Java 11 + // (see maven.compiler.release), but the KEM API (JEP 452) is only available since + // Java 21, so it is accessed via reflection rather than a compile-time import - the + // same reason the ML-DSA/ML-KEM JCA algorithm names are looked up dynamically rather + // than depending on BouncyCastle at compile time. + private static final String KEM_CLASS = "javax.crypto.KEM"; + private static final String KEM_ENCAPSULATOR_CLASS = "javax.crypto.KEM$Encapsulator"; + private static final String KEM_DECAPSULATOR_CLASS = "javax.crypto.KEM$Decapsulator"; + private static final String KEM_ENCAPSULATED_CLASS = "javax.crypto.KEM$Encapsulated"; + + /** + * The result of a KEM encapsulation: the encapsulation/ciphertext (traditionally called "C0") + * to be sent to the recipient, and the AES key-wrap key derived from the KEM shared secret. + */ + public static final class KemEncapsulation { + private final byte[] encapsulation; + private final SecretKey wrapKey; + + KemEncapsulation(byte[] encapsulation, SecretKey wrapKey) { + this.encapsulation = encapsulation; + this.wrapKey = wrapKey; + } + + public byte[] getEncapsulation() { + return encapsulation; + } + + public SecretKey getWrapKey() { + return wrapKey; + } + } + + /** + * The result of a KEM decapsulation: the AES key-wrap key derived from the KEM shared secret, + * and the remainder of the ciphertext (traditionally called "C1", the AES-wrapped CEK) once the + * leading KEM encapsulation octets have been stripped off. + */ + public static final class KemDecapsulation { + private final SecretKey wrapKey; + private final byte[] wrappedKey; + + KemDecapsulation(SecretKey wrapKey, byte[] wrappedKey) { + this.wrapKey = wrapKey; + this.wrappedKey = wrappedKey; + } + + public SecretKey getWrapKey() { + return wrapKey; + } + + public byte[] getWrappedKey() { + return wrappedKey; + } + } + + /** + * Encapsulate a fresh shared secret to the recipient's KEM public key (e.g. ML-KEM, FIPS 203) and + * derive an AES key-wrap key from it, per the W3C "XML Security: Generic Hybrid Cipher" note + * (https://www.w3.org/TR/xmlsec-generic-hybrid/, section 5 "Using Key Encapsulation Algorithms for + * Key Transport"). Uses the JDK's {@code javax.crypto.KEM} API via reflection - see {@link #KEM_CLASS}. + * + * @param recipientPublicKey the recipient's KEM public key + * @param kemAlgorithmURI the KEM algorithm URI (e.g. {@code ALGO_ID_KEYTRANSPORT_MLKEM_512}) + * @param keyDerivationParameter the key derivation parameters used to derive the AES key-wrap key + * from the KEM shared secret + * @return the KEM encapsulation (C0) and the derived AES key-wrap key + * @throws XMLEncryptionException if {@code kemAlgorithmURI} is not one of the registered ML-KEM + * algorithms, the KEM API is unavailable (requires Java 21+), the KEM algorithm is not + * supported by the configured JCE provider, or key derivation fails + */ + public static KemEncapsulation kemEncapsulate(PublicKey recipientPublicKey, String kemAlgorithmURI, + KeyDerivationParameters keyDerivationParameter) + throws XMLEncryptionException { + validateKemAlgorithm(kemAlgorithmURI); + try { + String jceKemName = JCEMapper.translateURItoJCEID(kemAlgorithmURI); + Object kem = kemGetInstance(jceKemName); + Object encapsulator = invoke(kem, kem.getClass(), "newEncapsulator", + new Class[]{PublicKey.class}, recipientPublicKey); + Object encapsulated = invoke(encapsulator, Class.forName(KEM_ENCAPSULATOR_CLASS), "encapsulate", + new Class[0]); + Class encapsulatedClass = Class.forName(KEM_ENCAPSULATED_CLASS); + byte[] c0 = (byte[]) invoke(encapsulated, encapsulatedClass, "encapsulation", new Class[0]); + SecretKey sharedSecret = (SecretKey) invoke(encapsulated, encapsulatedClass, "key", new Class[0]); + byte[] kek = deriveKeyEncryptionKey(sharedSecret.getEncoded(), keyDerivationParameter); + return new KemEncapsulation(c0, new SecretKeySpec(kek, "AES")); + } catch (ReflectiveOperationException e) { + throw new XMLEncryptionException(e); + } catch (XMLSecurityException e) { + throw new XMLEncryptionException(e); + } + } + + /** + * Decapsulate a shared secret using the recipient's KEM private key and derive the AES key-wrap + * key from it, splitting the leading KEM encapsulation octets (C0) off the combined ciphertext + * first (its length is algorithm-specific and obtained from the KEM API itself, so no hardcoded + * per-algorithm length table is required). See {@link #kemEncapsulate}. + * + * @param recipientPrivateKey the recipient's KEM private key + * @param kemAlgorithmURI the KEM algorithm URI (e.g. {@code ALGO_ID_KEYTRANSPORT_MLKEM_512}) + * @param combinedCiphertext the concatenation of the KEM encapsulation (C0) and the AES-wrapped + * CEK (C1), as read from {@code xenc:CipherValue} + * @param keyDerivationParameter the key derivation parameters used to derive the AES key-wrap key + * from the KEM shared secret + * @return the derived AES key-wrap key, and the remaining AES-wrapped CEK bytes (C1) + * @throws XMLEncryptionException if {@code kemAlgorithmURI} is not one of the registered ML-KEM + * algorithms (this method is reachable with an algorithm URI parsed directly from + * untrusted input XML, so it is validated explicitly rather than delegating entirely to + * the JCE provider), the KEM API is unavailable (requires Java 21+), the KEM algorithm is + * not supported by the configured JCE provider, the ciphertext is shorter than the + * algorithm's expected encapsulation size, or key derivation fails + */ + public static KemDecapsulation kemDecapsulate(PrivateKey recipientPrivateKey, String kemAlgorithmURI, + byte[] combinedCiphertext, KeyDerivationParameters keyDerivationParameter) + throws XMLEncryptionException { + validateKemAlgorithm(kemAlgorithmURI); + try { + String jceKemName = JCEMapper.translateURItoJCEID(kemAlgorithmURI); + Object kem = kemGetInstance(jceKemName); + Object decapsulator = invoke(kem, kem.getClass(), "newDecapsulator", + new Class[]{PrivateKey.class}, recipientPrivateKey); + Class decapsulatorClass = Class.forName(KEM_DECAPSULATOR_CLASS); + int encapsulationSize = (int) invoke(decapsulator, decapsulatorClass, "encapsulationSize", new Class[0]); + if (combinedCiphertext.length < encapsulationSize) { + throw new XMLEncryptionException("KeyDerivation.MissingParameters"); + } + byte[] c0 = Arrays.copyOfRange(combinedCiphertext, 0, encapsulationSize); + byte[] c1 = Arrays.copyOfRange(combinedCiphertext, encapsulationSize, combinedCiphertext.length); + SecretKey sharedSecret = (SecretKey) invoke(decapsulator, decapsulatorClass, "decapsulate", + new Class[]{byte[].class}, (Object) c0); + byte[] kek = deriveKeyEncryptionKey(sharedSecret.getEncoded(), keyDerivationParameter); + return new KemDecapsulation(new SecretKeySpec(kek, "AES"), c1); + } catch (ReflectiveOperationException e) { + throw new XMLEncryptionException(e); + } catch (XMLSecurityException e) { + throw new XMLEncryptionException(e); + } + } + + /** + * Restricts a Key Encapsulation Method algorithm URI (e.g. parsed from + * {@code ghc:KeyEncapsulationMethod/@Algorithm} of untrusted input XML) to the ML-KEM + * algorithms this library actually registers, rather than accepting any URI JCEMapper + * happens to resolve. + * + * @param kemAlgorithmURI the KEM algorithm URI to validate + * @throws XMLEncryptionException if the URI is not one of the supported ML-KEM algorithms + */ + private static void validateKemAlgorithm(String kemAlgorithmURI) throws XMLEncryptionException { + if (!SUPPORTED_KEM_ALGORITHMS.contains(kemAlgorithmURI)) { + throw new XMLEncryptionException("algorithms.NoSuchAlgorithm", + new Object[] { kemAlgorithmURI, "not a registered ML-KEM Key Encapsulation Method algorithm" }); + } + } + + private static Object kemGetInstance(String jceKemName) throws ReflectiveOperationException { + Class kemClass = Class.forName(KEM_CLASS); + Method getInstance = kemClass.getMethod("getInstance", String.class); + return getInstance.invoke(null, jceKemName); + } + + private static Object invoke(Object target, Class declaringClass, String methodName, Class[] paramTypes, + Object... args) throws ReflectiveOperationException { + Method method = declaringClass.getMethod(methodName, paramTypes); + return method.invoke(target, args); + } } diff --git a/src/main/resources/bindings/schemas/xenc-schema-11.xsd b/src/main/resources/bindings/schemas/xenc-schema-11.xsd index 0550c3b25..27264deb9 100644 --- a/src/main/resources/bindings/schemas/xenc-schema-11.xsd +++ b/src/main/resources/bindings/schemas/xenc-schema-11.xsd @@ -55,7 +55,7 @@ - + diff --git a/src/main/resources/bindings/schemas/xenc-schema.xsd b/src/main/resources/bindings/schemas/xenc-schema.xsd index d8ea060f2..3db7f443b 100644 --- a/src/main/resources/bindings/schemas/xenc-schema.xsd +++ b/src/main/resources/bindings/schemas/xenc-schema.xsd @@ -30,7 +30,7 @@ - + diff --git a/src/main/resources/security-config.xml b/src/main/resources/security-config.xml index f6c91db07..9bef7a5d8 100644 --- a/src/main/resources/security-config.xml +++ b/src/main/resources/security-config.xml @@ -505,6 +505,40 @@ RequiredKey="RSA" JCEName="RSA/ECB/OAEPPadding"/> + + + + + + + + + + Key pairs are generated on the fly in {@code @BeforeAll}; no pre-generated + * key material is committed to the repository. + * + *

Run with the Maven {@code bouncycastle} profile on Java 21+ (the {@code javax.crypto.KEM} + * API, JEP 452, is used internally via reflection - see {@link KeyUtils#kemEncapsulate}): + *

mvn test -Dtest=XMLEncryptionMLKEMTest -P bouncycastle
+ */ +class XMLEncryptionMLKEMTest { + + private static boolean mlKemAvailable; + private static boolean bcAddedForTheTest; + + private static java.util.Map keyPairs = new java.util.HashMap<>(); + + @BeforeAll + static void setUp() { + org.apache.xml.security.Init.init(); + + if (Security.getProvider("BC") == null) { + try { + Class cls = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider"); + Provider bc = (Provider) cls.getConstructor().newInstance(); + Security.addProvider(bc); + bcAddedForTheTest = true; + } catch (ReflectiveOperationException e) { + mlKemAvailable = false; + return; + } + } + + try { + for (String alg : new String[]{"ML-KEM-512", "ML-KEM-768", "ML-KEM-1024"}) { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg, "BC"); + keyPairs.put(alg, kpg.generateKeyPair()); + } + // javax.crypto.KEM (JEP 452) is only available since Java 21; the KemEncapsulation + // helper is used via reflection, so probe it here rather than failing deep inside + // encryptKey. + Class.forName("javax.crypto.KEM"); + mlKemAvailable = true; + } catch (Exception | LinkageError e) { + mlKemAvailable = false; + } + } + + @AfterAll + static void tearDown() { + if (bcAddedForTheTest) { + Security.removeProvider("BC"); + } + } + + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024", + }) + void testMLKEMEncryptDecrypt(String keyEncapsulationUri, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + PublicKey pubKey = keyPairs.get(jcaAlgorithm).getPublic(); + PrivateKey privKey = keyPairs.get(jcaAlgorithm).getPrivate(); + + // Build a minimal XML document to encrypt + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document doc = dbf.newDocumentBuilder().newDocument(); + Element root = doc.createElement("PaymentInfo"); + root.setTextContent("CardNumber:4019111111111111"); + doc.appendChild(root); + + // Generate a random AES-256 content-encryption key (CEK) + KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(256); + SecretKey cek = kg.generateKey(); + + // --- ENCRYPT --- + // Encapsulate a shared secret to the recipient's ML-KEM public key, derive an + // AES-256 key-wrap key from it via HKDF-SHA256, and wrap the CEK with that key. + String kwAlgorithm = EncryptionConstants.ALGO_ID_KEYWRAP_AES256; + int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(kwAlgorithm); + HKDFParams kdfParams = HKDFParams.createBuilder(wrapKeyBitLength, XMLSignature.ALGO_ID_MAC_HMAC_SHA256).build(); + AlgorithmParameterSpec keyEncapsulationParameters = + new KeyEncapsulationParameters(keyEncapsulationUri, kdfParams); + + XMLCipher keyCipher = XMLCipher.getInstance(kwAlgorithm); + keyCipher.init(XMLCipher.WRAP_MODE, pubKey); + EncryptedKey encryptedKey = keyCipher.encryptKey(doc, cek, keyEncapsulationParameters, null); + + // Verify the produced EncryptedKey uses the Generic Hybrid Cipher structure, not an + // opaque flat key-transport blob + assertEquals(EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID, + encryptedKey.getEncryptionMethod().getAlgorithm()); + assertEquals(keyEncapsulationUri, encryptedKey.getEncryptionMethod().getKeyEncapsulationAlgorithm()); + assertEquals(kwAlgorithm, encryptedKey.getEncryptionMethod().getDataEncapsulationAlgorithm()); + assertTrue(encryptedKey.getEncryptionMethod().getKeyEncapsulationKeyLength() > 0); + + // Encrypt the document content with AES-256-GCM + XMLCipher dataCipher = XMLCipher.getInstance(XMLCipher.AES_256_GCM); + dataCipher.init(XMLCipher.ENCRYPT_MODE, cek); + EncryptedData encryptedData = dataCipher.getEncryptedData(); + + KeyInfo keyInfo = new KeyInfo(doc); + keyInfo.add(encryptedKey); + encryptedData.setKeyInfo(keyInfo); + + doc = dataCipher.doFinal(doc, root, false); + + // Serialise to bytes to simulate wire transfer + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + javax.xml.transform.Transformer t = + javax.xml.transform.TransformerFactory.newInstance().newTransformer(); + t.transform(new javax.xml.transform.dom.DOMSource(doc), + new javax.xml.transform.stream.StreamResult(bos)); + + // Verify the serialised XML carries the spec's element names, per + // https://www.w3.org/TR/xmlsec-generic-hybrid/ section 6.1 "Key Transport Example" + String serialized = bos.toString(java.nio.charset.StandardCharsets.UTF_8); + assertTrue(serialized.contains("GenericHybridCipherMethod"), "Missing GenericHybridCipherMethod element"); + assertTrue(serialized.contains("KeyEncapsulationMethod"), "Missing KeyEncapsulationMethod element"); + assertTrue(serialized.contains("DataEncapsulationMethod"), "Missing DataEncapsulationMethod element"); + assertTrue(serialized.contains("http://www.w3.org/2010/xmlsec-ghc#generic-hybrid"), + "Missing Generic Hybrid Cipher EncryptionMethod algorithm"); + + // --- DECRYPT --- + Document encDoc = dbf.newDocumentBuilder() + .parse(new java.io.ByteArrayInputStream(bos.toByteArray())); + + Element encDataElem = (Element) encDoc.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecNS, "EncryptedData").item(0); + + XMLCipher decryptCipher = XMLCipher.getInstance(); + decryptCipher.init(XMLCipher.DECRYPT_MODE, null); + EncryptedData encData = decryptCipher.loadEncryptedData(encDoc, encDataElem); + + // Unwrap the CEK using the recipient's ML-KEM private key + EncryptedKey ek = encData.getKeyInfo().itemEncryptedKey(0); + XMLCipher unwrapCipher = XMLCipher.getInstance(); + unwrapCipher.init(XMLCipher.UNWRAP_MODE, privKey); + Key recoveredCek = unwrapCipher.decryptKey( + ek, encData.getEncryptionMethod().getAlgorithm()); + + // Decrypt document content + decryptCipher.init(XMLCipher.DECRYPT_MODE, recoveredCek); + Document decryptedDoc = decryptCipher.doFinal(encDoc, encDataElem); + + Element decryptedRoot = decryptedDoc.getDocumentElement(); + assertEquals("PaymentInfo", decryptedRoot.getLocalName()); + assertEquals("CardNumber:4019111111111111", decryptedRoot.getTextContent()); + } + + @Test + void testMLKEMWrongRecipientPrivateKeyFailsCleanly() throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + PublicKey recipientAPub = keyPairs.get("ML-KEM-768").getPublic(); + byte[] encryptedXml = encryptToRecipient(recipientAPub); + + // A second, independent recipient - not the one the message was encrypted to. + KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-KEM-768", "BC"); + PrivateKey wrongPrivateKey = kpg.generateKeyPair().getPrivate(); + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document encDoc = dbf.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(encryptedXml)); + Element encDataElem = (Element) encDoc.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecNS, "EncryptedData").item(0); + + XMLCipher decryptCipher = XMLCipher.getInstance(); + decryptCipher.init(XMLCipher.DECRYPT_MODE, null); + EncryptedData encData = decryptCipher.loadEncryptedData(encDoc, encDataElem); + EncryptedKey ek = encData.getKeyInfo().itemEncryptedKey(0); + + XMLCipher unwrapCipher = XMLCipher.getInstance(); + unwrapCipher.init(XMLCipher.UNWRAP_MODE, wrongPrivateKey); + + // Decapsulating with the wrong private key yields a different shared secret (ML-KEM's + // implicit-rejection design does not signal failure at that layer), so the derived + // AES key-wrap key is wrong; the AES-KeyWrap integrity check then fails. The important + // property under test is that this throws rather than silently handing back a Key + // built from the wrong shared secret - decryptKey() must never return in this case. + assertThrows(XMLEncryptionException.class, + () -> unwrapCipher.decryptKey(ek, encData.getEncryptionMethod().getAlgorithm())); + } + + @Test + void testMLKEMTruncatedEncapsulationRejected() throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + PrivateKey privKey = keyPairs.get("ML-KEM-768").getPrivate(); + PublicKey pubKey = keyPairs.get("ML-KEM-768").getPublic(); + byte[] encryptedXml = encryptToRecipient(pubKey); + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document encDoc = dbf.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(encryptedXml)); + + // Truncate the EncryptedKey's CipherValue (the concatenation of the KEM encapsulation C0 + // and the AES-wrapped CEK C1) to well under half its length - shorter than any ML-KEM + // variant's encapsulationSize() - *before* it is ever parsed into an EncryptedKey object, + // so the corruption is guaranteed to be observed on the decrypt path. + Element encryptedKeyElem = (Element) encDoc.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecNS, "EncryptedKey").item(0); + Element cipherValueElem = (Element) encryptedKeyElem.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecNS, "CipherValue").item(0); + byte[] combined = Base64.getMimeDecoder().decode(cipherValueElem.getTextContent()); + byte[] truncated = java.util.Arrays.copyOf(combined, combined.length / 2); + replaceTextContent(encDoc, cipherValueElem, Base64.getEncoder().encodeToString(truncated)); + + Element encDataElem = (Element) encDoc.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecNS, "EncryptedData").item(0); + XMLCipher decryptCipher = XMLCipher.getInstance(); + decryptCipher.init(XMLCipher.DECRYPT_MODE, null); + EncryptedData encData = decryptCipher.loadEncryptedData(encDoc, encDataElem); + EncryptedKey ek = encData.getKeyInfo().itemEncryptedKey(0); + + XMLCipher unwrapCipher = XMLCipher.getInstance(); + unwrapCipher.init(XMLCipher.UNWRAP_MODE, privKey); + + // KeyUtils.kemDecapsulate() explicitly checks the ciphertext length against + // encapsulationSize() and throws XMLEncryptionException("KeyDerivation.MissingParameters") + // rather than reading past the end of the array. + assertThrows(XMLEncryptionException.class, + () -> unwrapCipher.decryptKey(ek, encData.getEncryptionMethod().getAlgorithm())); + } + + /** + * Runs the encrypt half of the ML-KEM-768 round trip (same structure as + * {@link #testMLKEMEncryptDecrypt}) and returns the serialised encrypted XML, for tests that + * want to corrupt or otherwise interfere with the decrypt half. + */ + private byte[] encryptToRecipient(PublicKey pubKey) throws Exception { + String keyEncapsulationUri = EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768; + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document doc = dbf.newDocumentBuilder().newDocument(); + Element root = doc.createElement("PaymentInfo"); + root.setTextContent("CardNumber:4019111111111111"); + doc.appendChild(root); + + KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(256); + SecretKey cek = kg.generateKey(); + + String kwAlgorithm = EncryptionConstants.ALGO_ID_KEYWRAP_AES256; + int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(kwAlgorithm); + HKDFParams kdfParams = HKDFParams.createBuilder(wrapKeyBitLength, XMLSignature.ALGO_ID_MAC_HMAC_SHA256).build(); + AlgorithmParameterSpec keyEncapsulationParameters = + new KeyEncapsulationParameters(keyEncapsulationUri, kdfParams); + + XMLCipher keyCipher = XMLCipher.getInstance(kwAlgorithm); + keyCipher.init(XMLCipher.WRAP_MODE, pubKey); + EncryptedKey encryptedKey = keyCipher.encryptKey(doc, cek, keyEncapsulationParameters, null); + + XMLCipher dataCipher = XMLCipher.getInstance(XMLCipher.AES_256_GCM); + dataCipher.init(XMLCipher.ENCRYPT_MODE, cek); + EncryptedData encryptedData = dataCipher.getEncryptedData(); + + KeyInfo keyInfo = new KeyInfo(doc); + keyInfo.add(encryptedKey); + encryptedData.setKeyInfo(keyInfo); + + doc = dataCipher.doFinal(doc, root, false); + + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + javax.xml.transform.Transformer t = + javax.xml.transform.TransformerFactory.newInstance().newTransformer(); + t.transform(new javax.xml.transform.dom.DOMSource(doc), + new javax.xml.transform.stream.StreamResult(bos)); + return bos.toByteArray(); + } + + private void replaceTextContent(Document doc, Element element, String newText) { + NodeList children = element.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + element.removeChild(children.item(i)); + } + Text textNode = doc.createTextNode(newText); + element.appendChild(textNode); + } +} diff --git a/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java new file mode 100644 index 000000000..7a4d3c37a --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java @@ -0,0 +1,358 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.test.stax.encryption; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamReader; +import javax.xml.stream.XMLStreamWriter; + +import org.apache.xml.security.encryption.EncryptedData; +import org.apache.xml.security.encryption.EncryptedKey; +import org.apache.xml.security.encryption.XMLCipher; +import org.apache.xml.security.encryption.XMLEncryptionException; +import org.apache.xml.security.keys.KeyInfo; +import org.apache.xml.security.stax.ext.InboundXMLSec; +import org.apache.xml.security.stax.ext.OutboundXMLSec; +import org.apache.xml.security.stax.ext.SecurePart; +import org.apache.xml.security.stax.ext.XMLSec; +import org.apache.xml.security.stax.ext.XMLSecurityConstants; +import org.apache.xml.security.stax.ext.XMLSecurityProperties; +import org.apache.xml.security.test.stax.utils.StAX2DOM; +import org.apache.xml.security.test.stax.utils.XMLSecEventAllocator; +import org.apache.xml.security.test.stax.utils.XmlReaderToWriter; +import org.apache.xml.security.utils.EncryptionConstants; +import org.apache.xml.security.utils.XMLUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.w3c.dom.Text; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * StAX-path tests for ML-KEM key transport with AES-256-GCM content encryption, using the W3C + * "XML Security: Generic Hybrid Cipher" key transport structure + * (https://www.w3.org/TR/xmlsec-generic-hybrid/, see SANTUARIO-633) - the same structure exercised + * by the DOM {@code XMLCipher} API in {@code XMLEncryptionMLKEMTest}. + */ +class StaxMLKEMEncryptionTest { + + private static boolean mlKemAvailable; + private static boolean bcAddedForTheTest; + private static final Map keyPairs = new HashMap<>(); + private final XMLInputFactory xmlInputFactory; + + @BeforeAll + static void setUp() { + org.apache.xml.security.Init.init(); + if (Security.getProvider("BC") == null) { + try { + Class cls = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider"); + Provider bc = (Provider) cls.getConstructor().newInstance(); + Security.insertProviderAt(bc, 2); + bcAddedForTheTest = true; + } catch (ReflectiveOperationException e) { + mlKemAvailable = false; + return; + } + } + try { + for (String alg : new String[]{"ML-KEM-512", "ML-KEM-768", "ML-KEM-1024"}) { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg, "BC"); + keyPairs.put(alg, kpg.generateKeyPair()); + } + // javax.crypto.KEM (JEP 452) is only available since Java 21 + Class.forName("javax.crypto.KEM"); + mlKemAvailable = true; + } catch (Exception | LinkageError e) { + mlKemAvailable = false; + } + } + + @AfterAll + static void cleanup() { + if (bcAddedForTheTest) { + Security.removeProvider("BC"); + } + } + + public StaxMLKEMEncryptionTest() throws Exception { + org.apache.xml.security.Init.init(); + xmlInputFactory = XMLInputFactory.newInstance(); + xmlInputFactory.setEventAllocator(new XMLSecEventAllocator()); + } + + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024" + }) + void testMLKEMEncryptDecrypt(String keyEncapsulationUri, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + XMLSecurityProperties properties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.ENCRYPTION); + properties.setActions(actions); + + KeyGenerator keygen = KeyGenerator.getInstance("AES"); + keygen.init(256); + SecretKey cek = keygen.generateKey(); + properties.setEncryptionKey(cek); + properties.setEncryptionSymAlgorithm("http://www.w3.org/2009/xmlenc11#aes256-gcm"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + properties.setEncryptionKeyTransportAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID); + properties.setEncryptionKeyEncapsulationAlgorithm(keyEncapsulationUri); + properties.setEncryptionDataEncapsulationAlgorithm(EncryptionConstants.ALGO_ID_KEYWRAP_AES256); + properties.setEncryptionTransportKey(kp.getPublic()); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), SecurePart.Modifier.Element); + properties.addEncryptionPart(securePart); + + byte[] output = process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", properties); + + // Verify the produced XML carries the spec's element names, per + // https://www.w3.org/TR/xmlsec-generic-hybrid/ section 6.1 "Key Transport Example" + String serialized = new String(output, StandardCharsets.UTF_8); + assertTrue(serialized.contains("GenericHybridCipherMethod"), "Missing GenericHybridCipherMethod element"); + assertTrue(serialized.contains("KeyEncapsulationMethod"), "Missing KeyEncapsulationMethod element"); + assertTrue(serialized.contains("DataEncapsulationMethod"), "Missing DataEncapsulationMethod element"); + assertTrue(serialized.contains("http://www.w3.org/2010/xmlsec-ghc#generic-hybrid"), + "Missing Generic Hybrid Cipher EncryptionMethod algorithm"); + + Document document; + try (InputStream is = new ByteArrayInputStream(output)) { + document = XMLUtils.read(is, false); + } + + NodeList nodeList = document.getElementsByTagNameNS("urn:example:po", "PaymentInfo"); + assertEquals(0, nodeList.getLength()); + + nodeList = document.getElementsByTagNameNS("urn:example:po", "CreditCard"); + assertEquals(0, nodeList.getLength()); + + nodeList = document.getElementsByTagNameNS( + XMLSecurityConstants.TAG_xenc_EncryptedData.getNamespaceURI(), + XMLSecurityConstants.TAG_xenc_EncryptedData.getLocalPart() + ); + assertEquals(1, nodeList.getLength()); + + Document decrypted = decryptUsingDOM(document, kp.getPrivate()); + + nodeList = decrypted.getElementsByTagNameNS("urn:example:po", "CreditCard"); + assertEquals(1, nodeList.getLength()); + } + + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024" + }) + void testMLKEMStaxEncryptStaxDecrypt(String keyEncapsulationUri, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + XMLSecurityProperties encryptProperties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.ENCRYPTION); + encryptProperties.setActions(actions); + + KeyGenerator keygen = KeyGenerator.getInstance("AES"); + keygen.init(256); + SecretKey cek = keygen.generateKey(); + encryptProperties.setEncryptionKey(cek); + encryptProperties.setEncryptionSymAlgorithm("http://www.w3.org/2009/xmlenc11#aes256-gcm"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + encryptProperties.setEncryptionKeyTransportAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID); + encryptProperties.setEncryptionKeyEncapsulationAlgorithm(keyEncapsulationUri); + encryptProperties.setEncryptionDataEncapsulationAlgorithm(EncryptionConstants.ALGO_ID_KEYWRAP_AES256); + encryptProperties.setEncryptionTransportKey(kp.getPublic()); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), SecurePart.Modifier.Element); + encryptProperties.addEncryptionPart(securePart); + + byte[] encrypted = process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", encryptProperties); + + XMLSecurityProperties decryptProperties = new XMLSecurityProperties(); + decryptProperties.setDecryptionKey(kp.getPrivate()); + InboundXMLSec inboundXMLSec = XMLSec.getInboundWSSec(decryptProperties); + XMLStreamReader xmlStreamReader = + xmlInputFactory.createXMLStreamReader(new ByteArrayInputStream(encrypted)); + XMLStreamReader securityStreamReader = inboundXMLSec.processInMessage(xmlStreamReader, null, null); + + Document decrypted = StAX2DOM.readDoc(securityStreamReader); + + NodeList nodeList = decrypted.getElementsByTagNameNS("urn:example:po", "CreditCard"); + assertEquals(1, nodeList.getLength()); + } + + private byte[] process(String inputXmlFile, XMLSecurityProperties properties) throws Exception { + OutboundXMLSec outboundXMLSec = XMLSec.getOutboundXMLSec(properties); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + XMLStreamWriter xmlStreamWriter = outboundXMLSec.processOutMessage(baos, StandardCharsets.UTF_8.name()); + try (InputStream sourceDocument = this.getClass().getClassLoader().getResourceAsStream(inputXmlFile)) { + XMLStreamReader xmlStreamReader = null; + try { + xmlStreamReader = xmlInputFactory.createXMLStreamReader(sourceDocument); + XmlReaderToWriter.writeAll(xmlStreamReader, xmlStreamWriter); + return baos.toByteArray(); + } finally { + if (xmlStreamReader != null) { + xmlStreamReader.close(); + } + } + } finally { + xmlStreamWriter.close(); + } + } + + private Document decryptUsingDOM(Document document, Key privateKey) throws Exception { + NodeList nodeList = document.getElementsByTagNameNS( + XMLSecurityConstants.TAG_xenc_EncryptedData.getNamespaceURI(), + XMLSecurityConstants.TAG_xenc_EncryptedData.getLocalPart() + ); + Element ee = (Element) nodeList.item(0); + + XMLCipher cipher = XMLCipher.getInstance(); + cipher.init(XMLCipher.DECRYPT_MODE, null); + EncryptedData encryptedData = cipher.loadEncryptedData(document, ee); + + XMLCipher kwCipher = XMLCipher.getInstance(); + kwCipher.init(XMLCipher.UNWRAP_MODE, privateKey); + KeyInfo ki = encryptedData.getKeyInfo(); + EncryptedKey encryptedKey = ki.itemEncryptedKey(0); + Key symmetricKey = kwCipher.decryptKey( + encryptedKey, encryptedData.getEncryptionMethod().getAlgorithm() + ); + + cipher.init(XMLCipher.DECRYPT_MODE, symmetricKey); + return cipher.doFinal(document, ee); + } + + @Test + void testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly() throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + KeyPair kp = keyPairs.get("ML-KEM-768"); + Document document = encryptToRecipient(kp.getPublic()); + + // A second, independent recipient - not the one the message was encrypted to. + KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-KEM-768", "BC"); + PrivateKey wrongPrivateKey = kpg.generateKeyPair().getPrivate(); + + // See the equivalent DOM-path test (XMLEncryptionMLKEMTest) for why this must throw + // rather than return a Key built from the wrong shared secret. + assertThrows(XMLEncryptionException.class, () -> decryptUsingDOM(document, wrongPrivateKey)); + } + + @Test + void testMLKEMStaxTruncatedEncapsulationRejected() throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + KeyPair kp = keyPairs.get("ML-KEM-768"); + Document document = encryptToRecipient(kp.getPublic()); + + // Truncate the EncryptedKey's CipherValue to well under half its length - shorter than + // any ML-KEM variant's encapsulationSize() - before it is parsed into an EncryptedKey + // object, mirroring XMLEncryptionMLKEMTest#testMLKEMTruncatedEncapsulationRejected. + NodeList encryptedKeyNodes = document.getElementsByTagNameNS( + XMLSecurityConstants.TAG_xenc_EncryptedKey.getNamespaceURI(), + XMLSecurityConstants.TAG_xenc_EncryptedKey.getLocalPart()); + Element encryptedKeyElem = (Element) encryptedKeyNodes.item(0); + Element cipherValueElem = (Element) encryptedKeyElem.getElementsByTagNameNS( + XMLSecurityConstants.TAG_xenc_EncryptedKey.getNamespaceURI(), "CipherValue").item(0); + byte[] combined = Base64.getMimeDecoder().decode(cipherValueElem.getTextContent()); + byte[] truncated = Arrays.copyOf(combined, combined.length / 2); + + NodeList children = cipherValueElem.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + cipherValueElem.removeChild(children.item(i)); + } + Text newText = document.createTextNode(Base64.getEncoder().encodeToString(truncated)); + cipherValueElem.appendChild(newText); + + assertThrows(XMLEncryptionException.class, () -> decryptUsingDOM(document, kp.getPrivate())); + } + + /** + * Runs the encrypt half of the ML-KEM-768 round trip (same properties as + * {@link #testMLKEMEncryptDecrypt}) and returns the parsed resulting document, for tests + * that want to corrupt or otherwise interfere with the decrypt half. + */ + private Document encryptToRecipient(java.security.PublicKey pubKey) throws Exception { + XMLSecurityProperties properties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.ENCRYPTION); + properties.setActions(actions); + + KeyGenerator keygen = KeyGenerator.getInstance("AES"); + keygen.init(256); + SecretKey cek = keygen.generateKey(); + properties.setEncryptionKey(cek); + properties.setEncryptionSymAlgorithm("http://www.w3.org/2009/xmlenc11#aes256-gcm"); + + properties.setEncryptionKeyTransportAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID); + properties.setEncryptionKeyEncapsulationAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768); + properties.setEncryptionDataEncapsulationAlgorithm(EncryptionConstants.ALGO_ID_KEYWRAP_AES256); + properties.setEncryptionTransportKey(pubKey); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), SecurePart.Modifier.Element); + properties.addEncryptionPart(securePart); + + byte[] output = process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", properties); + + try (InputStream is = new ByteArrayInputStream(output)) { + return XMLUtils.read(is, false); + } + } +} From df4925c8e59e349b346d163f6c2704730845ef79 Mon Sep 17 00:00:00 2001 From: arpansharma Date: Fri, 21 Aug 2026 12:55:18 -0500 Subject: [PATCH 2/4] Parameterize the negative encryption tests across all ML-KEM parameter sets Converts the wrong-recipient-key and truncated-encapsulation rejection tests on both the DOM and StAX paths from single hardcoded ML-KEM-768 cases to @ParameterizedTest/@CsvSource across ML-KEM-512/768/1024, matching the style of the existing encrypt-decrypt tests. The encryptToRecipient helpers take the key encapsulation algorithm as a parameter instead of hardcoding ML-KEM-768. --- .../encryption/XMLEncryptionMLKEMTest.java | 42 +++++++++++-------- .../encryption/StaxMLKEMEncryptionTest.java | 42 ++++++++++++------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java b/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java index 431bd29e4..73dbc2d6c 100644 --- a/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java +++ b/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java @@ -45,7 +45,6 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.w3c.dom.Document; @@ -220,15 +219,21 @@ void testMLKEMEncryptDecrypt(String keyEncapsulationUri, String jcaAlgorithm) th assertEquals("CardNumber:4019111111111111", decryptedRoot.getTextContent()); } - @Test - void testMLKEMWrongRecipientPrivateKeyFailsCleanly() throws Exception { + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024", + }) + void testMLKEMWrongRecipientPrivateKeyFailsCleanly(String keyEncapsulationUri, String jcaAlgorithm) + throws Exception { Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); - PublicKey recipientAPub = keyPairs.get("ML-KEM-768").getPublic(); - byte[] encryptedXml = encryptToRecipient(recipientAPub); + PublicKey recipientAPub = keyPairs.get(jcaAlgorithm).getPublic(); + byte[] encryptedXml = encryptToRecipient(recipientAPub, keyEncapsulationUri); // A second, independent recipient - not the one the message was encrypted to. - KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-KEM-768", "BC"); + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "BC"); PrivateKey wrongPrivateKey = kpg.generateKeyPair().getPrivate(); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); @@ -254,13 +259,18 @@ void testMLKEMWrongRecipientPrivateKeyFailsCleanly() throws Exception { () -> unwrapCipher.decryptKey(ek, encData.getEncryptionMethod().getAlgorithm())); } - @Test - void testMLKEMTruncatedEncapsulationRejected() throws Exception { + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024", + }) + void testMLKEMTruncatedEncapsulationRejected(String keyEncapsulationUri, String jcaAlgorithm) throws Exception { Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); - PrivateKey privKey = keyPairs.get("ML-KEM-768").getPrivate(); - PublicKey pubKey = keyPairs.get("ML-KEM-768").getPublic(); - byte[] encryptedXml = encryptToRecipient(pubKey); + PrivateKey privKey = keyPairs.get(jcaAlgorithm).getPrivate(); + PublicKey pubKey = keyPairs.get(jcaAlgorithm).getPublic(); + byte[] encryptedXml = encryptToRecipient(pubKey, keyEncapsulationUri); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(true); @@ -296,13 +306,11 @@ void testMLKEMTruncatedEncapsulationRejected() throws Exception { } /** - * Runs the encrypt half of the ML-KEM-768 round trip (same structure as - * {@link #testMLKEMEncryptDecrypt}) and returns the serialised encrypted XML, for tests that - * want to corrupt or otherwise interfere with the decrypt half. + * Runs the encrypt half of the round trip for the given key encapsulation algorithm (same + * structure as {@link #testMLKEMEncryptDecrypt}) and returns the serialised encrypted XML, + * for tests that want to corrupt or otherwise interfere with the decrypt half. */ - private byte[] encryptToRecipient(PublicKey pubKey) throws Exception { - String keyEncapsulationUri = EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768; - + private byte[] encryptToRecipient(PublicKey pubKey, String keyEncapsulationUri) throws Exception { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(true); Document doc = dbf.newDocumentBuilder().newDocument(); diff --git a/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java index 7a4d3c37a..f32d2526b 100644 --- a/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java +++ b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java @@ -61,7 +61,6 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.w3c.dom.Document; @@ -278,15 +277,21 @@ private Document decryptUsingDOM(Document document, Key privateKey) throws Excep return cipher.doFinal(document, ee); } - @Test - void testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly() throws Exception { + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024" + }) + void testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly(String keyEncapsulationUri, String jcaAlgorithm) + throws Exception { Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); - KeyPair kp = keyPairs.get("ML-KEM-768"); - Document document = encryptToRecipient(kp.getPublic()); + KeyPair kp = keyPairs.get(jcaAlgorithm); + Document document = encryptToRecipient(kp.getPublic(), keyEncapsulationUri); // A second, independent recipient - not the one the message was encrypted to. - KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-KEM-768", "BC"); + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "BC"); PrivateKey wrongPrivateKey = kpg.generateKeyPair().getPrivate(); // See the equivalent DOM-path test (XMLEncryptionMLKEMTest) for why this must throw @@ -294,12 +299,18 @@ void testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly() throws Exception { assertThrows(XMLEncryptionException.class, () -> decryptUsingDOM(document, wrongPrivateKey)); } - @Test - void testMLKEMStaxTruncatedEncapsulationRejected() throws Exception { + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024" + }) + void testMLKEMStaxTruncatedEncapsulationRejected(String keyEncapsulationUri, String jcaAlgorithm) + throws Exception { Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); - KeyPair kp = keyPairs.get("ML-KEM-768"); - Document document = encryptToRecipient(kp.getPublic()); + KeyPair kp = keyPairs.get(jcaAlgorithm); + Document document = encryptToRecipient(kp.getPublic(), keyEncapsulationUri); // Truncate the EncryptedKey's CipherValue to well under half its length - shorter than // any ML-KEM variant's encapsulationSize() - before it is parsed into an EncryptedKey @@ -324,11 +335,12 @@ void testMLKEMStaxTruncatedEncapsulationRejected() throws Exception { } /** - * Runs the encrypt half of the ML-KEM-768 round trip (same properties as - * {@link #testMLKEMEncryptDecrypt}) and returns the parsed resulting document, for tests - * that want to corrupt or otherwise interfere with the decrypt half. + * Runs the encrypt half of the round trip for the given key encapsulation algorithm (same + * properties as {@link #testMLKEMEncryptDecrypt}) and returns the parsed resulting document, + * for tests that want to corrupt or otherwise interfere with the decrypt half. */ - private Document encryptToRecipient(java.security.PublicKey pubKey) throws Exception { + private Document encryptToRecipient(java.security.PublicKey pubKey, String keyEncapsulationUri) + throws Exception { XMLSecurityProperties properties = new XMLSecurityProperties(); List actions = new ArrayList<>(); actions.add(XMLSecurityConstants.ENCRYPTION); @@ -341,7 +353,7 @@ private Document encryptToRecipient(java.security.PublicKey pubKey) throws Excep properties.setEncryptionSymAlgorithm("http://www.w3.org/2009/xmlenc11#aes256-gcm"); properties.setEncryptionKeyTransportAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_GENERIC_HYBRID); - properties.setEncryptionKeyEncapsulationAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768); + properties.setEncryptionKeyEncapsulationAlgorithm(keyEncapsulationUri); properties.setEncryptionDataEncapsulationAlgorithm(EncryptionConstants.ALGO_ID_KEYWRAP_AES256); properties.setEncryptionTransportKey(pubKey); From 9fc4af25e4078c1fdfe09f249b616ecabbadcdfb Mon Sep 17 00:00:00 2001 From: arpansharma Date: Fri, 21 Aug 2026 18:46:45 -0500 Subject: [PATCH 3/4] Add StAX inbound negative tests for ML-KEM decryption The existing ML-KEM StAX negative tests (wrong recipient key, truncated encapsulation) decrypt through the DOM XMLCipher helper, so the StAX inbound handler's Generic Hybrid Cipher failure path was only covered on the happy path. Add two parameterized tests across ML-KEM-512/768/1024 that drive the same two rejections through InboundXMLSec#processInMessage. Because ML-KEM implicit rejection and the inbound handler's random-CEK timing mitigation defer the failure to the AES-256-GCM tag check, both surface to the caller as an XMLStreamException; the tests assert the inbound path rejects the message rather than yielding plaintext. --- .../encryption/StaxMLKEMEncryptionTest.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java index f32d2526b..e1a4e228d 100644 --- a/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java +++ b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java @@ -39,6 +39,7 @@ import javax.crypto.SecretKey; import javax.xml.namespace.QName; import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; import javax.xml.stream.XMLStreamWriter; @@ -334,6 +335,94 @@ void testMLKEMStaxTruncatedEncapsulationRejected(String keyEncapsulationUri, Str assertThrows(XMLEncryptionException.class, () -> decryptUsingDOM(document, kp.getPrivate())); } + /** + * Wrong-recipient rejection driven through the real StAX inbound path + * ({@link InboundXMLSec#processInMessage}), not the DOM {@code XMLCipher} helper used by + * {@link #testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly}. This exercises + * {@code XMLEncryptedKeyInputHandler}'s Generic Hybrid Cipher branch, which is otherwise only + * covered on the happy path. ML-KEM's implicit rejection means decapsulation with the wrong + * private key does not fail; the handler derives a wrong key-wrap key and substitutes a random + * CEK (timing mitigation), so rejection surfaces late at the AES-256-GCM tag check and reaches + * the caller as an {@link XMLStreamException}. The property under test is that the inbound path + * rejects the message rather than yielding plaintext. + */ + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024" + }) + void testMLKEMStaxInboundWrongRecipientKeyRejected(String keyEncapsulationUri, String jcaAlgorithm) + throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + Document document = encryptToRecipient(kp.getPublic(), keyEncapsulationUri); + + // A second, independent recipient - not the one the message was encrypted to. + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "BC"); + PrivateKey wrongPrivateKey = kpg.generateKeyPair().getPrivate(); + + assertThrows(XMLStreamException.class, () -> decryptUsingStax(document, wrongPrivateKey)); + } + + /** + * Truncated-encapsulation rejection driven through the StAX inbound path, the streaming + * counterpart of {@link #testMLKEMStaxTruncatedEncapsulationRejected} (which uses the DOM + * helper). A {@code CipherValue} shorter than any ML-KEM variant's {@code encapsulationSize()} + * fails the length check in {@code KeyUtils#kemDecapsulate}; the inbound handler catches that + * and substitutes a random CEK, so here too rejection surfaces at the GCM tag check as an + * {@link XMLStreamException}. The message must be rejected, not decrypted. + */ + @ParameterizedTest + @CsvSource({ + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_512 + ",ML-KEM-512", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768 + ",ML-KEM-768", + EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_1024 + ",ML-KEM-1024" + }) + void testMLKEMStaxInboundTruncatedEncapsulationRejected(String keyEncapsulationUri, String jcaAlgorithm) + throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + Document document = encryptToRecipient(kp.getPublic(), keyEncapsulationUri); + + Element encryptedKeyElem = (Element) document.getElementsByTagNameNS( + XMLSecurityConstants.TAG_xenc_EncryptedKey.getNamespaceURI(), + XMLSecurityConstants.TAG_xenc_EncryptedKey.getLocalPart()).item(0); + Element cipherValueElem = (Element) encryptedKeyElem.getElementsByTagNameNS( + XMLSecurityConstants.TAG_xenc_EncryptedKey.getNamespaceURI(), "CipherValue").item(0); + byte[] combined = Base64.getMimeDecoder().decode(cipherValueElem.getTextContent()); + byte[] truncated = Arrays.copyOf(combined, combined.length / 2); + NodeList children = cipherValueElem.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + cipherValueElem.removeChild(children.item(i)); + } + cipherValueElem.appendChild(document.createTextNode(Base64.getEncoder().encodeToString(truncated))); + + assertThrows(XMLStreamException.class, () -> decryptUsingStax(document, kp.getPrivate())); + } + + /** + * Decrypts a document through the real StAX inbound path ({@link InboundXMLSec#processInMessage} + * + {@link StAX2DOM#readDoc}), the counterpart of {@link #decryptUsingDOM} for the tests that + * need to exercise the inbound {@code XMLEncryptedKeyInputHandler} rather than the DOM cipher. + */ + private Document decryptUsingStax(Document document, PrivateKey key) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + javax.xml.transform.TransformerFactory.newInstance().newTransformer().transform( + new javax.xml.transform.dom.DOMSource(document), + new javax.xml.transform.stream.StreamResult(bos)); + + XMLSecurityProperties decryptProperties = new XMLSecurityProperties(); + decryptProperties.setDecryptionKey(key); + InboundXMLSec inboundXMLSec = XMLSec.getInboundWSSec(decryptProperties); + XMLStreamReader xmlStreamReader = + xmlInputFactory.createXMLStreamReader(new ByteArrayInputStream(bos.toByteArray())); + XMLStreamReader securityStreamReader = inboundXMLSec.processInMessage(xmlStreamReader, null, null); + return StAX2DOM.readDoc(securityStreamReader); + } + /** * Runs the encrypt half of the round trip for the given key encapsulation algorithm (same * properties as {@link #testMLKEMEncryptDecrypt}) and returns the parsed resulting document, From d6aabe3838731056e50193a4dec80aca720a4868 Mon Sep 17 00:00:00 2001 From: arpansharma Date: Sat, 22 Aug 2026 13:19:18 -0500 Subject: [PATCH 4/4] Reject malformed ML-KEM key-transport metadata with XMLEncryptionException Parsing the EncryptedKey's key-transport metadata could escape the declared XMLEncryptionException with an unchecked exception before any private-key operation: a childless or non-numeric ghc:KeyLen threw NullPointerException or NumberFormatException from an unguarded Integer.parseInt, malformed base64 in the HKDF Salt or Info threw IllegalArgumentException from Base64.Decoder, and a KeyDerivationMethod that failed to parse was rethrown wrapped in a RuntimeException. Guard the KeyLen parse and the base64 decode, report all three through XMLEncryptionException (reusing the KeyDerivation.InvalidParameter message), and let newEncryptionMethod(Element) declare the checked exception instead of wrapping it; both of its callers already declare XMLEncryptionException. Adds DOM negative tests for the reproduced cases; each fails against the previous code. --- .../xml/security/encryption/XMLCipher.java | 26 +++++- .../security/encryption/XMLCipherUtil.java | 21 ++++- .../encryption/XMLEncryptionMLKEMTest.java | 93 ++++++++++++++++++- 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/apache/xml/security/encryption/XMLCipher.java b/src/main/java/org/apache/xml/security/encryption/XMLCipher.java index ca91219e5..938af962b 100644 --- a/src/main/java/org/apache/xml/security/encryption/XMLCipher.java +++ b/src/main/java/org/apache/xml/security/encryption/XMLCipher.java @@ -2607,7 +2607,7 @@ KeyInfo newKeyInfo(Element element) throws XMLEncryptionException { * @param element * @return a new EncryptionMethod */ - EncryptionMethod newEncryptionMethod(Element element) { + EncryptionMethod newEncryptionMethod(Element element) throws XMLEncryptionException { String encAlgorithm = element.getAttributeNS(null, EncryptionConstants._ATT_ALGORITHM); EncryptionMethod result = newEncryptionMethod(encAlgorithm); @@ -2668,7 +2668,7 @@ EncryptionMethod newEncryptionMethod(Element element) { result.setKeyEncapsulationKeyDerivationMethod( new KeyDerivationMethodImpl(keyDerivationMethodElement, null)); } catch (XMLSecurityException xse) { - throw new RuntimeException(xse); + throw new XMLEncryptionException(xse); } } @@ -2678,7 +2678,7 @@ EncryptionMethod newEncryptionMethod(Element element) { EncryptionConstants._TAG_KEYLEN).item(0); if (keyLenElement != null) { result.setKeyEncapsulationKeyLength( - Integer.parseInt(keyLenElement.getFirstChild().getNodeValue())); + parseKeyEncapsulationKeyLength(keyLenElement)); } } @@ -2698,6 +2698,26 @@ EncryptionMethod newEncryptionMethod(Element element) { return result; } + /** + * Parses the {@code ghc:KeyLen} element content. The element is read from the + * (untrusted) message, so a missing, empty or non-numeric value is reported as an + * {@link XMLEncryptionException}, the exception type the decrypt API declares, rather + * than escaping as a {@code NullPointerException} or {@code NumberFormatException}. + */ + private int parseKeyEncapsulationKeyLength(Element keyLenElement) throws XMLEncryptionException { + Node child = keyLenElement.getFirstChild(); + String text = child == null ? null : child.getNodeValue(); + if (text == null || text.trim().isEmpty()) { + throw new XMLEncryptionException("KeyDerivation.InvalidParameter", EncryptionConstants._TAG_KEYLEN); + } + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + throw new XMLEncryptionException(e, "KeyDerivation.InvalidParameter", + new Object[]{EncryptionConstants._TAG_KEYLEN}); + } + } + /** * @param element * @return a new EncryptionProperties diff --git a/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java b/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java index 31d95fb51..747911d64 100644 --- a/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java +++ b/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java @@ -28,6 +28,7 @@ import org.apache.xml.security.encryption.params.KeyAgreementParameters; import org.apache.xml.security.encryption.params.KeyDerivationParameters; import org.apache.xml.security.exceptions.XMLSecurityException; +import org.apache.xml.security.utils.Constants; import org.apache.xml.security.utils.EncryptionConstants; import org.apache.xml.security.utils.KeyUtils; import org.w3c.dom.Document; @@ -279,13 +280,29 @@ public static KeyDerivationParameters constructKeyDerivationParameter(KeyDerivat } HKDFParamsImpl hKDFParams = (HKDFParamsImpl) kdfParams; return HKDFParams.createBuilder(keyBitLength, hKDFParams.getPRFAlgorithm()) - .salt(hKDFParams.getSalt() != null ? Base64.getDecoder().decode(hKDFParams.getSalt()) : null) - .info(hKDFParams.getInfo() != null ? Base64.getDecoder().decode(hKDFParams.getInfo()) : null) + .salt(decodeBase64Parameter(hKDFParams.getSalt(), Constants._TAG_SALT)) + .info(decodeBase64Parameter(hKDFParams.getInfo(), EncryptionConstants._TAG_INFO)) .build(); } throw new XMLEncryptionException("unknownAlgorithm", keyDerivationAlgorithm); } + /** + * Base64-decodes an optional key derivation parameter read from the message. Malformed + * base64 is reported as an {@link XMLEncryptionException} rather than escaping as the + * {@link IllegalArgumentException} thrown by {@link Base64.Decoder#decode(String)}. + */ + private static byte[] decodeBase64Parameter(String value, String parameterName) throws XMLEncryptionException { + if (value == null) { + return null; + } + try { + return Base64.getDecoder().decode(value); + } catch (IllegalArgumentException e) { + throw new XMLEncryptionException(e, "KeyDerivation.InvalidParameter", new Object[]{parameterName}); + } + } + /** * Construct a {@code KeyDerivationMethod} DOM element from the given {@link KeyDerivationParameters}. * The inverse of {@link #constructKeyDerivationParameter(KeyDerivationMethod, int)}. Supports the same diff --git a/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java b/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java index 73dbc2d6c..3caf2ac3e 100644 --- a/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java +++ b/src/test/java/org/apache/xml/security/test/dom/encryption/XMLEncryptionMLKEMTest.java @@ -45,6 +45,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.w3c.dom.Document; @@ -53,6 +54,7 @@ import org.w3c.dom.Text; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -79,6 +81,9 @@ class XMLEncryptionMLKEMTest { private static boolean mlKemAvailable; private static boolean bcAddedForTheTest; + /** Namespace of the HKDFParams / Salt / Info elements (xmldsig-more, 2021). */ + private static final String XMLDSIG_MORE_NS = "http://www.w3.org/2021/04/xmldsig-more#"; + private static java.util.Map keyPairs = new java.util.HashMap<>(); @BeforeAll @@ -311,6 +316,13 @@ void testMLKEMTruncatedEncapsulationRejected(String keyEncapsulationUri, String * for tests that want to corrupt or otherwise interfere with the decrypt half. */ private byte[] encryptToRecipient(PublicKey pubKey, String keyEncapsulationUri) throws Exception { + int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(EncryptionConstants.ALGO_ID_KEYWRAP_AES256); + HKDFParams kdfParams = HKDFParams.createBuilder(wrapKeyBitLength, XMLSignature.ALGO_ID_MAC_HMAC_SHA256).build(); + return encryptToRecipient(pubKey, keyEncapsulationUri, kdfParams); + } + + private byte[] encryptToRecipient(PublicKey pubKey, String keyEncapsulationUri, HKDFParams kdfParams) + throws Exception { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(true); Document doc = dbf.newDocumentBuilder().newDocument(); @@ -323,8 +335,6 @@ private byte[] encryptToRecipient(PublicKey pubKey, String keyEncapsulationUri) SecretKey cek = kg.generateKey(); String kwAlgorithm = EncryptionConstants.ALGO_ID_KEYWRAP_AES256; - int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(kwAlgorithm); - HKDFParams kdfParams = HKDFParams.createBuilder(wrapKeyBitLength, XMLSignature.ALGO_ID_MAC_HMAC_SHA256).build(); AlgorithmParameterSpec keyEncapsulationParameters = new KeyEncapsulationParameters(keyEncapsulationUri, kdfParams); @@ -350,6 +360,85 @@ private byte[] encryptToRecipient(PublicKey pubKey, String keyEncapsulationUri) return bos.toByteArray(); } + + /** + * Malformed key-transport metadata in the EncryptedKey must be rejected as + * {@link XMLEncryptionException}, the decrypt API's declared failure type, rather than + * escaping as a NumberFormatException (non-numeric or empty {@code ghc:KeyLen}) or an + * IllegalArgumentException (malformed base64 in the HKDF {@code Salt} or {@code Info}). + * All of these values are parsed from the untrusted message before any private-key operation. + */ + @ParameterizedTest + @CsvSource({ + EncryptionConstants.EncryptionSpecGHCNS + ",KeyLen,notanumber", + EncryptionConstants.EncryptionSpecGHCNS + ",KeyLen,''", + XMLDSIG_MORE_NS + ",Salt,!!!not-base64!!!", + XMLDSIG_MORE_NS + ",Info,@@@@" + }) + void testMLKEMMalformedKeyTransportMetadataRejected(String namespace, String localName, String badText) + throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + KeyPair kp = keyPairs.get("ML-KEM-768"); + Document encDoc = parse(encryptToRecipientWithHkdfSaltAndInfo(kp.getPublic())); + Element target = (Element) encDoc.getElementsByTagNameNS(namespace, localName).item(0); + assertNotNull(target, "expected a <" + localName + "> element to mutate"); + replaceTextContent(encDoc, target, badText); + + assertThrows(XMLEncryptionException.class, () -> decryptDocument(encDoc, kp.getPrivate())); + } + + /** + * A childless {@code } has no text node at all; reading it must not surface as + * a NullPointerException from the decrypt path. + */ + @Test + void testMLKEMChildlessKeyLenRejected() throws Exception { + Assumptions.assumeTrue(mlKemAvailable, "ML-KEM requires BouncyCastle 1.84+ and Java 21+ (javax.crypto.KEM)"); + + KeyPair kp = keyPairs.get("ML-KEM-768"); + Document encDoc = parse(encryptToRecipient(kp.getPublic(), EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768)); + Element keyLen = (Element) encDoc.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecGHCNS, "KeyLen").item(0); + assertNotNull(keyLen, "expected a element to mutate"); + while (keyLen.hasChildNodes()) { + keyLen.removeChild(keyLen.getFirstChild()); + } + + assertThrows(XMLEncryptionException.class, () -> decryptDocument(encDoc, kp.getPrivate())); + } + + /** Encrypts with an HKDF that carries explicit Salt and Info elements, so they exist to mutate. */ + private byte[] encryptToRecipientWithHkdfSaltAndInfo(PublicKey pubKey) throws Exception { + int wrapKeyBitLength = KeyUtils.getAESKeyBitSizeForWrapAlgorithm(EncryptionConstants.ALGO_ID_KEYWRAP_AES256); + HKDFParams kdfParams = HKDFParams.createBuilder(wrapKeyBitLength, XMLSignature.ALGO_ID_MAC_HMAC_SHA256) + .salt(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}) + .info(new byte[]{9, 10, 11, 12}) + .build(); + return encryptToRecipient(pubKey, EncryptionConstants.ALGO_ID_KEYTRANSPORT_MLKEM_768, kdfParams); + } + + private Document parse(byte[] xml) throws Exception { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml)); + } + + /** The full decrypt path: load the EncryptedData, unwrap the CEK with the ML-KEM private key, decrypt. */ + private void decryptDocument(Document encDoc, PrivateKey privKey) throws Exception { + Element encDataElem = (Element) encDoc.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecNS, "EncryptedData").item(0); + XMLCipher decryptCipher = XMLCipher.getInstance(); + decryptCipher.init(XMLCipher.DECRYPT_MODE, null); + EncryptedData encData = decryptCipher.loadEncryptedData(encDoc, encDataElem); + EncryptedKey ek = encData.getKeyInfo().itemEncryptedKey(0); + XMLCipher unwrapCipher = XMLCipher.getInstance(); + unwrapCipher.init(XMLCipher.UNWRAP_MODE, privKey); + Key cek = unwrapCipher.decryptKey(ek, encData.getEncryptionMethod().getAlgorithm()); + decryptCipher.init(XMLCipher.DECRYPT_MODE, cek); + decryptCipher.doFinal(encDoc, encDataElem); + } + private void replaceTextContent(Document doc, Element element, String newText) { NodeList children = element.getChildNodes(); for (int i = children.getLength() - 1; i >= 0; i--) {