From 82db3a9ea675043aea2405ab265efcabe37921c5 Mon Sep 17 00:00:00 2001 From: Paul Schaub Date: Tue, 13 May 2025 12:28:54 +0200 Subject: [PATCH] Port CertificateAuthority to KeyIdentifier, add tests for authenticated cert selection --- .../authentication/CertificateAuthenticity.kt | 57 +++++- .../authentication/CertificateAuthority.kt | 45 ++++- .../MessageMetadata.kt | 3 +- .../encryption_signing/EncryptionOptions.kt | 4 +- .../VerifyWithCertificateAuthorityTest.java | 166 ++++++++++++++++++ 5 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 pgpainless-core/src/test/java/org/pgpainless/decryption_verification/VerifyWithCertificateAuthorityTest.java diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthenticity.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthenticity.kt index f3d60bf6..650b3722 100644 --- a/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthenticity.kt +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthenticity.kt @@ -5,14 +5,46 @@ package org.pgpainless.authentication import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.api.OpenPGPCertificate +import org.pgpainless.PGPainless +/** + * A certificate authenticity record, indicating, to what degree the certificate is authenticated. + * + * @param userId identity, was changed to [CharSequence] instead of [String] starting with + * PGPainless 2.0. + * @param certificate certificate, was changed to [OpenPGPCertificate] instead of + * [PGPPublicKeyRing]. Use [pgpPublicKeyRing] if you need to access a [PGPPublicKeyRing]. + * @param certificationChains map of chains and their trust degrees + * @param targetAmount targeted trust amount + */ class CertificateAuthenticity( - val userId: String, - val certificate: PGPPublicKeyRing, + val userId: CharSequence, + val certificate: OpenPGPCertificate, val certificationChains: Map, val targetAmount: Int ) { + /** Legacy constructor accepting a [PGPPublicKeyRing]. */ + @Deprecated("Pass in an OpenPGPCertificate instead of a PGPPublicKeyRing.") + constructor( + userId: String, + certificate: PGPPublicKeyRing, + certificationChains: Map, + targetAmount: Int + ) : this( + userId, + PGPainless.getInstance().toCertificate(certificate), + certificationChains, + targetAmount) + + /** + * Field was introduced to allow backwards compatibility with pre-2.0 API as replacement for + * [certificate]. + */ + @Deprecated("Use certificate instead.", replaceWith = ReplaceWith("certificate")) + val pgpPublicKeyRing: PGPPublicKeyRing = certificate.pgpPublicKeyRing + val totalTrustAmount: Int get() = certificationChains.values.sum() @@ -44,5 +76,22 @@ class CertificateAuthenticity( */ class CertificationChain(val trustAmount: Int, val chainLinks: List) {} -/** A chain link contains a node in the trust chain. */ -class ChainLink(val certificate: PGPPublicKeyRing) {} +/** + * A chain link contains a node in the trust chain. + * + * @param certificate chain link certificate, was changed from [PGPPublicKeyRing] to + * [OpenPGPCertificate] with PGPainless 2.0. Use [pgpPublicKeyRing] if you need to access the + * field as [PGPPublicKeyRing]. + */ +class ChainLink(val certificate: OpenPGPCertificate) { + constructor( + certificate: PGPPublicKeyRing + ) : this(PGPainless.getInstance().toCertificate(certificate)) + + /** + * Field was introduced to allow backwards compatibility with pre-2.0 API as replacement for + * [certificate]. + */ + @Deprecated("Use certificate instead.", replaceWith = ReplaceWith("certificate")) + val pgpPublicKeyRing: PGPPublicKeyRing = certificate.pgpPublicKeyRing +} diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthority.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthority.kt index 093c2325..861ac22c 100644 --- a/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthority.kt +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/authentication/CertificateAuthority.kt @@ -5,6 +5,7 @@ package org.pgpainless.authentication import java.util.* +import org.bouncycastle.bcpg.KeyIdentifier import org.pgpainless.key.OpenPgpFingerprint /** @@ -36,7 +37,30 @@ interface CertificateAuthority { email: Boolean, referenceTime: Date, targetAmount: Int - ): CertificateAuthenticity + ): CertificateAuthenticity? = + authenticateBinding(fingerprint.keyIdentifier, userId, email, referenceTime, targetAmount) + + /** + * Determine the authenticity of the binding between the given cert identifier and the userId. + * In other words, determine, how much evidence can be gathered, that the certificate with the + * given fingerprint really belongs to the user with the given userId. + * + * @param certIdentifier identifier of the certificate + * @param userId userId + * @param email if true, the userId will be treated as an email address and all user-IDs + * containing the email address will be matched. + * @param referenceTime reference time at which the binding shall be evaluated + * @param targetAmount target trust amount (120 = fully authenticated, 240 = doubly + * authenticated, 60 = partially authenticated...) + * @return information about the authenticity of the binding + */ + fun authenticateBinding( + certIdentifier: KeyIdentifier, + userId: CharSequence, + email: Boolean, + referenceTime: Date, + targetAmount: Int + ): CertificateAuthenticity? /** * Lookup certificates, which carry a trustworthy binding to the given userId. @@ -50,7 +74,7 @@ interface CertificateAuthority { * @return list of identified bindings */ fun lookupByUserId( - userId: String, + userId: CharSequence, email: Boolean, referenceTime: Date, targetAmount: Int @@ -70,5 +94,22 @@ interface CertificateAuthority { fingerprint: OpenPgpFingerprint, referenceTime: Date, targetAmount: Int + ): List = + identifyByFingerprint(fingerprint.keyIdentifier, referenceTime, targetAmount) + + /** + * Identify trustworthy bindings for a certificate. The result is a list of authenticatable + * userIds on the certificate. + * + * @param certIdentifier identifier of the certificate + * @param referenceTime reference time for trust calculations + * @param targetAmount target trust amount (120 = fully authenticated, 240 = doubly + * authenticated, 60 = partially authenticated...) + * @return list of identified bindings + */ + fun identifyByFingerprint( + certIdentifier: KeyIdentifier, + referenceTime: Date, + targetAmount: Int ): List } 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 c1dfac93..8e257a23 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 @@ -295,7 +295,8 @@ class MessageMetadata(val message: Message) { email, it.signature.creationTime, targetAmount) - .authenticated + ?.authenticated + ?: false } } diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/encryption_signing/EncryptionOptions.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/encryption_signing/EncryptionOptions.kt index 1f3852f3..16333df4 100644 --- a/pgpainless-core/src/main/kotlin/org/pgpainless/encryption_signing/EncryptionOptions.kt +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/encryption_signing/EncryptionOptions.kt @@ -90,9 +90,7 @@ class EncryptionOptions(private val purpose: EncryptionPurpose, private val api: authority .lookupByUserId(userId, email, evaluationDate, targetAmount) .filter { it.isAuthenticated() } - .forEach { - addRecipient(api.toCertificate(it.certificate)).also { foundAcceptable = true } - } + .forEach { addRecipient(it.certificate).also { foundAcceptable = true } } require(foundAcceptable) { "Could not identify any trust-worthy certificates for '$userId' and target trust amount $targetAmount." } diff --git a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/VerifyWithCertificateAuthorityTest.java b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/VerifyWithCertificateAuthorityTest.java new file mode 100644 index 00000000..6dfdf7db --- /dev/null +++ b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/VerifyWithCertificateAuthorityTest.java @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2025 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.decryption_verification; + +import org.bouncycastle.bcpg.KeyIdentifier; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.bouncycastle.openpgp.api.OpenPGPKey; +import org.bouncycastle.util.io.Streams; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.pgpainless.PGPainless; +import org.pgpainless.authentication.CertificateAuthenticity; +import org.pgpainless.authentication.CertificateAuthority; +import org.pgpainless.authentication.CertificationChain; +import org.pgpainless.authentication.ChainLink; +import org.pgpainless.encryption_signing.EncryptionOptions; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; +import org.pgpainless.encryption_signing.SigningOptions; +import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.util.TestAllImplementations; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class VerifyWithCertificateAuthorityTest { + + @TestTemplate + @ExtendWith(TestAllImplementations.class) + public void testVerifySignatureFromAuthenticatedCert() throws PGPException, IOException { + PGPainless api = PGPainless.getInstance(); + + OpenPGPKey aliceKey = api.generateKey().modernKeyRing("Alice "); + OpenPGPCertificate aliceCert = aliceKey.toCertificate(); + + SimpleCertificateAuthority authority = new SimpleCertificateAuthority(); + authority.addDirectlyAuthenticatedCert(aliceCert, 120); + + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + EncryptionStream eOut = api.generateMessage() + .onOutputStream(bOut) + .withOptions(ProducerOptions.signAndEncrypt( + EncryptionOptions.encryptCommunications() + .addAuthenticatableRecipients("Alice ", false, authority), + SigningOptions.get().addInlineSignature(SecretKeyRingProtector.unprotectedKeys(), aliceKey) + )); + + eOut.write("Hello, World!\n".getBytes(StandardCharsets.UTF_8)); + eOut.close(); + + ByteArrayInputStream bIn = new ByteArrayInputStream(bOut.toByteArray()); + DecryptionStream dIn = api.processMessage() + .onInputStream(bIn) + .withOptions(ConsumerOptions.get() + .addVerificationCert(aliceCert) + .addDecryptionKey(aliceKey)); + Streams.drain(dIn); + dIn.close(); + + MessageMetadata metadata = dIn.getMetadata(); + assertTrue(metadata.isEncryptedFor(aliceCert)); + + assertTrue(metadata.isAuthenticatablySignedBy("Alice ", false, authority)); + assertTrue(metadata.isAuthenticatablySignedBy("alice@pgpainless.org", true, authority)); + assertFalse(metadata.isAuthenticatablySignedBy("mallory@pgpainless.org", true, authority)); + } + + public static class SimpleCertificateAuthority implements CertificateAuthority { + + Map directlyAuthenticatedCerts = new HashMap<>(); + + public void addDirectlyAuthenticatedCert(OpenPGPCertificate cert, int trustAmount) { + directlyAuthenticatedCerts.put(cert, trustAmount); + } + + @Override + public CertificateAuthenticity authenticateBinding( + @NotNull KeyIdentifier certIdentifier, + @NotNull CharSequence userId, + boolean email, + @NotNull Date referenceTime, + int targetAmount) { + Optional opt = directlyAuthenticatedCerts.keySet().stream() + .filter(it -> it.getKey(certIdentifier) != null) + .findFirst(); + if (opt.isEmpty()) { + return null; + } + + OpenPGPCertificate cert = opt.get(); + Optional uid; + if (email) { + uid = cert.getAllUserIds().stream().filter(it -> it.getUserId().contains("<" + userId + ">")) + .findFirst(); + } else { + uid = cert.getAllUserIds().stream().filter(it -> it.getUserId().contentEquals(userId)) + .findFirst(); + } + return uid.map(openPGPUserId -> authenticatedUserId(openPGPUserId, targetAmount)).orElse(null); + } + + @NotNull + @Override + public List lookupByUserId( + @NotNull CharSequence userId, + boolean email, + @NotNull Date referenceTime, + int targetAmount) { + List matches = new ArrayList<>(); + + for (OpenPGPCertificate cert : directlyAuthenticatedCerts.keySet()) { + cert.getAllUserIds() + .stream().filter(it -> { + if (email) return it.getUserId().contains("<" + userId + ">"); + else return it.getUserId().contentEquals(userId); + }).forEach(it -> { + matches.add(authenticatedUserId(it, targetAmount)); + }); + } + return matches; + } + + @NotNull + @Override + public List identifyByFingerprint( + @NotNull KeyIdentifier certIdentifier, + @NotNull Date referenceTime, + int targetAmount) { + List matches = new ArrayList<>(); + + directlyAuthenticatedCerts.keySet() + .stream().filter(it -> it.getKey(certIdentifier) != null) + .forEach(it -> { + for (OpenPGPCertificate.OpenPGPUserId userId : it.getAllUserIds()) { + matches.add(authenticatedUserId(userId, targetAmount)); + } + }); + + return matches; + } + + private CertificateAuthenticity authenticatedUserId(OpenPGPCertificate.OpenPGPUserId userId, int targetAmount) { + OpenPGPCertificate cert = userId.getCertificate(); + int certTrust = directlyAuthenticatedCerts.get(cert); + Map chains = new HashMap<>(); + chains.put(new CertificationChain(certTrust, Collections.singletonList(new ChainLink(cert))), certTrust); + return new CertificateAuthenticity(userId.getUserId(), cert, chains, targetAmount); + } + } +}