From aae98d7ce8a2bdfd0ac5058d8e2ca4156683efb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Fri, 10 Feb 2023 19:54:50 +0100 Subject: [PATCH] Add unit tests - Unit tests for RFC-7508 type signatures - Unit tests for evaluation of trusted headers --- .../acme4j/smime/email/EmailProcessor.java | 4 + .../acme4j/smime/email/ResponseGenerator.java | 1 - .../AcmeInvalidMessageException.java | 49 +- .../acme4j/smime/wrapper/SignedMail.java | 7 +- .../smime/wrapper/SignedMailBuilder.java | 44 +- .../smime/email/EmailProcessorTest.java | 77 ++- .../smime/wrapper/SignedMailBuilderTest.java | 39 ++ .../acme4j/smime/wrapper/SignedMailTest.java | 457 ++++++++++++++++++ .../src/test/resources/7508-fake-ca.jks | Bin 0 -> 1270 bytes .../src/test/resources/7508-valid-ca.jks | Bin 0 -> 934 bytes .../test/resources/email/valid-mail-7508.eml | 148 ++++++ 11 files changed, 782 insertions(+), 44 deletions(-) create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilderTest.java create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailTest.java create mode 100644 acme4j-smime/src/test/resources/7508-fake-ca.jks create mode 100644 acme4j-smime/src/test/resources/7508-valid-ca.jks create mode 100644 acme4j-smime/src/test/resources/email/valid-mail-7508.eml diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java index 1c68a1f5..a78cc69b 100644 --- a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java @@ -482,6 +482,8 @@ public final class EmailProcessor { /** * Uses the given truststore for signature verification. + *

+ * This is for self-signed certificates. No revocation checks will take place. * * @param trustStore * {@link KeyStore} of the truststore to be used. @@ -497,6 +499,8 @@ public final class EmailProcessor { /** * Uses the given certificate for signature verification. + *

+ * This is for self-signed certificates. No revocation checks will take place. * * @param certificate * {@link X509Certificate} of the CA diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java index 6700c260..ad1ddd42 100644 --- a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java @@ -24,7 +24,6 @@ import jakarta.mail.Address; import jakarta.mail.Message; import jakarta.mail.MessagingException; import jakarta.mail.Session; -import jakarta.mail.Transport; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/exception/AcmeInvalidMessageException.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/exception/AcmeInvalidMessageException.java index 145c8aa4..23e6af52 100644 --- a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/exception/AcmeInvalidMessageException.java +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/exception/AcmeInvalidMessageException.java @@ -13,6 +13,15 @@ */ package org.shredzone.acme4j.smime.exception; +import static java.util.Collections.unmodifiableList; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.bouncycastle.i18n.ErrorBundle; +import org.bouncycastle.i18n.LocalizedException; import org.shredzone.acme4j.exception.AcmeException; /** @@ -32,26 +41,60 @@ import org.shredzone.acme4j.exception.AcmeException; public class AcmeInvalidMessageException extends AcmeException { private static final long serialVersionUID = 5607857024718309330L; + private final List errors; + /** * Creates a new {@link AcmeInvalidMessageException}. * * @param msg - * Reason of the exception + * Reason of the exception */ public AcmeInvalidMessageException(String msg) { super(msg); + this.errors = Collections.emptyList(); } /** * Creates a new {@link AcmeInvalidMessageException}. * * @param msg - * Reason of the exception + * Reason of the exception + * @param errors + * List of {@link ErrorBundle} with further details + * @since 2.16 + */ + public AcmeInvalidMessageException(String msg, List errors) { + super(msg); + this.errors = unmodifiableList(errors); + } + + /** + * Creates a new {@link AcmeInvalidMessageException}. + * + * @param msg + * Reason of the exception * @param cause - * Cause + * Cause */ public AcmeInvalidMessageException(String msg, Throwable cause) { super(msg, cause); + List errors = new ArrayList<>(1); + Optional.ofNullable(cause) + .filter(LocalizedException.class::isInstance) + .map(LocalizedException.class::cast) + .map(LocalizedException::getErrorMessage) + .ifPresent(errors::add); + this.errors = unmodifiableList(errors); + } + + /** + * Returns a list with further error details, if available. The list may be empty, but + * is never {@code null}. + * + * @since 2.16 + */ + public List getErrors() { + return errors; } } diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java index 70c35528..3d08fab2 100644 --- a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java @@ -17,7 +17,6 @@ import static java.util.Arrays.asList; import static java.util.Collections.unmodifiableSet; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Enumeration; @@ -290,7 +289,7 @@ public class SignedMail implements Mail { * @throws AcmeInvalidMessageException * if a header with the same value was not found */ - private void checkDuplicatedField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { + protected void checkDuplicatedField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { long count = headers.stream() .filter(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed)) .peek(MailHeader::setTrusted) @@ -315,7 +314,7 @@ public class SignedMail implements Mail { * @throws AcmeInvalidMessageException * if a header with the same value was not found */ - private void deleteField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { + protected void deleteField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed))) { throw new AcmeInvalidMessageException("Secured header '" + header + "' was not found in envelope header for deletion"); @@ -335,7 +334,7 @@ public class SignedMail implements Mail { * @throws AcmeInvalidMessageException * if the header was not found */ - private void modifyField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { + protected void modifyField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed))) { throw new AcmeInvalidMessageException("Secured header '" + header + "' was not found in envelope header for modification"); diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java index 22e945db..fb71809e 100644 --- a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java @@ -27,7 +27,6 @@ import java.security.cert.CertificateException; import java.security.cert.PKIXParameters; import java.security.cert.X509Certificate; import java.util.Collection; -import java.util.Objects; import java.util.Properties; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -75,20 +74,10 @@ public class SignedMailBuilder { return this; } - /** - * Uses the given truststore for certificate validation. - * - * @param trustStore {@link KeyStore} to use. - * @return itself - */ - public SignedMailBuilder withTrustStore(KeyStore trustStore) - throws KeyStoreException, InvalidAlgorithmParameterException { - requireNonNull(trustStore, "trustStore"); - return withPKIXParameters(new PKIXParameters(trustStore)); - } - /** * Uses the given {@link X509Certificate} for certificate validation. + *

+ * This is for self-signed certificates. No revocation checks will take place. * * @param signCert {@link X509Certificate} to use. * @return itself @@ -106,6 +95,22 @@ public class SignedMailBuilder { } } + /** + * Uses the given truststore for certificate validation. + *

+ * This is for self-signed certificates. No revocation checks will take place. + * + * @param trustStore {@link KeyStore} to use. + * @return itself + */ + public SignedMailBuilder withTrustStore(KeyStore trustStore) + throws KeyStoreException, InvalidAlgorithmParameterException { + requireNonNull(trustStore, "trustStore"); + PKIXParameters param = new PKIXParameters(trustStore); + param.setRevocationEnabled(false); + return withPKIXParameters(param); + } + /** * Uses the given {@link PKIXParameters} for certificate validation. * @@ -227,6 +232,7 @@ public class SignedMailBuilder { * if the signature is invalid, or if the message was signed with more than * one signature. */ + @SuppressWarnings("unchecked") private SignerInformation validateSignature(MimeMessage message, PKIXParameters pkixParameters) throws AcmeInvalidMessageException { try { @@ -236,9 +242,15 @@ public class SignedMailBuilder { if (store.size() != 1) { throw new AcmeInvalidMessageException("Expected exactly one signer, but found " + store.size()); } - return store.getSigners().iterator().next(); + + SignerInformation si = store.getSigners().iterator().next(); + SignedMailValidator.ValidationResult vr = smv.getValidationResult(si); + if (!vr.isValidSignature()) { + throw new AcmeInvalidMessageException("Invalid signature", vr.getErrors()); + } + return si; } catch (SignedMailValidatorException ex) { - throw new AcmeInvalidMessageException("Invalid signature", ex); + throw new AcmeInvalidMessageException("Cannot validate signature", ex); } } @@ -289,7 +301,7 @@ public class SignedMailBuilder { * * @return CaCerts truststore */ - private static KeyStore getCaCertsTrustStore() { + protected static KeyStore getCaCertsTrustStore() { KeyStore caCerts = CACERTS_TRUSTSTORE.get(); if (caCerts == null) { String javaHome = System.getProperty("java.home"); diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java index 68349a82..2cd83dff 100644 --- a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java @@ -17,6 +17,7 @@ import static jakarta.mail.Message.RecipientType.TO; import static org.assertj.core.api.Assertions.*; import java.io.IOException; +import java.security.KeyStore; import java.security.Security; import java.security.cert.X509Certificate; import java.util.Optional; @@ -70,19 +71,25 @@ public class EmailProcessorTest extends SMIMETests { assertThatNoException().isThrownBy(() -> { MimeMessage message = mockMessage("valid-mail"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); + EmailProcessor.builder().certificate(certificate).strict().build(message); }); } @Test public void testInvalidSignature() { - assertThatExceptionOfType(AcmeInvalidMessageException.class) - .isThrownBy(() -> { + AcmeInvalidMessageException ex = catchThrowableOfType(() -> { MimeMessage message = mockMessage("invalid-signed-mail"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); - }) - .withMessage("Message is not signed by the expected sender"); + EmailProcessor.builder().certificate(certificate).strict().build(message); + }, AcmeInvalidMessageException.class); + + assertThat(ex).isNotNull(); + assertThat(ex.getMessage()).isEqualTo("Invalid signature"); + assertThat(ex.getErrors()).hasSize(2); + assertThat(ex.getErrors()) + .first().hasFieldOrPropertyWithValue("id", "SignedMailValidator.emailFromCertMismatch"); + assertThat(ex.getErrors()) + .element(1).hasFieldOrPropertyWithValue("id", "SignedMailValidator.certPathInvalid"); } @Test @@ -91,31 +98,37 @@ public class EmailProcessorTest extends SMIMETests { .isThrownBy(() -> { MimeMessage message = mockMessage("invalid-nosan"); X509Certificate certificate = readCertificate("valid-signer-nosan"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); + EmailProcessor.builder().certificate(certificate).strict().build(message); }) .withMessage("Certificate does not have a subjectAltName extension"); } @Test public void testSANDoesNotMatchFrom() { - assertThatExceptionOfType(AcmeInvalidMessageException.class) - .isThrownBy(() -> { + AcmeInvalidMessageException ex = catchThrowableOfType(() -> { MimeMessage message = mockMessage("invalid-cert-mismatch"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); - }) - .withMessage("Secured header 'From' does not match envelope header"); + EmailProcessor.builder().certificate(certificate).strict().build(message); + }, AcmeInvalidMessageException.class); + + assertThat(ex).isNotNull(); + assertThat(ex.getMessage()).isEqualTo("Invalid signature"); + assertThat(ex.getErrors()) + .singleElement().hasFieldOrPropertyWithValue("id", "SignedMailValidator.emailFromCertMismatch"); } @Test public void testInvalidProtectedFromHeader() { - assertThatExceptionOfType(AcmeInvalidMessageException.class) - .isThrownBy(() -> { + AcmeInvalidMessageException ex = catchThrowableOfType(() -> { MimeMessage message = mockMessage("invalid-protected-mail-from"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); - }) - .withMessage("Secured header 'From' does not match envelope header"); + EmailProcessor.builder().certificate(certificate).strict().build(message); + }, AcmeInvalidMessageException.class); + + assertThat(ex).isNotNull(); + assertThat(ex.getMessage()).isEqualTo("Invalid signature"); + assertThat(ex.getErrors()) + .singleElement().hasFieldOrPropertyWithValue("id", "SignedMailValidator.emailFromCertMismatch"); } @Test @@ -124,7 +137,7 @@ public class EmailProcessorTest extends SMIMETests { .isThrownBy(() -> { MimeMessage message = mockMessage("invalid-protected-mail-to"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); + EmailProcessor.builder().certificate(certificate).strict().build(message); }) .withMessage("Secured header 'To' does not match envelope header"); } @@ -135,7 +148,7 @@ public class EmailProcessorTest extends SMIMETests { .isThrownBy(() -> { MimeMessage message = mockMessage("invalid-protected-mail-subject"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, true); + EmailProcessor.builder().certificate(certificate).strict().build(message); }) .withMessage("Secured header 'Subject' does not match envelope header"); } @@ -146,10 +159,34 @@ public class EmailProcessorTest extends SMIMETests { .isThrownBy(() -> { MimeMessage message = mockMessage("invalid-protected-mail-subject"); X509Certificate certificate = readCertificate("valid-signer"); - EmailProcessor.smimeMessage(message, mailSession, certificate, false); + EmailProcessor.builder().certificate(certificate).relaxed().build(message); }); } + @Test + public void testValidSignatureRfc7508() throws Exception { + MimeMessage message = mockMessage("valid-mail-7508"); + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(EmailProcessorTest.class.getResourceAsStream("/7508-valid-ca.jks"), "test123".toCharArray()); + + EmailProcessor processor = EmailProcessor.builder().trustStore(keyStore).build(message); + assertThat(processor.getSender()).isEqualTo(new InternetAddress("acme-challenge@dc-bsd.my.corp")); + assertThat(processor.getRecipient()).isEqualTo(new InternetAddress("gitlab@dc-bsd.my.corp")); + assertThat(processor.getToken1()).isEqualTo("ABxfL5s4bjvmyVRvl6y-Y_GhdzTdWpKqlmrKAIVe"); + } + + @Test + public void testInvalidSignatureRfc7508() throws Exception { + MimeMessage message = mockMessage("valid-mail-7508"); + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(EmailProcessorTest.class.getResourceAsStream("/7508-fake-ca.jks"), "test123".toCharArray()); + + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(() -> EmailProcessor.builder().trustStore(keyStore).build(message)); + } + @Test public void textExpectedFromFails() { assertThatExceptionOfType(AcmeProtocolException.class) diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilderTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilderTest.java new file mode 100644 index 00000000..6603fc62 --- /dev/null +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilderTest.java @@ -0,0 +1,39 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2023 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.smime.wrapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.KeyStore; +import java.security.KeyStoreException; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SignedMailBuilder}. + */ +public class SignedMailBuilderTest { + + @Test + public void testDefaultTrustStoreIsCreated() throws KeyStoreException { + KeyStore keyStore = SignedMailBuilder.getCaCertsTrustStore(); + assertThat(keyStore).isNotNull(); + assertThat(keyStore.size()).isGreaterThan(0); + + // Make sure the instance is cached + KeyStore keyStore2 = SignedMailBuilder.getCaCertsTrustStore(); + assertThat(keyStore2).isSameAs(keyStore); + } + +} \ No newline at end of file diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailTest.java new file mode 100644 index 00000000..363388c8 --- /dev/null +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/wrapper/SignedMailTest.java @@ -0,0 +1,457 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2023 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.smime.wrapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; + +import jakarta.mail.Header; +import jakarta.mail.internet.InternetAddress; +import org.junit.jupiter.api.Test; +import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException; + +/** + * Unit tests for {@link SignedMail}. + */ +public class SignedMailTest { + + @Test + public void testCheckDuplicatedStrictGood() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com" + )); + + // Success: Field is present and identical + signedMail.checkDuplicatedField("From", "foo@example.com", false); + + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("foo@example.com")); + } + + @Test + public void testCheckDuplicatedStrictBad() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", " foo@example.com " + )); + + // Failure: Field is same, but has extra whitespaces + assertThatExceptionOfType(AcmeInvalidMessageException.class).isThrownBy(() -> + signedMail.checkDuplicatedField("From", "foo@example.com", false) + ); + } + + @Test + public void testCheckDuplicatedRelaxedGood() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "FROM", " foo@example.com " + )); + + // Good: Field is there and identical (ignoring case and whitespaces) + signedMail.checkDuplicatedField("From", "foo@example.com", true); + + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("foo@example.com")); + } + + @Test + public void testCheckDuplicatedRelaxedBad() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "bar@example.com" + )); + + // Failure: Field is present, but different value + assertThatExceptionOfType(AcmeInvalidMessageException.class).isThrownBy(() -> + signedMail.checkDuplicatedField("From", "foo@example.com", true) + ); + } + + @Test + public void testDeleteFieldStrictGood() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com" + )); + + // Good: Field is present and identical + signedMail.deleteField("From", "foo@example.com", false); + + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getFrom) + .withMessage("Protected 'FROM' header is required, but missing"); + } + + @Test + public void testDeleteFieldStrictBad() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "bar@example.com" + )); + + // Bad: Field is present, but has different value + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(() -> signedMail.deleteField("From", "foo@example.com", false)) + .withMessage("Secured header 'From' was not found in envelope header for deletion"); + } + + @Test + public void testDeleteFieldRelaxedGood() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "FROM", " foo@example.com " + )); + + // Good: Field is present and identical (ignoring case and whitespaces) + signedMail.deleteField("From", "foo@example.com", true); + + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getFrom) + .withMessage("Protected 'FROM' header is required, but missing"); + } + + @Test + public void testDeleteFieldRelaxedBad() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "FROM", "bar@example.com" + )); + + // Bad: Field is present, but has different value + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(() -> signedMail.deleteField("From", "foo@example.com", true)) + .withMessage("Secured header 'From' was not found in envelope header for deletion"); + } + + @Test + public void testModifyFieldStrictGood() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com" + )); + + // Good: field is present, content is replaced + signedMail.modifyField("From", "bar@example.com", false); + + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("bar@example.com")); + } + + @Test + public void testModifyFieldStrictBad() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "FROM", "foo@example.com" + )); + + // Failure: Field is not present because it's all-caps + assertThatExceptionOfType(AcmeInvalidMessageException.class).isThrownBy(() -> + signedMail.modifyField("From", "bar@example.com", false) + ); + } + + @Test + public void testModifyFieldRelaxedGood() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "FROM", "foo@example.com" + )); + + // Good: Field is present (ignoring case) + signedMail.modifyField("From", "bar@example.com", true); + + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("bar@example.com")); + } + + @Test + public void testModifyFieldRelaxedBad() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "To", "foo@example.com" + )); + + // Failure: Field is not present at all + assertThatExceptionOfType(AcmeInvalidMessageException.class).isThrownBy(() -> + signedMail.modifyField("From", "foo@example.com", true) + ); + } + + @Test + public void testImportUntrusted() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com", + "Message-Id", "123456ABCDEF" + )); + + // Success because Message ID does not need to be trusted + assertThat(signedMail.getMessageId()).isNotEmpty().contains("123456ABCDEF"); + + // Failure because From is required to be trusted + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getFrom); + } + + @Test + public void testImportTrustedStrict() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com", + "Message-Id", "123456ABCDEF" + )); + signedMail.importTrustedHeaders(withHeaders( + "From", "foo@example.com" + )); + + // Success because Message ID does not need to be trusted + assertThat(signedMail.getMessageId()).isNotEmpty().contains("123456ABCDEF"); + + // Success because From is trusted + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("foo@example.com")); + } + + @Test + public void testImportTrustedRelaxed() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "Message-Id", "123456ABCDEF" + )); + signedMail.importTrustedHeadersRelaxed(withHeaders( + "From", "foo@example.com" + )); + + // Success because Message ID does not need to be trusted + assertThat(signedMail.getMessageId()).isNotEmpty().contains("123456ABCDEF"); + + // Success because From is trusted + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("foo@example.com")); + } + + @Test + public void testImportStrictFails() { + SignedMail signedMail = new SignedMail(); + + // Fails because there is no matching untrusted header + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(() -> signedMail.importTrustedHeaders(withHeaders( + "From", "foo@example.com" + ))); + } + + @Test + public void testFromEmpty() { + SignedMail signedMail = new SignedMail(); + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getFrom) + .withMessage("Protected 'FROM' header is required, but missing"); + } + + @Test + public void testFromUntrusted() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com" + )); + + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getFrom) + .withMessage("Protected 'FROM' header is required, but missing"); + } + + @Test + public void testFromTrusted() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importTrustedHeadersRelaxed(withHeaders( + "From", "foo@example.com" + )); + + assertThat(signedMail.getFrom()).isEqualTo(new InternetAddress("foo@example.com")); + } + + @Test + public void testToEmpty() { + SignedMail signedMail = new SignedMail(); + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getTo) + .withMessage("Protected 'TO' header is required, but missing"); + } + + @Test + public void testToUntrusted() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "To", "foo@example.com" + )); + + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getTo) + .withMessage("Protected 'TO' header is required, but missing"); + } + + @Test + public void testToTrusted() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importTrustedHeadersRelaxed(withHeaders( + "To", "foo@example.com" + )); + + assertThat(signedMail.getTo()).isEqualTo(new InternetAddress("foo@example.com")); + } + + @Test + public void testSubjectEmpty() { + SignedMail signedMail = new SignedMail(); + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getSubject) + .withMessage("Protected 'SUBJECT' header is required, but missing"); + } + + @Test + public void testSubjectUntrusted() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "Subject", "abc123" + )); + + assertThatExceptionOfType(AcmeInvalidMessageException.class) + .isThrownBy(signedMail::getSubject) + .withMessage("Protected 'SUBJECT' header is required, but missing"); + } + + @Test + public void testSubjectTrusted() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importTrustedHeadersRelaxed(withHeaders( + "Subject", "abc123" + )); + + assertThat(signedMail.getSubject()).isEqualTo("abc123"); + } + + @Test + public void testMessageIdEmpty() { + SignedMail signedMail = new SignedMail(); + assertThat(signedMail.getMessageId()).isEmpty(); + } + + @Test + public void testMessageId() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "Message-Id", "12345ABCDE" + )); + + assertThat(signedMail.getMessageId()).isNotEmpty().contains("12345ABCDE"); + } + + @Test + public void testReplyToEmpty() throws Exception { + SignedMail signedMail = new SignedMail(); + assertThat(signedMail.getReplyTo()).isEmpty(); + } + + @Test + public void testReplyTo() throws Exception { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "Reply-To", "foo@example.com", + "Reply-To", "bar@example.org" + )); + + assertThat(signedMail.getReplyTo()).contains( + new InternetAddress("foo@example.com"), + new InternetAddress("bar@example.org") + ); + } + + @Test + public void testIsAutoSubmitted() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "Auto-Submitted", "auto-generated; type=acme" + )); + + assertThat(signedMail.isAutoSubmitted()).isTrue(); + } + + @Test + public void testIsNotAutoSubmitted() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "Auto-Submitted", "no" + )); + + assertThat(signedMail.isAutoSubmitted()).isFalse(); + } + + @Test + public void testIsAutoSubmittedMissing() { + SignedMail signedMail = new SignedMail(); + assertThat(signedMail.isAutoSubmitted()).isFalse(); + } + + @Test + public void testMissingSecuredHeadersEmpty() { + SignedMail signedMail = new SignedMail(); + assertThat(signedMail.getMissingSecuredHeaders()).contains("FROM", "TO", "SUBJECT"); + } + + @Test + public void testMissingSecuredHeadersGood() { + SignedMail signedMail = new SignedMail(); + signedMail.importTrustedHeadersRelaxed(withHeaders( + "From", "foo@example.com", + "To", "bar@example.org", + "Subject", "foo123" + )); + + assertThat(signedMail.getMissingSecuredHeaders()).isEmpty(); + } + + @Test + public void testMissingSecuredHeadersTrustedBad() { + SignedMail signedMail = new SignedMail(); + signedMail.importTrustedHeadersRelaxed(withHeaders( + "From", "foo@example.com", + "To", "bar@example.org" + )); + + assertThat(signedMail.getMissingSecuredHeaders()).contains("SUBJECT"); + } + + @Test + public void testMissingSecuredHeadersUntustedBad() { + SignedMail signedMail = new SignedMail(); + signedMail.importUntrustedHeaders(withHeaders( + "From", "foo@example.com", + "To", "bar@example.org", + "Subject", "foo123" + )); + + assertThat(signedMail.getMissingSecuredHeaders()).contains("FROM", "TO", "SUBJECT"); + } + + private Enumeration

withHeaders(String... kv) { + List
headers = new ArrayList<>(); + for (int ix = 0; ix < kv.length; ix += 2) { + headers.add(new Header(kv[ix], kv[ix+1])); + } + return Collections.enumeration(headers); + } + +} \ No newline at end of file diff --git a/acme4j-smime/src/test/resources/7508-fake-ca.jks b/acme4j-smime/src/test/resources/7508-fake-ca.jks new file mode 100644 index 0000000000000000000000000000000000000000..da2f685a9b5bdde5e3a88fa11aa3efe1ee5f8ff1 GIT binary patch literal 1270 zcmV&LNQU+thDZTr0|Wso1Q5j8hmMmLBCN{Q8T_c>$5nuW1MoA^Bbv@nS>Cb#saoQMDm0i+ zaqXKCra>l?Wl1r8F;bS=rZCpXw|c|zfBFMJ<7erj#t@qUssEQx7!&&jHY+;CZL zZ1vtVjkL)alWsn`-jNY4w{Xu^4bikHz#8ySd8|+255et%BS<9wi(bMxm#OM4;J9b; z18og&%>dqc-T?9KVC9{#TfWd6?OTMpoKs>IO~RhMjJ!w|Q9mWi(~(<4tJ!kBmF9~8 z)W8;B(0wq5a9xvtO`QHQo}*Iy>|1p7?LQABW6>~w8O46i_MTE{FVXk9VTTOd8)LE* znNG{TSk9G(fW9vNm-1nyJYD|~1vTD>zr2u#%eNGUZ`-m79boQYcIp$#tr+*%`0 zUM~2`j7%9~EVbOV$)|iYYT#FF9bcRZuyq5GkkL16=6E7|P`Y8!6)D|h1{0&WFW#tK zY^|m9l1$UJU>>;&u7G>MI;eK;>~RfSs~U%VvZ`hdlm z`n}I$BeWthfi{0R94nCxrHsoC)iN^KZKv$}tUhI^lhlhXA}dO+_PyPpYQxji2>0?) z0^ffUv(i+vVf_T4T^G_rq=k3Ni6Ch+qE+s)^|Rb!YClziLZVL^@OuMk&iD%t{TUZb z^0U<()$XmZ$$4o0bXvv=@~x)8QIT~Qe|bg|7QY>Ra7aYW3eQV2P=eWSVE83oOn4p? z!5apcbeR4!J+9_d6T5`!}WB53>u`J}>xfTGWB;6g0 zf^zkAFItIh!NC9O71OfpC00bb_VmuTi90_Yknfi{bX{gsvKs#`wkgnce?`DdweY)8|14JIaa0VYNT10FUG zh~-S2tPBRSY@7*g9*n8XER0$#0)M>^N&axIchBFbS6%q>vtL6K(*oliEFZ!`3ks~H zoEmtH7G?&0>{4)!s93i`>easATkYdy))z{d@ko|m?VYh`-m`dH)4KPZOGP5QyJe5= z>C;ucK3hRW!1IckuzKr){hE`u6inGH?WMTtLd`4le=1Xb_dK1dykpC`OApIlH6^4h z)qR}Z!uG~-(cd18cQ;PBeS8zzo3-NDTE;^uCi_hFP6l^XaV~r4*dZa>eL{=7_IKKS zqf_gXSG*9+^?GmqWP329a*t(j_65FO#h+F`Tw!+d)W3)~PtxX{2v&-|ETgnpLRI}m zYJTTZs~=_gw!5N2x0)_&;bb{?x@Or{2A1j0PZa}S|L;^)b_p^mGwwQ=_IyGo?(|%&VBaY|LU>* zlY<@;qnC>rmEXL#Mo!tEZ@~|nw{pd5yh=h=UUR>1Udza!k@oZ6o!i|4vt*rTTiu>y zboZRKc-mL953EmYB|f?O^7f_*P1=`~Y5HS=z}fYB(fnK=|4*#nu;i?ARcL3?x76;2 z$rF6UpSN#q+x2wchUEF#-kgH0pT+eISt~DC+Ge-kT(u`}as2$PB_Ydh{xO{V<@nKR zm%@o%dJ9F4`~17Q>5yYs+De1qE32m2=>&c~bIw<%IbvH{(67+opRB&uMJ@OqM~W6D z%0@(1-Tl!t)n9mH!q~ zwm8$me@D~j-t+kQ<*U!7lg9mz=bu0O!eDEY-sKz1_iLEm5H83>bg(qVsMpgzE1)ipz_xJ6alfLrZffn&O w9BNZ5?Gl&V`dXE&V(#)}Zp2F#k>(5XRk_jT`43&*tEVwMo;u6kw;Gg103Y?5iU0rr literal 0 HcmV?d00001 diff --git a/acme4j-smime/src/test/resources/email/valid-mail-7508.eml b/acme4j-smime/src/test/resources/email/valid-mail-7508.eml new file mode 100644 index 00000000..04ee5317 --- /dev/null +++ b/acme4j-smime/src/test/resources/email/valid-mail-7508.eml @@ -0,0 +1,148 @@ +Return-Path: +X-Original-To: gitlab@dc-bsd.my.corp +Delivered-To: gitlab@dc-bsd.my.corp +Received: from [127.0.0.1] (acme-ca-02.dc-bsd.my.corp [10.70.15.231]) + by mail.dc-bsd.my.corp (Postfix) with ESMTP id 0119D9CC22 + for ; Sat, 26 Nov 2022 21:09:39 +0000 (UTC) +Content-Type: multipart/signed; protocol="application/pkcs7-signature"; + micalg=sha256; boundary="--_NmP-1d902f4d1e8a735a-Part_1" +Auto-Submitted: auto-generated; type=acme +From: acme-challenge@dc-bsd.my.corp +To: gitlab@dc-bsd.my.corp +Subject: ACME: ABxfL5s4bjvmyVRvl6y-Y_GhdzTdWpKqlmrKAIVe +Message-ID: +Date: Sat, 26 Nov 2022 21:09:38 +0000 +MIME-Version: 1.0 + +----_NmP-1d902f4d1e8a735a-Part_1 +Content-Type: multipart/alternative; + boundary="--_NmP-1d902f4d1e8a735a-Part_2" + +----_NmP-1d902f4d1e8a735a-Part_2 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +

This is an automatically generated ACME challenge for email = +address gitlab@dc-bsd.my.corp. If you haven't requested an S/MIME = +certificate generation for this email address, be very afraid. If you did = +request it, your email client might be able to process this request = +automatically, or you might have to paste the first token part into an = +external program.

Please reply to this mail and fill out the = +following template:

-----BEGIN ACME RESPONSE-----
+<fill in challengeResponse here>
+-----END ACME RESPONSE-----
+
Use the value of the following calculation inside the ACME response: +
  token =3D (decodeBase64url(token-part1) || decodeBase64url(token-par=
+t2))
+  keyAuthorization =3D base64url(token) || '.' || base64url(Thumbprint(acco=
+untKey))
+  challengeResponse =3D base64url(SHA256(keyAuthorization))
+
Where can I find all the ingredients for this?
  • token-part1 is = +in the subject of this email after 'ACME: ',
  • token-part2 can be = +found in your challenge request (over https),
  • accountKey has been = +generated in your ACME client.

+----_NmP-1d902f4d1e8a735a-Part_2 +Content-Type: application/json; charset=utf8 +Content-Encoding: utf8 + +{ "token-part1": "001c5f2f9b386e3be6c9546f97acbe63f1a17734dd5a92aa966aca00855e" } +----_NmP-1d902f4d1e8a735a-Part_2-- + +----_NmP-1d902f4d1e8a735a-Part_1 +Content-Type: application/pkcs7-signature; name=smime.p7s +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename=smime.p7s + +MIIUXQYJKoZIhvcNAQcCoIIUTjCCFEoCAQExDzANBglghkgBZQMEAgEFADCCBkkGCSqGSIb3DQEH +AaCCBjoEggY2Q29udGVudC1UeXBlOiBtdWx0aXBhcnQvYWx0ZXJuYXRpdmU7DQogYm91bmRhcnk9 +Ii0tX05tUC0xZDkwMmY0ZDFlOGE3MzVhLVBhcnRfMiINCg0KLS0tLV9ObVAtMWQ5MDJmNGQxZThh +NzM1YS1QYXJ0XzINCkNvbnRlbnQtVHlwZTogdGV4dC9odG1sOyBjaGFyc2V0PXV0Zi04DQpDb250 +ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBxdW90ZWQtcHJpbnRhYmxlDQoNCjxodG1sPjxwPlRoaXMg +aXMgYW4gYXV0b21hdGljYWxseSBnZW5lcmF0ZWQgQUNNRSBjaGFsbGVuZ2UgZm9yIGVtYWlsID0N +CmFkZHJlc3MgPGVtPmdpdGxhYkBkYy1ic2QubXkuY29ycDwvZW0+LiBJZiB5b3UgaGF2ZW4ndCBy +ZXF1ZXN0ZWQgYW4gUy9NSU1FID0NCmNlcnRpZmljYXRlIGdlbmVyYXRpb24gZm9yIHRoaXMgZW1h +aWwgYWRkcmVzcywgYmUgdmVyeSBhZnJhaWQuIElmIHlvdSBkaWQgPQ0KcmVxdWVzdCBpdCwgeW91 +ciBlbWFpbCBjbGllbnQgbWlnaHQgYmUgYWJsZSB0byBwcm9jZXNzIHRoaXMgcmVxdWVzdCA9DQph +dXRvbWF0aWNhbGx5LCBvciB5b3UgbWlnaHQgaGF2ZSB0byBwYXN0ZSB0aGUgZmlyc3QgdG9rZW4g +cGFydCBpbnRvIGFuID0NCmV4dGVybmFsIHByb2dyYW0uPC9wPiA8cD5QbGVhc2UgcmVwbHkgdG8g +dGhpcyBtYWlsIGFuZCBmaWxsIG91dCB0aGUgPQ0KZm9sbG93aW5nIHRlbXBsYXRlOiA8cHJlPi0t +LS0tQkVHSU4gQUNNRSBSRVNQT05TRS0tLS0tDQombHQ7ZmlsbCBpbiBjaGFsbGVuZ2VSZXNwb25z +ZSBoZXJlJmd0Ow0KLS0tLS1FTkQgQUNNRSBSRVNQT05TRS0tLS0tDQo8L3ByZT5Vc2UgdGhlIHZh +bHVlIG9mIHRoZSBmb2xsb3dpbmcgY2FsY3VsYXRpb24gaW5zaWRlIHRoZSBBQ01FIHJlc3BvbnNl +Og0KPHByZT4gIHRva2VuID0zRCAoZGVjb2RlQmFzZTY0dXJsKHRva2VuLXBhcnQxKSB8fCBkZWNv +ZGVCYXNlNjR1cmwodG9rZW4tcGFyPQ0KdDIpKQ0KICBrZXlBdXRob3JpemF0aW9uID0zRCBiYXNl +NjR1cmwodG9rZW4pIHx8ICcuJyB8fCBiYXNlNjR1cmwoVGh1bWJwcmludChhY2NvPQ0KdW50S2V5 +KSkNCiAgY2hhbGxlbmdlUmVzcG9uc2UgPTNEIGJhc2U2NHVybChTSEEyNTYoa2V5QXV0aG9yaXph +dGlvbikpDQo8L3ByZT5XaGVyZSBjYW4gSSBmaW5kIGFsbCB0aGUgaW5ncmVkaWVudHMgZm9yIHRo +aXM/PHVsPjxsaT50b2tlbi1wYXJ0MSBpcyA9DQppbiB0aGUgc3ViamVjdCBvZiB0aGlzIGVtYWls +IGFmdGVyICdBQ01FOiAnLDwvbGk+PGxpPnRva2VuLXBhcnQyIGNhbiBiZSA9DQpmb3VuZCBpbiB5 +b3VyIGNoYWxsZW5nZSByZXF1ZXN0IChvdmVyIGh0dHBzKSw8L2xpPjxsaT5hY2NvdW50S2V5IGhh +cyBiZWVuID0NCmdlbmVyYXRlZCBpbiB5b3VyIEFDTUUgY2xpZW50LjwvbGk+PC91bD48L3A+PC9o +dG1sPg0KLS0tLV9ObVAtMWQ5MDJmNGQxZThhNzM1YS1QYXJ0XzINCkNvbnRlbnQtVHlwZTogYXBw +bGljYXRpb24vanNvbjsgY2hhcnNldD11dGY4DQpDb250ZW50LUVuY29kaW5nOiB1dGY4DQoNCnsg +InRva2VuLXBhcnQxIjogIjAwMWM1ZjJmOWIzODZlM2JlNmM5NTQ2Zjk3YWNiZTYzZjFhMTc3MzRk +ZDVhOTJhYTk2NmFjYTAwODU1ZSIgfQ0KLS0tLV9ObVAtMWQ5MDJmNGQxZThhNzM1YS1QYXJ0XzIt +LQ0KoIIK5DCCBI8wggQUoAMCAQICFGFq2BiR4ebW84K3/HrjMpkODKUtMAoGCCqGSM49BAMDMEkx +CzAJBgNVBAYTAkpQMQ4wDAYDVQQHDAVUb2tpbzEQMA4GA1UECgwHTXkuQ29ycDEYMBYGA1UEAwwP +Um9vdCBDQSAyMDIyIEcxMB4XDTIyMTEyNDIxNDAxN1oXDTI1MDUyODIxNDAxNlowSDELMAkGA1UE +BhMCSlAxDjAMBgNVBAcMBVRva2lvMRAwDgYDVQQKDAdNeS5Db3JwMRcwFQYDVQQDDA5TdWIgQ0Eg +MjAyMiBHMTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAIILhCsJsrzhrAELwxAbrKaH +ALeP7liQqUiPDbN+JfRvqyEBpBG8JBkGC76uM5TIQ+f+28IBIs0YwdjIiCNPeew/nAPZ1aNZEYco +vQ7G3CQ7mUAhFkeGKOVaS+2LOqvmdry5p1SKlC0ziw/Yf3dD1KSQ4sBPBa7gOp8pDpe9YKX/j/fX +k83iSTFvrp1LTe4BILMh9YzVAukKR5A3WgVkrR156gLoV6Rew6uSwHAOtl4JAYBclRohmvbjU/FP +k2kX8h6NK+V21HXD0inhyI/NnyVPxEO+n2isnrrtz/R6t2pUPn28xhNjbnFEBk8KZ4fQH23/BRtA +cIibh3dzetVRJdoVZEjoYmZdveMB6TqsJJtlSSHTVIKcWNUFKAiqS5wSHVaX2XnjdVgqkQU3j5kD +JBCwPG47KpoNdTTa4JWQwI9G57kGAvPomj+6ljYSavzfCeQ8nxwkoRhG0eXjalpOnjUPAxowqhmE +mRYTg7hzIFPXYInhFIvHwd4zFkPh8HOa7wIDAQABo4IBjjCCAYowEgYDVR0TAQH/BAgwBgEB/wIB +ADAOBgNVHQ8BAf8EBAMCAYYwHgYDVR0gBBcwFTAIBgZngQwBAgEwCQYHZ4EMAQUBAjB+BggrBgEF +BQcBAQRyMHAwbgYIKwYBBQUHMAKGYmh0dHA6Ly9hY21lLWNhLTAxLmRjLWJzZC5teS5jb3JwL2Rv +d25sb2FkL1Jvb3QvNTQ1MWYxOWZkZDg5MmNjYmU5YmU3ODM1ODc2NjcwZmQxYjY5OTllOC9jYS5j +cnQuY2VyMGwGA1UdHwRlMGMwYaBfoF2GW2h0dHA6Ly9hY21lLWNhLTAxLmRjLWJzZC5teS5jb3Jw +L2Rvd25sb2FkL1Jvb3QvNTQ1MWYxOWZkZDg5MmNjYmU5YmU3ODM1ODc2NjcwZmQxYjY5OTllOC5j +cmwwKwYDVR0jBCQwIoAgxGqTGFlSJtP48rVLeyMc58TIb5WWC/mBW4AKc0OmIm0wKQYDVR0OBCIE +INUBWh3giyFeu6hqGeExhD+S2sWYkeQcsGY6tX6AUgULMAoGCCqGSM49BAMDA2kAMGYCMQDW0NKq +k5JJl+DQvDBAVWZ99LFzKP2y9H8RHNynK2g+VlF6h2141+SWO+ev2GAFj5kCMQC8HuhmB1g+aYuP +wcuBtpOSZGrdTHXFmRubI/rb4K8rZTgP/FQXzbK81Mctv1V+AxgwggZNMIIEtaADAgECAhRTWC34 +33N0r2EkwdKjLxrvITOOgDANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJKUDEOMAwGA1UEBwwF +VG9raW8xEDAOBgNVBAoMB015LkNvcnAxFzAVBgNVBAMMDlN1YiBDQSAyMDIyIEcxMB4XDTIyMTEy +NDIxNDExNVoXDTIzMDIyMjIxNDExNFowVzELMAkGA1UEBhMCSlAxDjAMBgNVBAcMBVRva2lvMRAw +DgYDVQQKDAdNeS5Db3JwMSYwJAYDVQQDFh1hY21lLWNoYWxsZW5nZUBkYy1ic2QubXkuY29ycDCC +AaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAInbq8HuIIHbaSgUP5jMSZuIt82r34Cbd4b6 +C1OgzJWW2CY++t1wkwmAA4NRHZEesm3c6AsqfYbbvifdAlll4bDuQXAtMDphyICgllLqyvdhI7ya +FjhMzTyGHKXKztbjNyLwz9gFP/g6v/44EsPdagaFaVjFGeaBGr/JSih2cdBo8oICaEFhRymtARQP +95mu+A0bV16zE2JnSqvN6ivIrN2uHQjlo0uymWRXJsK3Jv3Xm71vnKiESclkYQHNXi7PyA1GuOEO +oxSln7FQhzXYYurGenjzg9WAemtPPVw3jcMg9rM1k+Lkns/VLj3VTXhDZg1Ye6CsmTpUEmVo3seu +vM2Y8GyTndmNt/tVAwiD77Ok5BRbSCBzQkopwMuzajg5PYXd0VGjhMUOxfgZavZGGFpjwPkEqY7s +hhSwp5Wmq5OBehpPmh47ILHgUrctjALcgWgc4gaLwTVeDOOvHA0IbQBAFqD1mxNb1i4gSEJc9dUa +lxc4LGa6ubhVLsNg9n7mdQIDAQABo4ICHjCCAhowCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCBaAw +FgYDVR0lAQH/BAwwCgYIKwYBBQUHAwQwKAYDVR0RBCEwH4EdYWNtZS1jaGFsbGVuZ2VAZGMtYnNk +Lm15LmNvcnAwEwYDVR0gBAwwCjAIBgZngQwBAgIwgeAGCCsGAQUFBwEBBIHTMIHQMF8GCCsGAQUF +BzABhlNodHRwOi8vYWNtZS1jYS0wMS5kYy1ic2QubXkuY29ycC9vY3NwL1N1Yi82MTZhZDgxODkx +ZTFlNmQ2ZjM4MmI3ZmM3YWUzMzI5OTBlMGNhNTJkLzBtBggrBgEFBQcwAoZhaHR0cDovL2FjbWUt +Y2EtMDEuZGMtYnNkLm15LmNvcnAvZG93bmxvYWQvU3ViLzYxNmFkODE4OTFlMWU2ZDZmMzgyYjdm +YzdhZTMzMjk5MGUwY2E1MmQvY2EuY3J0LmNlcjBrBgNVHR8EZDBiMGCgXqBchlpodHRwOi8vYWNt +ZS1jYS0wMS5kYy1ic2QubXkuY29ycC9kb3dubG9hZC9TdWIvNjE2YWQ4MTg5MWUxZTZkNmYzODJi +N2ZjN2FlMzMyOTkwZTBjYTUyZC5jcmwwKwYDVR0jBCQwIoAg1QFaHeCLIV67qGoZ4TGEP5LaxZiR +5BywZjq1foBSBQswKQYDVR0OBCIEIFWVbzWpC2IKkBu6DBLEtmO/1GjEXKJaYigKZkkPYBsgMA0G +CSqGSIb3DQEBCwUAA4IBgQAFvMhQ3dpIYwXch5Op0v3qhnRpZFpZh/3DCjLAQt2d+O71nVGGt6Bj +jsiewHU6Dtl5zxDZfz9cQTaRhEWfXka2byFWKfYw3xq64cmr5a7+9RynvtrSBjx9a+IOVNAylyjK +xnNdWVZLa0Qna1Qqngz32i7aXxENuES2kZcBTnLDEaA3WBuGdlqBsFn1T0A6pP20/np8TS4Q17hR +GEAE9jrhcmRcbP7ABqDz7+Jcq0qnpXE8zJwm9DsR4HJ1Cudsm3iIz3BSDOchUMPPcOOG+JltYOus +eaheeNFDdyxKIx4TZkRwI+avl27DJ4O2n/OKqDQZTYcg/HxH2xWwNfq2/Z08YeH/I4xJnlPxZSDG +DjvVNcQMsAwOdG2O55Lq7q4wmp7ZH2JVA97vekBAJwltjyy3APDOeqi4CnzlaZcOn0GRg7MasQmF +AYbOlo8Ti8oe4U65w8Y2q4ip1EbiwfNMrAkcaAVGIJm2pTapp9nOraed68uMogF7xPhucJLA7Zer +WMMxggL9MIIC+QIBATBgMEgxCzAJBgNVBAYTAkpQMQ4wDAYDVQQHDAVUb2tpbzEQMA4GA1UECgwH +TXkuQ29ycDEXMBUGA1UEAwwOU3ViIENBIDIwMjIgRzECFFNYLfjfc3SvYSTB0qMvGu8hM46AMA0G +CWCGSAFlAwQCAQUAoIHvMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwLwYJKoZIhvcNAQkEMSIE +IIbuwptDlJ0DVnN9vhoyQl6l6BeOjhuVjyRkfKXrS2bSMIGhBgsqhkiG9w0BCRACNzGBkTGBjgoB +ATCBiDA8GgdTdWJqZWN0DC5BQ01FOiBBQnhmTDVzNGJqdm15VlJ2bDZ5LVlfR2hkelRkV3BLcWxt +cktBSVZlAgEAMB4aAlRvDBVnaXRsYWJAZGMtYnNkLm15LmNvcnACAQAwKBoERnJvbQwdYWNtZS1j +aGFsbGVuZ2VAZGMtYnNkLm15LmNvcnACAQAwDQYJKoZIhvcNAQELBQAEggGAGUyk3DnAEFJeB3xS +qbgx6NdbkP3G1ah9el01wEez4jQC1pvRe07dMApkJImlSxnOB5Lp/06a14YvAu/rGdmTIAZjeuXV +VPxo+urSTUj4dZB26HNKHyUwXvKWgIhfwlrc/kK+sXyYXk8cNNtAuOQlTJyU1B1NG5nFKTi4UOZI +9B1Lc0MktnrAntB70bOVcT0Lz5Qgslc8jwIUePJLyGKohKTe/744QPqZAz7nQaXS9E30yd1oTB7I +FH4Fg9GjWnD8rLvdCR/S54zEqsdIX/YCmky5DzyjLthY1Al4AJwaodd5gPQn2xhtkxssHLg48/X+ +3Se+JTyad6MXmMixnlJFL8DoL8r/BcfZbT4b8oLWMrbURooMPVnxYbTx67IOGxuIYFolM9d9F39P +GBcFkTGIbcVUOIyV2vGH0fXOiVx4ktm4j3Ds843KZ8hj52SZr7RBAsgileucs713UOjfhrQqkbZh +nF5r9fOKs8ky8tSePQ8izk/jXC6PY5D7xyELoPrh +----_NmP-1d902f4d1e8a735a-Part_1-- \ No newline at end of file