From 94febc33dfd2e78d371deb4c355b6387f9fac764 Mon Sep 17 00:00:00 2001 From: Paul Schaub Date: Wed, 30 Apr 2025 15:57:54 +0200 Subject: [PATCH] Expose encryption mechanism during decryption --- .../MessageMetadata.kt | 23 +++++- .../OpenPgpMessageInputStream.kt | 82 ++++++++++++++----- .../MessageMetadataTest.java | 5 +- .../EncryptDecryptTest.java | 81 ++++++++++++++++++ 4 files changed, 167 insertions(+), 24 deletions(-) diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/MessageMetadata.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/MessageMetadata.kt index 3a62bc72..d36159ee 100644 --- a/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/MessageMetadata.kt +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/MessageMetadata.kt @@ -9,6 +9,7 @@ import javax.annotation.Nonnull import org.bouncycastle.bcpg.KeyIdentifier import org.bouncycastle.openpgp.PGPKeyRing import org.bouncycastle.openpgp.PGPLiteralData +import org.bouncycastle.openpgp.api.MessageEncryptionMechanism import org.bouncycastle.openpgp.api.OpenPGPCertificate import org.pgpainless.algorithm.CompressionAlgorithm import org.pgpainless.algorithm.StreamEncoding @@ -32,22 +33,34 @@ class MessageMetadata(val message: Message) { * The [SymmetricKeyAlgorithm] of the outermost encrypted data packet, or null if message is * unencrypted. */ + @Deprecated("Deprecated in favor of encryptionMechanism", + replaceWith = ReplaceWith("encryptionMechanism") + ) val encryptionAlgorithm: SymmetricKeyAlgorithm? get() = encryptionAlgorithms.let { if (it.hasNext()) it.next() else null } + val encryptionMechanism: MessageEncryptionMechanism? + get() = encryptionMechanisms.let { if (it.hasNext()) it.next() else null } + /** * [Iterator] of each [SymmetricKeyAlgorithm] encountered in the message. The first item * returned by the iterator is the algorithm of the outermost encrypted data packet, the next * item that of the next nested encrypted data packet and so on. The iterator might also be * empty, in case of an unencrypted message. */ + @Deprecated("Deprecated in favor of encryptionMechanisms", + replaceWith = ReplaceWith("encryptionMechanisms") + ) val encryptionAlgorithms: Iterator get() = encryptionLayers.asSequence().map { it.algorithm }.iterator() + val encryptionMechanisms: Iterator + get() = encryptionLayers.asSequence().map { it.mechanism }.iterator() + val isEncrypted: Boolean get() = - if (encryptionAlgorithm == null) false - else encryptionAlgorithm != SymmetricKeyAlgorithm.NULL + if (encryptionMechanism == null) false + else encryptionMechanism!!.symmetricKeyAlgorithm != SymmetricKeyAlgorithm.NULL.algorithmId fun isEncryptedFor(cert: OpenPGPCertificate): Boolean { return encryptionLayers.asSequence().any { @@ -472,7 +485,8 @@ class MessageMetadata(val message: Message) { * @param algorithm symmetric key algorithm used to encrypt the packet. * @param depth nesting depth at which this packet was encountered. */ - class EncryptedData(val algorithm: SymmetricKeyAlgorithm, depth: Int) : Layer(depth), Nested { + class EncryptedData(val mechanism: MessageEncryptionMechanism, depth: Int) : + Layer(depth), Nested { /** [SessionKey] used to decrypt the packet. */ var sessionKey: SessionKey? = null @@ -480,6 +494,9 @@ class MessageMetadata(val message: Message) { /** List of all recipient key ids to which the packet was encrypted for. */ val recipients: List = mutableListOf() + val algorithm: SymmetricKeyAlgorithm = + SymmetricKeyAlgorithm.requireFromId(mechanism.symmetricKeyAlgorithm) + fun addRecipients(keyIds: List) = apply { (recipients as MutableList).addAll(keyIds) } diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/OpenPgpMessageInputStream.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/OpenPgpMessageInputStream.kt index 01456849..a7f1cb22 100644 --- a/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/OpenPgpMessageInputStream.kt +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/decryption_verification/OpenPgpMessageInputStream.kt @@ -11,9 +11,11 @@ import java.io.OutputStream import java.util.zip.Inflater import java.util.zip.InflaterInputStream import openpgp.openPgpKeyId +import org.bouncycastle.bcpg.AEADEncDataPacket import org.bouncycastle.bcpg.BCPGInputStream import org.bouncycastle.bcpg.CompressionAlgorithmTags import org.bouncycastle.bcpg.KeyIdentifier +import org.bouncycastle.bcpg.SymmetricEncIntegrityPacket import org.bouncycastle.bcpg.UnsupportedPacketVersionException import org.bouncycastle.openpgp.PGPCompressedData import org.bouncycastle.openpgp.PGPEncryptedData @@ -27,6 +29,8 @@ import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData import org.bouncycastle.openpgp.PGPSessionKey import org.bouncycastle.openpgp.PGPSignature import org.bouncycastle.openpgp.PGPSignatureException +import org.bouncycastle.openpgp.api.EncryptedDataPacketType +import org.bouncycastle.openpgp.api.MessageEncryptionMechanism import org.bouncycastle.openpgp.api.OpenPGPCertificate import org.bouncycastle.openpgp.api.OpenPGPKey import org.bouncycastle.openpgp.api.OpenPGPKey.OpenPGPPrivateKey @@ -318,20 +322,38 @@ class OpenPgpMessageInputStream( } private fun processEncryptedData(): Boolean { - LOGGER.debug( - "Symmetrically Encrypted Data Packet at depth ${layerMetadata.depth} encountered.") + // TODO: Replace by dedicated encryption packet type input symbols syntaxVerifier.next(InputSymbol.ENCRYPTED_DATA) + val encDataList = packetInputStream!!.readEncryptedDataList() - if (!encDataList.isIntegrityProtected && !encDataList.get(0).isAEAD) { - LOGGER.warn("Symmetrically Encrypted Data Packet is not integrity-protected.") - if (!options.isIgnoreMDCErrors()) { - throw MessageNotIntegrityProtectedException() + val esks = ESKsAndData(encDataList) + + when (EncryptedDataPacketType.of(encDataList)!!) { + EncryptedDataPacketType.SEIPDv2 -> + LOGGER.debug( + "Symmetrically Encrypted Integrity Protected Data Packet version 2 at depth " + + "${layerMetadata.depth} encountered.") + EncryptedDataPacketType.SEIPDv1 -> + LOGGER.debug( + "Symmetrically Encrypted Integrity Protected Data Packet version 1 at depth " + + "${layerMetadata.depth} encountered.") + EncryptedDataPacketType.LIBREPGP_OED -> + LOGGER.debug( + "LibrePGP OCB-Encrypted Data Packet at depth " + + "${layerMetadata.depth} encountered.") + EncryptedDataPacketType.SED -> { + LOGGER.debug( + "(Deprecated) Symmetrically Encrypted Data Packet at depth " + + "${layerMetadata.depth} encountered.") + LOGGER.warn("Symmetrically Encrypted Data Packet is not integrity-protected.") + if (!options.isIgnoreMDCErrors()) { + throw MessageNotIntegrityProtectedException() + } } } - val esks = SortedESKs(encDataList) LOGGER.debug( - "Symmetrically Encrypted Integrity-Protected Data has ${esks.skesks.size} SKESK(s) and" + + "Encrypted Data has ${esks.skesks.size} SKESK(s) and" + " ${esks.pkesks.size + esks.anonPkesks.size} PKESK(s) from which ${esks.anonPkesks.size} PKESK(s)" + " have an anonymous recipient.") @@ -359,7 +381,7 @@ class OpenPgpMessageInputStream( val pgpSk = PGPSessionKey(sk.algorithm.algorithmId, sk.key) val decryptorFactory = api.implementation.sessionKeyDataDecryptorFactory(pgpSk) - val layer = EncryptedData(sk.algorithm, layerMetadata.depth + 1) + val layer = esks.toEncryptedData(sk, layerMetadata.depth + 1) val skEncData = encDataList.extractSessionKeyEncryptedData() try { val decrypted = skEncData.getDataStream(decryptorFactory) @@ -506,7 +528,7 @@ class OpenPgpMessageInputStream( } private fun decryptWithPrivateKey( - esks: SortedESKs, + esks: ESKsAndData, privateKey: PGPKeyPair, decryptionKeyId: SubkeyIdentifier, pkesk: PGPPublicKeyEncryptedData @@ -529,7 +551,7 @@ class OpenPgpMessageInputStream( } private fun decryptSKESKAndStream( - esks: SortedESKs, + esks: ESKsAndData, skesk: PGPPBEEncryptedData, decryptorFactory: PBEDataDecryptorFactory ): Boolean { @@ -537,7 +559,7 @@ class OpenPgpMessageInputStream( val decrypted = skesk.getDataStream(decryptorFactory) val sessionKey = SessionKey(skesk.getSessionKey(decryptorFactory)) throwIfUnacceptable(sessionKey.algorithm) - val encryptedData = EncryptedData(sessionKey.algorithm, layerMetadata.depth + 1) + val encryptedData = esks.toEncryptedData(sessionKey, layerMetadata.depth + 1) encryptedData.sessionKey = sessionKey encryptedData.addRecipients(esks.pkesks.map { it.keyIdentifier }) LOGGER.debug("Successfully decrypted data with passphrase") @@ -555,7 +577,7 @@ class OpenPgpMessageInputStream( } private fun decryptPKESKAndStream( - esks: SortedESKs, + esks: ESKsAndData, decryptionKeyId: SubkeyIdentifier, decryptorFactory: PublicKeyDataDecryptorFactory, pkesk: PGPPublicKeyEncryptedData @@ -565,11 +587,7 @@ class OpenPgpMessageInputStream( val sessionKey = SessionKey(pkesk.getSessionKey(decryptorFactory)) throwIfUnacceptable(sessionKey.algorithm) - val encryptedData = - EncryptedData( - SymmetricKeyAlgorithm.requireFromId( - pkesk.getSymmetricAlgorithm(decryptorFactory)), - layerMetadata.depth + 1) + val encryptedData = esks.toEncryptedData(sessionKey, layerMetadata.depth) encryptedData.decryptionKey = decryptionKeyId encryptedData.sessionKey = sessionKey encryptedData.addRecipients(esks.pkesks.plus(esks.anonPkesks).map { it.keyIdentifier }) @@ -730,7 +748,32 @@ class OpenPgpMessageInputStream( } } - private class SortedESKs(esks: PGPEncryptedDataList) { + private class ESKsAndData(private val esks: PGPEncryptedDataList) { + fun toEncryptedData(sk: SessionKey, depth: Int): EncryptedData { + return when (EncryptedDataPacketType.of(esks)!!) { + EncryptedDataPacketType.SED -> + EncryptedData( + MessageEncryptionMechanism.legacyEncryptedNonIntegrityProtected( + sk.algorithm.algorithmId), + depth) + EncryptedDataPacketType.SEIPDv1 -> + EncryptedData( + MessageEncryptionMechanism.integrityProtected(sk.algorithm.algorithmId), + depth) + EncryptedDataPacketType.SEIPDv2 -> { + val seipd2 = esks.encryptedData as SymmetricEncIntegrityPacket + EncryptedData( + MessageEncryptionMechanism.aead( + seipd2.cipherAlgorithm, seipd2.aeadAlgorithm), + depth) + } + EncryptedDataPacketType.LIBREPGP_OED -> { + val oed = esks.encryptedData as AEADEncDataPacket + EncryptedData(MessageEncryptionMechanism.librePgp(oed.algorithm.toInt()), depth) + } + }.also { it.sessionKey = sk } + } + val skesks: List val pkesks: List val anonPkesks: List @@ -739,6 +782,7 @@ class OpenPgpMessageInputStream( skesks = mutableListOf() pkesks = mutableListOf() anonPkesks = mutableListOf() + for (esk in esks) { if (esk is PGPPBEEncryptedData) { skesks.add(esk) diff --git a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MessageMetadataTest.java b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MessageMetadataTest.java index 7c443829..dfbbe7b6 100644 --- a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MessageMetadataTest.java +++ b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MessageMetadataTest.java @@ -4,6 +4,7 @@ package org.pgpainless.decryption_verification; +import org.bouncycastle.openpgp.api.MessageEncryptionMechanism; import org.junit.JUtils; import org.junit.jupiter.api.Test; import org.pgpainless.algorithm.CompressionAlgorithm; @@ -29,8 +30,8 @@ public class MessageMetadataTest { MessageMetadata.Message message = new MessageMetadata.Message(); MessageMetadata.CompressedData compressedData = new MessageMetadata.CompressedData(CompressionAlgorithm.ZIP, message.getDepth() + 1); - MessageMetadata.EncryptedData encryptedData = new MessageMetadata.EncryptedData(SymmetricKeyAlgorithm.AES_128, compressedData.getDepth() + 1); - MessageMetadata.EncryptedData encryptedData1 = new MessageMetadata.EncryptedData(SymmetricKeyAlgorithm.AES_256, encryptedData.getDepth() + 1); + MessageMetadata.EncryptedData encryptedData = new MessageMetadata.EncryptedData(MessageEncryptionMechanism.integrityProtected(SymmetricKeyAlgorithm.AES_128.getAlgorithmId()), compressedData.getDepth() + 1); + MessageMetadata.EncryptedData encryptedData1 = new MessageMetadata.EncryptedData(MessageEncryptionMechanism.integrityProtected(SymmetricKeyAlgorithm.AES_256.getAlgorithmId()), encryptedData.getDepth() + 1); MessageMetadata.LiteralData literalData = new MessageMetadata.LiteralData(); message.setChild(compressedData); diff --git a/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/EncryptDecryptTest.java b/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/EncryptDecryptTest.java index 1ecf856f..8f8b2b67 100644 --- a/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/EncryptDecryptTest.java +++ b/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/EncryptDecryptTest.java @@ -15,18 +15,25 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.Set; import org.bouncycastle.bcpg.ArmoredOutputStream; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.api.MessageEncryptionMechanism; import org.bouncycastle.openpgp.api.OpenPGPCertificate; import org.bouncycastle.openpgp.api.OpenPGPKey; import org.bouncycastle.util.io.Streams; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.AEADAlgorithm; +import org.pgpainless.algorithm.AEADCipherMode; import org.pgpainless.algorithm.DocumentSignatureType; +import org.pgpainless.algorithm.Feature; +import org.pgpainless.algorithm.KeyFlag; +import org.pgpainless.algorithm.OpenPGPKeyVersion; import org.pgpainless.algorithm.SymmetricKeyAlgorithm; import org.pgpainless.decryption_verification.ConsumerOptions; import org.pgpainless.decryption_verification.DecryptionStream; @@ -34,7 +41,11 @@ import org.pgpainless.decryption_verification.MessageMetadata; import org.pgpainless.exception.KeyException; import org.pgpainless.key.SubkeyIdentifier; import org.pgpainless.key.TestKeys; +import org.pgpainless.key.generation.KeySpec; +import org.pgpainless.key.generation.type.KeyType; +import org.pgpainless.key.generation.type.eddsa_legacy.EdDSALegacyCurve; import org.pgpainless.key.generation.type.rsa.RsaLength; +import org.pgpainless.key.generation.type.xdh_legacy.XDHLegacySpec; import org.pgpainless.key.protection.SecretKeyRingProtector; import org.pgpainless.key.protection.UnprotectedKeysProtector; import org.pgpainless.util.ArmoredOutputStreamFactory; @@ -306,4 +317,74 @@ public class EncryptDecryptTest { EncryptionOptions.encryptCommunications(api) .addRecipient(publicKeys)); } + + @TestTemplate + @ExtendWith(TestAllImplementations.class) + public void testEncryptToOnlyV4CertWithOnlySEIPD1Feature() throws PGPException, IOException { + PGPainless api = PGPainless.getInstance(); + OpenPGPKey v4Key = api.buildKey(OpenPGPKeyVersion.v4) + .setPrimaryKey(KeySpec.getBuilder(KeyType.EDDSA_LEGACY(EdDSALegacyCurve._Ed25519), KeyFlag.CERTIFY_OTHER, KeyFlag.SIGN_DATA) + .overridePreferredSymmetricKeyAlgorithms(SymmetricKeyAlgorithm.AES_192) + .overrideFeatures(Feature.MODIFICATION_DETECTION)) // the key only supports SEIPD1 + .addSubkey(KeySpec.getBuilder(KeyType.XDH_LEGACY(XDHLegacySpec._X25519), KeyFlag.ENCRYPT_COMMS, KeyFlag.ENCRYPT_STORAGE)) + .build(); + + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + EncryptionStream eOut = api.generateMessage().onOutputStream(bOut) + .withOptions(ProducerOptions.encrypt(EncryptionOptions.encryptCommunications() + .addRecipient(v4Key.toCertificate()))); + eOut.write(testMessage.getBytes(StandardCharsets.UTF_8)); + eOut.close(); + + ByteArrayInputStream bIn = new ByteArrayInputStream(bOut.toByteArray()); + DecryptionStream dIn = PGPainless.decryptAndOrVerify() + .onInputStream(bIn) + .withOptions(ConsumerOptions.get().addDecryptionKey(v4Key)); + + bOut = new ByteArrayOutputStream(); + Streams.pipeAll(dIn, bOut); + dIn.close(); + + assertEquals(testMessage, bOut.toString()); + MessageMetadata metadata = dIn.getMetadata(); + MessageEncryptionMechanism encryptionMechanism = metadata.getEncryptionMechanism(); + assertEquals( + MessageEncryptionMechanism.integrityProtected(SymmetricKeyAlgorithm.AES_192.getAlgorithmId()), + encryptionMechanism); + } + + @TestTemplate + @ExtendWith(TestAllImplementations.class) + public void testEncryptToOnlyV6CertWithOnlySEIPD2Features() throws IOException, PGPException { + PGPainless api = PGPainless.getInstance(); + OpenPGPKey v6Key = api.buildKey(OpenPGPKeyVersion.v6) + .setPrimaryKey(KeySpec.getBuilder(KeyType.Ed25519(), KeyFlag.CERTIFY_OTHER, KeyFlag.SIGN_DATA) + .overridePreferredAEADAlgorithms(new AEADCipherMode(AEADAlgorithm.OCB, SymmetricKeyAlgorithm.AES_128)) + .overrideFeatures(Feature.MODIFICATION_DETECTION_2)) // the key only supports SEIPD2 + .addSubkey(KeySpec.getBuilder(KeyType.X25519(), KeyFlag.ENCRYPT_COMMS, KeyFlag.ENCRYPT_STORAGE)) + .build(); + + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + EncryptionStream eOut = api.generateMessage().onOutputStream(bOut) + .withOptions(ProducerOptions.encrypt(EncryptionOptions.encryptCommunications() + .addRecipient(v6Key.toCertificate()))); + eOut.write(testMessage.getBytes(StandardCharsets.UTF_8)); + eOut.close(); + + ByteArrayInputStream bIn = new ByteArrayInputStream(bOut.toByteArray()); + DecryptionStream dIn = PGPainless.decryptAndOrVerify() + .onInputStream(bIn) + .withOptions(ConsumerOptions.get().addDecryptionKey(v6Key)); + + bOut = new ByteArrayOutputStream(); + Streams.pipeAll(dIn, bOut); + dIn.close(); + + assertEquals(testMessage, bOut.toString()); + MessageMetadata metadata = dIn.getMetadata(); + MessageEncryptionMechanism encryptionMechanism = metadata.getEncryptionMechanism(); + assertEquals( + MessageEncryptionMechanism.aead(SymmetricKeyAlgorithm.AES_128.getAlgorithmId(), AEADAlgorithm.OCB.getAlgorithmId()), + encryptionMechanism); + } }