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..938af962b 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: \"" @@ -2475,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); @@ -2514,12 +2646,78 @@ 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 XMLEncryptionException(xse); + } + } + + Element keyLenElement = + (Element) keyEncapsulationMethodElement.getElementsByTagNameNS( + EncryptionConstants.EncryptionSpecGHCNS, + EncryptionConstants._TAG_KEYLEN).item(0); + if (keyLenElement != null) { + result.setKeyEncapsulationKeyLength( + parseKeyEncapsulationKeyLength(keyLenElement)); + } + } + + 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 // 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 @@ -3120,6 +3318,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 +3393,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 +3505,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..747911d64 100644 --- a/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java +++ b/src/main/java/org/apache/xml/security/encryption/XMLCipherUtil.java @@ -22,13 +22,16 @@ 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; 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; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.IvParameterSpec; @@ -277,13 +280,77 @@ 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 + * 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; + + /** 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 + 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()); + } + + @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(jcaAlgorithm).getPublic(); + byte[] encryptedXml = encryptToRecipient(recipientAPub, keyEncapsulationUri); + + // A second, independent recipient - not the one the message was encrypted to. + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "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())); + } + + @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(jcaAlgorithm).getPrivate(); + PublicKey pubKey = keyPairs.get(jcaAlgorithm).getPublic(); + byte[] encryptedXml = encryptToRecipient(pubKey, keyEncapsulationUri); + + 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 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, 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(); + 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; + 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(); + } + + + /** + * 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--) { + 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..e1a4e228d --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/encryption/StaxMLKEMEncryptionTest.java @@ -0,0 +1,459 @@ +/** + * 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.XMLStreamException; +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.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); + } + + @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(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(); + + // 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)); + } + + @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(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 + // 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())); + } + + /** + * 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, + * for tests that want to corrupt or otherwise interfere with the decrypt half. + */ + private Document encryptToRecipient(java.security.PublicKey pubKey, String keyEncapsulationUri) + 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(keyEncapsulationUri); + 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); + } + } +}