diff --git a/README.adoc b/README.adoc index 64e0546..90580aa 100644 --- a/README.adoc +++ b/README.adoc @@ -1,7 +1,9 @@ = JAMES jDKIM library -Library dealing with parsing and crytography to sign and verify DKIM signatures. +Library dealing with parsing and cryptography to sign and verify DKIM signatures. +It also provides DMARC verification and ARC (Authenticated Received Chain) +support for Java-based mail processing workflows. The mailet has been moved to James project: https://github.com/apache/james-project/tree/master/server/mailet/dkim @@ -56,33 +58,154 @@ List verifiedSignatures = verifier.verify(stream); List results = verifier.getResults(); ---- +=== Checking DMARC + +DMARC verification combines SPF and DKIM results with the RFC5322 `From` +domain and the domain DMARC policy. + +[source,java] +---- +import org.apache.james.dmarc.DMARCVerifier; +import org.apache.james.dmarc.DmarcValidationResult; +import org.apache.james.dmarc.PublicKeyRecordRetrieverDmarc; +import org.apache.james.mime4j.dom.Message; + +PublicKeyRecordRetrieverDmarc recordRetriever = null; +Message message = null; + +DMARCVerifier dmarcVerifier = new DMARCVerifier(recordRetriever); +DmarcValidationResult dmarcResult = dmarcVerifier.runDmarcCheck( + message, + "pass client-ip=192.0.2.1; envelope-from=sender@example.org", + "example.org", + "pass", + "example.org"); + +String authenticationResult = dmarcResult.toString(); +---- + +More complete DMARC examples can be found in +https://github.com/apache/james-jdkim/blob/master/dmarc/src/main/test/java/org/apache/james/dmarc/DMARCTest.java[DMARCTest]. + +== ARC Support + +ARC support adds RFC 8617 signing and validation for intermediaries that need to +preserve authentication results across forwarding hops. + +=== Building An ARC Set + +Generating ARC headers for a MIME message can be achieved using the following +snippet. + +[source,java] +---- +import java.io.InputStream; +import java.security.PrivateKey; +import java.util.Map; + +import org.apache.james.arc.ArcSetBuilder; +import org.apache.james.arc.PublicKeyRetrieverArc; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.DefaultMessageBuilder; + +String amsTemplate = "i=; a=rsa-sha256; c=relaxed/relaxed; d=example.org; s=arc; t=; h=Subject:From:To; bh=; b="; +String sealTemplate = "i=; cv=; a=rsa-sha256; d=example.org; s=arc; t=; b="; + +PrivateKey privateKey = null; +InputStream stream = null; +Message message = new DefaultMessageBuilder().parseMessage(stream); + +ArcSetBuilder arcSetBuilder = new ArcSetBuilder( + privateKey, + amsTemplate, + sealTemplate, + "mx.example.org", + System.currentTimeMillis() / 1000); + +PublicKeyRetrieverArc keyRecordRetriever = null; +Map arcSet = arcSetBuilder.buildArcSet( + message, + "mail.example.org", + "sender@example.org", + "192.0.2.1", + keyRecordRetriever); + +String authenticationResults = arcSet.get("Authentication-Results"); +String arcAuthenticationResults = arcSet.get("ARC-Authentication-Results"); +String arcMessageSignature = arcSet.get("ARC-Message-Signature"); +String arcSeal = arcSet.get("ARC-Seal"); +---- + +The generated map contains the ARC headers for the current hop, ready to be +added to the message. + +=== Validating An ARC Chain + +Validating ARC headers on a MIME message can be achieved using the following +snippet. + +[source,java] +---- +import java.io.InputStream; + +import org.apache.james.arc.ARCChainValidator; +import org.apache.james.arc.ArcValidationOutcome; +import org.apache.james.arc.PublicKeyRetrieverArc; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.DefaultMessageBuilder; + +PublicKeyRetrieverArc keyRecordRetriever = null; +InputStream stream = null; +Message message = new DefaultMessageBuilder().parseMessage(stream); + +ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); +ArcValidationOutcome validation = arcChainValidator.validateArcChain(message); + +String chainValidation = validation.getResult().toString(); +String description = validation.getDescription(); +---- + +More complete ARC usage can be found in +https://github.com/apache/james-jdkim/blob/master/arc/src/test/java/org/apache/james/arc/ARCTest.java[ARCTest]. + +== Test Coverage + +ARC functionality is covered by tests in +https://github.com/apache/james-jdkim/blob/master/arc/src/test/java/org/apache/james/arc/ARCTest.java[ARCTest]. +The coverage is based on the ARC protocol requirements from +https://datatracker.ietf.org/doc/html/rfc8617[RFC 8617] and on the public +https://github.com/ValiMail/arc_test_suite[ValiMail ARC test suite]. The tests +exercise ARC set creation, chain validation, ARC-Seal verification, +ARC-Message-Signature canonicalization, body hash handling, required tag +validation, and malformed or tampered ARC header cases. + == Cryptography Notice ---- - This distribution includes cryptographic software. The country in - which you currently reside may have restrictions on the import, - possession, use, and/or re-export to another country, of - encryption software. BEFORE using any encryption software, please + This distribution includes cryptographic software. The country in + which you currently reside may have restrictions on the import, + possession, use, and/or re-export to another country, of + encryption software. BEFORE using any encryption software, please check your country's laws, regulations and policies concerning the - import, possession, or use, and re-export of encryption software, to + import, possession, or use, and re-export of encryption software, to see if this is permitted. See http://www.wassenaar.org for more information. The U.S. Government Department of Commerce, Bureau of Industry and - Security (BIS), has classified this software as Export Commodity + Security (BIS), has classified this software as Export Commodity Control Number (ECCN) 5D002.C.1, which includes information security software using or performing cryptographic functions with asymmetric algorithms. The form and manner of this Apache Software Foundation distribution makes it eligible for export under the License Exception - ENC Technology Software Unrestricted (TSU) exception (see the BIS - Export Administration Regulations, Section 740.13) for both object + ENC Technology Software Unrestricted (TSU) exception (see the BIS + Export Administration Regulations, Section 740.13) for both object code and source code. The following provides more details on the included cryptographic software: - + - jDKIM includes code designed to work with Java SE Security Export classifications and source links can be found at http://www.apache.org/licenses/exports/. ----- \ No newline at end of file +---- diff --git a/arc/pom.xml b/arc/pom.xml new file mode 100644 index 0000000..820ffb6 --- /dev/null +++ b/arc/pom.xml @@ -0,0 +1,85 @@ + + + + 4.0.0 + + + apache-jdkim-project + org.apache.james.jdkim + 0.6-SNAPSHOT + ../pom.xml + + + apache-arc-library + + Apache James :: ARC + A Java implementation for the ARC specification. + http://james.apache.org/jdkim/main/ + 2008 + + + + org.apache.james.jdkim + apache-dmarc-library + ${project.version} + + + org.apache.james.jdkim + apache-dmarc-library + ${project.version} + test-jar + test + + + org.apache.james.jdkim + apache-jdkim-library + ${project.version} + + + org.apache.james.jdkim + apache-jdkim-library + ${project.version} + test-jar + test + + + org.apache.james.jspf + apache-jspf-resolver + ${jspf-resolver.version} + + + junit + junit + + + org.apache.james + apache-mime4j-core + + + org.apache.james + apache-mime4j-dom + + + org.assertj + assertj-core + test + + + diff --git a/arc/src/main/java/org/apache/james/arc/ARCChainValidator.java b/arc/src/main/java/org/apache/james/arc/ARCChainValidator.java new file mode 100644 index 0000000..d2e690c --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ARCChainValidator.java @@ -0,0 +1,198 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.dom.Header; +import org.apache.james.mime4j.stream.Field; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Validates the ARC (Authenticated Received Chain) chain in an email message. + *

+ * This class provides methods to validate the ARC chain by checking the structure, + * verifying ARC-Message-Signature and ARC-Seal headers, and ensuring the integrity + * of previous ARC hops. It uses DNS records and cryptographic verification to + * ensure the authenticity of the ARC chain. + *

+ */ +public class ARCChainValidator { + public static final String ARC_MESSAGE_SIGNATURE = "ARC-Message-Signature"; + public static final String ARC_SEAL = "ARC-Seal"; + private static final String SHA256_RSA = "SHA256withRSA"; + private final Pattern INST_RGX_PATTERN = Pattern.compile("i=([0-9]+)"); + private final PublicKeyRetrieverArc _keyRecordRetriever; + + public ARCChainValidator(PublicKeyRetrieverArc keyRecordRetriever) { + this._keyRecordRetriever = keyRecordRetriever; + } + + public ArcValidationOutcome validateArcChain(Message message) { + + Header messageHeaders = message.getHeader(); + int curInstance = getCurrentInstance(messageHeaders); // Incremented by 1 + + if (curInstance == 1) { //we are the first ARC Hop and there is no previous ARC hops in the chain to validate + return new ArcValidationOutcome(ArcValidationResult.NONE, "No previous ARC hops to validate"); + } + else if (curInstance > 51) { // Not allowed to be > 50 + return new ArcValidationOutcome(ArcValidationResult.FAIL, "ARC instance number exceeds maximum allowed value of 50"); + } + else { // there are previous ARC hops that need to be validated + return validatePreviousArcHops(message, messageHeaders, curInstance); + } + } + + private ArcValidationOutcome validatePreviousArcHops(Message message, Header messageHeaders, int myInstance) { + ARCVerifier arcVerifier = new ARCVerifier(_keyRecordRetriever); + Map> arcHeadersByI; + try { + arcHeadersByI = arcVerifier.getArcHeadersByI(messageHeaders.getFields()); + } catch (IllegalStateException | NumberFormatException e) { + return new ArcValidationOutcome(ArcValidationResult.FAIL, e.getMessage()); + } + int numArcInstances = myInstance - 1; + boolean isArcSetStructureOK; + try { + isArcSetStructureOK = arcVerifier.validateArcSetStructure(arcHeadersByI); + } catch (ArcException | IllegalStateException e) { + return new ArcValidationOutcome(ArcValidationResult.FAIL, e.getMessage()); + } + if (!isArcSetStructureOK) { + return new ArcValidationOutcome(ArcValidationResult.FAIL, "ARC set structure is invalid"); + } + + // RFC 8617 section 5.2: verify AMS for every instance from i=1 to i=N. + for (int i = 1; i <= numArcInstances; i++) { + Set arcSet = arcVerifier.extractArcSet(messageHeaders, i); + if (arcSet == null || !checkArcAms(arcSet, message, arcVerifier)) { + return new ArcValidationOutcome(ArcValidationResult.FAIL, "Previous ARC hop validation failed at i=" + i); + } + } + boolean asOk; + try { + asOk = checkArcSeal(messageHeaders.getFields(), numArcInstances, arcVerifier); + } catch (ArcException | IllegalArgumentException e) { + return new ArcValidationOutcome(ArcValidationResult.FAIL, e.getMessage()); + } + if (asOk) { + return new ArcValidationOutcome(ArcValidationResult.PASS, "All previous ARC hops validated successfully"); + } + return new ArcValidationOutcome(ArcValidationResult.FAIL, "Previous ARC hops validation failed"); + } + + private boolean checkArcAms(Set prevArcSet, Message message, ARCVerifier arcVerifier){ + boolean retVal = false; + + Field amsHeader = prevArcSet.stream() + .filter(f -> f.getName().equalsIgnoreCase(ARC_MESSAGE_SIGNATURE)) + .findFirst().orElse(null); + if (amsHeader == null) return retVal; + + String txtDnsRecord = arcVerifier.getTxtDnsRecordByField(amsHeader); + if (txtDnsRecord == null) return retVal; + + try { + retVal = arcVerifier.verifyAms(amsHeader, message, txtDnsRecord); + } catch (ArcException | IllegalArgumentException e) { + return false; + } + + return retVal; + } + + private boolean checkArcSeal(List headers, int instToVerify, ARCVerifier arcVerifier) { + boolean retVal = false; + Map> arcHeadersByI = arcVerifier.getArcHeadersByI(headers); + ArcSealVerifyData verifyData = arcVerifier.buildArcSealSigningData(arcHeadersByI, instToVerify); + Field arcSealHeader = arcHeadersByI.get(instToVerify).stream() + .filter(f -> f.getName().equalsIgnoreCase(ARC_SEAL)) + .findFirst().orElse(null); + if (arcSealHeader == null) return retVal; + String algorithm = arcVerifier.parseTagGeneric(arcSealHeader.getBody(), "a"); + if (algorithm == null || algorithm.isEmpty()) return false; + arcVerifier.validateSupportedAlgorithm(ARC_SEAL, algorithm); + + String txtDnsRecord = arcVerifier.getTxtDnsRecordByField(arcSealHeader); + if (txtDnsRecord == null) return retVal; + + PublicKey publicKey; + try { + publicKey = arcVerifier.parsePublicKeyFromDns(txtDnsRecord); + } catch (ArcException | IllegalArgumentException e) { + return false; + } + if (publicKey == null) { + throw new ArcException(String.format("Unable to parse public key from dns record %s", txtDnsRecord)); + } + + String b64 = verifyData.getB64Signature(); + String data = verifyData.getSignedData(); + + try { + Signature sig = Signature.getInstance(SHA256_RSA); + sig.initVerify(publicKey); + sig.update(data.getBytes(StandardCharsets.UTF_8)); + if (b64 == null || b64.isEmpty()) { + return false; + } + byte[] signatureBytes = Base64.getDecoder().decode(b64); + retVal = sig.verify(signatureBytes); + } catch (NoSuchAlgorithmException e) { + throw new ArcException("Unsupported signing algorithm", e); + } + catch (InvalidKeyException e) { + throw new ArcException(String.format("Invalid public key used for %s record", txtDnsRecord), e); + } catch (IllegalArgumentException e) { + return false; + } catch (SignatureException e) { + return false; + } + return retVal; + } + + public int getCurrentInstance(Header messageHeaders) { + int retVal = 1; + for (Field field : messageHeaders.getFields()) { + if (field.getName().startsWith("ARC-")) { + Matcher m = INST_RGX_PATTERN.matcher(field.getBody()); + if (m.find()) { + int iVal = Integer.parseInt(m.group(1)); + if (iVal >= retVal) { + retVal = iVal + 1; + } + } + } + } + return retVal; + } +} diff --git a/arc/src/main/java/org/apache/james/arc/ARCCommon.java b/arc/src/main/java/org/apache/james/arc/ARCCommon.java new file mode 100644 index 0000000..09ea6c0 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ARCCommon.java @@ -0,0 +1,134 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.jdkim.api.Headers; +import org.apache.james.jdkim.api.SignatureRecord; +import org.apache.james.jdkim.exceptions.PermFailException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.Signature; +import java.security.SignatureException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +/** + * Utility class for ARC (Authenticated Received Chain) operations. + *

+ * Provides methods for: + *

    + *
  • Canonicalizing and updating cryptographic signatures for ARC headers
  • + *
  • Signing ARC-Message-Signature and ARC-Seal headers
  • + *
  • Copying streams
  • + *
  • Decoding Base64-encoded PKCS#8 private keys
  • + *
+ *

+ * This class is not intended to be instantiated. + */ +public class ARCCommon { + private ARCCommon(){} + + private static void updateSignature(Signature signature, boolean relaxed, + CharSequence header, String fv) throws SignatureException { + if (relaxed) { + signature.update(header.toString().toLowerCase().getBytes()); + signature.update(":".getBytes()); + String headerValue = fv.substring(fv.indexOf(':') + 1); + headerValue = headerValue.replaceAll("\r\n[\t ]", " "); + headerValue = headerValue.replaceAll("[\t ]+", " "); + headerValue = headerValue.trim(); + signature.update(headerValue.getBytes()); + } else { + signature.update(fv.getBytes()); + } + } + + static void amsSign(Headers h, SignatureRecord sign, + List headers, Signature signature) + throws SignatureException, PermFailException { + + boolean relaxedHeaders = isRelaxedHeaders(sign, true); + + Map processedHeader = new HashMap<>(); + + for (CharSequence header : headers) { + List hl = h.getFields(header.toString()); + if (hl != null && !hl.isEmpty()) { + Integer done = processedHeader.get(header.toString()); + if (done == null) + done = 0; + int doneHeaders = done + 1; + if (doneHeaders <= hl.size()) { + String fv = hl.get(hl.size() - doneHeaders); + updateSignature(signature, relaxedHeaders, header, fv); + signature.update("\r\n".getBytes()); + processedHeader.put(header.toString(), doneHeaders); + } + } + } + + String amsHeader = "ARC-Message-Signature:" + sign.toUnsignedString(); + updateSignature(signature, relaxedHeaders, "arc-message-signature", amsHeader); + } + + static void arcSeal(SignatureRecord sign, + List> headersToSeal, Signature signature) + throws SignatureException, PermFailException { + + boolean relaxedHeaders = isRelaxedHeaders(sign, false); + + for (Map.Entry headerEntry : headersToSeal) { + String headerName = headerEntry.getKey(); + String headerValue = headerName+": " +headerEntry.getValue(); + updateSignature(signature, relaxedHeaders, headerName, headerValue); + signature.update("\r\n".getBytes()); + } + + String signatureStub = "ARC-Seal:" + sign.toUnsignedString(); + updateSignature(signature, relaxedHeaders, "arc-seal", + signatureStub); + } + + private static boolean isRelaxedHeaders(SignatureRecord sign, boolean isAms) throws PermFailException { + boolean relaxedHeaders = !isAms || SignatureRecord.RELAXED.equals(sign + .getHeaderCanonicalisationMethod()); // RFC 8617 : ARC-seal: only "relaxed" header field canonicalization allowed + if (!relaxedHeaders + && !SignatureRecord.SIMPLE.equals(sign + .getHeaderCanonicalisationMethod())) { + + throw new PermFailException( + "Unsupported canonicalization algorithm: " + + sign.getHeaderCanonicalisationMethod()); + } + return relaxedHeaders; + } + + public static void streamCopy(InputStream bodyIs, OutputStream out) + throws IOException { + byte[] buffer = new byte[2048]; + int read; + while ((read = bodyIs.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + bodyIs.close(); + out.close(); + } +} diff --git a/arc/src/main/java/org/apache/james/arc/ARCSigner.java b/arc/src/main/java/org/apache/james/arc/ARCSigner.java new file mode 100644 index 0000000..74335f1 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ARCSigner.java @@ -0,0 +1,186 @@ +/**************************************************************** + * 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.james.arc; +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.jdkim.api.BodyHasher; +import org.apache.james.jdkim.api.Headers; +import org.apache.james.jdkim.api.SignatureRecord; +import org.apache.james.jdkim.exceptions.PermFailException; +import org.apache.james.jdkim.impl.BodyHasherImpl; +import org.apache.james.jdkim.impl.Message; + +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.List; +import java.util.Map; + +/** + * ARCSigner is responsible for generating and sealing ARC (Authenticated Received Chain) + * signatures for email messages. It uses a provided private key and signature record template + * to create ARC signature records, hash message bodies, and sign headers or message content. + *

+ * Main responsibilities: + *

    + *
  • Generate ARC signature records using a template
  • + *
  • Hash message bodies for signing
  • + *
  • Sign message headers and bodies using the provided private key
  • + *
  • Seal headers with ARC signatures
  • + *
+ *

+ * This class relies on the Java Cryptography Architecture and helper classes from the + * org.apache.james.jdkim and org.apache.james.arc packages. + */ +public class ARCSigner { + private final PrivateKey privateKey; + private final String signatureRecordTemplate; + + public ARCSigner(String signatureRecordTemplate, PrivateKey privateKey) { + this.privateKey = privateKey; + this.signatureRecordTemplate = signatureRecordTemplate; + } + + public SignatureRecord newSignatureRecordTemplate(String sigRecord) { + return new ArcSignatureRecordImpl(sigRecord); + } + + public BodyHasher newBodyHasher(SignatureRecord signRecord) + throws PermFailException { + return new BodyHasherImpl(signRecord); + } + + public String generateAms(InputStream is){ + Message message; + try (is) { + message = getMessage(is); + return getAmsHeader(message); + } catch (IOException e) { + throw new ArcException("IOException when working with email input stream", e); + } + } + + private String getAmsHeader(Message message) { + try { + SignatureRecord srt = newSignatureRecordTemplate(signatureRecordTemplate); + BodyHasher bhj = newBodyHasher(srt); + + ARCCommon.streamCopy(message.getBodyInputStream(), bhj + .getOutputStream()); + + return generateAms(message, bhj); + } catch (PermFailException | IOException e) { + throw new ArcException("Invalid signature record template", e); + } finally { + message.dispose(); + } + } + + private static Message getMessage(InputStream is) { + Message message; + try { + message = new Message(is); + } catch (Exception e1) { + throw new ArcException("MIME parsing exception: " + + e1.getMessage(), e1); + } + return message; + } + + public String sealHeaders(List> headersToSeal) { + SignatureRecord srt = newSignatureRecordTemplate(signatureRecordTemplate); + return seal(srt, headersToSeal); + } + + public String generateAms(Headers message, BodyHasher bh) throws PermFailException { + if (!(bh instanceof BodyHasherImpl)) { + throw new PermFailException( + "Supplied BodyHasher has not been generated with this signer"); + } + + BodyHasherImpl bhj = (BodyHasherImpl) bh; + List headers; + byte[] computedHash = bhj.getDigest(); + bhj.getSignatureRecord().setBodyHash(computedHash); + headers = bhj.getSignatureRecord().getHeaders(); + + try { + byte[] signatureHash = signatureSign(message, bhj + .getSignatureRecord(), privateKey, headers); + + bhj.getSignatureRecord().setSignature(signatureHash); + return "ARC-element:" + ((ArcSignatureRecordImpl)bhj.getSignatureRecord()).getStringInTemplateOrder(); + } catch (InvalidKeyException e) { + throw new ArcException("Invalid key: " + e.getMessage(), e); + } catch (NoSuchAlgorithmException e) { + throw new ArcException("Unknown algorithm: " + e.getMessage(), e); + } catch (SignatureException e) { + throw new ArcException("Signing exception: " + e.getMessage(), e); + } + } + + public String seal(SignatureRecord signatureRecord, List> headersToSeal) { + + try { + byte[] signatureHash = signatureSeal(signatureRecord, privateKey, headersToSeal); + + signatureRecord.setSignature(signatureHash); + return "ARC-element:" + ((ArcSignatureRecordImpl)signatureRecord).getStringInTemplateOrder(); + } catch (InvalidKeyException e) { + throw new ArcException("Invalid key: " + e.getMessage(), e); + } catch (NoSuchAlgorithmException e) { + throw new ArcException("Unknown algorithm: " + e.getMessage(), e); + } catch (SignatureException e) { + throw new ArcException("Signing exception: " + e.getMessage(), e); + } catch (PermFailException e) { + throw new ArcException("PermFail exception received " + e.getMessage(), e); + } + } + + private byte[] signatureSeal(SignatureRecord sign, PrivateKey key, List> headersToSeal) + throws NoSuchAlgorithmException, InvalidKeyException, + SignatureException, PermFailException { + Signature signature = Signature.getInstance(sign.getHashMethod() + .toString().toUpperCase() + + "with" + sign.getHashKeyType().toString().toUpperCase()); + signature.initSign(key); + + ARCCommon.arcSeal(sign, headersToSeal, signature); + return signature.sign(); + + } + + private byte[] signatureSign(Headers h, SignatureRecord sign, + PrivateKey key, List headers) + throws NoSuchAlgorithmException, InvalidKeyException, + SignatureException, PermFailException { + + Signature signature = Signature.getInstance(sign.getHashMethod() + .toString().toUpperCase() + + "with" + sign.getHashKeyType().toString().toUpperCase()); + signature.initSign(key); + + ARCCommon.amsSign(h, sign, headers, signature); + return signature.sign(); + } +} + diff --git a/arc/src/main/java/org/apache/james/arc/ARCVerifier.java b/arc/src/main/java/org/apache/james/arc/ARCVerifier.java new file mode 100644 index 0000000..7c80aca --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ARCVerifier.java @@ -0,0 +1,712 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.jdkim.exceptions.PermFailException; +import org.apache.james.jdkim.exceptions.TempFailException; +import org.apache.james.mime4j.dom.Body; +import org.apache.james.mime4j.dom.Entity; +import org.apache.james.mime4j.dom.Header; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.dom.Multipart; +import org.apache.james.mime4j.dom.SingleBody; +import org.apache.james.mime4j.dom.field.ContentTypeField; +import org.apache.james.mime4j.io.EOLConvertingInputStream; +import org.apache.james.mime4j.stream.NameValuePair; +import org.apache.james.mime4j.stream.Field; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility class for verifying ARC (Authenticated Received Chain) headers in email messages. + *

+ * Provides methods for: + *

    + *
  • Verifying ARC-Message-Signature (AMS) using public keys from DNS
  • + *
  • Parsing and canonicalizing ARC headers and bodies
  • + *
  • Validating ARC set structure and continuity
  • + *
  • Building DNS queries for public key retrieval
  • + *
  • Extracting and organizing ARC headers by instance
  • + *
  • Looking up DNS TXT records for ARC public keys
  • + *
  • Building signing data for ARC-Seal verification
  • + *
+ *

+ * Instances are configured with a public key retriever and an optional clock. + */ +public class ARCVerifier { + public static final String RSA = "RSA"; + public static final String B_TAG_REGEX = "b=[^;]*"; + public static final Pattern TAG_PATTERN = Pattern.compile("([a-z]+)=([^;]+)"); + public static final Pattern PUBLIC_KEY_PATTERN = Pattern.compile("p=([^;]+)"); + public static final String ARC_AUTHENTICATION_RESULTS = "ARC-Authentication-Results"; + public static final String ARC_MESSAGE_SIGNATURE = "ARC-Message-Signature"; + public static final String ARC_SEAL = "ARC-Seal"; + public static final String SHA256RSA = "SHA256withRSA"; + private static final Pattern DOMAIN_PATTERN = Pattern.compile("(?i)^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$"); + private static final Pattern SELECTOR_PATTERN = Pattern.compile("(?i)^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$"); + private static final int MIN_RSA_KEY_BITS = 1024; + private static final int MIN_ARC_INSTANCE = 1; + private static final int MAX_ARC_INSTANCE = 50; + private static final String DNS_RECORD_TYPE = "_domainkey"; + private final PublicKeyRetrieverArc _keyRecordRetriever; + private final Clock clock; + + public ARCVerifier(PublicKeyRetrieverArc keyRecordRetriever) { + this(keyRecordRetriever, Clock.systemUTC()); + } + + public ARCVerifier(PublicKeyRetrieverArc keyRecordRetriever, Clock clock) { + _keyRecordRetriever = Objects.requireNonNull(keyRecordRetriever); + this.clock = Objects.requireNonNull(clock); + } + + public boolean verifyAms(Field amsField, Message message, String publicKeyDnsRecord) { + // Extract AMS params + String amsValue = amsField.getBody(); + Map tags = parseTagList(amsValue); + + String algorithm = tags.get("a"); + String signedHeaders = tags.get("h"); + String bodyHash = tags.get("bh"); + String signatureB64 = tags.get("b"); + if (!validateAmsTags(tags) || algorithm == null || algorithm.isEmpty() || bodyHash == null || bodyHash.isEmpty() + || signatureB64 == null || signatureB64.isEmpty()) { + return false; + } + validateSupportedAlgorithm("ARC-Message-Signature", algorithm); + validateAmsTimestamp(tags); + String b64 = signatureB64 + .replaceAll("\\s+", "") // remove spaces, tabs, newlines + .replace(";", ""); // defensive: strip trailing semicolon if present + + if (signedHeaders == null) { + throw new ArcException("AMS missing required tags"); + } + if (signsArcSealHeader(signedHeaders)) { + return false; + } + Canonicalization canonicalization = getCanonicalization(tags.get("c")); + if (canonicalization == null || !verifyAmsBodyHash(tags, message, canonicalization.body)) { + return false; + } + + String amsForSigning = amsValue.replaceFirst(B_TAG_REGEX, "b="); + // Canonicalize headers listed in h= + StringBuilder signingData = new StringBuilder(); + Map processedHeaders = new HashMap<>(); + for (String hName : signedHeaders.split(":")) { + hName = hName.trim(); + List fields = message.getHeader().getFields(hName); + if (fields != null && !fields.isEmpty()) { + Integer done = processedHeaders.get(hName); + if (done == null) { + done = 0; + } + int doneHeaders = done + 1; + if (doneHeaders > fields.size()) { + continue; + } + Field f = fields.get(fields.size() - doneHeaders); + signingData.append(canonicalizeRegularHeader(f, canonicalization.header)); + processedHeaders.put(hName, doneHeaders); + } + } + + // AMS itself must be included last + signingData.append(canonicalizeHeader(amsField.getName(), amsForSigning, canonicalization.header)); + + // Build RSA public key from DNS record + PublicKey publicKey = parsePublicKeyFromDns(publicKeyDnsRecord); + + // Verify signature + Signature sig = getSignature( publicKey, signingData); + + byte[] signatureBytes = Base64.getDecoder().decode(b64); + + boolean result = false; + if (sig != null) { + try { + result = sig.verify(signatureBytes); + } catch (SignatureException e) { + throw new ArcException("Signature verification failed", e); + } + } + return result; + } + + void validateSupportedAlgorithm(String headerName, String algorithm) { + if (!"rsa-sha256".equals(algorithm)) { + throw new ArcException(headerName + " uses unsupported algorithm: " + algorithm); + } + } + + private boolean signsArcSealHeader(String signedHeaders) { + return Arrays.stream(signedHeaders.split(":")) + .map(String::trim) + .map(headerName -> headerName.toLowerCase(Locale.US)) + .anyMatch("arc-seal"::equals); + } + + private boolean validateAmsTags(Map tags) { + String domain = tags.get("d"); + String selector = tags.get("s"); + String timestamp = tags.get("t"); + String expiration = tags.get("x"); + if (domain == null || domain.isEmpty() || !DOMAIN_PATTERN.matcher(domain).matches()) { + return false; + } + if (selector == null || selector.isEmpty() || !SELECTOR_PATTERN.matcher(selector).matches()) { + return false; + } + return (timestamp == null || timestamp.matches("\\d+")) + && (expiration == null || expiration.matches("\\d+")); + } + + private void validateAmsTimestamp(Map tags) { + String timestamp = tags.get("t"); + String expiration = tags.get("x"); + long now = clock.instant().getEpochSecond(); + if (timestamp != null && Long.parseLong(timestamp) > now) { + throw new ArcException("AMS t= timestamp must not be in the future"); + } + if (expiration == null) { + return; + } + long expirationEpoch = Long.parseLong(expiration); + if (timestamp != null && expirationEpoch <= Long.parseLong(timestamp)) { + throw new ArcException("AMS x= expiration must be greater than t= timestamp"); + } + if (expirationEpoch < now) { + throw new ArcException("AMS signature is expired"); + } + } + + private boolean verifyAmsBodyHash(Map tags, Message message, String bodyCanonicalization) { + String bodyHash = tags.get("bh"); + byte[] expectedBodyHash; + try { + expectedBodyHash = Base64.getDecoder().decode(bodyHash.replaceAll("\\s+", "")); + } catch (IllegalArgumentException e) { + return false; + } + + byte[] computedBodyHash; + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] bodyBytes = message.getBody() == null ? new byte[0] : readBodyBytes(message.getBody()); + byte[] canonicalizedBody = canonicalizeBody(bodyBytes, bodyCanonicalization); + computedBodyHash = messageDigest.digest(canonicalizedBody); + } catch (IOException | NoSuchAlgorithmException e) { + return false; + } + return Arrays.equals(expectedBodyHash, computedBodyHash); + } + + private byte[] readBodyBytes(Body body) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeBody(body, out); + return out.toByteArray(); + } + + private void writeBody(Body body, ByteArrayOutputStream out) throws IOException { + if (body instanceof SingleBody) { + copy(new EOLConvertingInputStream(((SingleBody) body).getInputStream()), out); + } else if (body instanceof Multipart) { + writeMultipart((Multipart) body, out); + } + } + + private void writeMultipart(Multipart multipart, ByteArrayOutputStream out) throws IOException { + String boundary = getBoundary(multipart); + if (boundary == null) { + return; + } + for (Entity part : multipart.getBodyParts()) { + out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + for (Field field : part.getHeader().getFields()) { + out.write((field.getName() + ": " + field.getBody() + "\r\n").getBytes(StandardCharsets.UTF_8)); + } + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + writeBody(part.getBody(), out); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + out.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); + } + + private String getBoundary(Multipart multipart) { + for (NameValuePair parameter : multipart.getContentTypeParameters()) { + if ("boundary".equalsIgnoreCase(parameter.getName())) { + return parameter.getValue(); + } + } + Entity parent = multipart.getParent(); + if (parent != null && parent.getHeader() != null + && parent.getHeader().getField("Content-Type") instanceof ContentTypeField) { + return ((ContentTypeField) parent.getHeader().getField("Content-Type")).getBoundary(); + } + return null; + } + + private void copy(InputStream inputStream, ByteArrayOutputStream out) throws IOException { + try (InputStream in = inputStream) { + byte[] buffer = new byte[2048]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + } + } + + private byte[] canonicalizeSimpleBody(byte[] body) { + String normalized = new String(body, StandardCharsets.UTF_8).replaceAll("(? 2) { + return null; + } + headerCanonicalization = parts[0]; + bodyCanonicalization = parts.length == 1 ? "simple" : parts[1]; + } + if (!"simple".equals(headerCanonicalization) && !"relaxed".equals(headerCanonicalization)) { + return null; + } + if (!"simple".equals(bodyCanonicalization) && !"relaxed".equals(bodyCanonicalization)) { + return null; + } + return new Canonicalization(headerCanonicalization, bodyCanonicalization); + } + + private Signature getSignature(PublicKey publicKey, StringBuilder signingData) { + Signature sig; + try { + sig = Signature.getInstance(SHA256RSA); + sig.initVerify(publicKey); + String dataToSign = signingData.toString(); + sig.update(dataToSign.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new ArcException("Unsupported signing algorithm when used with public key", e); + } catch (InvalidKeyException e) { + throw new ArcException("Invalid key when used with public key", e); + } catch (SignatureException e) { + throw new ArcException("Invalid signature when used with public key", e); + } + return sig; + } + + public Map parseTagList(String value) { + Map map = new HashMap<>(); + String[] parts = value.split(";"); + for (String part : parts) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + int equal = trimmed.indexOf('='); + if (equal == -1) { + continue; + } + String tag = trimmed.substring(0, equal).trim(); + String tagValue = trimmed.substring(equal + 1).trim(); + if (tag.matches("[a-z]+")) { + map.put(tag, tagValue); + } + } + return map; + } + + private String canonicalizeRegularHeader(Field field, String headerCanonicalization) { + String retVal = canonicalizeHeader(field.getName(), field.getBody(), headerCanonicalization); + return retVal + "\r\n"; + } + + private String canonicalizeHeader(String name, String value, String headerCanonicalization) { + if ("simple".equals(headerCanonicalization)) { + String separator = value.startsWith(" ") || value.startsWith("\t") ? ":" : ": "; + return name + separator + value; + } + // relaxed canonicalization: lowercase field name, unfold spaces, trim + String n = name.toLowerCase(Locale.ROOT); + String v = value.replaceAll("[\\r\\n]+", " ") + .replaceAll("\\s+", " ") + .trim(); + return n + ":" + v; + } + + private static class Canonicalization { + private final String header; + private final String body; + + private Canonicalization(String header, String body) { + this.header = header; + this.body = body; + } + } + + public PublicKey parsePublicKeyFromDns(String dnsRecord) { + Matcher m = PUBLIC_KEY_PATTERN.matcher(dnsRecord); + + if (!m.find()) { + throw new IllegalArgumentException("Illegal argument exception -- No p= tag in DNS record"); + } + + String base64Key = m.group(1).replaceAll("\\s+", ""); // remove ALL spaces/newlines + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + PublicKey pubKey; + try { + pubKey = KeyFactory.getInstance(RSA).generatePublic(spec); + } catch (InvalidKeySpecException e) { + throw new ArcException("Invalid key provided when getting public key", e); + } catch (NoSuchAlgorithmException e) { + throw new ArcException("Unsupported algorithm provided when getting public key", e); + } + if (pubKey instanceof RSAPublicKey + && ((RSAPublicKey) pubKey).getModulus().bitLength() < MIN_RSA_KEY_BITS) { + throw new ArcException("RSA public key must be at least " + MIN_RSA_KEY_BITS + " bits"); + } + return pubKey; + } + + public boolean validateArcSetStructure(Map> arcHeadersByI) { + for (int i = 1; i <= arcHeadersByI.size(); i++) { + List arcSet = arcHeadersByI.get(i); + if (arcSet == null) { // continuity of instances is broken + throw new IllegalStateException("ARC Chain validation fails due to i instances not continued after [" + (i - 1) + "] instance."); + } + + boolean eachOfOne = checkArcSetCompose(arcSet); + if (!eachOfOne) { + throw new ArcException("ARC Chain validation fails due to one or more ARC Set headers missing at instance [" + i + "]."); + } + + if (arcSet.size() != 3){ + throw new ArcException("ARC Chain validation fails due to incorrect size of Arc Headers (not 3) at instance [" + i + "]."); + } + + boolean cvOk = checkCv(arcSet, i); + if (!cvOk) { + throw new ArcException("ARC Chain validation fails due to cv check failing at instance [" + i + "]."); + } + + boolean sealTagsOk = checkArcSealTags(arcSet); + if (!sealTagsOk) { + throw new ArcException("ARC Chain validation fails due to invalid ARC-Seal tags at instance [" + i + "]."); + } + } + return true; + } + + private boolean checkArcSealTags(List arcSet) { + Optional arcSealHeader = arcSet.stream() + .filter(f -> f.getName().equalsIgnoreCase(ARC_SEAL)) + .findFirst(); + return arcSealHeader + .map(field -> { + Map tags = parseTagList(field.getBody()); + return hasRequiredArcSealTags(tags) + && isValidArcSealCv(tags.get("cv")) + && DOMAIN_PATTERN.matcher(tags.get("d")).matches() + && SELECTOR_PATTERN.matcher(tags.get("s")).matches() + && !tags.containsKey("h"); + }) + .orElse(false); + } + + private boolean hasRequiredArcSealTags(Map tags) { + return hasNonEmptyTag(tags, "i") + && hasNonEmptyTag(tags, "a") + && hasNonEmptyTag(tags, "cv") + && hasNonEmptyTag(tags, "d") + && hasNonEmptyTag(tags, "s") + && hasNonEmptyTag(tags, "b"); + } + + private boolean hasNonEmptyTag(Map tags, String tagName) { + String value = tags.get(tagName); + return value != null && !value.replaceAll("\\s+", "").isEmpty(); + } + + private boolean isValidArcSealCv(String cv) { + return "none".equalsIgnoreCase(cv) + || "pass".equalsIgnoreCase(cv) + || "fail".equalsIgnoreCase(cv); + } + + private boolean checkCv(List lastArcSet, int instToVerify) { + Optional arcSealHeader = lastArcSet.stream().filter(f -> f.getName().equalsIgnoreCase(ARC_SEAL)).findFirst(); + if (arcSealHeader.isPresent()) { + Map tags = parseTagList(arcSealHeader.get().getBody()); + String lastCv = tags.get("cv"); + if (lastCv == null) { + return false; + } + return (instToVerify == 1 && lastCv.equalsIgnoreCase("none")) || + (instToVerify > 1 && lastCv.equalsIgnoreCase("pass")); + } + return false; + } + + private boolean checkArcSetCompose(List arcSet) { + Optional aar = arcSet.stream().filter(p-> p.getName() + .equalsIgnoreCase(ARC_AUTHENTICATION_RESULTS)).findFirst(); + + Optional ams = arcSet.stream().filter(p-> p.getName() + .equalsIgnoreCase(ARC_MESSAGE_SIGNATURE)).findFirst(); + + Optional as = arcSet.stream().filter(p-> p.getName() + .equalsIgnoreCase(ARC_SEAL)).findFirst(); + return aar.isPresent() && ams.isPresent() && as.isPresent(); + } + + public String buildDnsQuery(Field signedField, String recordType) { + String retVal = ""; + Map tags = parseTagList(signedField.getBody()); + if (tags.isEmpty()) { // we should always have tags on the valid AMS + return retVal; + } + String amsSelector = tags.get("s"); + String amsDomain = tags.get("d"); + if (amsSelector == null || amsDomain == null) { // we should always have these tags on the valid AMS + return retVal; + } + retVal = amsSelector+"."+ recordType+"."+amsDomain; + return retVal; + } + + public Map> getArcHeadersByI(List headers) { + Map> headersByI = new TreeMap<>(); + for (Field f : headers) { + String name = f.getName().toUpperCase(Locale.ROOT); + if (name.startsWith("ARC-")) { + int i = parseArcInstance(f); + headersByI.computeIfAbsent(i, k -> new ArrayList<>()).add(f); + } + } + return headersByI; + } + + private int parseArcInstance(Field field) { + String iTag = parseTagGeneric(field.getBody(), "i"); + if (iTag == null) { + throw new IllegalStateException("ARC Header missing i= tag"); + } + int instance; + try { + instance = Integer.parseInt(iTag); + } catch (NumberFormatException e) { + throw new IllegalStateException("ARC Header has invalid i= tag", e); + } + if (instance < MIN_ARC_INSTANCE || instance > MAX_ARC_INSTANCE) { + throw new IllegalStateException("ARC Header i= tag must be between 1 and 50"); + } + return instance; + } + + public String canonicalizeBody(String body) { + body = body.replaceAll("\r\n[\t ]", " "); + body = body.replaceAll("[\t ]+", " "); + body = body.trim(); + return body; + } + + public String parseTagGeneric(String record, String tag) { + String[] parts = record.split(";"); + for (String part : parts) { + String trimmed = part.trim(); + int equal = trimmed.indexOf('='); + if (equal == -1) { + continue; + } + String tagName = trimmed.substring(0, equal).trim(); + if (tagName.equals(tag)) { + return trimmed.substring(equal + 1).trim(); + } + } + return null; + } + + public ArcSealVerifyData buildArcSealSigningData(Map> headersByI, int targetI) { + ArcSealVerifyData result = null; + StringBuilder signingData = new StringBuilder(); + + //Iterate over hops in ascending i order, for the last hop, make sure to clear b= tag on the ARC-Seal + for (Map.Entry> entry : headersByI.entrySet()) { + int hopI = entry.getKey(); + if (hopI > targetI) break; + + List hopFields = entry.getValue(); + Optional aar = hopFields.stream().filter(p-> p.getName() + .equalsIgnoreCase(ARC_AUTHENTICATION_RESULTS)).findFirst(); + + Optional ams = hopFields.stream().filter(p-> p.getName() + .equalsIgnoreCase(ARC_MESSAGE_SIGNATURE)).findFirst(); + + Optional as = hopFields.stream().filter(p-> p.getName() + .equalsIgnoreCase(ARC_SEAL)).findFirst(); + + aar.ifPresent(f -> signingData + .append(f.getName().toLowerCase(Locale.ROOT)) + .append(":").append(canonicalizeBody(f.getBody())) + .append("\r\n")); + + ams.ifPresent(f -> signingData + .append(f.getName().toLowerCase(Locale.ROOT)) + .append(":").append(canonicalizeBody(f.getBody())) + .append("\r\n")); + + if (hopI == targetI && as.isPresent()) { // this is last hop so we need to clear b= tag on the ARC-Seal Header and not tail it with CRLF + Field asField = as.get(); + Map tags = parseTagList(asField.getBody()); + String signatureB64 = tags.get("b"); + String b64 = signatureB64 == null ? null : signatureB64.replaceAll("\\s+", "").replace(";", ""); + String arcSealBodyClearedB = asField.getBody().replaceAll("\\bb=([^;]*)", "b="); + signingData.append(asField.getName().toLowerCase(Locale.ROOT)) + .append(":").append(canonicalizeBody(arcSealBodyClearedB)); + result = new ArcSealVerifyData(b64, signingData.toString()); + break; // we have the target hop, can exit loop + } + else { // this is one of the previous hops, not the last one, so we want to preserve b= tag on the ARC-Seal and tail it with CRLF + as.ifPresent(f -> signingData + .append(f.getName().toLowerCase(Locale.ROOT)) + .append(":").append(canonicalizeBody(f.getBody())) + .append("\r\n")); + } + } + return result; + } + + public Set extractArcSet(Header messageHeaders, int instance) { + Set prevArcSet = null; + for (Field field : messageHeaders.getFields()) { + if (field.getName().startsWith("ARC-") && hasArcInstance(field, instance)) { + if (prevArcSet == null) { + prevArcSet = new HashSet<>(); + } + prevArcSet.add(field); + } + } + return prevArcSet; + } + + private boolean hasArcInstance(Field field, int instance) { + String iTag = parseTagGeneric(field.getBody(), "i"); + if (iTag == null) { + return false; + } + try { + return Integer.parseInt(iTag) == instance; + } catch (NumberFormatException e) { + return false; + } + } + + public String getTxtDnsRecordByField(Field signedHeader) { + String dnsQuery = buildDnsQuery(signedHeader, DNS_RECORD_TYPE); + if (dnsQuery == null || dnsQuery.isEmpty()) return null; // corrupted AMS - unable to pull PubKey from DNS + Map tags = parseTagList(signedHeader.getBody()); + if (tags.isEmpty()) { // we should always have tags on the valid AMS + throw new ArcException("Missing tags for dns record") ; + } + String amsSelector = tags.get("s"); + String amsDomain = tags.get("d"); + + try { + List results = getPublicKeyRecordRetriever().getRecords("dns/txt", amsSelector, amsDomain); + if (results != null && !results.isEmpty()) { + return results.get(0); //Todo: handle multiple records? + } + } catch (TempFailException e) { + throw new RuntimeException(e); + } catch (PermFailException e) { + throw new RuntimeException(e); + } + return null; + } + + protected PublicKeyRetrieverArc getPublicKeyRecordRetriever() + { + return _keyRecordRetriever; + } +} diff --git a/arc/src/main/java/org/apache/james/arc/ArcSealVerifyData.java b/arc/src/main/java/org/apache/james/arc/ArcSealVerifyData.java new file mode 100644 index 0000000..12b449c --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ArcSealVerifyData.java @@ -0,0 +1,37 @@ +/**************************************************************** + * 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.james.arc; + +public final class ArcSealVerifyData { + private final String b64; + private final String dataToVerify; + + public ArcSealVerifyData(String b64, String dataToVerify) { + this.b64 = b64; + this.dataToVerify = dataToVerify; + } + + public String getB64Signature() { + return b64; + } + + public String getSignedData() { + return dataToVerify; + } +} \ No newline at end of file diff --git a/arc/src/main/java/org/apache/james/arc/ArcSetBuilder.java b/arc/src/main/java/org/apache/james/arc/ArcSetBuilder.java new file mode 100644 index 0000000..6ca5d51 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ArcSetBuilder.java @@ -0,0 +1,180 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.dmarc.exceptions.DmarcException; +import org.apache.james.mime4j.dom.Header; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.DefaultMessageWriter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.PrivateKey; +import java.time.Instant; +import org.apache.james.mime4j.stream.Field; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Builder class for generating ARC (Authenticated Received Chain) header sets. + *

+ * This class is responsible for constructing and signing ARC-Authentication-Results, + * ARC-Message-Signature, and ARC-Seal headers for a given email message, using + * provided templates and cryptographic keys. + *

+ *

+ * Usage involves providing the necessary templates, DMARC responses, authentication + * service, and private key. The {@link #buildArcSet(Message, String, String, String, PublicKeyRetrieverArc)} + * method generates the ARC headers and returns them as a map. + *

+ */ +public class ArcSetBuilder { + public static final String ARC_ELEMENT = "ARC-element:"; + public static final String ARC_SEAL = "ARC-Seal"; + public static final String ARC_MESSAGE_SIGNATURE = "ARC-Message-Signature"; + public static final String AUTHENTICATION_RESULTS = "Authentication-Results"; + public static final String ARC_AUTHENTICATION_RESULTS = "ARC-Authentication-Results"; + + private final PrivateKey _arcPrivateKey; + private final String _arcAmsTemplate; + private final String _arcSealTemplate; + private final String _authService; + private long _debugTimestamp; + + public ArcSetBuilder(PrivateKey arcPrivateKey, String arcAmsTemplate, String arcSealTemplate, + String authService, long debugTimestamp) { + this(arcPrivateKey, arcAmsTemplate, arcSealTemplate, authService); + _debugTimestamp = debugTimestamp; + } + + public ArcSetBuilder(PrivateKey arcPrivateKey, String arcAmsTemplate, String arcSealTemplate, + String authService) { + _arcAmsTemplate = arcAmsTemplate; + _arcSealTemplate = arcSealTemplate; + _arcPrivateKey = arcPrivateKey; + _authService = authService; + } + + /** + * Builds the ARC (Authenticated Received Chain) header set for the given email message. + *

+ * This method generates and signs the ARC-Authentication-Results, ARC-Message-Signature, + * and ARC-Seal headers using the provided message, HELO, MAIL FROM, and IP address. + * The headers are constructed using configured templates and cryptographic keys. + *

+ * + * @param message the email message to process + * @param helo the HELO/EHLO string from the SMTP transaction + * @param mailFrom the MAIL FROM address from the SMTP transaction + * @param ip the connecting client IP address + * @param keyRecordRetriever + * @return a map containing the generated ARC headers and their values + * @throws ArcException if ARC header generation or signing fails + */ + public Map buildArcSet(Message message, String helo, String mailFrom, String ip, PublicKeyRetrieverArc keyRecordRetriever) throws DmarcException { + Map arcHeaders = new HashMap<>(); + + Header headers = message.getHeader(); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + AuthResultsBuilder authResultsBuilder = new AuthResultsBuilder(_authService, keyRecordRetriever); + ArcValidationOutcome cvOutcome = arcChainValidator.validateArcChain(message); + String cv = cvOutcome.getResult().toString().toLowerCase(); + int instance = arcChainValidator.getCurrentInstance(headers); + + //Build ARC-Authentication-Results header + String arHeaderValue = authResultsBuilder.getAuthResultsHeader(message, helo, mailFrom,ip); + if (arHeaderValue == null){ + throw new ArcException("Unable to build Authentication-Results header"); + } + + arcHeaders.put(AUTHENTICATION_RESULTS, arHeaderValue); + + // Collect all prior ARC headers (i=1..instance-1) in spec-required order: AAR, AMS, AS per hop + List> headersToSeal = new ArrayList<>(); + ARCVerifier arcVerifier = new ARCVerifier(keyRecordRetriever); + Map> priorArcHeaders = arcVerifier.getArcHeadersByI(headers.getFields()); + for (Map.Entry> hopEntry : priorArcHeaders.entrySet()) { + List hopFields = hopEntry.getValue(); + for (String arcHdrName : Arrays.asList(ARC_AUTHENTICATION_RESULTS, ARC_MESSAGE_SIGNATURE, ARC_SEAL)) { + hopFields.stream() + .filter(f -> f.getName().equalsIgnoreCase(arcHdrName)) + .findFirst() + .ifPresent(f -> headersToSeal.add(new AbstractMap.SimpleEntry<>(f.getName(), f.getBody()))); + } + } + + String aarHeaderValue = "i=" + instance + "; " + arHeaderValue.trim(); + arcHeaders.put(ARC_AUTHENTICATION_RESULTS, aarHeaderValue); + headersToSeal.add(new AbstractMap.SimpleEntry<>(ARC_AUTHENTICATION_RESULTS, aarHeaderValue)); + DefaultMessageWriter writer = new DefaultMessageWriter(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + + try { + writer.writeMessage(message,os); + } catch (IOException e) { + throw new ArcException("Unable to copy email message into the output stream", e); + } + + Map fmContext = new HashMap<>(); + fmContext.put("instance", instance); + long timestamp = Instant.now().getEpochSecond(); + if (_debugTimestamp != 0) { + timestamp = _debugTimestamp; + } + fmContext.put("timestamp", Long.toString(timestamp)); + fmContext.put("cv", cv); + + //Build and add ARC-AMS header + String amsTemplate = fillArcTemplate(_arcAmsTemplate, instance, timestamp); + ARCSigner amsSigner = new ARCSigner(amsTemplate, _arcPrivateKey); + + String amsHeader = null; + amsHeader = amsSigner.generateAms(new ByteArrayInputStream(os.toByteArray())); + + String amsValue = amsHeader.split(ARC_ELEMENT)[1]; + arcHeaders.put(ARC_MESSAGE_SIGNATURE, amsValue); + headersToSeal.add(new AbstractMap.SimpleEntry<>(ARC_MESSAGE_SIGNATURE, amsValue)); + + //Build and add ARC-Seal header + String asTemplate = fillArcSealTemplate(_arcSealTemplate, instance, timestamp, cv); + ARCSigner asSigner = new ARCSigner(asTemplate, _arcPrivateKey); + String asHeader = asSigner.sealHeaders(headersToSeal ); + String asValue = asHeader.split(ARC_ELEMENT)[1]; + arcHeaders.put(ARC_SEAL, asValue); + return arcHeaders; + } + + private String fillArcSealTemplate(String template, int instance, long timestamp, String cv) { + String filledCv = template.replaceAll("cv=\\s*;", "cv=" + cv + ";"); + return fillArcTemplate(filledCv, instance, timestamp); + } + + private String fillArcTemplate(String template, int instance, long timestamp) { + return template + .replaceAll("i=\\s*;", "i=" + instance + ";") + .replaceAll("t=\\s*;", "t=" + timestamp + ";"); + } +} diff --git a/arc/src/main/java/org/apache/james/arc/ArcSignatureRecordImpl.java b/arc/src/main/java/org/apache/james/arc/ArcSignatureRecordImpl.java new file mode 100644 index 0000000..4eac960 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ArcSignatureRecordImpl.java @@ -0,0 +1,176 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.jdkim.tagvalue.SignatureRecordImpl; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +/** + * Implementation of an ARC (Authenticated Received Chain) signature record. + *

+ * This class extends {@link SignatureRecordImpl} to provide parsing, validation, + * and string formatting for ARC signature header fields as defined in the ARC protocol. + * It maintains the original order of tags and supports validation of expiration, + * header lists, and tag/value syntax. + *

+ */ +public class ArcSignatureRecordImpl extends SignatureRecordImpl { + private static final Pattern hdrNamePattern = Pattern.compile("^[^: \r\n\t]+$"); + private static final Pattern tagPattern = Pattern.compile("^[A-Za-z][A-Za-z0-9_]*$"); + private static final String tagValFormatPattern = "[^; \t\r\n]++"; + private static final Pattern valuePattern = Pattern.compile("^(?:" + tagValFormatPattern + + "(?:(?:(?:\r\n)?[\t ])++" + tagValFormatPattern + ")*+)?$"); + private final Map tagValuesOriginal = new LinkedHashMap<>(); + + public ArcSignatureRecordImpl(String data) { + super(data); + parseOriginal(data); + } + + @Override + public void validate() throws ArcException { + if (getValue("x") != null) { + long expiration = Long.parseLong(getValue("x").toString()); + long lifetime = (expiration - System.currentTimeMillis() / 1000); + if (lifetime < 0) { + String expired = getTimeMeasureText(lifetime); + throw new ArcException("Signature is expired since " + + expired + " ago."); + } + } + } + + @Override + public List getHeaders() { + if (getValue("h") == null) + return new ArrayList<>(); + else + return stringToColonSeparatedList(getValue("h").toString(), + hdrNamePattern); + } + + private String getTimeMeasureText(long lifetime) { + Duration duration = Duration.ofSeconds(lifetime); + long days = duration.toDays(); + long hours = duration.toHours() % 24; + long minutes = duration.toMinutes() % 60; + long seconds = duration.getSeconds() % 60; + + return String.format("%d days, %d hours, %d minutes, %d seconds", + days, hours, minutes, seconds); + } + + @Override + public String toUnsignedString() { + String retValue = toString().replaceFirst("b=[^;]*", "b="); + return getOrigOrderedString(retValue); + } + + private String getOrigOrderedString(String retValue) { + List retValPartsList = Arrays.asList(retValue.trim().split(";")); + StringBuilder sb = new StringBuilder(); + int originalTagIndex = 0; + for (String tag : tagValuesOriginal.keySet()) { + String tagPart = retValPartsList.stream().filter(p -> p.trim().startsWith(tag + "=")).findFirst().orElse(null); + if (tagPart != null) { + boolean isLastTag = originalTagIndex == tagValuesOriginal.size() - 1; + if (tagPart.trim().startsWith("h") && tagPart.contains(":")) { + tagPart = tagPart.replace(":", " : "); + sb.append(tagPart.toLowerCase().trim()); + } else { + sb.append(tagPart.trim()); + } + if (!isLastTag) { + sb.append("; "); + } + } + originalTagIndex++; + } + return sb.toString(); + } + + public String getStringInTemplateOrder(){ + return getOrigOrderedString(toString()); + } + + private void parseOriginal(String data) { + for (int i = 0; i < data.length(); i++) { + int equal = data.indexOf('=', i); + if (equal == -1) { + String rest = data.substring(i); + if (!rest.isEmpty() + && trimFWS(rest, 0, rest.length() - 1, true).length() > 0) { + throw new IllegalStateException( + "Unexpected termination at position " + i + ": " + + data + " | [" + rest + "]"); + } + i = data.length(); + continue; + } + // we could start from "equals" but we start from "i" in + // order to spot invalid values before validation. + int next = data.indexOf(';', i); + if (next == -1) { + next = data.length(); + } + + if (equal > next) { + throw new IllegalStateException("Found ';' before '=' in " + + data); + } + + CharSequence tag = trimFWS(data, i, equal - 1, true).toString(); + if (VALIDATION && !tagPattern.matcher(tag).matches()) { + throw new IllegalStateException("Syntax error in tag: " + tag); + } + String tagString = tag.toString(); + if (tagValuesOriginal.containsKey(tagString)) { + throw new IllegalStateException( + "Syntax error (duplicate tag): " + tag); + } + + CharSequence value = trimFWS(data, equal + 1, next - 1, true); + if (VALIDATION && !valuePattern.matcher(value).matches()) { + throw new IllegalStateException("Syntax error in value: " + + value); + } + + tagValuesOriginal.put(tagString, value); + i = next; + } + } + + @Override + public CharSequence getIdentity() { + // In ARC, i= is just an integer + return getValue("i"); + } + + @Override + public CharSequence getIdentityLocalPart() { + // Not applicable for ARC + return getIdentity(); + } +} diff --git a/arc/src/main/java/org/apache/james/arc/ArcValidationOutcome.java b/arc/src/main/java/org/apache/james/arc/ArcValidationOutcome.java new file mode 100644 index 0000000..9f42386 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ArcValidationOutcome.java @@ -0,0 +1,42 @@ +/**************************************************************** + * 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.james.arc; + +public class ArcValidationOutcome { + private final ArcValidationResult result; + private final String description; + + public ArcValidationOutcome(ArcValidationResult result, String explanation) { + this.result = result; + this.description = explanation; + } + + public ArcValidationResult getResult() { + return result; + } + + public String getDescription() { + return description; + } + + @Override + public String toString() { + return result + (description != null ? " (" + description + ")" : ""); + } +} diff --git a/arc/src/main/java/org/apache/james/arc/ArcValidationResult.java b/arc/src/main/java/org/apache/james/arc/ArcValidationResult.java new file mode 100644 index 0000000..a549919 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/ArcValidationResult.java @@ -0,0 +1,25 @@ +/**************************************************************** + * 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.james.arc; + +public enum ArcValidationResult { + NONE, + PASS, + FAIL +} diff --git a/arc/src/main/java/org/apache/james/arc/AuthResultsBuilder.java b/arc/src/main/java/org/apache/james/arc/AuthResultsBuilder.java new file mode 100644 index 0000000..26b561e --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/AuthResultsBuilder.java @@ -0,0 +1,224 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.dmarc.DMARCVerifier; +import org.apache.james.dmarc.DmarcValidationResult; +import org.apache.james.jdkim.DKIMVerifier; +import org.apache.james.jdkim.api.SignatureRecord; +import org.apache.james.jdkim.exceptions.FailException; +import org.apache.james.jdkim.exceptions.PermFailException; +import org.apache.james.jdkim.exceptions.TempFailException; +import org.apache.james.jdkim.tagvalue.SignatureRecordImpl; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.DefaultMessageWriter; +import org.apache.james.mime4j.stream.Field; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Builds the Authentication-Results header for email messages by performing SPF, DKIM, and DMARC checks. + *

+ * This class runs SPF and DKIM verifications on the provided message and then evaluates DMARC alignment + * using the results and the message's From domain. It constructs a formatted Authentication-Results header + * string summarizing the authentication status. + *

+ *
    + *
  • SPF: Uses the sender's IP, HELO, and envelope-from address.
  • + *
  • DKIM: Verifies DKIM signatures in the message.
  • + *
  • DMARC: Checks alignment and policy based on DNS records for the From domain.
  • + *
+ *

+ * Throws {@link org.apache.james.arc.exceptions.ArcException} for errors in the authentication process. + *

+ */ +public class AuthResultsBuilder { + public static final String HEADER_I = "header.i="; + public static final String AUTHENTICATION_RESULTS = "Authentication-Results"; + private final PublicKeyRetrieverArc _keyRecordRetriever; + private String _authService; + + public AuthResultsBuilder(String authService, PublicKeyRetrieverArc keyRecordRetriever) { + this._authService = authService; + this._keyRecordRetriever = keyRecordRetriever; + } + + public String getAuthResultsHeader(Message message, String helo, String from, String ip) { + String consolidatedAuthResults = consolidateExistingAuthResults(message); + if (consolidatedAuthResults != null) { + return consolidatedAuthResults; + } + + // 1. Run SPF check + String spfResultText = _keyRecordRetriever.getSpfRecord(helo, from, ip); + + // 2. Run DKIM verification + String dkimResultFull; + try { + dkimResultFull = runDkimCheck(message); + } catch (IOException e) { + throw new ArcException("IO Error while checking DKIM results", e); + } + String dkimResultShort = dkimResultFull.split(" ")[0]; + + // 3. Run DMARC check (using SPF + DKIM results + From domain) + String dkimDomain = extractDkimDomain(dkimResultFull); + String spfDomain = extractSpfDomain(spfResultText); + DMARCVerifier dmarcVerifier = new DMARCVerifier(_keyRecordRetriever.getDmarcRetriever()); + DmarcValidationResult dmarcResult = dmarcVerifier.runDmarcCheck(message, spfResultText, spfDomain, dkimResultShort, dkimDomain); + + return _authService + "; " + + "spf=" + spfResultText.replace(";", "") + "; " + + "dkim=" + dkimResultFull + "; " + + dmarcResult.toString(); + } + + private String consolidateExistingAuthResults(Message message) { + List results = new ArrayList<>(); + for (Field field : message.getHeader().getFields(AUTHENTICATION_RESULTS)) { + String body = normalizeAuthResultsBody(field.getBody()); + int separator = body.indexOf(';'); + if (separator == -1) { + continue; + } + String authservId = body.substring(0, separator).trim(); + if (!_authService.equalsIgnoreCase(authservId)) { + continue; + } + String result = body.substring(separator + 1).trim(); + if (!result.isEmpty()) { + results.add(result); + } + } + + if (results.isEmpty()) { + return null; + } + + StringBuilder consolidated = new StringBuilder(_authService); + for (int i = 0; i < results.size(); i++) { + String result = results.get(i); + consolidated.append(i == 0 ? "; " : " "); + consolidated.append(result); + if (!result.endsWith(";") && i < results.size() - 1) { + consolidated.append(";"); + } + } + return consolidated.toString(); + } + + private String normalizeAuthResultsBody(String body) { + return body.replaceAll("[\\r\\n]+[\\t ]*", " ") + .replaceAll("[\\t ]+", " ") + .trim(); + } + + private String runDkimCheck(Message message) throws IOException { + final DKIMVerifier verifier = new DKIMVerifier(_keyRecordRetriever); + InputStream is = messageToInputStream(message); + + // Verify DKIM signatures + List results; + try { + results = verifier.verify(is); + if (!results.isEmpty() && results.stream().allMatch(Objects::nonNull) && results.get(0) != null) { + SignatureRecord signatureRecord = results.get(0); + String iTag = computeITag(signatureRecord); + CharSequence sTag = signatureRecord.getSelector(); + Set tags = ((SignatureRecordImpl) signatureRecord).getTags(); + String bTag = computeBTag(tags, signatureRecord); + String outcome = "pass"; + return outcome + " header.i=" + iTag + " header.s=" + sTag+ " header.b=" + bTag; + } + } + catch (PermFailException e) { + throw new ArcException("DKIM PermFail", e); + } catch (TempFailException e) { + throw new ArcException("DKIM TempFail", e); + } catch (FailException e) { + throw new ArcException("DKIM Fail", e); + } catch (Exception e) { + throw new ArcException("DKIM Error", e); + } + return "fail (no valid signature records)"; + } + + private static String computeITag(SignatureRecord signatureRecord) { + String iTag = (String) signatureRecord.getIdentity(); + if (iTag == null || iTag.isEmpty()) { + iTag = (String) signatureRecord.getDToken(); + } + iTag = iTag.replace("@", ""); //most implementations drop the leading @ + return iTag; + } + + private static String computeBTag(Set tags, SignatureRecord signatureRecord) { + String bTag = ""; + if (!tags.isEmpty() && tags.contains("b")) { + byte[] signature = signatureRecord.getSignature(); + bTag = Base64.getEncoder().encodeToString(signature); + bTag=bTag.substring(0,8); + } + return bTag; + } + + private String extractSpfDomain(String spfHeaderText) { + String[] parts = spfHeaderText.split(" "); + for (String part : parts) { + if (part.startsWith("envelope-from=")) { + String[] subParts = part.substring("envelope-from=".length()).split("@"); + if (subParts.length < 2) return null; + String envFrom = subParts[1]; + envFrom = envFrom.replaceAll("[<>]", "").replace(";", ""); + return envFrom.trim(); + } + } + return null; + } + + private String extractDkimDomain(String dkimResultFull) { + String[] parts = dkimResultFull.split(" "); + for (String part : parts) { + if (part.startsWith(HEADER_I)) { + String partValue = part.substring(HEADER_I.length()); + if (partValue.contains("@")) //some implementations drop the leading @ + return partValue.split("@")[1].trim(); + else + return partValue.trim(); + } + } + return null; + } + + private InputStream messageToInputStream(Message message) throws IOException { + DefaultMessageWriter writer = new DefaultMessageWriter(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writer.writeEntity(message,os); + return new ByteArrayInputStream(os.toByteArray()); + } +} diff --git a/arc/src/main/java/org/apache/james/arc/DNSPublicKeyRecordRetrieverArc.java b/arc/src/main/java/org/apache/james/arc/DNSPublicKeyRecordRetrieverArc.java new file mode 100644 index 0000000..18719dd --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/DNSPublicKeyRecordRetrieverArc.java @@ -0,0 +1,44 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.dmarc.DNSPublicKeyRecordRetrieverDmarc; +import org.apache.james.dmarc.PublicKeyRecordRetrieverDmarc; +import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever; +import org.apache.james.jspf.impl.DefaultSPF; +import org.apache.james.jspf.impl.SPF; + +public class DNSPublicKeyRecordRetrieverArc extends DNSPublicKeyRecordRetriever implements PublicKeyRetrieverArc { + public static final DNSPublicKeyRecordRetrieverDmarc DMARC = new DNSPublicKeyRecordRetrieverDmarc(); + + public DNSPublicKeyRecordRetrieverArc() { + super(); + } + + @Override + public String getSpfRecord(String helo, String from, String ip) { + SPF spf = new DefaultSPF(); + return spf.checkSPF(ip, from, helo).getHeaderText(); + } + + @Override + public PublicKeyRecordRetrieverDmarc getDmarcRetriever() { + return DMARC; + } +} diff --git a/arc/src/main/java/org/apache/james/arc/PublicKeyRetrieverArc.java b/arc/src/main/java/org/apache/james/arc/PublicKeyRetrieverArc.java new file mode 100644 index 0000000..50dc70e --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/PublicKeyRetrieverArc.java @@ -0,0 +1,29 @@ +/**************************************************************** + * 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.james.arc; + +import org.apache.james.dmarc.PublicKeyRecordRetrieverDmarc; +import org.apache.james.jdkim.api.PublicKeyRecordRetriever; + +public interface PublicKeyRetrieverArc extends PublicKeyRecordRetriever { + + String getSpfRecord(String helo, String from, String ip); + + PublicKeyRecordRetrieverDmarc getDmarcRetriever(); +} diff --git a/arc/src/main/java/org/apache/james/arc/exceptions/ArcException.java b/arc/src/main/java/org/apache/james/arc/exceptions/ArcException.java new file mode 100644 index 0000000..e5e4549 --- /dev/null +++ b/arc/src/main/java/org/apache/james/arc/exceptions/ArcException.java @@ -0,0 +1,29 @@ +/**************************************************************** + * 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.james.arc.exceptions; + +public class ArcException extends RuntimeException { + public ArcException(String message, Throwable cause) { + super(message, cause); + } + + public ArcException(String message) { + super(message); + } +} diff --git a/arc/src/test/java/org/apache/james/arc/ARCTest.java b/arc/src/test/java/org/apache/james/arc/ARCTest.java new file mode 100644 index 0000000..e3aa0b7 --- /dev/null +++ b/arc/src/test/java/org/apache/james/arc/ARCTest.java @@ -0,0 +1,3597 @@ +/****************************************************************************** + * 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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.dmarc.MockPublicKeyRecordRetrieverDmarc; +import org.apache.james.jdkim.DKIMCommon; +import org.apache.james.jdkim.MockPublicKeyRecordRetriever; +import org.apache.james.mime4j.dom.Body; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.BodyPartBuilder; +import org.apache.james.mime4j.message.DefaultMessageBuilder; +import org.apache.james.mime4j.message.MultipartBuilder; +import org.apache.james.mime4j.stream.NameValuePair; +import org.apache.james.mime4j.stream.RawField; +import org.junit.Test; + +import org.apache.james.mime4j.stream.Field; +import java.util.Base64; +import java.util.List; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.Signature; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class ARCTest { + public static final String AUTHENTICATION_RESULTS = "Authentication-Results"; + public static final String ARC_AUTHENTICATION_RESULTS = "ARC-Authentication-Results"; + public static final String ARC_MESSAGE_SIGNATURE = "ARC-Message-Signature"; + public static final String ARC_SEAL = "ARC-Seal"; + + private final MockPublicKeyRecordRetrieverDmarc dmarcRetriever = new MockPublicKeyRecordRetrieverDmarc( + MockPublicKeyRecordRetrieverDmarc.DmarcRecord.dmarcOf( + "d1.example", + "k=rsa; v=DMARC1; p=reject; pct=100; rua=mailto:noc@d1.example" + ) + ); + + private final MockPublicKeyRecordRetrieverArc keyRecordRetriever = new MockPublicKeyRecordRetrieverArc( dmarcRetriever, + MockPublicKeyRecordRetriever.Record.of( + "arc", + "dmarc.example", + "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";" + ), + MockPublicKeyRecordRetriever.Record.of( + "origin2015", + "d1.example", + "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyDkim.getEncoded()) + ";" + ), + MockPublicKeyRecordRetrieverArc.SpfRecord.spfOf("d1.example", + "jqd@d1.example", + "222.222.222.222", + "softfail (spfCheck: transitioning domain of d1.example does not designate 222.222.222.222 as permitted sender) client-ip=222.222.222.222; envelope-from=jqd@d1.example; helo=d1.example") + ); + + private final MockPublicKeyRecordRetrieverArc mixedArcKeyRecordRetriever = new MockPublicKeyRecordRetrieverArc( dmarcRetriever, + MockPublicKeyRecordRetriever.Record.of( + "arc", + "dmarc.example", + "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";" + ), + MockPublicKeyRecordRetriever.Record.of( + "arc-alt", + "alt.example", + "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyDkim.getEncoded()) + ";" + ), + MockPublicKeyRecordRetriever.Record.of( + "origin2015", + "d1.example", + "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyDkim.getEncoded()) + ";" + ), + MockPublicKeyRecordRetrieverArc.SpfRecord.spfOf("d1.example", + "jqd@d1.example", + "222.222.222.222", + "softfail (spfCheck: transitioning domain of d1.example does not designate 222.222.222.222 as permitted sender) client-ip=222.222.222.222; envelope-from=jqd@d1.example; helo=d1.example") + ); + + private static final String VALIMAIL_DUMMY_PUBLIC_KEY = + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3id" + + "Y6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lx" + + "j+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB"; + private static final String VALIMAIL_DUMMY2_PUBLIC_KEY = + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDR3lRpGZS+xO96Znv/BPNQxi" + + "m7ZD0v6yFmZa9Rni5FHCeWuQwcp+PH/XXOyF6JsmB+kS0ybxJnx594ulqH2KvLMNsGAD+yRl2bJSXbBH" + + "ea7K9C5WX8Vjx3oPoGgw7QCONptnjUsbIIoxUZBEUe17eG44H/PbDqGwCBiyI20KEC/wIDAQAB"; + private static final String VALIMAIL_512_PUBLIC_KEY = + "v=DKIM1; k=rsa; p=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIWmlgix/84GJ+dfgjm7LTc9EPdfk" + + "ftlgiPpCq4/kbDAZmU0VvYKDljjleJ1dfvS+CGy9U/kk1tG3EeEvb82xAcCAwEAAQ=="; + private static final String VALIMAIL_1024_PUBLIC_KEY = + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCyBwu6PiaDN87t3DVZ84zIrE" + + "hCoxtFuv7g52oCwAUXTDnXZ+0XHM/rhkm8XSGr1yLsDc1zLGX8IfITY1dL2CzptdgyiX7vgYjzZqG368" + + "C8BtGB5m6nj26NyhSKEdlV7MS9KbASd359ggCeGTT5QjRKEMSauVyVSeapq6ZcpZ9JwQIDAQAB"; + private static final String VALIMAIL_2048_PUBLIC_KEY = + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv+7VkwpTtICeJFM4Hf" + + "UZsvv2OaA+QMrW9Af1PpTOzVP0uvUFK20lcaxMvt81ia/sGYW4gHp/WUIk0BIQMPVhUeCIuM1mcOQNFS" + + "OflR8pLo916rjEZXpRP/XGo4HwWzdqD2qQeb3+fv1IrzfHiDb9THbamoz05EX7JX+wVSAhdSW/igwhA/" + + "+beuzWR0RDDyGMT1b1Sb/lrGfwSXm7QoZQtj5PRiTX+fsL7WlzL+fBThySwS8ZBZcHcd8iWOSGKZ0gYK" + + "zxyuOf8VCX71C4xDhahN+HXWZFn9TZb+uZX9m+WXM3t+P8CdfxsaOdnVg6imgNDlUWX4ClLTZhco0Kmi" + + "BU+QIDAQAB"; + + private final MockPublicKeyRecordRetrieverArc valimailKeyRecordRetriever = new MockPublicKeyRecordRetrieverArc( + dmarcRetriever, + MockPublicKeyRecordRetriever.Record.of("dummy", "example.org", VALIMAIL_DUMMY_PUBLIC_KEY) + ); + + private final MockPublicKeyRecordRetrieverArc valimailInvalidPublicKeyRecordRetriever = new MockPublicKeyRecordRetrieverArc( + dmarcRetriever, + MockPublicKeyRecordRetriever.Record.of("dummy", "example.org", VALIMAIL_DUMMY_PUBLIC_KEY), + MockPublicKeyRecordRetriever.Record.of("invalid", "example.org", "v=DKIM1; k=rsa; omgwhatsgoingon") + ); + + private final MockPublicKeyRecordRetrieverArc valimailMixedDomainKeyRecordRetriever = new MockPublicKeyRecordRetrieverArc( + dmarcRetriever, + MockPublicKeyRecordRetriever.Record.of("dummy", "example.org", VALIMAIL_DUMMY_PUBLIC_KEY), + MockPublicKeyRecordRetriever.Record.of("dummy2", "example2.org", VALIMAIL_DUMMY2_PUBLIC_KEY) + ); + + private final MockPublicKeyRecordRetrieverArc valimailKeySizeRecordRetriever = new MockPublicKeyRecordRetrieverArc( + dmarcRetriever, + MockPublicKeyRecordRetriever.Record.of("dummy", "example.org", VALIMAIL_DUMMY_PUBLIC_KEY), + MockPublicKeyRecordRetriever.Record.of("512", "example.org", VALIMAIL_512_PUBLIC_KEY), + MockPublicKeyRecordRetriever.Record.of("1024", "example.org", VALIMAIL_1024_PUBLIC_KEY), + MockPublicKeyRecordRetriever.Record.of("2048", "example.org", VALIMAIL_2048_PUBLIC_KEY) + ); + + /** + * - "a" field will be added by the signer based on signer setup + * - "bh=" and "b=" placeholder are required for now because the same implementation is used for + * signing and verifying. The fields are mandatory for verifying. + */ + private static final String ARC_AMS_TEMPLATE = "i=; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; t=; h=Subject:From:To; bh=; b="; + private static final String ARC_SEAL_TEMPLATE = "i=; cv=; a=rsa-sha256; d=dmarc.example; s=arc; t=; b="; + + private static final String AUTH_SERVICE = "smtp.d1.example"; + private static final String HELO = "d1.example"; + + private static final String MAIL_FROM = "jqd@d1.example"; + private static final String IP = "222.222.222.222"; + private static final long TIMESTAMP = 1755918846L; // fixed timestamp for repeatable tests + ArcSetBuilder arcSetBuilder = new ArcSetBuilder(ArcTestKeys.privateKeyArc, ARC_AMS_TEMPLATE, ARC_SEAL_TEMPLATE, AUTH_SERVICE, TIMESTAMP); + + // Happy path: signs a fresh message (no prior ARC chain), pins the exact header values produced, + // then validates the resulting i=1 chain and asserts cv=pass. + @Test + public void generate_and_verify_arc_set() throws Exception { + String expectedCv = "pass"; + String authResultsExp = "smtp.d1.example; spf=softfail (spfCheck: transitioning domain of d1.example does not designate 222.222.222.222 as permitted sender) client-ip=222.222.222.222 envelope-from=jqd@d1.example helo=d1.example; dkim=pass header.i=d1.example header.s=origin2015 header.b=iEn8fLQ/; dmarc=pass (p=reject) header.from=d1.example"; + String arcAuthResultsExp = "i=1; smtp.d1.example; spf=softfail (spfCheck: transitioning domain of d1.example does not designate 222.222.222.222 as permitted sender) client-ip=222.222.222.222 envelope-from=jqd@d1.example helo=d1.example; dkim=pass header.i=d1.example header.s=origin2015 header.b=iEn8fLQ/; dmarc=pass (p=reject) header.from=d1.example"; + String arcSignExp = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; t=1755918846; h=subject : from : to; bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; b=FL3H8cG2U7RcyMSdx4j8iAD/7Uhzhl4XmWicLD+Uuxf3VsVghJ/lswvdQrjnyr6R9oyfPzP7rE2BEX0CFKlSvTVWy5/+8Vc3CXqj+tnKYoHnuWxH4sH0jMTpHzgceGLgMXvamilPyYWrCeF3r5yaUPYQ04fhfeAFAs6OTLeKvL0="; + String arcSealExp = "i=1; cv=none; a=rsa-sha256; d=dmarc.example; s=arc; t=1755918846; b=LsqQnv1KZhtbEX6SYLn0gk0t+Pjg3WmLu0aqNVwHa3nMcRq1dt4wJX1ka9lZAY/RARH74hwtfGnW1ba1gXLZ2WhevLwXvQcuw3NK6aC2YcYCjQ9kQWmlpvLe96xXsASl8MPXWyOmTEOdCeH06mkf3jahb4+bBjp1875568hTFhQ="; + + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + DefaultMessageBuilder builder = new DefaultMessageBuilder(); + Message message = builder.parseMessage(emailStream); + +// Map arcSet = arcSetBuilder.buildArcSet(message, HELO , MAIL_FROM,IP, new DNSPublicKeyRecordRetrieverArc()); // use this for real/external DNS lookups + Map arcSet = arcSetBuilder.buildArcSet(message, HELO , MAIL_FROM,IP, keyRecordRetriever); // mock DNS for testing + + assertThat(arcSet).hasSize(4); + + String authResults = arcSet.get(AUTHENTICATION_RESULTS); + String arcAuthResults = arcSet.get(ARC_AUTHENTICATION_RESULTS); + String arcMsgSignature = arcSet.get(ARC_MESSAGE_SIGNATURE); + String arcSeal = arcSet.get(ARC_SEAL); + assertThat(authResults).isEqualTo(authResultsExp); + assertThat(arcAuthResults).isEqualTo(arcAuthResultsExp); + assertThat(arcMsgSignature).isEqualTo(arcSignExp); + assertThat(arcSeal).isEqualTo(arcSealExp); + + //add new ARC set to the message and do chain validation on it + for (Map.Entry entry: arcSet.entrySet()){ + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo(expectedCv); + } + + // i0_base: signing a message with no incoming ARC chain starts at i=1 and seals cv=none. + @Test + public void build_arc_set_for_message_without_arc_chain_uses_first_instance_and_cv_none() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + assertThat(arcSet.get(ARC_AUTHENTICATION_RESULTS)).startsWith("i=1;"); + assertThat(arcSet.get(ARC_MESSAGE_SIGNATURE)).contains("i=1;"); + assertThat(arcSet.get(ARC_SEAL)).contains("i=1; cv=none;"); + } + + // cv_fail_i1_ams_invalid: builds a valid i=1 ARC set, then replaces the AMS b= signature with + // wrong bytes before adding headers to the message, expecting chain validation to return cv=fail. + @Test + public void validate_arc_chain_fails_when_ams_signature_is_invalid() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + // Replace b= with 128 zero bytes (correct RSA key length but wrong value) so sig.verify() returns false + String fakeB64 = Base64.getEncoder().encodeToString(new byte[128]); + String corruptedAms = arcSet.get(ARC_MESSAGE_SIGNATURE) + .replaceAll("; b=.*$", "; b=" + fakeB64); + arcSet.put(ARC_MESSAGE_SIGNATURE, corruptedAms); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + assertThat(cv.getDescription()).isEqualTo("Previous ARC hop validation failed at i=1"); + } + + // cv_fail_i1_as_invalid: builds a valid i=1 ARC set, then replaces the ARC-Seal b= signature with + // wrong bytes before adding headers to the message, expecting chain validation to return cv=fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_signature_is_invalid() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + // Replace b= with 128 zero bytes (correct RSA key length but wrong value) so sig.verify() returns false + String fakeB64 = Base64.getEncoder().encodeToString(new byte[128]); + String corruptedSeal = arcSet.get(ARC_SEAL) + .replaceAll("; b=.*$", "; b=" + fakeB64); + arcSet.put(ARC_SEAL, corruptedSeal); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i1_ams_na: if the ARC-Message-Signature header is absent from the i=1 set entirely, + // the chain is structurally incomplete and must be rejected with cv=fail. + @Test + public void validate_arc_chain_fails_when_ams_header_is_missing() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + arcSet.remove(ARC_MESSAGE_SIGNATURE); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i1_as_na: if the ARC-Seal header is absent from the i=1 set entirely, + // the chain is structurally incomplete and must be rejected with cv=fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_header_is_missing() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + arcSet.remove(ARC_SEAL); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i1_as_pass: the ARC-Seal at i=1 must always carry cv=none (there is no prior chain to + // have passed). If it says cv=pass, the structure is invalid and the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_cv_is_pass_on_first_hop() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + String tamperedSeal = arcSet.get(ARC_SEAL).replace("cv=none", "cv=pass"); + arcSet.put(ARC_SEAL, tamperedSeal); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i1_as_cv_fail: the ARC-Seal at i=1 carrying cv=fail means the chain was declared + // broken from the very first hop, so validation must return cv=fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_cv_is_fail_on_first_hop() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + String tamperedSeal = arcSet.get(ARC_SEAL).replace("cv=none", "cv=fail"); + arcSet.put(ARC_SEAL, tamperedSeal); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_empty: a completely empty message (no headers, no body) has no ARC chain — the validator + // must return cv=none rather than crash or return cv=fail. + @Test + public void validate_arc_chain_returns_none_for_empty_message() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream(new byte[0])); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("none"); + } + + // cv_no_headers: a message with no headers at all has no ARC chain — the validator must return + // cv=none gracefully without throwing. + @Test + public void validate_arc_chain_returns_none_for_message_with_no_headers() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream("\r\nbody text here".getBytes(StandardCharsets.UTF_8))); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("none"); + } + + // cv_no_body: a message that has headers but no body is legal — the validator must process the + // (absent) ARC chain normally and return cv=none when no ARC headers are present. + @Test + public void validate_arc_chain_returns_none_for_message_with_no_body() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream("From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: test\r\n\r\n" + .getBytes(StandardCharsets.UTF_8))); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("none"); + } + + // cv_base1: a normal single-part message without ARC headers has no chain to validate. + @Test + public void validate_arc_chain_returns_none_for_base_message_one_without_arc_headers() throws Exception { + assertValimailFixtureHasResult(basicMessageWithoutAuthenticationResults(), "none", valimailKeyRecordRetriever); + } + + // cv_base2: a normal multipart message without ARC headers has no chain to validate. + @Test + public void validate_arc_chain_returns_none_for_base_message_two_without_arc_headers() throws Exception { + assertValimailFixtureHasResult(basicMultipartMessageWithoutAuthenticationResults(), "none", valimailKeyRecordRetriever); + } + + // cv_pass_i1_1: the upstream single-hop base1 fixture must validate as pass. + @Test + public void validate_arc_chain_passes_for_single_hop_base_message_one() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage(baseMessageOneSignedTail())); + } + + // cv_pass_i1_2: the upstream single-hop base2 fixture must validate as pass. + @Test + public void validate_arc_chain_passes_for_single_hop_base_message_two() throws Exception { + assertValimailFixturePasses(valimailSingleHopBaseTwoMessage()); + } + + // public_key_na: if the AS selector/domain has no DNS public key, validation must fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_public_key_is_missing() throws Exception { + assertValimailFixtureFails(valimailPublicKeyMessage( + "example.org", + "na", + "xEoL/6DZn2+/oIsSIAFRrnQdhyrH/aSGdRqBphcyZvTLhDyd8sPHIqNsr0HROjIybe3lUG" + + "/YlYIftmAUP3E7kWbfU7HrolZ/5f4eB0tciltpSyBUPzM2D30IxGmqUvQxk5ATb7WxKAUs4x" + + "XiTmx1MaAUKAExlm45pwp5wEoU/D8=")); + } + + // public_key_invalid: if the AS DNS public key record is malformed, validation must fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_public_key_is_invalid() throws Exception { + assertValimailFixtureFails( + valimailPublicKeyMessage( + "example.org", + "invalid", + "G6sqFlzmC87EiD80V9Da8JURM2MUxp1tK3iUxrQdSJ6odUYPT8ApwE1GWodzs8UDuKemL+" + + "qn7E29nhcK8pwjLjWNilPTZJ1Bt1TS8QersJsEe4tD+rcbGd8ZU8C2UcUpv0TFv3m4GrNbwx" + + "JFFf9r1x5VkXulzTwIo1VW6avKShw="), + valimailInvalidPublicKeyRecordRetriever); + } + + // ams_as_diff_s_d: AMS and AS may use different selector/domain values. + @Test + public void validate_arc_chain_passes_when_ams_and_arc_seal_use_different_selector_domains() throws Exception { + assertValimailFixturePasses( + valimailPublicKeyMessage( + "example2.org", + "dummy2", + "Q6K/T+/5h+nkCtO8UVhb5uwy5ozplfBvOV0lSOCIuzDoTlPNg1chaN+04US/AWxvOrBTZf" + + "hzXXdVjXMv2sX4+4ebSegZN7GTakDCd+vfBtF30jR4csBqlhW25NSyLeleZnIMf5I5G4vu5+" + + "Ab38xWCoKnMKTPsPebT273ALMfzOw="), + valimailMixedDomainKeyRecordRetriever); + } + + // cv_fail_i2_ams_na: in a two-hop chain, if the ARC-Message-Signature for i=2 is missing, the + // chain is structurally incomplete and must be rejected with cv=fail. + @Test + public void validate_arc_chain_fails_when_i2_ams_is_missing() throws Exception { + Message message = buildTwoHopChain(); + removeHeaderByInstanceAndType(message, ARC_MESSAGE_SIGNATURE, "i=2"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_ams_invalid: in a two-hop chain, if the ARC-Message-Signature at i=2 has a bad + // cryptographic signature, the chain must be rejected even if i=1 was valid. + @Test + public void validate_arc_chain_fails_when_i2_ams_signature_is_invalid() throws Exception { + Message message = buildTwoHopChain(); + corruptSignatureOnHeader(message, ARC_MESSAGE_SIGNATURE, "i=2"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as2_na: in a two-hop chain, if the ARC-Seal for i=2 is missing, the chain is + // structurally incomplete and must be rejected with cv=fail. + @Test + public void validate_arc_chain_fails_when_i2_arc_seal_is_missing() throws Exception { + Message message = buildTwoHopChain(); + removeHeaderByInstanceAndType(message, ARC_SEAL, "i=2"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as2_invalid: in a two-hop chain, if the ARC-Seal at i=2 has been tampered with, + // the chain must be rejected with cv=fail. + @Test + public void validate_arc_chain_fails_when_i2_arc_seal_signature_is_invalid() throws Exception { + Message message = buildTwoHopChain(); + corruptSignatureOnHeader(message, ARC_SEAL, "i=2"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as2_none: the ARC-Seal at i=2 must carry cv=pass because i=1 was valid. If it + // incorrectly says cv=none, the chain structure is wrong and must be rejected. + @Test + public void validate_arc_chain_fails_when_i2_arc_seal_cv_is_none() throws Exception { + Message message = buildTwoHopChain(); + replaceTagOnHeader(message, ARC_SEAL, "i=2", "cv=pass", "cv=none"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as2_fail: if the ARC-Seal at i=2 says cv=fail, the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_i2_arc_seal_cv_is_fail() throws Exception { + Message message = buildTwoHopChain(); + replaceTagOnHeader(message, ARC_SEAL, "i=2", "cv=pass", "cv=fail"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as1_na: in a two-hop chain, if the ARC-Seal from i=1 is missing, the second + // server's seal cannot be verified and the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_i1_arc_seal_is_missing_in_two_hop_chain() throws Exception { + Message message = buildTwoHopChain(); + removeHeaderByInstanceAndType(message, ARC_SEAL, "i=1"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as1_invalid: in a two-hop chain, if the i=1 ARC-Seal has a bad signature, the + // entire chain must be rejected even if i=2 looks fine. + @Test + public void validate_arc_chain_fails_when_i1_arc_seal_signature_is_invalid_in_two_hop_chain() throws Exception { + Message message = buildTwoHopChain(); + corruptSignatureOnHeader(message, ARC_SEAL, "i=1"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as1_pass: in a two-hop chain, the i=1 ARC-Seal must say cv=none (not cv=pass). + // If it says cv=pass, the chain structure is wrong and must be rejected. + @Test + public void validate_arc_chain_fails_when_i1_arc_seal_cv_is_pass_in_two_hop_chain() throws Exception { + Message message = buildTwoHopChain(); + replaceTagOnHeader(message, ARC_SEAL, "i=1", "cv=none", "cv=pass"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_as1_fail: in a two-hop chain, if the i=1 ARC-Seal says cv=fail, the chain was + // already declared broken at hop one and the whole chain must be rejected. + @Test + public void validate_arc_chain_fails_when_i1_arc_seal_cv_is_fail_in_two_hop_chain() throws Exception { + Message message = buildTwoHopChain(); + replaceTagOnHeader(message, ARC_SEAL, "i=1", "cv=none", "cv=fail"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_pass_i2_1: a message that passed through two mail servers, each adding a valid ARC set, + // should validate as cv=pass. + @Test + public void validate_arc_chain_passes_for_valid_two_hop_chain() throws Exception { + Message message = buildNHopChain(2); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // cv_pass_i2_2: same two-hop happy path using a second message variant, confirming validation + // is not tied to a single email structure. + @Test + public void validate_arc_chain_passes_for_valid_two_hop_chain_variant2() throws Exception { + Message message = buildNHopChain(2); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // cv_pass_i2_1_ams1_invalid: if the i=1 ARC-Message-Signature is corrupted after the chain was + // built, the overall chain must be rejected even if the i=2 seal is intact. + @Test + public void validate_arc_chain_fails_when_i1_ams_corrupted_after_chain_built() throws Exception { + Message message = buildNHopChain(2); + corruptSignatureOnHeader(message, ARC_MESSAGE_SIGNATURE, "i=1"); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_fail_i2_1_ams1_invalid_resigned: if a forwarder corrupts i=1 AMS and then re-seals the chain + // at i=2 (so i=2 AS honestly covers the corrupted i=1 AMS), the chain must still be rejected + // because the validator must independently verify every AMS, not just the last one. + @Test + public void validate_arc_chain_fails_when_i1_ams_corrupted_and_chain_resigned_at_i2() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + + // Build valid i=1 ARC set + Map hop1 = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + for (Map.Entry entry : hop1.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + // Corrupt i=1 AMS before hop 2 seals the chain + corruptSignatureOnHeader(message, ARC_MESSAGE_SIGNATURE, "i=1"); + + // Build i=2 ARC set over the already-corrupted chain; i=2 AS honestly covers corrupted i=1 AMS + Map hop2 = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + for (Map.Entry entry : hop2.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // cv_pass_i2_as_keys_differ: ARC-Seal verification must use the key from the seal being verified, + // not an earlier seal in the chain. + @Test + public void validate_arc_chain_passes_when_latest_arc_seal_uses_different_key_than_previous_seal() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + ArcSetBuilder altArcSetBuilder = new ArcSetBuilder( + ArcTestKeys.privateKeyDkim, + "i=; a=rsa-sha256; c=relaxed/relaxed; d=alt.example; s=arc-alt; t=; h=Subject:From:To; bh=; b=", + "i=; cv=; a=rsa-sha256; d=alt.example; s=arc-alt; t=; b=", + AUTH_SERVICE, + TIMESTAMP); + + Map hop1 = altArcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, mixedArcKeyRecordRetriever); + for (Map.Entry entry : hop1.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + Map hop2 = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, mixedArcKeyRecordRetriever); + for (Map.Entry entry : hop2.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(mixedArcKeyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // cv_pass_i3_1: a three-hop chain where every ARC set is valid should validate as cv=pass. + @Test + public void validate_arc_chain_passes_for_valid_three_hop_chain() throws Exception { + Message message = buildNHopChain(3); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // cv_pass_i4_1: a four-hop chain where every ARC set is valid should validate as cv=pass. + @Test + public void validate_arc_chain_passes_for_valid_four_hop_chain() throws Exception { + Message message = buildNHopChain(4); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // cv_pass_i5_1: a five-hop chain where every ARC set is valid should validate as cv=pass. + @Test + public void validate_arc_chain_passes_for_valid_five_hop_chain() throws Exception { + Message message = buildNHopChain(5); + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // arc_set_extract_i10: extracting i=1 must not accidentally include i=10 ARC headers. + @Test + public void extract_arc_set_matches_exact_instance_number() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream("Subject: exact instance test\n\nbody".getBytes(StandardCharsets.UTF_8))); + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, "i=1; mx.example; arc=none")); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, "i=1; d=example.org; s=arc; b=one")); + message.getHeader().addField(new RawField(ARC_SEAL, "i=1; cv=none; d=example.org; s=arc; b=one")); + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, "i=10; mx.example; arc=pass")); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, "i=10; d=example.org; s=arc; b=ten")); + message.getHeader().addField(new RawField(ARC_SEAL, "i=10; cv=pass; d=example.org; s=arc; b=ten")); + ARCVerifier arcVerifier = new ARCVerifier(keyRecordRetriever); + + Set arcSet = arcVerifier.extractArcSet(message.getHeader(), 1); + + assertThat(arcSet).hasSize(3); + assertThat(arcSet) + .allMatch(field -> "1".equals(arcVerifier.parseTagGeneric(field.getBody(), "i"))); + } + + @Test + public void arc_header_grouping_rejects_zero_instance_number() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream("Subject: invalid instance test\n\nbody".getBytes(StandardCharsets.UTF_8))); + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, "i=0; mx.example; arc=none")); + + assertThatThrownBy(() -> new ARCVerifier(keyRecordRetriever).getArcHeadersByI(message.getHeader().getFields())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("ARC Header i= tag must be between 1 and 50"); + } + + @Test + public void arc_header_grouping_rejects_instance_number_above_fifty() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream("Subject: invalid instance test\n\nbody".getBytes(StandardCharsets.UTF_8))); + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, "i=51; mx.example; arc=pass")); + + assertThatThrownBy(() -> new ARCVerifier(keyRecordRetriever).getArcHeadersByI(message.getHeader().getFields())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("ARC Header i= tag must be between 1 and 50"); + } + + // ams_struct_i_na: an ARC-Message-Signature with no i= tag at all must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_has_no_instance_tag() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replaceFirst("i=1;\\s*", ""); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_struct_i_empty: an ARC-Message-Signature with i= but no value (i=;) must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_has_empty_instance_tag() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replaceFirst("i=1;", "i=;"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_struct_i_zero: an ARC-Message-Signature with i=0 must be rejected — instance numbers start at 1. + @Test + public void validate_arc_chain_fails_when_ams_has_zero_instance_tag() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replaceFirst("i=1;", "i=0;"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_struct_i_invalid: an ARC-Message-Signature with a non-numeric i= value must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_has_non_numeric_instance_tag() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replaceFirst("i=1;", "i=abc;"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_struct_dup: two ARC-Message-Signature headers both claiming i=1 make the set ambiguous and + // must be rejected — each instance number must appear exactly once. + @Test + public void validate_arc_chain_fails_when_ams_is_duplicated_at_same_instance() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + // Add a second AMS header at i=1 — duplicates the instance number + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, arcSet.get(ARC_MESSAGE_SIGNATURE))); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_i_dup1: duplicate ARC-Message-Signature instance numbers must be rejected even + // when the duplicate appears before the rest of the ARC set in header order. + @Test + public void validate_arc_chain_fails_when_duplicate_ams_instance_appears_before_arc_set() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, arcSet.get(ARC_MESSAGE_SIGNATURE))); + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_i_dup2: duplicate ARC-Message-Signature instance numbers must be rejected when + // the duplicate appears after the existing AMS header. + @Test + public void validate_arc_chain_fails_when_duplicate_ams_instance_appears_after_arc_set() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams, true); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_a_na: AMS without a= does not declare a supported signature algorithm. + @Test + public void validate_arc_chain_fails_when_ams_algorithm_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replaceFirst("a=rsa-sha256;\\s*", ""), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_a_empty: AMS with an empty a= does not declare a supported signature algorithm. + @Test + public void validate_arc_chain_fails_when_ams_algorithm_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replace("a=rsa-sha256", "a="), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_a_sha1: rsa-sha1 is not supported for AMS verification. + @Test + public void validate_arc_chain_fails_when_ams_algorithm_is_sha1() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replace("a=rsa-sha256", "a=rsa-sha1"), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + @Test + public void verify_ams_throws_clear_exception_when_algorithm_is_sha1() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + Field amsField = new RawField(ARC_MESSAGE_SIGNATURE, + arcSet.get(ARC_MESSAGE_SIGNATURE).replace("a=rsa-sha256", "a=rsa-sha1")); + String publicKeyDnsRecord = "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";"; + + assertThatThrownBy(() -> new ARCVerifier(keyRecordRetriever).verifyAms(amsField, message, publicKeyDnsRecord)) + .isInstanceOf(ArcException.class) + .hasMessage("ARC-Message-Signature uses unsupported algorithm: rsa-sha1"); + } + + // ams_fields_a_unknown: unknown AMS signature algorithms must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_algorithm_is_unknown() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replace("a=rsa-sha256", "a=ed25519-sha256"), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_b_ignores_wsp: whitespace inside AMS b= must be ignored during base64 decode. + @Test + public void validate_arc_chain_passes_when_ams_signature_contains_whitespace() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=L8GsQ6v/7miEWKMGu16QVCPF6IT8j9+DV/ZHzgm86gi5m2JYAq+BlkmiIDofRPW+QzAq85\n" + + " 2UlxwI2NZrhyAKgtM4FKO7+84P1eYwJKh57DZfCyUpqRx1Je2+vzT8ZggXQWYjFEu36MTDFX\n" + + " fRKVqPV3omyP+CFBzjJFFDLehJaPk=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=QsRzR /UqwRfVLBc1TnoQomlVw5qi6jp08q8lHpBSl4RehWyHQtY3uOIAGdghDk/mO+/Xpm\n" + + " 9JA5UVrPyDV0f+2q/YAHuwvP11iCkBQkocmFvgTSxN8H+DwFFPrVVUudQYZV7UDDycXoM6UE\n" + + " cdfzLLzVNPOAHEDIi/uzoV4sUqZ18=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + valimailCommonMessageTail()); + } + + // ams_fields_b_na: missing AMS b= leaves no message signature to verify and must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_signature_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replaceAll("; b=.*$", ""), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_b_empty: empty AMS b= leaves no message signature to verify and must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_signature_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replaceAll("; b=.*$", "; b="), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_b_base64: AMS b= must be base64. + @Test + public void validate_arc_chain_fails_when_ams_signature_is_not_base64() throws Exception { + Message message = buildOneHopChainWithAms(ams -> ams.replaceAll("; b=.*$", "; b=not-base64!"), false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_b_mod_sig: a modified AMS signature must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_signature_is_modified() throws Exception { + Message message = buildOneHopChainWithAms( + ams -> ams.replaceAll("; b=.*$", "; b=" + Base64.getEncoder().encodeToString(new byte[128])), + false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_fields_b_head_case: AMS relaxed canonicalization lowercases signed header names. + @Test + public void validate_arc_chain_passes_when_signed_header_name_case_changes() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "FROM: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_b_head_unfold: folded signed headers must verify under relaxed canonicalization. + @Test + public void validate_arc_chain_passes_when_signed_header_is_folded() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe\n" + + " \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_b_eol_wsp: signed-header line-end whitespace must be stripped. + @Test + public void validate_arc_chain_passes_when_signed_header_has_end_of_line_whitespace() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_b_inl_wsp: repeated inline whitespace in signed headers must be reduced. + @Test + public void validate_arc_chain_passes_when_signed_header_has_extra_inline_whitespace() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_b_col_wsp: whitespace after signed-header colons must be stripped. + @Test + public void validate_arc_chain_passes_when_signed_headers_have_colon_whitespace() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_b_mod_headers1: modifying a signed From header must invalidate AMS. + @Test + public void validate_arc_chain_fails_when_signed_from_header_is_modified() throws Exception { + assertValimailFixtureFails(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_b_mod_headers2: modifying a signed Subject header must invalidate AMS. + @Test + public void validate_arc_chain_fails_when_signed_subject_header_is_modified() throws Exception { + assertValimailFixtureFails(valimailAmsCanonicalizationMessage( + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1 (Mod)\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_bh_ignores_wsp: whitespace inside bh= is ignored when decoding the body hash. + @Test + public void validate_arc_chain_passes_when_ams_body_hash_contains_whitespace() throws Exception { + assertValimailFixturePasses(valimailAmsBodyHashMessage( + "E8x7AZqCzrIuoNF9dWTyteDmtDLHk3J6CSXj1DfRHjk2cd0oeHUIXvtrNtMhYs2sFHoZRR\n" + + " NuVvgDUIwPcbtr2Bz9eYvUTuOToBRn9FZFqnpR/rHl5VbPAIhSwE98WT6PJt8pqNCyKyZU3I\n" + + " szoWq5cB3OWUv6QJ8ctb6rCZLbk3g=", + "xWBIUyGnx5WlX005xU8TYkieptAqvslDc7lkuqyFpACyOklw0t4cAONgr6qUavTnRJyZoJ\n" + + " mXXIvvPk/7xgH9eT9lCFYk49vpo+fqZACxJwpRk6WbB3fwbfeZe8C2aL6X/G40ROlh4EVcy2\n" + + " +NjgNS2X9ZEmxKuGEehFLqaJnx8yM=", + "K WSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=", + "relaxed/relaxed", + baseMessageOneBody())); + } + + // ams_fields_bh_sim_base: simple body canonicalization base case must verify. + @Test + public void validate_arc_chain_passes_with_simple_body_canonicalization() throws Exception { + assertValimailFixturePasses(valimailSimpleBodyHashMessage(baseMessageOneBody())); + } + + // ams_fields_bh_sim_end_lines: simple body canonicalization ignores trailing empty lines. + @Test + public void validate_arc_chain_passes_when_simple_body_has_trailing_empty_lines() throws Exception { + assertValimailFixturePasses(valimailSimpleBodyHashMessage(baseMessageOneBody() + "\n\n")); + } + + // ams_fields_bh_sim_inl_wsp: simple body canonicalization preserves inline whitespace. + @Test + public void validate_arc_chain_fails_when_simple_body_inline_whitespace_changes() throws Exception { + assertValimailFixtureFails(valimailSimpleBodyHashMessage( + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_bh_rel_eol_wsp: relaxed body canonicalization strips line-end whitespace. + @Test + public void validate_arc_chain_passes_when_relaxed_body_has_line_end_whitespace() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + baseMessageOneSignedHeaders() + + "Hey gang, \n" + + "This is a test message. \n" + + "--J. ")); + } + + // ams_fields_bh_rel_inl_wsp: relaxed body canonicalization collapses inline whitespace. + @Test + public void validate_arc_chain_passes_when_relaxed_body_has_extra_inline_whitespace() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + baseMessageOneSignedHeaders() + + "Hey gang,\n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_bh_rel_end_lines: relaxed body canonicalization ignores trailing empty lines. + @Test + public void validate_arc_chain_passes_when_relaxed_body_has_trailing_empty_lines() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + baseMessageOneSignedHeaders() + baseMessageOneBody() + "\n\n")); + } + + // ams_fields_bh_rel_trail_crlf: relaxed body canonicalization adds the final CRLF. + @Test + public void validate_arc_chain_passes_when_relaxed_body_has_no_extra_trailing_crlf() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + baseMessageOneSignedHeaders() + baseMessageOneBody())); + } + + @Test + public void multipart_body_reconstruction_adds_crlf_after_closing_boundary() throws Exception { + Body body = MultipartBuilder.create("alternative") + .addContentTypeParameter(new NameValuePair("boundary", "abc")) + .addBodyPart(BodyPartBuilder.create() + .setContentType("text/plain") + .setBody("plain", StandardCharsets.UTF_8)) + .build(); + + byte[] bodyBytes = readBodyBytesWithVerifier(body); + + assertThat(new String(bodyBytes, StandardCharsets.UTF_8)) + .endsWith("--abc--\r\n"); + } + + // ams_fields_bh_na: missing bh= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_body_hash_tag_is_missing() throws Exception { + assertValimailFixtureFails(valimailInvalidBodyHashMessage(null)); + } + + // ams_fields_bh_empty: empty bh= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_body_hash_tag_is_empty() throws Exception { + assertValimailFixtureFails(valimailInvalidBodyHashMessage("")); + } + + // ams_fields_bh_base64: non-base64 bh= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_body_hash_is_not_base64() throws Exception { + assertValimailFixtureFails(valimailInvalidBodyHashMessage("not_base_64")); + } + + // ams_fields_bh_mod_sig: modified bh= must be rejected even when the header signature verifies. + @Test + public void validate_arc_chain_fails_when_ams_body_hash_is_modified() throws Exception { + assertValimailFixtureFails(valimailInvalidBodyHashMessage("Z3JlbWxpbnM=")); + } + + @Test + public void verify_ams_fails_when_no_body_message_has_wrong_body_hash() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage(new ByteArrayInputStream( + ("From: sender@example.org\r\n" + + "To: recipient@example.org\r\n" + + "Subject: no body\r\n").getBytes(StandardCharsets.UTF_8))); + String amsWithoutSignature = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; " + + "t=12345; h=from:to:subject; bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; b="; + String signature = signRelaxedAmsForNoBodyMessage(message, amsWithoutSignature); + Field amsField = new RawField(ARC_MESSAGE_SIGNATURE, amsWithoutSignature + signature); + String publicKeyDnsRecord = "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";"; + + assertThat(new ARCVerifier(keyRecordRetriever).verifyAms(amsField, message, publicKeyDnsRecord)) + .isFalse(); + } + + @Test + public void verify_ams_fails_when_empty_multipart_has_wrong_body_hash() throws Exception { + Message message = parseRawEmail( + "From: sender@example.org\n" + + "To: recipient@example.org\n" + + "Subject: empty multipart\n" + + "Content-Type: multipart/alternative; boundary=abc\n" + + "\n"); + String amsWithoutSignature = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; " + + "t=12345; h=from:to:subject; bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; b="; + String signature = signRelaxedAmsForNoBodyMessage(message, amsWithoutSignature); + Field amsField = new RawField(ARC_MESSAGE_SIGNATURE, amsWithoutSignature + signature); + String publicKeyDnsRecord = "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";"; + + assertThat(new ARCVerifier(keyRecordRetriever).verifyAms(amsField, message, publicKeyDnsRecord)) + .isFalse(); + } + + // ams_fields_bh_mod_body: body changes outside relaxed canonicalization must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_signed_body_is_modified() throws Exception { + assertValimailFixtureFails(valimailAmsCanonicalizationMessage( + baseMessageOneSignedHeaders() + + "Hey gang,\n" + + "This is a changed test message.\n" + + "--J.")); + } + + // ams_fields_c_na: missing c= uses the default simple/simple canonicalization. + @Test + public void validate_arc_chain_passes_when_ams_canonicalization_tag_is_missing() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationTagMessage( + "ygcIhWO/8u3FP5h+7kQH7X9Yqxs0MIHuMUA6PapmNf+8CP5Fb/mY/mZ5aUcLxJNozQ2oUU\n" + + " ukkGEysRaqm5uTJMhiy4YjZgJqMRVka3xMGeIaSw1PiugVu015l8wKR1ollDSN7POJaajQBC\n" + + " /4mUnAUFfND8OqfE/VimB6flYiUJ8=", + "1+WHHTxU+XLWVsbRsvjlW2kMRRhmGE+OE9jxnmLt4ryEa/AezAflCMmVzM7r1dKwxJA1oc\n" + + " YmkN0ga0CO/nxSvB9XR0dsg/TH7TTSQKIllCRxsmGLt+jG/9Mw5yTRxtBOOuFK4xbHbFbCLU\n" + + " vRCry9p9YZpoAemnEb24tm9vjlrsQ=", + "KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=", + null, + "from:to:date:subject:mime-version:arc-authentication-results", + baseMessageOneSignedTail())); + } + + // ams_fields_c_empty: empty c= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_canonicalization_tag_is_empty() throws Exception { + assertValimailFixtureFails(valimailAmsCanonicalizationTagMessage( + "eTLQqvFomQqHaOc36izhl5UMp6wVe8vGsLLuPCraumms100F7tOUhRpAII90YkwX0AK+RT\n" + + " 5ij+3Ngk2sQRpMupfFTgeF1olGU+jt943VkFbmSYXYp0AwBe4TGsLugWmfkUy2sGBSC1Rv7n\n" + + " ZaC9m6Y2bNMJcwix1EAuFFV6ck1Wg=", + "QdAvD1bnatYxK/JQCvI1uSuKxOYC+oR7wqg/twCt+zAFm8Tvu+fZpO79+TSx+cLAETXKNT\n" + + " 6mgQLaLROfq3sNf8tP0f/4oqzMUb6Ybz2syHL7hkmC6Za5Ii8RDKwMSc8lmvJk6HXUKgsndZ\n" + + " vWsQCfv+jyLmfDfCI8v9WP7xa2UEU=", + "KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=", + "", + "from:to:date:subject:mime-version:arc-authentication-results", + baseMessageOneSignedTail())); + } + + // ams_fields_c_rr: relaxed/relaxed canonicalization must be honored. + @Test + public void validate_arc_chain_passes_with_relaxed_header_and_relaxed_body_canonicalization() throws Exception { + assertValimailFixturePasses(valimailAmsCanonicalizationMessage( + baseMessageOneSignedHeaders() + + "Hey gang, \n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_c_rs: relaxed/simple canonicalization must be honored. + @Test + public void validate_arc_chain_passes_with_relaxed_header_and_simple_body_canonicalization() throws Exception { + assertValimailFixturePasses(valimailSimpleBodyHashMessage(baseMessageOneBody())); + } + + // ams_fields_c_sr: simple/relaxed canonicalization must be honored. + @Test + public void validate_arc_chain_passes_with_simple_header_and_relaxed_body_canonicalization() throws Exception { + assertValimailFixturePasses(valimailSimpleHeaderCanonicalizationMessage( + "rhXdX7jNW4wMS/SjYKBYC9eW6q5KnnQ7UGICE45CsYhwEoi38c3nM+91lvM3zhUILxo51X\n" + + " htsrMDLw5TJeZdiCqgXhQZmSEzR+KEdnu2oidezrK/hUzYPlKdO59EQgGIiDAmIRoKZ6+rGV\n" + + " fUCltnyjA07a9KpIpeXRKT3WDCE6A=", + "RWHWmB6euT01CXN0PJKCrmmoPPGc+pxxurfyJBjnNzkTizZKD7XwHLqTuNPaRG7PULU6ffq8FQ7IivdffwqXNj4L3ttpKNIjfsndMFvn5lpKZGfvJZfjTmbTJMhF4CCJZZm7l1xy7LbYMaMb12WY47vXOe9RNjW7jQyw8iqctcA=", + "KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=", + "simple/relaxed", + baseMessageOneBody())); + } + + // ams_fields_c_ss: simple/simple canonicalization must be honored. + @Test + public void validate_arc_chain_passes_with_simple_header_and_simple_body_canonicalization() throws Exception { + assertValimailFixturePasses(valimailSimpleHeaderCanonicalizationMessage( + "X9qtjasr0URzC564MZz0bwckcIVnBW9yUZP+xt4rStU7MIuuo266KZ1V/e5tbg/MOCZJ2m\n" + + " 3hvKRsVy1fMeIus2RVBg88zwfjyRMsJBC+zKV8oONpIcxriN8imZcaeWdcfsghbAFBM3viCE\n" + + " MdvebSvInMfz0vZsD1DJBYTjPel8w=", + "fv7KIaPfZRTQynzpQ7Gkg3thdZn78iGc5L1hTQoWrY1nSaE3pqQTHsGDW7+FRquewwFoakGLSERxBnC67Sdvw9Exv+/CEs/spqRrDjNygkCf/BIZcURb2nXXFHqPy31X6r2bufWKj6Lbo+5MCyaS2tWkV+KoZhUpolYSo0CoGfk=", + "hhFbTjokraRYc/Af+8v4zyKm/9ApHGkBSLO129NtPbo=", + "simple/simple", + "Hey gang, \n" + + "This is a test message.\n" + + "--J.")); + } + + // ams_fields_c_invalid: unknown canonicalization names must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_canonicalization_tag_is_invalid() throws Exception { + assertValimailFixtureFails(valimailAmsCanonicalizationTagMessage( + "YYGtMgeVAGSLLMZ0k9D0yRRzsfKpbHCoqfLAKz+Du2++GE82Dvz2OT60ebG9m6vmT6nT1t\n" + + " D+rMJnTXIZDUPZ6BLH8rLo8jMb33cBV5NzBD3SDYqWA7OOkYrMGRGmoMfxpcGV8m77YykscT\n" + + " +cpxxA2Ytld+YTd0mTtxdOCN3T1M4=", + "DJZENNFBf+SwDthFmU1ztUBIsKRAAaUdY9CjuGXejv8T29jf3q3EDUz6OnMevRWiSLj4ED\n" + + " gymMDJNGSTUaz3N85KmzWrTJ7QOLNke1H9L9kkfEFowatF8fW5cV/7Y6Ubzh0e1626TELeE+\n" + + " kvczpXT7prdjJZZjQAbDuHsWXkOys=", + "KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=", + "pancake/waffle", + "from:to:date:subject:mime-version:arc-authentication-results", + baseMessageOneSignedTail())); + } + + // ams_fields_d_na: missing AMS d= prevents public-key lookup and must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_domain_tag_is_missing() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "xPtYeQQruf8zzJ9kUrMESmH9ooORAIArDB3MhPcaL+0fgmuc99fprb+aMaSqY6OdZvAEoO\n" + + " EBczyfdtlGKcqLqa5qpXYlukRfG3q8mlOd+8UU1u1bikCzfT/JI8PNerzaoxlksJfmt8zJT0\n" + + " f40IWBJnoRpPNqJSBFb8acvLVZFcQ=", + "iDLI16Dzhtt9CmHLpkUXy7d5legcVvxkPMStdfrYQfNfpwVia165ca2lGI7Sx79pCoMmy3\n" + + " sSWBrLHsTQkKylsGswc0br0ycquKhxHgQh0WChxQd6ITVGQvFO/wZJd2jtE5E/KDbPKDjEio\n" + + " qLfCWpVe2KT1UZ89V+E9tg0T5TgwY=", + null, + "dummy", + "12345")); + } + + // ams_fields_d_empty: empty AMS d= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_domain_tag_is_empty() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "Nn38++8Vf80guievTz8fSFN9VjbPdeRVR5LmvzRt0IMRzZ75FThtzO1VM0grGeUj+D39ri\n" + + " 0ZwIgNyVtZXfG17FEO5BGQq4ZddLQoWHLKTeOWXL59FPhGRJkxiKNefS2c5YqZQ0NI8VkKY0\n" + + " HQlX6AeD/CHHE/bpcg7fFB5/WWnLE=", + "yKCB5xEcyzGr2+mbXWsVDHDZB1PYe9MqqTWySS7Y32uFObEA/MNJmt5yPnZLScwQUhzeTc\n" + + " WL701aDMyPmlYlGnqxl2/QkvEw5hZNfOmD5gltxTlIabWyRrC1Qq/1RS2zDqvF2Qf8SJL1U7\n" + + " gL6jf82iBTT61ckhPraYGIdgI9hlo=", + "", + "dummy", + "12345")); + } + + // ams_fields_d_invalid: invalid AMS d= domain syntax must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_domain_tag_is_invalid() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "G8wYXXsNzfrmW5ob/HLkPkg0hz37d0O01HmLr8E8IQUPAa4lywxmOekn0bmKfOvK5p77Dz\n" + + " JEue+awK3gHG7/obHdRLamg8cYxmj4qfR6Ay0baikigUF4Wyt77JsVUqCC1qedRNcRN3IGPx\n" + + " 7rrNSyzVlIWYPal3pQZc3E1ClpG2I=", + "rllEQ7rbed0w+ixVEkL/jiUZrjyDdTQ1d+qnNGEvpzzjh2xFla14BKDcXo7q/aX25lxl0e\n" + + " yzw6yf5PFJC6JWqj5h3sFtLO6hS+E0DXyPZx0ok9tNiv7QV4YqY9fWeA64OZD183DKISDZnD\n" + + " mx/r0Svb5thGZvzvyfuAQapHke/Rk=", + "example...", + "dummy", + "12345")); + } + + // ams_fields_h_empty: upstream suite accepts an empty AMS h= tag. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_tag_is_empty() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "V/iPFUptKaruDTBpwKcf5i6nu54GxrG3ss2bfPqqT3I5MGMyRmtE+J0kOVtU9qtHIhXUng\n" + + " Iezv5+gCOIf2jP1eYGvhN2Wmkf2zsShG6+Rfpnp9fih71C1f6fh6Qp4tTUhB6ww4ZOTKtVdv\n" + + " H0C2s/5in4RLMxS0FUWge8CvlTnGs=", + "ex6hirqdOz1yO1SZE3ALisw3dj1La5L4qHcv8/ttCs1qGajzw0zEtUyMnskTPQnt9cxxF3\n" + + " T74KRXlPVN/4Aqn+K/Q4NHtOW9vyuLt9ek9Vm6/xvZ10KTMrxv24u0eLnsigC6NfablL4wAM\n" + + " epZDlyjf/HPBd0yVLQL8yFDtQ5fE0=", + "", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_cws1: whitespace around AMS h= colon separators must be ignored. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_have_whitespace_around_colons() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "0+WA3Dpt9Y1lJ5wkoOZsh6KXEQFv0YE+ykvXAdS5t1toEui1UWzLyKWxSD/H/Xc6eCaQZM\n" + + " ji4IxybZ4OrIdV0yRe1fGqYN/bJ3KnkuzrHpaikXRWxXdX8tiIu5+I+HmERxuGzGqHdNv2zj\n" + + " 5L8PNAsGs4LDg3xQXEe3FQAvis9OA=", + "Lq5Sy1R3C0RaTxKWfggKBJ2MOdgAHeFy1nELK1c+CFnxdvSL+OxuvSxk8HYv7YMJDTR4Na\n" + + " 1D5GaFedB1uYVQsz1T5e3p9B+54W4bObByD14WvTGKV3ys8FlOf4MdRIlD4o6N3INfHrNbYX\n" + + " zwPKjkoYbteAEQ/kTpjESOpm131io=", + "from : to : date : subject : mime-version : arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_cws2: folded whitespace around AMS h= colon separators must be ignored. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_have_folded_whitespace_around_colons() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "0+WA3Dpt9Y1lJ5wkoOZsh6KXEQFv0YE+ykvXAdS5t1toEui1UWzLyKWxSD/H/Xc6eCaQZM\n" + + " ji4IxybZ4OrIdV0yRe1fGqYN/bJ3KnkuzrHpaikXRWxXdX8tiIu5+I+HmERxuGzGqHdNv2zj\n" + + " 5L8PNAsGs4LDg3xQXEe3FQAvis9OA=", + "Lq5Sy1R3C0RaTxKWfggKBJ2MOdgAHeFy1nELK1c+CFnxdvSL+OxuvSxk8HYv7YMJDTR4Na\n" + + " 1D5GaFedB1uYVQsz1T5e3p9B+54W4bObByD14WvTGKV3ys8FlOf4MdRIlD4o6N3INfHrNbYX\n" + + " zwPKjkoYbteAEQ/kTpjESOpm131io=", + "from : to : date : subject : mime-version :\n" + + " arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_case: AMS h= header names are case-insensitive. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_use_mixed_case() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "me1uYrnpt5Cdjkfj+bqK8X6abs8TET4r5Wp6e6ZuZ2FAtSzfx8WdnHCnBLUj7t/PR+EGne\n" + + " h4auyljzkm2gz09I0MbaYkd+xDmkRoN2WrFotceq+iROoDLf2NgZJb3SfDcVFp8emRMyyaGL\n" + + " WAtshPjJWnjoNfm+3clEpXzPw4WM4=", + "OCzwOGeJy6YL07Rh1A970C9pAK2YJeXr0rDVVbsd/aOxTeKbrIxOfQsJ5hYaze0aeE5U0p\n" + + " y/45cz4Jg07Ch61xZ0G3R3ne4eXxPauAU6QKPwr45HxO2gDywmNruiJP0JPTzcC9SVV/YjyL\n" + + " OGobZNIwUWR1hEkd5/UuAXHk23Q4g=", + "From:To:Date:Subject:Mime-version:Arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_dup1: duplicate signed headers must be selected from the bottom up. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_duplicate_existing_header_in_bottom_up_order() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "tv8fgth8OQw5DylJlW253wBM12VcMvjFLj+TwonVXPiSPJ1hV7F24q0rgmYeVhSBK/+4Ou\n" + + " kPW3e9oqILXx95sXrE4fiiz46//FtZK7z0YVzy/B3QpR7fGxzzA5uVoUh4WNd0oQEejwDKss\n" + + " ILrzkyu6fDUZ1kLeKyk3clE7b/NJo=", + "rGZpmx8nA8Fe0yQ319Ns+DPmwx9ToC7Z5Ba5NNGYtmXF87xboR0Cy7yxlJ2ek6j8WqCRXI\n" + + " jKV32tgZBXu5upoveTLBGzSe+NPTL2SkU2nFnktJjjPwTiPAYyXVBY1Uy7uSv9dT+wfB4Hvg\n" + + " Hm/nSrzqTBOxPsND1F1b2rzE1elQo=", + "from:to:to:date:subject:mime-version:arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTailWithTwoToHeaders())); + } + + // ams_fields_h_non_existant: signing a non-existent header is allowed. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_include_non_existent_header() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "cEfCkdG3zAUpq2XMYEvcI8e+nD53NUuUr/NQ74UBTzSVJBOsNQKADtUWqYirSlB9AFeEIq\n" + + " VGstwfXqh5TiMv1Uk9O04vM7WxrmMsqZI+GiRQvtaanfZQMcaYME1pCURdkDbMK/MOUGV+W2\n" + + " j9anSPB91SOQruKUDtqgwq8z87Ajc=", + "QHma3KzZiiP6Yq5jWp+mLznldNAMpK9ffvI87mbvEFFd1YSfoJu9JrxtBgv3/MEBFHLPm9\n" + + " qTii8g+94xOLgp/LEC/dM2E/u7yPAKKMz5fMzJfwqSGAGyBg2f12Mkyaqs3dzv97nZTZFkj8\n" + + " mHCV6SHNfnC+lkIs5XpJNRtddvolQ=", + "from:to:date:subject:mime-version:arc-authentication-results", + "", + baseMessageOneSignedTail())); + } + + // ams_fields_h_non_existant_dup: duplicate non-existent signed headers are allowed. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_include_duplicate_non_existent_header() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "akTog4W3hR16mF9pNZIhHzcceyST1LHWaIsDPobRX6iy5jBRbpb+lyKlcyZmS02T2kFYG9\n" + + " iOWQ6UZruiQXQu/u/GSkn0RSCwHWTfb25YqrQBLwH7pki4bDGHrTSrGbuYnFEHadYl2B8Gxo\n" + + " UXYn2/XBBil6Dkku2SswdN6RZhhoM=", + "CvqFe5bB3kFEFvITOTVx7VcrJQBT5aAtUJjX0h1L1Gh0MtUQofgKfOakgKr5kUIxv2foZY\n" + + " KJzwNSuUNnDyY87HJeT02j4JlpYnj0+PzB8xjW2Kj4/4TrLMkcJsfC2wujZClzXW65uCsFEb\n" + + " 0ht8EEQis3581f6/S2V+2pHxvqRiM=", + "from:to:date:subject:mime-version:arc-authentication-results:mime-version", + "", + baseMessageOneSignedTail())); + } + + // ams_fields_h_mis_hdr: blank header names in AMS h= are ignored. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_include_blank_header_name() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersMessage( + "FCq5UA4xGNozfvMgZkQ0Wpu4Q0dkGbrNvMKc0SNQnbObHCA84DNwUUp+I41h5ZvwQBAGxf\n" + + " hvUfjmsMFHBtsYj/aQ5kkehVPkOZ/6hengnO0IJs78Ab/5eivdD7MRLuShcTWd9qx32dVFJD\n" + + " yx8qIaRZplvJYl30ry7sOJQu4qSZk=", + "4TbROXpBlHvYUMMvecTyaEqk0DtgISmfrz9L7QEizbAaI6vgDPu1xD8LSj4CfHpak6GMde\n" + + " zpqtfiITgVTBKbkZi2kuFQwmu5xWsReExZEiNq7Tr6L5iObGjL0A27RIBj4znEmO6mk2Umnl\n" + + " +c6LR5XzyE65FGLZ+9nSH2U12klzI=", + "from:to::date:subject:mime-version:arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_includes_ams: upstream suite currently permits including AMS in h=. + @Test + public void validate_arc_chain_passes_when_ams_signed_headers_include_arc_message_signature() throws Exception { + assertValimailFixturePasses(valimailAmsSignedHeadersIncludesAmsMessage()); + } + + // ams_fields_h_na: AMS h= is required. + @Test + public void validate_arc_chain_fails_when_ams_signed_headers_tag_is_missing() throws Exception { + assertValimailFixtureFails(valimailAmsMissingSignedHeadersMessage()); + } + + // ams_fields_h_dup2: duplicate signed headers in the wrong bottom-up order are rejected. + @Test + public void validate_arc_chain_fails_when_ams_signed_headers_duplicate_existing_header_in_wrong_order() throws Exception { + assertValimailFixtureFails(valimailAmsSignedHeadersMessage( + "tv8fgth8OQw5DylJlW253wBM12VcMvjFLj+TwonVXPiSPJ1hV7F24q0rgmYeVhSBK/+4Ou\n" + + " kPW3e9oqILXx95sXrE4fiiz46//FtZK7z0YVzy/B3QpR7fGxzzA5uVoUh4WNd0oQEejwDKss\n" + + " ILrzkyu6fDUZ1kLeKyk3clE7b/NJo=", + "rGZpmx8nA8Fe0yQ319Ns+DPmwx9ToC7Z5Ba5NNGYtmXF87xboR0Cy7yxlJ2ek6j8WqCRXI\n" + + " jKV32tgZBXu5upoveTLBGzSe+NPTL2SkU2nFnktJjjPwTiPAYyXVBY1Uy7uSv9dT+wfB4Hvg\n" + + " Hm/nSrzqTBOxPsND1F1b2rzE1elQo=", + "from:to:to:date:subject:mime-version:arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTailWithTwoToHeadersInReverseOrder())); + } + + // ams_fields_h_order: misordered signed headers must not validate. + @Test + public void validate_arc_chain_fails_when_ams_signed_headers_are_misordered() throws Exception { + assertValimailFixtureFails(valimailAmsSignedHeadersMessage( + "vTCiDmh8p+YFqH8WSxCrLVT3IS1Xmt35hs9y2Fb4EriRTTEmD7lWa0UrCe9j/a3yftcMAb\n" + + " 8W01KgTrdIhmUMF7YrElyT1cGc0ChGHmdkuA2MpVBnLJMCgtXEQkWcVRne38KB9P+GLvr5uD\n" + + " nBOjOJNoBt4Nt+Y8zCKG/tN2RetKk=", + "2o+Wl1gzbDmg4Hv5q52M7V+E6KBhMISVmqTDrk1HfOgMJwJ+0v8Nl18EjbL+iOTu6Vxz9+\n" + + " 1m64cPsNr1Tgm79jjqugOKDI/yaU7h4DaFMmN54tGX8j1ElMXSl8ghcfaknApLU060vKVUoo\n" + + " F2GfD1qo+SSox3wkZNkPQdGKjNmQM=", + "from:to:date:subject:mime-version:arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_empty_added: a header added after signing a missing header must be rejected. + @Test + public void validate_arc_chain_fails_when_previously_missing_ams_signed_header_is_added() throws Exception { + assertValimailFixtureFails(valimailAmsSignedHeadersMessage( + "cEfCkdG3zAUpq2XMYEvcI8e+nD53NUuUr/NQ74UBTzSVJBOsNQKADtUWqYirSlB9AFeEIq\n" + + " VGstwfXqh5TiMv1Uk9O04vM7WxrmMsqZI+GiRQvtaanfZQMcaYME1pCURdkDbMK/MOUGV+W2\n" + + " j9anSPB91SOQruKUDtqgwq8z87Ajc=", + "QHma3KzZiiP6Yq5jWp+mLznldNAMpK9ffvI87mbvEFFd1YSfoJu9JrxtBgv3/MEBFHLPm9\n" + + " qTii8g+94xOLgp/LEC/dM2E/u7yPAKKMz5fMzJfwqSGAGyBg2f12Mkyaqs3dzv97nZTZFkj8\n" + + " mHCV6SHNfnC+lkIs5XpJNRtddvolQ=", + "from:to:date:subject:mime-version:arc-authentication-results", + "MIME-Version: 1.0\n", + baseMessageOneSignedTail())); + } + + // ams_fields_h_includes_as: including ARC-Seal in AMS h= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_signed_headers_include_arc_seal() throws Exception { + assertValimailFixtureFails(valimailAmsSignedHeadersIncludesAsMessage()); + } + + // ams_fields_s_na: missing AMS s= prevents public-key lookup and must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_selector_tag_is_missing() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "zlVnN6R6lixbru5oAlqBAalgQAcbqVJi0fZe8u57TJTTLHNl+LRLeQRsLQ4OcZ2n5XLTSZ\n" + + " ZAEsfzFQWeFruAnDpA7yT7/YTUYvQM7KdVzx4vl4FSTllt1wb0UJ0SNjlNGiudA94D43LOsx\n" + + " CsESqhYaVWRz4gLkD2P6FfqZLGCZg=", + "1yhACoFkMMv54Xwy9PCxFazQ8BtUb99MhAUEk4Xwq7gVqDoyND9X+pa8CGMYSNUOn2I4tx\n" + + " 4PyDzLhPNf+a4AciBNvFhHwK4lljIQAS514NuaNfv3PR0KDkkoXYv8J1EkI9yAyvOzl5Ka2B\n" + + " 2yNTkGi6GucEwUlu2Qrk0RYhOYOVM=", + "example.org", + null, + "12345")); + } + + // ams_fields_s_empty: empty AMS s= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_selector_tag_is_empty() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "iUyd0NGqGiWwg11FiLSmb+053tfp1baKV04kpufd+RESTCeMHlAHj/N2ZyLCHnCZSfgDTb\n" + + " hJy5KSpxO1nsSOlG/FsI6zwfEWCEP91aNjzEQxrX9iCg/zihZ9uv3wgmSOasjjt2kVGCcJUM\n" + + " iLpzGuccZW6C0S8QyOA8ClL0cHnrs=", + "DMnmzfNSgbRhHJmeSr5Ahc9FzG0ZFQxd7FVPrmmbpB78dtA4tjLUywkekiqhABliJzs0ut\n" + + " zzkNYHyP0hlxGTaYOQ6OgV+1loymJCJDin9FhPV62CGOBXznuaRxFI+aWKHjW6SFFrZplQHG\n" + + " UQcAeHg8Dd8tdKV4dgUnuW+aphtiQ=", + "example.org", + "", + "12345")); + } + + // ams_fields_t_na: AMS t= is optional and a signature generated without it must validate. + @Test + public void validate_arc_chain_passes_when_ams_timestamp_tag_is_missing() throws Exception { + assertValimailFixturePasses(valimailAmsTagFieldsMessage( + "rx+UjBcicBZ6s5/J7S5oMw3YVWAWg+q4Sb4XqR0tMmhOyhjLq7702sEFlEDHJjdTuTVMg+\n" + + " c2qwv/XucEGW8/i4AMzNgkzpwk1Icsr0GHGbR7Jm8V+k6Z08tvQ4x1UaYgrTKmSQeyKq8rQQ\n" + + " rRdzsqqX73OFp/cKLa42T3JVTrQpc=", + "iRbmo9I0Qn8ZELD2xJ754eoEATUfoRxli5qMUi3AQTwGLHU6oaLFsAP7JDYjRm6al3XGp8\n" + + " 73NpnbncM6dnqlBvKK5OmekgztBKiyo7w0Uj6NZbq2KJXYiVW2vAbVkNwy4vPNhMHVTbD/xB\n" + + " PWROiovFOL0q2mHDT1KKLiSzEfrWA=", + "example.org", + "dummy", + null)); + } + + // ams_fields_t_empty: empty AMS t= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_timestamp_tag_is_empty() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "J1fBm2GXu8CCXApvRsyBIITcTcJ4MdgwPIUK2e+vU57BId7RYv2i7/ORWrImxasfuFD17v\n" + + " oU0TUpKqBmD/o6ZdLcgxg72iaYN7CoN9uK9Vr1llrVHuhJa4WUW0XG+a3XqKB2PXJh0LckJu\n" + + " 215qpJ4wqx+/6aGVuxQp5LXwktEDM=", + "GO1zQzqzWlsUbs6Rag7bYFPB2LgxCLkex8PRM+4/IbysgHm1TVtsPCVAAYp8+MK8UDyuuR\n" + + " s3wgba6Zgh08O4F3MGn5ouJmplCkS/mF1MTAuWF1BiBkzYTdNmwhESK3GBTDNgTzBwa0upsw\n" + + " aYiT87hDd1aqIKekvR3ZyEtZAN0Bc=", + "example.org", + "dummy", + "")); + } + + // ams_fields_t_invalid: non-numeric AMS t= must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_timestamp_tag_is_invalid() throws Exception { + assertValimailFixtureFails(valimailAmsTagFieldsMessage( + "g1Xr4aSSeSDH0CUBae/NLjI30AgmGDwAdG5BC2c/OuTKGROcimWkt3ikql9YlvBv/3O8AQ\n" + + " fe1XJqEq8EwFpKgk2YvMiWV4YKWPGb4DVNn/N2nk79o2KH/DlXNU4fLGvae9leiu1E+KJERC\n" + + " /sYt7EA0rffMCWMjfHivWEx1swomo=", + "B9XbvvEBkWcBoOY6hBRGeJLsADsuzM0ZRvpeBWgF/nx8itykfMZmdeVPzVY5SI7MRCi8jp\n" + + " +RtfP938tY75D6wfNd4+mrDkHyEQFAiE+UlYWjZOGx69go2UQyN5+wocPHHps4n9j279es08\n" + + " zmmxQXWG8wuoq53Y1CfrwNyniO824=", + "example.org", + "dummy", + "icecream")); + } + + @Test + public void verify_ams_throws_clear_exception_when_signature_is_expired() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage(new ByteArrayInputStream( + ("From: sender@example.org\r\n" + + "To: recipient@example.org\r\n" + + "Subject: expired ams\r\n").getBytes(StandardCharsets.UTF_8))); + String amsWithoutSignature = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; " + + "t=1; x=1000; h=from:to:subject; bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; b="; + String signature = signRelaxedAmsForNoBodyMessage(message, amsWithoutSignature); + Field amsField = new RawField(ARC_MESSAGE_SIGNATURE, amsWithoutSignature + signature); + String publicKeyDnsRecord = "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";"; + Clock clockAfterExpiration = Clock.fixed(Instant.ofEpochSecond(1001), ZoneOffset.UTC); + + assertThatThrownBy(() -> new ARCVerifier(keyRecordRetriever, clockAfterExpiration).verifyAms(amsField, message, publicKeyDnsRecord)) + .isInstanceOf(ArcException.class) + .hasMessage("AMS signature is expired"); + } + + @Test + public void verify_ams_throws_clear_exception_when_timestamp_is_in_the_future_without_expiration() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage(new ByteArrayInputStream( + ("From: sender@example.org\r\n" + + "To: recipient@example.org\r\n" + + "Subject: future ams timestamp\r\n").getBytes(StandardCharsets.UTF_8))); + String amsWithoutSignature = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; " + + "t=1001; h=from:to:subject; bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; b="; + String signature = signRelaxedAmsForNoBodyMessage(message, amsWithoutSignature); + Field amsField = new RawField(ARC_MESSAGE_SIGNATURE, amsWithoutSignature + signature); + String publicKeyDnsRecord = "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";"; + Clock clockBeforeTimestamp = Clock.fixed(Instant.ofEpochSecond(1000), ZoneOffset.UTC); + + assertThatThrownBy(() -> new ARCVerifier(keyRecordRetriever, clockBeforeTimestamp).verifyAms(amsField, message, publicKeyDnsRecord)) + .isInstanceOf(ArcException.class) + .hasMessage("AMS t= timestamp must not be in the future"); + } + + @Test + public void verify_ams_throws_clear_exception_when_expiration_is_not_after_timestamp() throws Exception { + Message message = new DefaultMessageBuilder().parseMessage(new ByteArrayInputStream( + ("From: sender@example.org\r\n" + + "To: recipient@example.org\r\n" + + "Subject: invalid ams lifetime\r\n").getBytes(StandardCharsets.UTF_8))); + String amsWithoutSignature = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; " + + "t=200; x=100; h=from:to:subject; bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; b="; + String signature = signRelaxedAmsForNoBodyMessage(message, amsWithoutSignature); + Field amsField = new RawField(ARC_MESSAGE_SIGNATURE, amsWithoutSignature + signature); + String publicKeyDnsRecord = "k=rsa; p=" + Base64.getEncoder().encodeToString(ArcTestKeys.publicKeyArc.getEncoded()) + ";"; + + assertThatThrownBy(() -> new ARCVerifier(keyRecordRetriever).verifyAms(amsField, message, publicKeyDnsRecord)) + .isInstanceOf(ArcException.class) + .hasMessage("AMS x= expiration must be greater than t= timestamp"); + } + + // aar_struct_i_na / aar_i_missing: an ARC-Authentication-Results header without i= is invalid. + @Test + public void validate_arc_chain_fails_when_aar_has_no_instance_tag() throws Exception { + Message message = buildOneHopChainWithAar("smtp.d1.example; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_struct_i_empty: an ARC-Authentication-Results header with empty i= is invalid. + @Test + public void validate_arc_chain_fails_when_aar_has_empty_instance_tag() throws Exception { + Message message = buildOneHopChainWithAar("i=; smtp.d1.example; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_struct_i_zero: ARC instance numbers start at 1, so AAR i=0 is invalid. + @Test + public void validate_arc_chain_fails_when_aar_has_zero_instance_tag() throws Exception { + Message message = buildOneHopChainWithAar("i=0; smtp.d1.example; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_struct_invalid: AAR instance numbers must be numeric. + @Test + public void validate_arc_chain_fails_when_aar_has_non_numeric_instance_tag() throws Exception { + Message message = buildOneHopChainWithAar("i=abc; smtp.d1.example; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_struct_dup: duplicate ARC-Authentication-Results headers for the same instance are invalid. + @Test + public void validate_arc_chain_fails_when_aar_is_duplicated_at_same_instance() throws Exception { + Message message = buildOneHopChainWithAar(null, true, true); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_struct_missing / aar_missing: an ARC set with AMS and AS but no AAR is incomplete. + @Test + public void validate_arc_chain_fails_when_aar_header_is_missing() throws Exception { + Message message = buildOneHopChainWithAar(null, false, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_i_wrong: an AAR whose i= does not match the rest of its ARC set is invalid. + @Test + public void validate_arc_chain_fails_when_aar_instance_does_not_match_arc_set() throws Exception { + Message message = buildOneHopChainWithAar("i=2; smtp.d1.example; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_i_not_prefixed: the AAR i= tag must be the leading ARC instance component. + @Test + public void validate_arc_chain_fails_when_aar_instance_tag_is_not_prefixed() throws Exception { + Message message = buildOneHopChainWithAar("smtp.d1.example; i=1; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar_i_no_semi: the AAR i= value must be followed by a semicolon separator. + @Test + public void validate_arc_chain_fails_when_aar_instance_tag_has_no_semicolon() throws Exception { + Message message = buildOneHopChainWithAar("i=1 smtp.d1.example; arc=none", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // aar2_missing: in a two-hop chain, the latest ARC set is incomplete if its i=2 AAR is missing. + @Test + public void validate_arc_chain_fails_when_i2_aar_header_is_missing() throws Exception { + Message message = buildNHopChain(2); + removeHeaderByInstanceAndType(message, ARC_AUTHENTICATION_RESULTS, "i=2"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_struct_missing: an ARC-Seal at i=1 with no corresponding ARC-Message-Signature means the + // set is incomplete and must be rejected — covered by validate_arc_chain_fails_when_ams_header_is_missing. + + // Pre-filled template used for direct ARCSigner canonicalization tests. + private static final String CANON_TEST_TEMPLATE = + "i=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; t=" + TIMESTAMP + + "; h=Subject:From:To; bh=; b="; + + // Minimal base email used for canonicalization tests. + private static final String BASE_EMAIL = + "From: jqd@d1.example\r\n" + + "To: arc@example.com\r\n" + + "Subject: test\r\n" + + "\r\n" + + "Hello world\r\n"; + + // Signs a raw email byte string directly with ARCSigner and returns "ARC-element:". + private String signRawEmail(String rawEmail) { + ARCSigner signer = new ARCSigner(CANON_TEST_TEMPLATE, ArcTestKeys.privateKeyArc); + return signer.generateAms(new ByteArrayInputStream(rawEmail.getBytes(StandardCharsets.UTF_8))); + } + + // Extracts a tag value from an AMS record (with or without the "ARC-element:" prefix). + private String extractAmsTag(String ams, String tagName) { + String body = ams.replaceFirst("^ARC-element:", ""); + for (String part : body.split(";")) { + String t = part.trim(); + if (t.startsWith(tagName + "=")) { + return t.substring((tagName + "=").length()).trim(); + } + } + return null; + } + + // message_body_eol_wsp: trailing whitespace on a body line must be stripped before body hashing, + // so two messages that differ only in trailing spaces produce the same bh=. + @Test + public void body_hash_is_invariant_under_body_line_trailing_whitespace() { + String variant = BASE_EMAIL.replace("Hello world\r\n", "Hello world \r\n"); + assertThat(extractAmsTag(signRawEmail(BASE_EMAIL), "bh")) + .isEqualTo(extractAmsTag(signRawEmail(variant), "bh")); + } + + // message_body_inl_wsp: runs of whitespace inside a body line must be collapsed to one space + // before body hashing, so double spaces produce the same bh= as single spaces. + @Test + public void body_hash_is_invariant_under_body_inline_whitespace() { + String variant = BASE_EMAIL.replace("Hello world\r\n", "Hello world\r\n"); + assertThat(extractAmsTag(signRawEmail(BASE_EMAIL), "bh")) + .isEqualTo(extractAmsTag(signRawEmail(variant), "bh")); + } + + // message_body_end_lines: trailing blank lines at the end of the body must be ignored when + // computing the body hash, so extra blank lines produce the same bh=. + @Test + public void body_hash_is_invariant_under_trailing_blank_lines() { + String variant = BASE_EMAIL.replace("Hello world\r\n", "Hello world\r\n\r\n\r\n"); + assertThat(extractAmsTag(signRawEmail(BASE_EMAIL), "bh")) + .isEqualTo(extractAmsTag(signRawEmail(variant), "bh")); + } + + // message_body_trail_crlf: a body that does not end with CRLF must have one appended before + // hashing, so it produces the same bh= as the same body that does end with CRLF. + @Test + public void body_hash_is_invariant_when_body_lacks_trailing_crlf() { + String variant = BASE_EMAIL.replace("Hello world\r\n", "Hello world"); + assertThat(extractAmsTag(signRawEmail(BASE_EMAIL), "bh")) + .isEqualTo(extractAmsTag(signRawEmail(variant), "bh")); + } + + // headers_field_name_case: header names must be lowercased before signing, so Subject and SUBJECT + // produce the same AMS (same bh= and same b=). + @Test + public void ams_is_invariant_under_header_name_case() { + String variant = BASE_EMAIL.replace("Subject: test\r\n", "SUBJECT: test\r\n"); + assertThat(signRawEmail(BASE_EMAIL)).isEqualTo(signRawEmail(variant)); + } + + // headers_field_unfold: folded headers (split with CRLF + whitespace continuation) must be + // joined back into one line before signing, so the folded and unfolded forms produce the same AMS. + @Test + public void ams_is_invariant_under_header_folding() { + String cleanEmail = + "From: jqd@d1.example\r\nTo: arc@example.com\r\nSubject: Hello world\r\n\r\nHello world\r\n"; + String foldedEmail = + "From: jqd@d1.example\r\nTo: arc@example.com\r\nSubject: Hello\r\n world\r\n\r\nHello world\r\n"; + assertThat(signRawEmail(cleanEmail)).isEqualTo(signRawEmail(foldedEmail)); + } + + // headers_eol_wsp: trailing whitespace at the end of a header value must be stripped before + // signing, so trailing spaces produce the same AMS as no trailing spaces. + @Test + public void ams_is_invariant_under_header_trailing_whitespace() { + String variant = BASE_EMAIL.replace("Subject: test\r\n", "Subject: test \r\n"); + assertThat(signRawEmail(BASE_EMAIL)).isEqualTo(signRawEmail(variant)); + } + + // headers_inl_wsp: runs of whitespace inside a header value must be collapsed to one space before + // signing, so double spaces inside a value produce the same AMS as a single space. + @Test + public void ams_is_invariant_under_header_inline_whitespace() { + String cleanEmail = + "From: jqd@d1.example\r\nTo: arc@example.com\r\nSubject: Hello world\r\n\r\nHello world\r\n"; + String variantEmail = + "From: jqd@d1.example\r\nTo: arc@example.com\r\nSubject: Hello world\r\n\r\nHello world\r\n"; + assertThat(signRawEmail(cleanEmail)).isEqualTo(signRawEmail(variantEmail)); + } + + // headers_col_wsp: whitespace around the colon separator in a header must be normalised before + // signing, so "Subject: test" and "Subject:test" (no space) produce the same AMS. + @Test + public void ams_is_invariant_under_header_colon_whitespace() { + String variant = BASE_EMAIL.replace("Subject: test\r\n", "Subject:test\r\n"); + assertThat(signRawEmail(BASE_EMAIL)).isEqualTo(signRawEmail(variant)); + } + + // i1_base: when a message already carries a valid i=1 ARC set, buildArcSet must produce an i=2 set + // whose seal carries cv=pass — the new server correctly extends the chain. + @Test + public void build_arc_set_generates_i2_cv_pass_when_signing_on_top_of_valid_i1_chain() throws Exception { + Message message = buildNHopChain(1); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + assertThat(arcSet.get(ARC_SEAL)).contains("cv=pass"); + assertThat(arcSet.get(ARC_SEAL)).contains("i=2"); + assertThat(arcSet.get(ARC_MESSAGE_SIGNATURE)).contains("i=2"); + assertThat(arcSet.get(ARC_AUTHENTICATION_RESULTS)).contains("i=2"); + } + + // i2_base: when a message already carries valid i=1 and i=2 ARC sets, buildArcSet must produce an i=3 + // set whose seal carries cv=pass — the new server correctly extends the chain. + @Test + public void build_arc_set_generates_i3_cv_pass_when_signing_on_top_of_valid_i2_chain() throws Exception { + Message message = buildNHopChain(2); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + assertThat(arcSet.get(ARC_SEAL)).contains("cv=pass"); + assertThat(arcSet.get(ARC_SEAL)).contains("i=3"); + assertThat(arcSet.get(ARC_MESSAGE_SIGNATURE)).contains("i=3"); + assertThat(arcSet.get(ARC_AUTHENTICATION_RESULTS)).contains("i=3"); + } + + // i1_base_fail: when the incoming i=1 chain is already broken (corrupt AMS), buildArcSet must still + // produce an i=2 set, but the new seal must carry cv=fail to faithfully record the broken chain. + @Test + public void build_arc_set_generates_cv_fail_seal_when_signing_on_top_of_broken_i1_chain() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + String fakeB64 = Base64.getEncoder().encodeToString(new byte[128]); + for (Map.Entry entry : arcSet.entrySet()) { + String value = entry.getKey().equals(ARC_MESSAGE_SIGNATURE) + ? entry.getValue().replaceAll("; b=.*$", "; b=" + fakeB64) + : entry.getValue(); + message.getHeader().addField(new RawField(entry.getKey(), value)); + } + + Map newArcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + assertThat(newArcSet.get(ARC_SEAL)).contains("cv=fail"); + assertThat(newArcSet.get(ARC_SEAL)).contains("i=2"); + } + + // i2_base_fail: when the incoming two-hop chain is already broken, buildArcSet must produce an i=3 + // set whose seal carries cv=fail — the broken state is faithfully recorded. + @Test + public void build_arc_set_generates_cv_fail_seal_when_signing_on_top_of_broken_i2_chain() throws Exception { + Message message = buildNHopChain(2); + corruptSignatureOnHeader(message, ARC_MESSAGE_SIGNATURE, "i=1"); + + Map newArcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + assertThat(newArcSet.get(ARC_SEAL)).contains("cv=fail"); + assertThat(newArcSet.get(ARC_SEAL)).contains("i=3"); + } + + // no_additional_sig: after signing on top of a broken chain and adding the new i=2 set to the message, + // the full chain validation must still return cv=fail — a valid new signature must not heal a broken chain. + @Test + public void validate_arc_chain_remains_fail_after_signing_on_top_of_broken_chain() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + String fakeB64 = Base64.getEncoder().encodeToString(new byte[128]); + for (Map.Entry entry : arcSet.entrySet()) { + String value = entry.getKey().equals(ARC_MESSAGE_SIGNATURE) + ? entry.getValue().replaceAll("; b=.*$", "; b=" + fakeB64) + : entry.getValue(); + message.getHeader().addField(new RawField(entry.getKey(), value)); + } + + Map newArcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + for (Map.Entry entry : newArcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ar_merged1: multiple Authentication-Results headers for the signing authserv-id must be + // consolidated into one ARC-Authentication-Results header, while other authserv-ids are ignored. + @Test + public void build_arc_set_merges_multiple_authentication_results_for_authserv_id() throws Exception { + Message message = parseRawEmail( + "Authentication-Results: lists.example.org; arc=none\n" + + "Authentication-Results: lists.example.org; spf=pass smtp.mfrom=jqd@d1.example\n" + + "Authentication-Results: lists.example.org; dkim=pass (1024-bit key) header.i=@d1.example\n" + + "Authentication-Results: lists.example.org; dmarc=pass\n" + + "Authentication-Results: nobody.example.org; something=ignored\n" + + basicMessageWithoutAuthenticationResults()); + + Map arcSet = buildArcSetWithAuthService(message, "lists.example.org"); + + assertThat(arcSet.get(ARC_AUTHENTICATION_RESULTS)).isEqualTo( + "i=1; lists.example.org; arc=none; spf=pass smtp.mfrom=jqd@d1.example; " + + "dkim=pass (1024-bit key) header.i=@d1.example; dmarc=pass"); + } + + // ar_merged2: folded Authentication-Results payloads must be unfolded and merged in order. + @Test + public void build_arc_set_merges_folded_authentication_results_for_authserv_id() throws Exception { + Message message = parseRawEmail( + "Authentication-Results: lists.example.org; arc=none;\n" + + " spf=pass smtp.mfrom=jqd@d1.example\n" + + "Authentication-Results: lists.example.org; dkim=pass (1024-bit key) header.i=@d1.example\n" + + "Authentication-Results: lists.example.org; dmarc=pass\n" + + "Authentication-Results: nobody.example.org; something=ignored\n" + + basicMessageWithoutAuthenticationResults()); + + Map arcSet = buildArcSetWithAuthService(message, "lists.example.org"); + + assertThat(arcSet.get(ARC_AUTHENTICATION_RESULTS)).isEqualTo( + "i=1; lists.example.org; arc=none; spf=pass smtp.mfrom=jqd@d1.example; " + + "dkim=pass (1024-bit key) header.i=@d1.example; dmarc=pass"); + } + + // ams_format_tags_unknown: an unrecognised tag in the ARC-Message-Signature must be silently ignored, + // so a chain signed with an extra z= tag must still validate as cv=pass. + @Test + public void validate_arc_chain_passes_when_ams_has_unknown_tag() throws Exception { + String templateWithUnknownTag = "i=; a=rsa-sha256; c=relaxed/relaxed; d=dmarc.example; s=arc; z=test; t=; h=Subject:From:To; bh=; b="; + ArcSetBuilder builderWithUnknownTag = new ArcSetBuilder(ArcTestKeys.privateKeyArc, templateWithUnknownTag, ARC_SEAL_TEMPLATE, AUTH_SERVICE, TIMESTAMP); + + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = builderWithUnknownTag.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // ams_format_inv_tag_key: a tag key starting with a digit (e.g. 1s=arc) is not a valid tag name + // and the selector cannot be resolved, so the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_has_invalid_tag_key_character() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replace("s=arc", "1s=arc"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_format_tags_dup: if the same tag key appears twice in an ARC-Message-Signature, the second + // value overrides the first, resolving to a different selector that is not in DNS, causing failure. + @Test + public void validate_arc_chain_fails_when_ams_has_duplicate_tag() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE) + "; s=invalid"; + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_format_tags_key_case: tag keys are case-sensitive — S=arc does not provide the s= tag, + // so the selector cannot be found and the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_uses_uppercase_tag_key() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replace("s=arc", "S=arc"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_format_tags_val_case: modifying a tag value's case (e.g. a=RSA-SHA256) changes the + // signed bytes so the signature no longer verifies, and the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_tag_value_has_wrong_case() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replace("a=rsa-sha256", "a=RSA-SHA256"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_format_tags_wsp: whitespace inside a tag value (s=ar c) changes the DNS selector name to + // one that does not exist, so the public key cannot be retrieved and the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_tag_value_contains_whitespace() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replace("s=arc", "s=ar c"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_format_tags_sc: an extra semicolon inside a tag value splits the value, making the s= tag + // resolve to a truncated selector that is not in DNS, so the chain must be rejected. + @Test + public void validate_arc_chain_fails_when_ams_tag_value_contains_semicolon() throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + String malformedAms = arcSet.get(ARC_MESSAGE_SIGNATURE).replace("s=arc", "s=ar;c"); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, malformedAms)); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // ams_format_sc_wsp: whitespace before the semicolon separator in an AMS tag list is valid. + // This mirrors the ValiMail arc_test_suite fixture and must validate as cv=pass. + @Test + public void validate_arc_chain_passes_when_ams_has_whitespace_around_semicolon_separator() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=OeNJ7p2NdW3mKv4hyenx+QbRuqqq8iwGAyY1WVX/EJiPHS2vNB5lEI/YmVB3diTkKPHWe8\n" + + " ZOq18DTVtOVuahLqM7s/4K/gvx3zal0vcedPL/mtRW4A1Ct0/wyLuFADX2HZ815cELx81SuX\n" + + " 3fEbbym1br+0JArsz6n8798lidnWY=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=NOLE9bNh30qiTx35h5yKbHlDPahxvhXUWjv8Yiy5L7Ks3NNznK54dmUPZ4D/80tkRYiil0\n" + + " 8sCqFTh7OH5ZTXXEfArxBMQQl3DAqTjOJQ1c3jPYwaDliWqCLLueSsH+ovaFGRGNPm2O41o0\n" + + " J8xUmyji1bXXLKMinB+Adv9ALXsw8=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ= ; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + valimailCommonMessageTail()); + } + + // ams_format_eq_wsp: whitespace around "=" in an AMS tag is valid and should not break parsing. + @Test + public void validate_arc_chain_passes_when_ams_has_whitespace_around_equals_separator() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=CcoQW04QZ7n7OTPACcP26R0vJtjEwVmcFpj4+PJnvT1kVeOMfcqwt7FEGlCjeJ0QIYMeNW\n" + + " TY6kND0fe0WJDVnWvhCyeOb5JjwllbJJ/ThP74I5UPgQ0Cwp1h/O9HIrUJkrje6HQ3nD6Dok\n" + + " la2keL/t4R7YtMyAmn9sPWuAOwSrE=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=KLZ8Io9rZzsWt0Q/Mrx8sYO7HPLptFwGoCdabHuyrQsek+1c5yo5tOQidcTc8ksw5PoAZH\n" + + " PNOIoyGVte9jMk0LdA1IYjjvvUmEANMZCJf0wm66exDWJ30xMrgbosLN2XvsRk3BDkoCg2AY\n" + + " HkR11isMdIhrefd7AHw9YEDTnohQw=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c = relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + valimailCommonMessageTail()); + } + + // ams_format_tags_trail_sc: a trailing semicolon at the end of the AMS tag list is valid. + @Test + public void validate_arc_chain_passes_when_ams_tag_list_has_trailing_semicolon() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=Q3iCsG7zmlydzz8zFIm4X+Nyr2636znsyGh+lRhCFtcWbw3m3v8fFrtK3uNvqSM+WW3Cmf\n" + + " TbteHFaG9YL34KUMi/ThuPoG8sOwJ18BPjXrdBS5EiXYBBFalkVRV0ktqyiNi57LBVS+VGWV\n" + + " FwOD85C/V/Fju2wETdy0ly1VjfLBg=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=H+XsRP2HBJwygQonE/YquKr2y1KqjjlhBQ/hEkIGFjjNhOIvMfuuO054H4+kxMmvHFdwk8\n" + + " a8Uwy1MxQBC3a4b0jAQ77rOn5VFhO1tAmCkfZP1bJSxewRfC2Eo7j/07+r8ZLuyuAzlQIW+n\n" + + " DPJtOhnIIEOGhLgPNlcTwc9R/XKiE=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345;\n" + + valimailCommonMessageTail()); + } + + // as_struct_i_na / as_fields_i_missing: an ARC-Seal without i= is invalid. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_no_instance_tag() throws Exception { + Message message = buildOneHopChainWithSeal( + seal -> seal.replaceFirst("i=1;\\s*", ""), + true, + false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_struct_i_empty: an ARC-Seal with empty i= is invalid. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_empty_instance_tag() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("i=1;", "i=;"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_struct_i_zero: ARC instance numbers start at 1, so AS i=0 is invalid. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_zero_instance_tag() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("i=1;", "i=0;"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_struct_i_invalid: ARC-Seal instance numbers must be numeric. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_non_numeric_instance_tag() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("i=1;", "i=abc;"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_struct_dup: duplicate ARC-Seal headers for the same instance are invalid. + @Test + public void validate_arc_chain_fails_when_arc_seal_is_duplicated_at_same_instance() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal, true, true); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_struct_missing: an ARC set with AAR and AMS but no AS is incomplete. + @Test + public void validate_arc_chain_fails_when_arc_seal_header_is_missing_from_arc_set() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal, false, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_format_sc_wsp: whitespace before an AS semicolon separator is valid. + @Test + public void validate_arc_chain_passes_when_arc_seal_has_whitespace_around_semicolon_separator() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=sQHCWC9A8lAbvcPG+3jfih4lRJY/A0OI/GBGE4AYHf8u9cgsxOvyCqDWF3mr91HE5PhNh4\n" + + " RZW95NC6qhxEhnXLaXswqco2JXMVR6/rM5Q49bDE2RtlNen7wubw56NoJD2A7IGUSOzHaAiJ\n" + + " QhRTSoyG5OwNBC8+GlugUJi5mmZNU=; cv=none; d=example.org; i=1 ; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_format_eq_wsp: whitespace around "=" in the AS i= tag is valid. + @Test + public void validate_arc_chain_passes_when_arc_seal_has_whitespace_around_equals_separator() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=u4XUza5aJKdMCwCMffAieua1x4N9tZpKlx7UwMcdgV+BuIZc48C3rF8xu6BnoRQCaulZmW\n" + + " 4EYspmshC6cGg+kmYaWR/sbW712Ag8W33enEcoh35XLTg9QHg7zWvftk746RrVFb5Ch8iRsU\n" + + " PJ0gkAieomzXwlqCIBZQD5Yz2LB38=; cv=none; d=example.org; i = 1; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_format_tags_trail_sc: a trailing semicolon at the end of the AS tag list is valid. + @Test + public void validate_arc_chain_passes_when_arc_seal_tag_list_has_trailing_semicolon() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=AcBD4PAxYztV5R8jYyYXKuMBWBRja89F6yBTQVtQ1FFUxQVYGOrFlnh3/r8/YtFt13NELg\n" + + " FpYeY3gnzudk30PoZZvM2MG9h07ByTgl0lSEsRLhN+ZtqoHRq1QGdW8oqOXntI51FbKwBdoe\n" + + " cHtLh18GzKAvazRWzv8//vQInYp/Y=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345;\n" + + valimailArcSealFormatCommonTail()); + } + + // as_format_tags_unknown: an unknown AS tag is valid when it was present at signing time. + @Test + public void validate_arc_chain_passes_when_arc_seal_has_unknown_tag() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=FriX6cOxgBHhZwNYHn0KXSWVqwHPNV6sRAKUy9iN1OqwvAK9USwMsg/P08yXrUH8LRaijm\n" + + " msJjp0KUFYiffoQrhsxHwv1hJIGceJZB7lOFeZn7Z5aym4eBp7q7idwNyIaGKL7E0WzVkeAT\n" + + " RQ5LhtOInN23gugfmW6z8MUUvow5Y=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345; w=catparty\n" + + valimailArcSealFormatCommonTail()); + } + + // as_format_inv_tag_key: invalid AS tag keys are rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_invalid_tag_key_character() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("t=1755918846", "_=; t=1755918846"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_format_tags_dup: duplicate AS tags are rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_duplicate_tag() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal + "; s=invalid", true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_format_tags_key_case: AS tag keys are case-sensitive. + @Test + public void validate_arc_chain_fails_when_arc_seal_uses_uppercase_tag_key() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("s=arc", "S=arc"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_format_tags_val_case: AS domain value changes are signature-sensitive and must fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_tag_value_has_wrong_case() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("d=dmarc.example", "d=Dmarc.example"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_format_tags_wsp: invalid whitespace inside an AS tag value must fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_tag_value_contains_whitespace() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("t=1755918846", "t=1755 918846"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_format_tags_sc: an extra semicolon inside the AS tag list must fail. + @Test + public void validate_arc_chain_fails_when_arc_seal_tag_value_contains_semicolon() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("s=arc", "s=arc;"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_i_dup: duplicate ARC-Seal headers at i=1 must be rejected. + @Test + public void validate_arc_chain_fails_when_i1_arc_seal_field_is_duplicated() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal, true, true); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_i_dup2: duplicate ARC-Seal headers at i=2 must be rejected. + @Test + public void validate_arc_chain_fails_when_i2_arc_seal_field_is_duplicated() throws Exception { + Message message = buildNHopChain(2); + Field seal = message.getHeader().getFields().stream() + .filter(f -> f.getName().equalsIgnoreCase(ARC_SEAL) && f.getBody().contains("i=2")) + .findFirst().orElseThrow(() -> new AssertionError("i=2 ARC-Seal not found")); + message.getHeader().addField(new RawField(ARC_SEAL, seal.getBody())); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_a_na: missing ARC-Seal a= changes the sealed data and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_algorithm_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("a=rsa-sha256;\\s*", ""), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_a_empty: empty ARC-Seal a= changes the sealed data and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_algorithm_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("a=rsa-sha256", "a="), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_a_sha1: changing ARC-Seal a= to rsa-sha1 invalidates the seal. + @Test + public void validate_arc_chain_fails_when_arc_seal_algorithm_is_sha1() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("a=rsa-sha256", "a=rsa-sha1"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_a_unknown: unknown ARC-Seal algorithms must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_algorithm_is_unknown() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("a=rsa-sha256", "a=ed25519-sha256"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_ignores_wsp: whitespace inside ARC-Seal b= must be ignored during base64 decode. + @Test + public void validate_arc_chain_passes_when_arc_seal_signature_contains_whitespace() throws Exception { + Message message = buildOneHopChainWithSeal(this::insertWhitespaceIntoSealSignature, true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // as_fields_b_1024: ARC-Seal signed with a 1024-bit RSA key must validate. + @Test + public void validate_arc_chain_passes_when_arc_seal_uses_1024_bit_key() throws Exception { + assertValimailFixturePasses( + valimailArcSealKeySizeMessage( + "1024", + "JZIhBQD/1SCIn7IUrIoqCDFZ4k2tDd5joLebC7dCEbEXy6HURnayDygFjEiVwoVjF8XZPo\n" + + " tDSWEVj18YLFQ08HZigNNDmhAdtIAeHs5bTfhz3ZDKGISGSrVbUqvS5QaL2dwaY5V3FhH1QC\n" + + " VEohhbx3rJKMBiFCbQoCRo555WNL0=", + "jCTMZoXkSSVEusJyP9cbvAoKEDLphi95R/yaX9+gWw2t/RduqINzxPSVJZUq8uVCbKdB5F\n" + + " BlBb2m7zbwaq6/oemTqI1tcnRaAt66Z0cyOKfPjRINTm9C8E3hUoI9DzplkwEoqmhR0wOjcJ\n" + + " H6ASJr96Kl5qLu092VFaQYYxkwh2I="), + valimailKeySizeRecordRetriever); + } + + // as_fields_b_2048: ARC-Seal signed with a 2048-bit RSA key must validate. + @Test + public void validate_arc_chain_passes_when_arc_seal_uses_2048_bit_key() throws Exception { + assertValimailFixturePasses( + valimailArcSealKeySizeMessage( + "2048", + "R6I8tV4Y0pBQWId+r4W9L3TDi82iVPot9d+ux5u69ET/VUTQUPFAiRfTBqMKAm0dY1HCdU\n" + + " JZggmlvj9BwZMOO9pFi8O1EXqkJ1CpNtFyNn76Get96owYXh7LlcP/C/a5AmxZMmvKblloh5\n" + + " 1rL2cNWicsp8/y3NS8jO0KWpSis2jK2yMn+r9gJ5gM2sUiBsKDwiYAhFBhjD8SFQOaG6DzLa\n" + + " mJzCw9FkuGdpLfQoNDq2lLQq6APq8GihFJai7o/s8M4FItAMoteuqxIfyYuH60oX4qNOsaIT\n" + + " B/6DnRCFshABODpSHRRIH4EvCu2fYYo6YDIU3VvDH2wOO5fQMcgvUoNw==", + "M0YyrXMDoG5zJ0ZjFzUqFNoDFatu/QxWTjyAH5wPvPRiSqw2Vvd4A1Al8VjYfmgbP4Jd8f\n" + + " TFDZg1kWwLYk2IO/th/P6iYPfyDg5qp6mgao/V8NBW9P/Mqlb+xhkn4R8c44vmen9atIUV3Z\n" + + " 04QzziVeuBxj+NFqxprbxf42Faxv5XymGmW3ZWVhOLEpwfcjy933drLsfZQezhyYlx4klptI\n" + + " v3hKM76++GaIUc1nWXvmkeKKjEQLiUzqxd9Om7SRNArNe/q5xnVIaufxSfZNUtTT/o7Ic1Br\n" + + " t7ZV8qwmj37sYpdZUo6H7QN+dp8E/J0jnbI0ZQU2mv8Gj3FqGOGzKwGQ=="), + valimailKeySizeRecordRetriever); + } + + // as_fields_b_512: ARC-Seal signed with a 512-bit RSA key must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_uses_512_bit_key() throws Exception { + assertValimailFixtureFails( + valimailArcSealKeySizeMessage( + "512", + "DCbMvnfI7UzqahO9GFjYXa7DAcon0abOMQ7mWykqtdkEe+rqeQmsy1/pV9oAeSrT9giBqP\n" + + " +cBNepG4Nycj93KQ==", + "BFnboE5xz5OBBIZeB04CaX0QVCRysZesZNKLQLDbq3ohfHL0eIkMWyt/ZkP3+bg7wVEtyb\n" + + " QfqbbfDRTQYC3GBA=="), + valimailKeySizeRecordRetriever); + } + + // as_fields_b_head_case: ARC-Seal relaxed canonicalization lowercases header names. + @Test + public void validate_arc_chain_passes_when_arc_seal_header_name_case_changes() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-SEAL: a=rsa-sha256;\n" + + " b=RkKDOauVsqcsTEFv6NVE6J0sxj8LUE4kfwRzs0CvMg/+KOqRDQoFxxJsJkI77EHZqcSgwr\n" + + " QKpt6aKsl2zyUovVhAppT65S0+vo+h3utd3f8jph++1uiAUhVf57PihDC/GcdhyRGa6YNQGh\n" + + " GoArSHaJKb06/qF5OBif8o9lmRC8E=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_fields_b_head_unfold: folded ARC-Seal header lines must verify under relaxed canonicalization. + @Test + public void validate_arc_chain_passes_when_arc_seal_signature_header_is_unfolded() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=RkKDOauVsqcsTEFv6NVE6J0sxj8LUE4kfwRzs0CvMg/+KOqRDQoFxxJsJkI77EHZqcSgwr QKpt6aKsl2zyUovVhAppT65S0+vo+h3utd3f8jph++1uiAUhVf57PihDC/GcdhyRGa6YNQGh\n" + + " GoArSHaJKb06/qF5OBif8o9lmRC8E=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_fields_b_eol_wsp: trailing line whitespace must be stripped during AS verification. + @Test + public void validate_arc_chain_passes_when_arc_seal_signature_has_end_of_line_whitespace() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=RkKDOauVsqcsTEFv6NVE6J0sxj8LUE4kfwRzs0CvMg/+KOqRDQoFxxJsJkI77EHZqcSgwr \n" + + " QKpt6aKsl2zyUovVhAppT65S0+vo+h3utd3f8jph++1uiAUhVf57PihDC/GcdhyRGa6YNQGh\n" + + " GoArSHaJKb06/qF5OBif8o9lmRC8E=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_fields_b_inl_wsp: repeated inline whitespace must be reduced during AS verification. + @Test + public void validate_arc_chain_passes_when_arc_seal_header_has_extra_inline_whitespace() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=RkKDOauVsqcsTEFv6NVE6J0sxj8LUE4kfwRzs0CvMg/+KOqRDQoFxxJsJkI77EHZqcSgwr\n" + + " QKpt6aKsl2zyUovVhAppT65S0+vo+h3utd3f8jph++1uiAUhVf57PihDC/GcdhyRGa6YNQGh\n" + + " GoArSHaJKb06/qF5OBif8o9lmRC8E=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_fields_b_col_wsp: whitespace around the ARC-Seal field-name colon must be ignored. + @Test + public void validate_arc_chain_passes_when_arc_seal_header_has_whitespace_after_colon() throws Exception { + assertValimailFixturePasses( + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=RkKDOauVsqcsTEFv6NVE6J0sxj8LUE4kfwRzs0CvMg/+KOqRDQoFxxJsJkI77EHZqcSgwr\n" + + " QKpt6aKsl2zyUovVhAppT65S0+vo+h3utd3f8jph++1uiAUhVf57PihDC/GcdhyRGa6YNQGh\n" + + " GoArSHaJKb06/qF5OBif8o9lmRC8E=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + valimailArcSealFormatCommonTail()); + } + + // as_fields_b_na: missing ARC-Seal b= leaves no signature to verify and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_signature_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceAll("; b=.*$", ""), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_empty: empty ARC-Seal b= leaves no signature to verify and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_signature_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceAll("; b=.*$", "; b="), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_base64: ARC-Seal b= must be base64. + @Test + public void validate_arc_chain_fails_when_arc_seal_signature_is_not_base64() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceAll("; b=.*$", "; b=not-base64!"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_mod_sig: a modified ARC-Seal signature must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_signature_is_modified() throws Exception { + Message message = buildOneHopChainWithSeal( + seal -> seal.replaceAll("; b=.*$", "; b=" + Base64.getEncoder().encodeToString(new byte[128])), + true, + false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_aar1: modifying sealed AAR data must invalidate the ARC-Seal. + @Test + public void validate_arc_chain_fails_when_sealed_aar_data_is_modified() throws Exception { + Message message = buildNHopChain(1); + replaceTagOnHeader(message, ARC_AUTHENTICATION_RESULTS, "i=1", "dmarc=pass", "dmarc=fail"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_ams1: modifying sealed AMS data must invalidate the ARC-Seal. + @Test + public void validate_arc_chain_fails_when_sealed_ams_data_is_modified() throws Exception { + Message message = buildNHopChain(1); + replaceTagOnHeader(message, ARC_MESSAGE_SIGNATURE, "i=1", "h=subject : from : to", "h=from : to : subject"); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_b_asb1: modifying sealed ARC-Seal data outside b= must invalidate the seal. + @Test + public void validate_arc_chain_fails_when_sealed_arc_seal_data_is_modified() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("t=1755918846", "t=1755918847"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_cv_na: missing ARC-Seal cv= must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_cv_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("cv=none;\\s*", ""), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_cv_empty: empty ARC-Seal cv= must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_cv_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("cv=none", "cv="), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_cv_invalid: invalid ARC-Seal cv= values must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_cv_tag_is_invalid() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("cv=none", "cv=maybe"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_d_na: missing ARC-Seal d= prevents key lookup and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_domain_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("d=dmarc.example;\\s*", ""), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_d_empty: empty ARC-Seal d= prevents key lookup and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_domain_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("d=dmarc.example", "d="), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_d_invalid: invalid ARC-Seal d= values must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_domain_tag_is_invalid() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("d=dmarc.example", "d=invalid_domain"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_h_present: ARC-Seal must not contain h=; it signs ARC set headers, not h=-listed headers. + @Test + public void validate_arc_chain_fails_when_arc_seal_has_header_list_tag() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("t=1755918846", "h=subject; t=1755918846"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_s_na: missing ARC-Seal s= prevents key lookup and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_selector_tag_is_missing() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replaceFirst("s=arc;\\s*", ""), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_s_empty: empty ARC-Seal s= prevents key lookup and must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_selector_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("s=arc", "s="), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_t_na: ARC-Seal t= is optional and a seal generated without it must validate. + @Test + public void validate_arc_chain_passes_when_arc_seal_timestamp_tag_is_missing() throws Exception { + String sealTemplateWithoutTimestamp = "i=; cv=; a=rsa-sha256; d=dmarc.example; s=arc; b="; + Message message = buildOneHopChainWithSealTemplate(sealTemplateWithoutTimestamp); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("pass"); + } + + // as_fields_t_empty: empty ARC-Seal t= must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_timestamp_tag_is_empty() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("t=1755918846", "t="), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // as_fields_t_invalid: invalid ARC-Seal t= values must be rejected. + @Test + public void validate_arc_chain_fails_when_arc_seal_timestamp_tag_is_invalid() throws Exception { + Message message = buildOneHopChainWithSeal(seal -> seal.replace("t=1755918846", "t=abc"), true, false); + + ARCChainValidator arcChainValidator = new ARCChainValidator(keyRecordRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo("fail"); + } + + // Builds a valid two-hop ARC chain: applies i=1 to the base message, then applies i=2 on top. + private Message buildTwoHopChain() throws Exception { + return buildNHopChain(2); + } + + private Message buildOneHopChainWithSealTemplate(String sealTemplate) throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + ArcSetBuilder builder = new ArcSetBuilder( + ArcTestKeys.privateKeyArc, + ARC_AMS_TEMPLATE, + sealTemplate, + AUTH_SERVICE, + TIMESTAMP); + Map arcSet = builder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + return message; + } + + private Message buildOneHopChainWithSeal(java.util.function.Function sealMutation, + boolean includeSeal, + boolean duplicateSeal) throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, arcSet.get(ARC_MESSAGE_SIGNATURE))); + if (includeSeal) { + String seal = sealMutation.apply(arcSet.get(ARC_SEAL)); + message.getHeader().addField(new RawField(ARC_SEAL, seal)); + if (duplicateSeal) { + message.getHeader().addField(new RawField(ARC_SEAL, seal)); + } + } + return message; + } + + private Message buildOneHopChainWithAms(java.util.function.Function amsMutation, + boolean duplicateAms) throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, arcSet.get(ARC_AUTHENTICATION_RESULTS))); + String ams = amsMutation.apply(arcSet.get(ARC_MESSAGE_SIGNATURE)); + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, ams)); + if (duplicateAms) { + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, ams)); + } + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + return message; + } + + private String insertWhitespaceIntoSealSignature(String seal) { + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("; b=([^;]+)$").matcher(seal); + if (!matcher.find()) { + throw new AssertionError("ARC-Seal b= not found"); + } + String signature = matcher.group(1); + String spacedSignature = signature.substring(0, 24) + " \r\n\t" + signature.substring(24, 64) + + " " + signature.substring(64); + return matcher.replaceFirst("; b=" + spacedSignature); + } + + private Message buildOneHopChainWithAar(String aarOverride, boolean includeAar, boolean duplicateAar) throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + + if (includeAar) { + String aar = aarOverride == null ? arcSet.get(ARC_AUTHENTICATION_RESULTS) : aarOverride; + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, aar)); + if (duplicateAar) { + message.getHeader().addField(new RawField(ARC_AUTHENTICATION_RESULTS, aar)); + } + } + message.getHeader().addField(new RawField(ARC_MESSAGE_SIGNATURE, arcSet.get(ARC_MESSAGE_SIGNATURE))); + message.getHeader().addField(new RawField(ARC_SEAL, arcSet.get(ARC_SEAL))); + return message; + } + + // Builds a valid N-hop ARC chain by repeatedly applying a new ARC set to the same message. + private Message buildNHopChain(int n) throws Exception { + ByteArrayInputStream emailStream = readFileToByteArrayInputStream("/mail/rfc8617_no_arc.eml"); + Message message = new DefaultMessageBuilder().parseMessage(emailStream); + for (int hop = 0; hop < n; hop++) { + Map arcSet = arcSetBuilder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + for (Map.Entry entry : arcSet.entrySet()) { + message.getHeader().addField(new RawField(entry.getKey(), entry.getValue())); + } + } + return message; + } + + // Removes the first header matching the given name that contains the given instance tag (e.g. "i=2"). + private void removeHeaderByInstanceAndType(Message message, String headerName, String instanceTag) { + Field toRemove = message.getHeader().getFields().stream() + .filter(f -> f.getName().equalsIgnoreCase(headerName) && f.getBody().contains(instanceTag)) + .findFirst().orElseThrow(() -> new AssertionError("Header not found: " + headerName + " with " + instanceTag)); + message.getHeader().removeFields(toRemove.getName()); + message.getHeader().getFields().stream() + .filter(f -> f.getName().equalsIgnoreCase(headerName) && !f.getBody().contains(instanceTag)) + .forEach(f -> message.getHeader().addField(f)); + } + + // Replaces the b= signature on a specific ARC header (identified by name + instance tag) with 128 zero bytes. + private void corruptSignatureOnHeader(Message message, String headerName, String instanceTag) { + String fakeB64 = Base64.getEncoder().encodeToString(new byte[128]); + List fields = new java.util.ArrayList<>(message.getHeader().getFields()); + message.getHeader().removeFields(headerName); + for (Field f : fields) { + if (f.getName().equalsIgnoreCase(headerName)) { + if (f.getBody().contains(instanceTag)) { + String corrupted = f.getBody().replaceAll("; b=.*$", "; b=" + fakeB64); + message.getHeader().addField(new RawField(f.getName(), corrupted)); + } else { + message.getHeader().addField(f); + } + } + } + } + + // Replaces a tag value (e.g. "cv=pass" → "cv=none") on the first matching header with the given instance tag. + private void replaceTagOnHeader(Message message, String headerName, String instanceTag, String oldVal, String newVal) { + List fields = new java.util.ArrayList<>(message.getHeader().getFields()); + message.getHeader().removeFields(headerName); + for (Field f : fields) { + if (f.getName().equalsIgnoreCase(headerName)) { + if (f.getBody().contains(instanceTag)) { + message.getHeader().addField(new RawField(f.getName(), f.getBody().replace(oldVal, newVal))); + } else { + message.getHeader().addField(f); + } + } + } + } + + private void assertValimailFixturePasses(String rawMessage) throws Exception { + assertValimailFixturePasses(rawMessage, valimailKeyRecordRetriever); + } + + private void assertValimailFixturePasses(String rawMessage, MockPublicKeyRecordRetrieverArc publicKeyRetriever) throws Exception { + assertValimailFixtureHasResult(rawMessage, "pass", publicKeyRetriever); + } + + private void assertValimailFixtureHasResult( + String rawMessage, + String expectedResult, + MockPublicKeyRecordRetrieverArc publicKeyRetriever) throws Exception { + Message message = new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream(rawMessage.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8))); + ARCChainValidator arcChainValidator = new ARCChainValidator(publicKeyRetriever); + ArcValidationOutcome cv = arcChainValidator.validateArcChain(message); + assertThat(cv.getResult().toString().toLowerCase()).isEqualTo(expectedResult); + } + + private void assertValimailFixtureFails(String rawMessage, MockPublicKeyRecordRetrieverArc publicKeyRetriever) throws Exception { + assertValimailFixtureHasResult(rawMessage, "fail", publicKeyRetriever); + } + + private void assertValimailFixtureFails(String rawMessage) throws Exception { + assertValimailFixtureFails(rawMessage, valimailKeyRecordRetriever); + } + + private Map buildArcSetWithAuthService(Message message, String authService) throws Exception { + ArcSetBuilder builder = new ArcSetBuilder( + ArcTestKeys.privateKeyArc, + ARC_AMS_TEMPLATE, + ARC_SEAL_TEMPLATE, + authService, + TIMESTAMP); + return builder.buildArcSet(message, HELO, MAIL_FROM, IP, keyRecordRetriever); + } + + private Message parseRawEmail(String rawMessage) throws Exception { + return new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream(rawMessage.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8))); + } + + private String basicMessageWithoutAuthenticationResults() { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J."; + } + + private String basicMultipartMessageWithoutAuthenticationResults() { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "Received: by 10.157.52.162 with SMTP id g31csp5274520otc;\n" + + " Tue, 3 Jan 2017 12:32:02 -0800 (PST)\n" + + "X-Received: by 10.36.31.84 with SMTP id d81mr49584685itd.26.1483475522271;\n" + + " Tue, 03 Jan 2017 12:32:02 -0800 (PST)\n" + + "Message-ID: \n" + + "Date: Tue, 3 Jan 2017 12:31:41 -080\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 2\n" + + "Content-Type: multipart/alternative; boundary=001a113e15fcdd0f9e0545366e8f\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "\n" + + "This is a test message\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/html; charset=UTF-8\n" + + "\n" + + "
This is a test message
\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f--"; + } + + private String baseMessageOneSignedTail() { + return baseMessageOneSignedHeaders() + baseMessageOneBody(); + } + + private String baseMessageOneSignedTailWithTwoToHeaders() { + return "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: morty@dmarc.org\n" + + "To: evil_morty@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + baseMessageOneBody(); + } + + private String baseMessageOneSignedTailWithTwoToHeadersInReverseOrder() { + return "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: evil_morty@dmarc.org\n" + + "To: morty@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + baseMessageOneBody(); + } + + private String baseMessageOneSignedHeaders() { + return "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n"; + } + + private String baseMessageOneBody() { + return "Hey gang,\n" + + "This is a test message.\n" + + "--J."; + } + + private String valimailCommonMessageTail() { + return "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: by 10.157.14.6 with HTTP; Tue, 3 Jan 2017 12:22:54 -0800 (PST)\n" + + "Message-ID: <54B84785.1060301@d1.example.org>\n" + + "Date: Thu, 14 Jan 2015 15:00:01 -0800\n" + + "From: John Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 1\n" + + "\n" + + "Hey gang,\n" + + "This is a test message.\n" + + "--J."; + } + + private String valimailPublicKeyMessage(String arcSealDomain, String arcSealSelector, String arcSealSignature) { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=" + arcSealDomain + "; i=1; s=" + arcSealSelector + ";\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=QsRzR/UqwRfVLBc1TnoQomlVw5qi6jp08q8lHpBSl4RehWyHQtY3uOIAGdghDk/mO+/Xpm\n" + + " 9JA5UVrPyDV0f+2q/YAHuwvP11iCkBQkocmFvgTSxN8H+DwFFPrVVUudQYZV7UDDycXoM6UE\n" + + " cdfzLLzVNPOAHEDIi/uzoV4sUqZ18=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + baseMessageOneSignedTail(); + } + + private String valimailSingleHopBaseTwoMessage() { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=RkKDOauVsqcsTEFv6NVE6J0sxj8LUE4kfwRzs0CvMg/+KOqRDQoFxxJsJkI77EHZqcSgwr\n" + + " QKpt6aKsl2zyUovVhAppT65S0+vo+h3utd3f8jph++1uiAUhVf57PihDC/GcdhyRGa6YNQGh\n" + + " GoArSHaJKb06/qF5OBif8o9lmRC8E=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=SMBCg/tHQkIAIzx7OFir0bMhCxk/zaMOx1nyOSAviXW88ERohOFOXIkBVGe74xfJDSh9ou\n" + + " ryKgNA4XhUt4EybBXOn1dlrMA07dDIUFOUE7n+8QsvX1Drii8aBIpiu+O894oBEDSYcd1R+z\n" + + " sZIdXhOjB/Lt4sTE1h5IT2p3UctgY=;\n" + + " bh=dHN66dCNljBC18wb03I1K6hlBvV0qqsKoDsetl+jxb8=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "Received: by 10.157.52.162 with SMTP id g31csp5274520otc;\n" + + " Tue, 3 Jan 2017 12:32:02 -0800 (PST)\n" + + "X-Received: by 10.36.31.84 with SMTP id d81mr49584685itd.26.1483475522271;\n" + + " Tue, 03 Jan 2017 12:32:02 -0800 (PST)\n" + + "Message-ID: \n" + + "Date: Thu, 5 Jan 2017 14:39:01 -0800\n" + + "From: Gene Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 2\n" + + "Content-Type: multipart/alternative; boundary=001a113e15fcdd0f9e0545366e8f\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "\n" + + "This is a test message\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/html; charset=UTF-8\n" + + "\n" + + "
This is a test message
\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f--"; + } + + private String valimailSimpleBodyHashMessage(String body) { + return valimailAmsBodyHashMessage( + "d6sLFV7dCrZT/WzJil6ZyWcA/W5tJGLkP+yx1Fln+uZdjkswYMjvPkO2V2kvMrh2GBgjee\n" + + " j9QiqfGHsJvGqAKrFVzxHEsgVA0IYN6tI5wTKMLgu09b8BeHUr49/XnBEemjbgO8W9n9SCyX\n" + + " hKjsZK5b5ZIYBqjCSDZUwWRWfJywk=", + "c+pRG+RBumfEVWDAjHVupy4hZHN2F/AMLHoj6Vha9px35oo6eoyMxxOFUvBgVIUVphuSwV\n" + + " 198baYTV6Of9DHw44VS5rf6MDZNtVc8lwm8ei8aSAgzSnuhnr0jW2j134QTsEL1TK1bWfs+l\n" + + " QGXDBN5AUDsbk4jN5akoDqmH7gNlc=", + "KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=", + "relaxed/simple", + body); + } + + private String valimailInvalidBodyHashMessage(String bodyHash) { + return valimailAmsBodyHashMessage( + "YoXbDMNRVADrsGTtqAuMLWVnRIj62jQOSDFCX875c5ksVoWcKstnor+cGw/PJnz0cPuFGH\n" + + " +vjw3y+tcgBDDbK1qBVyMUpHrahTLL/0IY2jMzoLgPYz7Yawv/gpn7GlyXL72Vdr58s/nEfk\n" + + " le/2NmfPZjlUezbwsw+UHbuqT5V38=", + "m5y+bcsy0duHt1KxJ2EakY2mOpwIrFaHD60tlw1PmqNdy4M7XLGTnA10R7k1OsFAQNQdZM\n" + + " n1aKsKDpYuRX21avSuDxximXFwkcWYevOqUmaklFXiWyJVXd9fHId0sEtNt0L28HInLwHeCf\n" + + " IPYbUuddJ8wRWei04RZjqdybh4f2o=", + bodyHash, + "relaxed/relaxed", + baseMessageOneBody()); + } + + private String signRelaxedAmsForNoBodyMessage(Message message, String amsWithoutSignature) throws Exception { + String signingData = "from:" + message.getHeader().getField("From").getBody() + "\r\n" + + "to:" + message.getHeader().getField("To").getBody() + "\r\n" + + "subject:" + message.getHeader().getField("Subject").getBody() + "\r\n" + + "arc-message-signature:" + amsWithoutSignature; + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(ArcTestKeys.privateKeyArc); + signature.update(signingData.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signature.sign()); + } + + private byte[] readBodyBytesWithVerifier(Body body) throws Exception { + Method readBodyBytes = ARCVerifier.class.getDeclaredMethod("readBodyBytes", Body.class); + readBodyBytes.setAccessible(true); + return (byte[]) readBodyBytes.invoke(new ARCVerifier(keyRecordRetriever), body); + } + + private String valimailAmsBodyHashMessage( + String arcSealSignature, + String arcMessageSignature, + String bodyHash, + String canonicalization, + String body) { + String bodyHashTag = bodyHash == null ? "" : " bh=" + bodyHash + "; "; + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=" + arcMessageSignature + ";\n" + + bodyHashTag + "c=" + canonicalization + ";\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + baseMessageOneSignedHeaders() + + body; + } + + private String valimailAmsCanonicalizationMessage(String signedMessageTail) { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=dOdFEyhrk/tw5wl3vMIogoxhaVsKJkrkEhnAcq2XqOLSQhPpGzhGBJzR7k1sWGokon3TmQ\n" + + " 7TX9zQLO6ikRpwd/pUswiRW5DBupy58fefuclXJAhErsrebfvfiueGyhHXV7C1LyJTztywzn\n" + + " QGG4SCciU/FTlsJ0QANrnLRoadfps=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=QsRzR/UqwRfVLBc1TnoQomlVw5qi6jp08q8lHpBSl4RehWyHQtY3uOIAGdghDk/mO+/Xpm\n" + + " 9JA5UVrPyDV0f+2q/YAHuwvP11iCkBQkocmFvgTSxN8H+DwFFPrVVUudQYZV7UDDycXoM6UE\n" + + " cdfzLLzVNPOAHEDIi/uzoV4sUqZ18=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + signedMessageTail; + } + + private String valimailAmsCanonicalizationTagMessage( + String arcSealSignature, + String arcMessageSignature, + String bodyHash, + String canonicalization, + String signedHeaders, + String signedMessageTail) { + String canonicalizationTag = canonicalization == null ? "" : " c=" + canonicalization + ";"; + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=" + arcMessageSignature + ";\n" + + " bh=" + bodyHash + ";" + canonicalizationTag + "\n" + + " d=example.org; h=" + signedHeaders + ";\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + signedMessageTail; + } + + private String valimailAmsMissingSignedHeadersMessage() { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=O9vrOnKLOdZXxa46F8RDPTzqW14JYE7idGn0AfedcpWh58mPFE9jXHeaMda5L59thiQrJN\n" + + " T7Smno713R6DU9CfvnOvq8rQXCJ6D7GzWFhhOn6wEbjTaFQQ3jHn67XVDVnb4yjLElVhixob\n" + + " pG5ouN8U1TPqPWf+41wrIrCd5Mocw=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=RidA92CmsCgK81At2aPnlGuFlbvNT5IdWz7Z/6j765oabi0LEDkpB+2q+C5TJfc28Gj0Ok\n" + + " gghf2ykPbb7WniSvCue66fvUYaABU5m84urSzGd3MG3F47vTzCQ5qLah7E0UssP2QbP2b1Rt\n" + + " Hry/RlkOzlWeSlxpCcPvArmmcADTc=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + baseMessageOneSignedTail(); + } + + private String valimailAmsSignedHeadersMessage( + String arcSealSignature, + String arcMessageSignature, + String signedHeaders, + String initialHeaders, + String signedMessageTail) { + return initialHeaders + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=" + arcMessageSignature + ";\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=" + signedHeaders + ";\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + signedMessageTail; + } + + private String valimailAmsSignedHeadersIncludesAmsMessage() { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=XSOc6bESO7Ek4iCPyVXVE7aR8HUBBOXdOKmFpJO/3DI8rLRHHfRT9XAML3OsBE2RYj+0yd\n" + + " ypsBg8UQEewpY6Z5KEUhxfzwaBGObKr1pgwjkYiOBpPTV1Xfv1lGT+1qlJtBR2AGJauCEs7G\n" + + " fNzwa3MI+iO9E8g6aO/m9Mk1BlLHY=; cv=pass; d=example.org; i=2; s=dummy;\n" + + " t=12346\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=vpypMlcZNGmeVETFS/+v/Uk9npQE1LhY8tha0XTaeeNMgK1fzWaxvUHY0cuumuzK2pU25O\n" + + " uWTt08QEXczUR/BLmiZaYUWQV8qGOAv5umtEshqjB+0KPg5W09N20vQp8OXMQrenjZz0YPsy\n" + + " VweEidqd3HAcWSbZgW3jAFKXHGSXc=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:arc-message-signature:date:subject:mime-version:arc-authentication-results;\n" + + " i=2; s=dummy; t=12346\n" + + "ARC-Authentication-Results: i=2; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=dOdFEyhrk/tw5wl3vMIogoxhaVsKJkrkEhnAcq2XqOLSQhPpGzhGBJzR7k1sWGokon3TmQ\n" + + " 7TX9zQLO6ikRpwd/pUswiRW5DBupy58fefuclXJAhErsrebfvfiueGyhHXV7C1LyJTztywzn\n" + + " QGG4SCciU/FTlsJ0QANrnLRoadfps=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=QsRzR/UqwRfVLBc1TnoQomlVw5qi6jp08q8lHpBSl4RehWyHQtY3uOIAGdghDk/mO+/Xpm\n" + + " 9JA5UVrPyDV0f+2q/YAHuwvP11iCkBQkocmFvgTSxN8H+DwFFPrVVUudQYZV7UDDycXoM6UE\n" + + " cdfzLLzVNPOAHEDIi/uzoV4sUqZ18=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + baseMessageOneSignedTail(); + } + + private String valimailAmsSignedHeadersIncludesAsMessage() { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=OuFcuRk6CdaxxeBmCdvzoFxM6G0xmA3XNh1F243uPQsstHJ+T0csqD6PADig/UPV/Aj6fQ\n" + + " kAOsyZOzIK1X9ZCZLB2idFymnyWtYc2spNgCiSfwQiQuS3SFVUtr+Y7v58PtyAy2HCb2pA5I\n" + + " OIY1WjbK1Pd4SrJbZ4/M0d0wgFt7g=; cv=pass; d=example.org; i=2; s=dummy;\n" + + " t=12346\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=T5uPa/aCBkG1PK5dsSgO5US5yVvKnf/DAsyxMDCLVgw3auULB52XaLkZbc5KAcbGwz4KQZ\n" + + " H8TTB1qbdHGyUpA/1Tq4QveM4z1x/s/2gK/thnoW0wWEHu5frgmd3tVg8kEjrmU6HOJ1SNYq\n" + + " Qgjxvsd/OwpjYsfOjODwgyGDR/doE=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:arc-seal:date:subject:mime-version:arc-authentication-results;\n" + + " i=2; s=dummy; t=12346\n" + + "ARC-Authentication-Results: i=2; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=dOdFEyhrk/tw5wl3vMIogoxhaVsKJkrkEhnAcq2XqOLSQhPpGzhGBJzR7k1sWGokon3TmQ\n" + + " 7TX9zQLO6ikRpwd/pUswiRW5DBupy58fefuclXJAhErsrebfvfiueGyhHXV7C1LyJTztywzn\n" + + " QGG4SCciU/FTlsJ0QANrnLRoadfps=; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=QsRzR/UqwRfVLBc1TnoQomlVw5qi6jp08q8lHpBSl4RehWyHQtY3uOIAGdghDk/mO+/Xpm\n" + + " 9JA5UVrPyDV0f+2q/YAHuwvP11iCkBQkocmFvgTSxN8H+DwFFPrVVUudQYZV7UDDycXoM6UE\n" + + " cdfzLLzVNPOAHEDIi/uzoV4sUqZ18=;\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + baseMessageOneSignedTail(); + } + + private String valimailSimpleHeaderCanonicalizationMessage( + String arcSealSignature, + String arcMessageSignature, + String bodyHash, + String canonicalization, + String body) { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256; b=" + arcMessageSignature + + "; bh=" + bodyHash + "; c=" + canonicalization + + "; d=example.org; h=From:To:Date:Subject:MIME-Version:ARC-Authentication-Results; i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org; spf=pass smtp.mfrom=jqd@d1.example; dkim=pass (1024-bit key) header.i=@d1.example; dmarc=pass\n" + + baseMessageOneSignedHeaders() + + body; + } + + private String valimailAmsTagFieldsMessage( + String arcSealSignature, + String arcMessageSignature, + String domain, + String selector, + String timestamp) { + String domainTag = domain == null ? "" : "d=" + domain + "; "; + String selectorTag = selector == null ? "" : "s=" + selector; + String timestampTag = timestamp == null ? "" : "; t=" + timestamp; + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=example.org; i=1; s=dummy;\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=" + arcMessageSignature + ";\n" + + " bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; c=relaxed/relaxed;\n" + + " " + domainTag + "h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; " + selectorTag + timestampTag + "\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + baseMessageOneSignedTail(); + } + + private String valimailArcSealFormatCommonTail() { + return "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=SMBCg/tHQkIAIzx7OFir0bMhCxk/zaMOx1nyOSAviXW88ERohOFOXIkBVGe74xfJDSh9ou\n" + + " ryKgNA4XhUt4EybBXOn1dlrMA07dDIUFOUE7n+8QsvX1Drii8aBIpiu+O894oBEDSYcd1R+z\n" + + " sZIdXhOjB/Lt4sTE1h5IT2p3UctgY=;\n" + + " bh=dHN66dCNljBC18wb03I1K6hlBvV0qqsKoDsetl+jxb8=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=dummy; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "Received: by 10.157.52.162 with SMTP id g31csp5274520otc;\n" + + " Tue, 3 Jan 2017 12:32:02 -0800 (PST)\n" + + "X-Received: by 10.36.31.84 with SMTP id d81mr49584685itd.26.1483475522271;\n" + + " Tue, 03 Jan 2017 12:32:02 -0800 (PST)\n" + + "Message-ID: \n" + + "Date: Thu, 5 Jan 2017 14:39:01 -0800\n" + + "From: Gene Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 2\n" + + "Content-Type: multipart/alternative; boundary=001a113e15fcdd0f9e0545366e8f\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "\n" + + "This is a test message\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/html; charset=UTF-8\n" + + "\n" + + "
This is a test message
\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f--"; + } + + private String valimailArcSealKeySizeMessage(String selector, String arcSealSignature, String arcMessageSignature) { + return "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "ARC-Seal: a=rsa-sha256;\n" + + " b=" + arcSealSignature + "; cv=none; d=example.org; i=1; s=" + selector + ";\n" + + " t=12345\n" + + "ARC-Message-Signature: a=rsa-sha256;\n" + + " b=" + arcMessageSignature + ";\n" + + " bh=dHN66dCNljBC18wb03I1K6hlBvV0qqsKoDsetl+jxb8=; c=relaxed/relaxed;\n" + + " d=example.org; h=from:to:date:subject:mime-version:arc-authentication-results;\n" + + " i=1; s=" + selector + "; t=12345\n" + + "ARC-Authentication-Results: i=1; lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "Received: from segv.d1.example (segv.d1.example [72.52.75.15])\n" + + " by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123\n" + + " for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST)\n" + + " (envelope-from jqd@d1.example)\n" + + "Authentication-Results: lists.example.org;\n" + + " spf=pass smtp.mfrom=jqd@d1.example;\n" + + " dkim=pass (1024-bit key) header.i=@d1.example;\n" + + " dmarc=pass\n" + + "MIME-Version: 1.0\n" + + "Return-Path: \n" + + "Received: by 10.157.52.162 with SMTP id g31csp5274520otc;\n" + + " Tue, 3 Jan 2017 12:32:02 -0800 (PST)\n" + + "X-Received: by 10.36.31.84 with SMTP id d81mr49584685itd.26.1483475522271;\n" + + " Tue, 03 Jan 2017 12:32:02 -0800 (PST)\n" + + "Message-ID: \n" + + "Date: Thu, 5 Jan 2017 14:39:01 -0800\n" + + "From: Gene Q Doe \n" + + "To: arc@dmarc.org\n" + + "Subject: Example 2\n" + + "Content-Type: multipart/alternative; boundary=001a113e15fcdd0f9e0545366e8f\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "\n" + + "This is a test message\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f\n" + + "Content-Type: text/html; charset=UTF-8\n" + + "\n" + + "
This is a test message
\n" + + "\n" + + "--001a113e15fcdd0f9e0545366e8f--"; + } + + private ByteArrayInputStream readFileToByteArrayInputStream(String fileName) throws URISyntaxException, IOException { + URL resource = this.getClass().getResource(fileName); + FileInputStream file = new FileInputStream(new File(resource.toURI())); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + DKIMCommon.streamCopy(file, byteArrayOutputStream); + String string = byteArrayOutputStream.toString(); + return new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/arc/src/test/java/org/apache/james/arc/ArcTestKeys.java b/arc/src/test/java/org/apache/james/arc/ArcTestKeys.java new file mode 100644 index 0000000..4178a33 --- /dev/null +++ b/arc/src/test/java/org/apache/james/arc/ArcTestKeys.java @@ -0,0 +1,78 @@ +/****************************************************************************** + * 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.james.arc; + +import java.util.Base64; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; + +public class ArcTestKeys { + public static final PrivateKey privateKeyArc = loadPrivateKey("keys/arc_test_pri.1.key"); + public static final PublicKey publicKeyArc = loadPublicKey("keys/arc_test_pub.1.pem"); + public static final PrivateKey privateKeyDkim = loadPrivateKey("keys/dkim_test_pri.1.key"); + public static final PublicKey publicKeyDkim = loadPublicKey("keys/dkim_test_pub.1.pem"); + public static final KeyPair keyPair = new KeyPair(publicKeyArc, privateKeyArc); + + private static PublicKey loadPublicKey(String uri) { + try { + String keyText = readFileContent(uri) + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll(System.lineSeparator(), ""); + byte[] decoded = Base64.getDecoder().decode(keyText); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decoded); + return keyFactory.generatePublic(keySpec); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String readFileContent(String uri) throws URISyntaxException, IOException { + URL resource = ArcTestKeys.class.getClassLoader().getResource(uri); + File file = new File(resource.toURI()); + return new String(Files.readAllBytes(file.toPath()), Charset.defaultCharset()); + } + + private static PrivateKey loadPrivateKey(String uri) { + try { + String keyText = readFileContent(uri) + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll(System.lineSeparator(), ""); + byte[] decoded = Base64.getDecoder().decode(keyText); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/arc/src/test/java/org/apache/james/arc/MockPublicKeyRecordRetrieverArc.java b/arc/src/test/java/org/apache/james/arc/MockPublicKeyRecordRetrieverArc.java new file mode 100644 index 0000000..8ed2e01 --- /dev/null +++ b/arc/src/test/java/org/apache/james/arc/MockPublicKeyRecordRetrieverArc.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.james.arc; + +import org.apache.james.arc.exceptions.ArcException; +import org.apache.james.dmarc.MockPublicKeyRecordRetrieverDmarc; +import org.apache.james.jdkim.MockPublicKeyRecordRetriever; +import org.apache.james.jdkim.exceptions.PermFailException; +import org.apache.james.jdkim.exceptions.TempFailException; + +import java.util.List; + +public class MockPublicKeyRecordRetrieverArc extends MockPublicKeyRecordRetriever implements PublicKeyRetrieverArc { + + public static final String SPF = "_spf."; + private final MockPublicKeyRecordRetrieverDmarc _dmarcRetriever; + + public static class SpfRecord extends MockPublicKeyRecordRetriever.Record { + + public SpfRecord(String helo, String from, String ip, String spfRecord) { + super(SPF, ip + helo + from, spfRecord); + } + + public static SpfRecord spfOf(String helo, String from, String ip, String spfRecord) { + return new SpfRecord(helo, from, ip, spfRecord); + } + } + + public MockPublicKeyRecordRetrieverArc(MockPublicKeyRecordRetrieverDmarc dmarcRetriever, Record... records) { + super(records); + _dmarcRetriever = dmarcRetriever; + } + + @Override + public String getSpfRecord(String helo, String from, String ip) { + try { + String token = ip + helo + from; + List recs = super.getRecords("dns/txt", SPF,token); + if (recs.isEmpty()) { + return null; + } + return recs.get(0); + } catch (TempFailException e) { + throw new ArcException("Temporary failure looking up DMARC record", e); + } catch (PermFailException e) { + throw new ArcException("Permanent failure looking up DMARC record", e); + } + } + + public MockPublicKeyRecordRetrieverDmarc getDmarcRetriever() { + return _dmarcRetriever; + } +} diff --git a/arc/src/test/resources/keys/arc_test_pri.1.key b/arc/src/test/resources/keys/arc_test_pri.1.key new file mode 100644 index 0000000..f196261 --- /dev/null +++ b/arc/src/test/resources/keys/arc_test_pri.1.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAKmDF45YX+LMhwzV +4ZsskhMLGXRBGxs96cjNdgbsmhzUcpjW0Zaxzi7IimWR6IAPSZXkC+DjZ6UABxk7 +YfD8VZ7QV0SzMXk0RAnOHOLrxuQrav+jflKkvl5cdkstWLQLCFBIbsDsHebDM+sv +35Efo9yXQDEOTt8v56n81BAvzy5fAgMBAAECgYEAh14YwaPxbrzGXImw0KqXPH3w +pdYYP3kB6Umqp3zq1XsSyNtEJIN5lAKyAsqyURHkQb8LfVwcuLd887loTXo1JIFO +355B6JtZPnejIUinfmMgmDr3y1IXQf0RX8132X9C5o6r2SslIYrAHSjWWZlFAV6o +qGkVfieO/+c2vFjj/IECQQDZ67UuPUBybw/Ul/uj3TdmQ6YY+C8pBJGvorvOWYVC +Z6IznOissegIrPf9IyYNWmOG2628UCLpWtxy7ZmByCQ7AkEAxyHo/RS+6gzjCgZi +zScTyo8SV88gshs3t+xiiCBBEAWIgdQ7h6rbWqVvH7Nn8VhKerQmkcpDc3y47Mr5 +xeVwLQJBALCrOdCJ0dS0G2Zj7Js1PbOHhoHZuwoK7T0xtgYdZz6lm8cyHyPae12F +NOsg8rmCnQt4z0nKwfLjObNm0rt3kX8CQBrqMG2UkkFcQIuoVU5ZS8mDEP2hV0/7 +ccqAPskbYu/hb5PstacepstXtO9Z9mCeiGKRWu01o2xGnVAUFzJyUnkCQQCcEZzG +g+5OX+wNEMyfTY/kEUoue+ZtqXbuBOPnDOb+k+3Hrh8+q3tGwKg7w2jUU9DEwLtC +FHkx1Nnb8sURPTqy +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/arc/src/test/resources/keys/arc_test_pub.1.pem b/arc/src/test/resources/keys/arc_test_pub.1.pem new file mode 100644 index 0000000..288f28d --- /dev/null +++ b/arc/src/test/resources/keys/arc_test_pub.1.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCpgxeOWF/izIcM1eGbLJITCxl0 +QRsbPenIzXYG7Joc1HKY1tGWsc4uyIplkeiAD0mV5Avg42elAAcZO2Hw/FWe0FdE +szF5NEQJzhzi68bkK2r/o35SpL5eXHZLLVi0CwhQSG7A7B3mwzPrL9+RH6Pcl0Ax +Dk7fL+ep/NQQL88uXwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/arc/src/test/resources/keys/dkim_test_pri.1.key b/arc/src/test/resources/keys/dkim_test_pri.1.key new file mode 100644 index 0000000..6bc101e --- /dev/null +++ b/arc/src/test/resources/keys/dkim_test_pri.1.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBANLJL27JQ8P6OKae +DYzvu2OnAjgkBxcadreX0HtSNFvuHJYwUWrw93kQ3ZSajAS/TqD2LS8+pZ/0Ak5+ +S5WmBQXN/bBPWjbqos/vEFOu5uTx7qn1fSqMlJEGxFzq08mO4ZRIRwawKQ7AohPt +Ew6vEb205AU+moCUtbteU/rD2wV5AgMBAAECgYAaBeKIP+rQ2CSEVYEAxFwTKnw4 +qCID9S1w7xo7D2QNcXEwDZkPpd43oSBqB0aAE4pGjv33FjnmbH6YaDk2qX93BZY0 +ggXM0IMYkvmgnIh0/DKli2w0Fjx5pQJRl60FUnFlcQrdwp0HOfmDS28RHZZGMnLV +lv0a4bDtBK7/NxmFPQJBAPIewEh5a86hH5hF6S7NZ/cJX8twqbiq0xbY3Hm+3GcC +qbSuqxDgYZgtNySyP3N6GSTdZDYMUwspvpPciXJgbLcCQQDe3pYpa1m7qlPqgwyO +TcXbrbTKB3TSgMunVrYCMgkMMdEn2dPW4dnlpA+ZMPvjTd1tuh9AWLXRUAnm+Uv6 +209PAkEAnC9CEn5hEPXXD79pYIuYWT9u0ClpEnr/mGlkMBTy0HBjUO6r40MbMbNZ +Mw7Y54EH30QBdOwWVckj6vYEpAeXmQJAJVbdiar2qb5ruMqj++OD1r5Pn9mH9Qyn +Ei4w6EVBxs1B4Y9ZMpM8UoEeK+hNC1QsWQnp2noCXEMwpYX2+NxteQJAPofx2296 +wdac8Qhy8l7tk+WLPQWWbFlCmLkaX7Fd6z2KqUXXTt/YoL+LpxoWmgIkAKmdxssC +nIVcOf1X+NIxRg== +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/arc/src/test/resources/keys/dkim_test_pub.1.pem b/arc/src/test/resources/keys/dkim_test_pub.1.pem new file mode 100644 index 0000000..27d7983 --- /dev/null +++ b/arc/src/test/resources/keys/dkim_test_pub.1.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDSyS9uyUPD+jimng2M77tjpwI4 +JAcXGna3l9B7UjRb7hyWMFFq8Pd5EN2UmowEv06g9i0vPqWf9AJOfkuVpgUFzf2w +T1o26qLP7xBTrubk8e6p9X0qjJSRBsRc6tPJjuGUSEcGsCkOwKIT7RMOrxG9tOQF +PpqAlLW7XlP6w9sFeQIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/arc/src/test/resources/mail/rfc8617_no_arc.eml b/arc/src/test/resources/mail/rfc8617_no_arc.eml new file mode 100644 index 0000000..2470990 --- /dev/null +++ b/arc/src/test/resources/mail/rfc8617_no_arc.eml @@ -0,0 +1,31 @@ +Return-Path: +Received: from example.org (example.org [208.69.40.157]) + by gmail.example with ESMTP id d200mr22663000ykb.93.1421363207 + for ; Thu, 14 Jan 2015 15:02:40 -0800 (PST) +Received: from segv.d1.example (segv.d1.example [72.52.75.15]) + by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123 + for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST) + (envelope-from jqd@d1.example) +Received: from [2001:DB8::1A] (w-x-y-z.dsl.static.isp.example [w.x.y.z]) + (authenticated bits=0) + by segv.d1.example with ESMTP id t0FN4a8O084569; + Thu, 14 Jan 2015 15:00:01 -0800 (PST) + (envelope-from jqd@d1.example) +Received: from mail-ob0-f188.google.example + (mail-ob0-f188.google.example [208.69.40.157]) by + clochette.example.org with ESMTP id d200mr22663000ykb.93.1421363268 + for ; Thu, 14 Jan 2015 15:03:15 -0800 (PST) +Message-ID: <54B84785.1060301@d1.example> +Date: Thu, 14 Jan 2015 15:00:01 -0800 +From: John Q Doe +To: arc@dmarc.example +Subject: [List 2] Example 1 +DKIM-Signature: a=rsa-sha256; + b=iEn8fLQ/ymdoZ4EkI3ELK3dTcc4jqn1VOvbNZWAMzcZcFiSKSZXgJ9kgXlBv8JGqaLFjuQi3+p73Al9P2JJU4IkBF1PSHrTI6rcdPyTWMP5yL6vKrn0tu0VdPhwPmbEr4H0yhYqc0KPPPzbJw668zoharH9Ljq43W8mj6sGSN18=; + c=relaxed/relaxed; s=origin2015; d=d1.example; v=1; + bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; h=Subject:From:To; + +Hey gang, +This is a test message. +--J. + diff --git a/dmarc/pom.xml b/dmarc/pom.xml new file mode 100644 index 0000000..00ddaab --- /dev/null +++ b/dmarc/pom.xml @@ -0,0 +1,90 @@ + + + + 4.0.0 + + + apache-jdkim-project + org.apache.james.jdkim + 0.6-SNAPSHOT + ../pom.xml + + + apache-dmarc-library + + Apache James :: DMARC + A Java implementation for the DMARC specification. + http://james.apache.org/jdkim/main/ + 2008 + + + + org.apache.james.jdkim + apache-jdkim-library + ${project.version} + + + org.apache.james.jdkim + apache-jdkim-library + ${project.version} + test-jar + test + + + junit + junit + + + org.apache.james + apache-mime4j-dom + + + com.google.guava + guava + true + + + org.assertj + assertj-core + test + + + + + src/main/test/java + + + src/main/test/resources + + + + + maven-jar-plugin + + + + test-jar + + + + + + + diff --git a/dmarc/src/main/java/org/apache/james/dmarc/DMARCVerifier.java b/dmarc/src/main/java/org/apache/james/dmarc/DMARCVerifier.java new file mode 100644 index 0000000..215b784 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/DMARCVerifier.java @@ -0,0 +1,116 @@ +/****************************************************************************** + * 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.james.dmarc; + +import org.apache.james.dmarc.exceptions.DmarcException; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.dom.address.Mailbox; +import org.apache.james.mime4j.dom.address.MailboxList; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public class DMARCVerifier { + public static final String FROM = "From"; + private final PublicKeyRecordRetrieverDmarc _recordRetriever; + private final PublicSuffixList publicSuffixList; + + public DMARCVerifier(PublicKeyRecordRetrieverDmarc recordRetriever) { + this(recordRetriever, new DefaultPublicSuffixList()); + } + + public DMARCVerifier(PublicKeyRecordRetrieverDmarc recordRetriever, PublicSuffixList publicSuffixList) { + _recordRetriever = Objects.requireNonNull(recordRetriever); + this.publicSuffixList = Objects.requireNonNull(publicSuffixList); + } + + public DmarcValidationResult runDmarcCheck(Message message, String spfHeaderText, String + spfDomain, String dkimResult, String dkimDomain) throws DmarcException { + // Combine SPF + DKIM results with From: domain + // 1. Extract RFC5322.From domain from the From header of the message + String shortSpfResult = spfHeaderText.split(" ")[0]; + MailboxList mailboxList = message.getFrom(); + if (mailboxList == null || mailboxList.size() != 1) { + return new DmarcValidationResult("permerror", null, null, "From header must contain exactly one mailbox"); + } + + Mailbox mailbox = message.getFrom().get(0); + String fromDomain = mailbox.getDomain(); + if (fromDomain == null || fromDomain.isEmpty()) { + return new DmarcValidationResult("permerror", null, null, "From header is missing a domain"); + } + + // 2. Fetch DMARC record from DNS + String dmarcRecord = _recordRetriever.getDmarcRecord(fromDomain); + if (dmarcRecord == null) { + return new DmarcValidationResult(fromDomain, null, null); + } + + // Parse DMARC policy + Map dmarcTags = getDmarcTags(dmarcRecord); + String policy = dmarcTags.getOrDefault("p", "none"); + String aspf = dmarcTags.getOrDefault("aspf", "r"); // default is "r" when omitted + String adkim = dmarcTags.getOrDefault("adkim", "r"); // default is "r" when omitted + + // 3. Alignment checks + boolean spfAligned = getDomainAlignment(aspf, shortSpfResult, fromDomain, spfDomain); + boolean dkimAligned = getDomainAlignment(adkim, dkimResult, fromDomain, dkimDomain); + + // 4. DMARC result logic + String result; + if (spfAligned || dkimAligned) { + result = "pass"; + } else { + result = "fail"; + } + + // 5. Build Authentication-Results string + return new DmarcValidationResult(result, policy, fromDomain); + } + + private Map getDmarcTags(String dmarcRecord) { + Map dmarcTags = new HashMap<>(); + String[] parts = dmarcRecord.split(";"); + for (String part : parts) { + String trimmed = part.trim(); + String[] tagValue = trimmed.split("="); + if (tagValue.length == 2) { + dmarcTags.put(tagValue[0].toLowerCase(), tagValue[1]); + } + } + return dmarcTags; + } + + private boolean getDomainAlignment(String flag, String result, String receivedDomain, String expectedDomain) { + // we expect flag to be either "s" or "r"; default is "r" when omitted + if (flag.equalsIgnoreCase("r")){ //relaxed + String fromOrgDomain = publicSuffixList.getOrgDomain(receivedDomain); //we get the organizational domain using PSL + String spfOrgDomain = publicSuffixList.getOrgDomain(expectedDomain); + + return "pass".equals(result) + && fromOrgDomain.equalsIgnoreCase(spfOrgDomain); + } + else if (flag.equalsIgnoreCase("s")){ // strict + return "pass".equals(result) && receivedDomain.equalsIgnoreCase(expectedDomain); + } + else { + throw new DmarcException(String.format("Unknown alignment flag value: %s", flag)); + } + } +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/DNSPublicKeyRecordRetrieverDmarc.java b/dmarc/src/main/java/org/apache/james/dmarc/DNSPublicKeyRecordRetrieverDmarc.java new file mode 100644 index 0000000..c73e4f2 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/DNSPublicKeyRecordRetrieverDmarc.java @@ -0,0 +1,75 @@ +/**************************************************************** + * 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.james.dmarc; + +import org.apache.james.dmarc.exceptions.DmarcException; +import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever; + +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import java.util.Hashtable; + +public class DNSPublicKeyRecordRetrieverDmarc extends DNSPublicKeyRecordRetriever implements PublicKeyRecordRetrieverDmarc { + public static final String JAVA_NAMING_FACTORY_INITIAL = "java.naming.factory.initial"; + public static final String COM_SUN_JNDI_DNS_DNS_CONTEXT_FACTORY = "com.sun.jndi.dns.DnsContextFactory"; + public static final String TXT = "TXT"; + + public DNSPublicKeyRecordRetrieverDmarc() { + super(); + } + + @Override + public String getDmarcRecord(String dnsLabel) { + Hashtable env = new Hashtable<>(); + env.put(JAVA_NAMING_FACTORY_INITIAL, COM_SUN_JNDI_DNS_DNS_CONTEXT_FACTORY); + DirContext ctx; + dnsLabel = "_dmarc." + dnsLabel; + try { + ctx = new InitialDirContext(env); + } catch (NamingException e) { + throw new DmarcException(String.format("Naming error when creating InitialDirContext using [%s]", dnsLabel), e); + } + + Attributes attrs; + try { + attrs = ctx.getAttributes(dnsLabel, new String[]{TXT}); + } catch (NamingException e) { + throw new DmarcException(String.format("Naming error when getting attributes using [%s]", dnsLabel), e); + } + + Attribute txtAttr = attrs.get(TXT); + try { + if (txtAttr != null) { + StringBuilder sb = new StringBuilder(); + NamingEnumeration e = txtAttr.getAll(); + while (e.hasMore()) { + sb.append(e.next().toString().replace("\"", "")); + } + return sb.toString(); + } + } catch (NamingException e) { + throw new DmarcException(String.format("Naming error when looping through attributes using [%s]", dnsLabel), e); + } + return null; + } +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/DefaultPublicSuffixList.java b/dmarc/src/main/java/org/apache/james/dmarc/DefaultPublicSuffixList.java new file mode 100644 index 0000000..257f5d7 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/DefaultPublicSuffixList.java @@ -0,0 +1,37 @@ +/****************************************************************************** + * 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.james.dmarc; + +import java.util.Locale; + +public class DefaultPublicSuffixList implements PublicSuffixList { + @Override + public String getOrgDomain(String domainToCheck) { + if (domainToCheck == null || domainToCheck.trim().isEmpty()) { + return domainToCheck; + } + + String normalizedDomain = domainToCheck.toLowerCase(Locale.ROOT).trim(); + String[] labels = normalizedDomain.split("\\."); + if (labels.length < 3) { + return normalizedDomain; + } + return labels[labels.length - 2] + "." + labels[labels.length - 1]; + } +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/DmarcValidationResult.java b/dmarc/src/main/java/org/apache/james/dmarc/DmarcValidationResult.java new file mode 100644 index 0000000..8f26d1e --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/DmarcValidationResult.java @@ -0,0 +1,50 @@ +/****************************************************************************** + * 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.james.dmarc; + +public class DmarcValidationResult { + private static final String DEFAULT_RESPONSE_TEMPLATE = "dmarc=%s (p=%s) header.from=%s"; + private static final String DEFAULT_NONE_RESPONSE_TEMPLATE = "dmarc=none (no policy) header.from=%s"; + private static final String DEFAULT_ERROR_RESPONSE_TEMPLATE = "dmarc=%s reason=\"%s\""; + private final String result; + private final String policy; + private final String domain; + private final String reason; + + public DmarcValidationResult(String result, String policy, String domain) { + this(result, policy, domain, null); + } + + public DmarcValidationResult(String result, String policy, String domain, String reason) { + this.result = result; + this.policy = policy; + this.domain = domain; + this.reason = reason; + } + + @Override + public String toString() { + if ("permerror".equals(result) && reason != null) { + return String.format(DEFAULT_ERROR_RESPONSE_TEMPLATE, result, reason); + } + return (policy == null || result == null) ? + String.format(DEFAULT_NONE_RESPONSE_TEMPLATE, domain) : + String.format(DEFAULT_RESPONSE_TEMPLATE, result, policy, domain); + } +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/GuavaPublicSuffixList.java b/dmarc/src/main/java/org/apache/james/dmarc/GuavaPublicSuffixList.java new file mode 100644 index 0000000..dc1112f --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/GuavaPublicSuffixList.java @@ -0,0 +1,43 @@ +/****************************************************************************** + * 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.james.dmarc; + +import java.util.Locale; + +import com.google.common.net.InternetDomainName; + +public class GuavaPublicSuffixList implements PublicSuffixList { + @Override + public String getOrgDomain(String domainToCheck) { + if (domainToCheck == null || domainToCheck.trim().isEmpty()) { + return domainToCheck; + } + + String normalizedDomain = domainToCheck.toLowerCase(Locale.ROOT).trim(); + try { + InternetDomainName domainName = InternetDomainName.from(normalizedDomain); + if (domainName.isUnderPublicSuffix()) { + return domainName.topPrivateDomain().toString(); + } + return normalizedDomain; + } catch (IllegalArgumentException e) { + return normalizedDomain; + } + } +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/PSLMatch.java b/dmarc/src/main/java/org/apache/james/dmarc/PSLMatch.java new file mode 100644 index 0000000..b89c308 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/PSLMatch.java @@ -0,0 +1,26 @@ +/****************************************************************************** + * 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.james.dmarc; + +public enum PSLMatch { + RULE, + WILDCARD, + EXCEPTION, + NONE +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/PSLMatchOutcome.java b/dmarc/src/main/java/org/apache/james/dmarc/PSLMatchOutcome.java new file mode 100644 index 0000000..1149642 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/PSLMatchOutcome.java @@ -0,0 +1,59 @@ +/**************************************************************** + * 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.james.dmarc; + +import java.util.Arrays; + +public class PSLMatchOutcome { + private final PSLMatch match; + private final String matchedCandidate; + private final String[] domainElements; + private final int matchedIndex; + + public PSLMatchOutcome(PSLMatch matchType, String candidate, String[] domainParts, int index) { + match = matchType; + matchedCandidate = candidate; + domainElements = domainParts; + matchedIndex = index; + } + + public String getRelaxedOrgDomain() { + switch (match) { + case RULE: + return matchedIndex >= 1 ? + String.join(".", Arrays.copyOfRange(domainElements, matchedIndex - 1, domainElements.length)) : + String.join(".", Arrays.copyOfRange(domainElements, 0, domainElements.length)); + case WILDCARD: + if (matchedIndex >= 2) { + return String.join(".", Arrays.copyOfRange(domainElements, matchedIndex - 2, domainElements.length)); + } + else if (matchedIndex == 1) { + return String.join(".", Arrays.copyOfRange(domainElements, 0, domainElements.length)); + } + else { + return matchedCandidate; + } + case EXCEPTION: + return String.join(".", Arrays.copyOfRange(domainElements, matchedIndex, domainElements.length)); + case NONE: + default: + return String.join(".", Arrays.copyOfRange(domainElements, 0, domainElements.length)); + } + } +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/PublicKeyRecordRetrieverDmarc.java b/dmarc/src/main/java/org/apache/james/dmarc/PublicKeyRecordRetrieverDmarc.java new file mode 100644 index 0000000..3576131 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/PublicKeyRecordRetrieverDmarc.java @@ -0,0 +1,26 @@ +/****************************************************************************** + * 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.james.dmarc; + +import org.apache.james.jdkim.api.PublicKeyRecordRetriever; + +public interface PublicKeyRecordRetrieverDmarc extends PublicKeyRecordRetriever { + + String getDmarcRecord(String query); +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/PublicSuffixList.java b/dmarc/src/main/java/org/apache/james/dmarc/PublicSuffixList.java new file mode 100644 index 0000000..24c38e1 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/PublicSuffixList.java @@ -0,0 +1,23 @@ +/****************************************************************************** + * 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.james.dmarc; + +public interface PublicSuffixList { + String getOrgDomain(String domainToCheck); +} diff --git a/dmarc/src/main/java/org/apache/james/dmarc/exceptions/DmarcException.java b/dmarc/src/main/java/org/apache/james/dmarc/exceptions/DmarcException.java new file mode 100644 index 0000000..bf3aea7 --- /dev/null +++ b/dmarc/src/main/java/org/apache/james/dmarc/exceptions/DmarcException.java @@ -0,0 +1,30 @@ +/**************************************************************** + * 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.james.dmarc.exceptions; + +public class DmarcException extends RuntimeException { + + public DmarcException(String message) { + super(message); + } + + public DmarcException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/dmarc/src/main/test/java/org/apache/james/dmarc/DMARCTest.java b/dmarc/src/main/test/java/org/apache/james/dmarc/DMARCTest.java new file mode 100644 index 0000000..f69ef2a --- /dev/null +++ b/dmarc/src/main/test/java/org/apache/james/dmarc/DMARCTest.java @@ -0,0 +1,120 @@ +/****************************************************************************** + * 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.james.dmarc; + +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.DefaultMessageBuilder; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DMARCTest { + + private final MockPublicKeyRecordRetrieverDmarc recordRetrieverDmarc = new MockPublicKeyRecordRetrieverDmarc( + MockPublicKeyRecordRetrieverDmarc.DmarcRecord.dmarcOf( + "d1.example", + "k=rsa; v=DMARC1; p=reject; pct=100; rua=mailto:noc@d1.example"), + MockPublicKeyRecordRetrieverDmarc.DmarcRecord.dmarcOf( + "mail.replit.app", + "k=rsa; v=DMARC1; p=reject; aspf=r; adkim=r; pct=100; rua=mailto:noc@d1.example"), + MockPublicKeyRecordRetrieverDmarc.DmarcRecord.dmarcOf( + "test.replit.app", + "k=rsa; v=DMARC1; p=reject; aspf=s; adkim=s; pct=100; rua=mailto:noc@d1.example") + ); + + private final List passRequests = List.of( + new DmarcRequestMock("/mail/e1.eml","pass", "d1.example", "softfail (spfCheck: transitioning domain of d1.example does not designate 222.222.222.222 as permitted sender) client-ip=222.222.222.222; envelope-from=jqd@d1.example; helo=d1.example", "d1.example", "dmarc=pass (p=reject) header.from=d1.example"), + new DmarcRequestMock("/mail/e2.eml","pass", "mail.replit.app", "pass client-ip=222.222.222.222; envelope-from=jqd@id.firewalledreplit.co; helo=replit.app", "mail.replit.app", "dmarc=pass (p=reject) header.from=mail.replit.app"), + new DmarcRequestMock("/mail/e3.eml","pass", "replit.app", "pass client-ip=222.222.222.222; envelope-from=jqd@id.firewalledreplit.co; helo=replit.app", "replit.app", "dmarc=fail (p=reject) header.from=test.replit.app") + ); + + DMARCVerifier dmarcVerifier = new DMARCVerifier(recordRetrieverDmarc); + + @Test + public void generate_and_verify_dmarc_pass() { + passRequests.forEach(r -> { + assertThat(dmarcVerifier.runDmarcCheck(r.message(), r.spfResult(), r.spfDomain(), r.dkimResult(), r.dkimDomain()).toString()).hasToString(r.expectedResult()); + }); + } + + @Test + public void dmarc_check_can_use_custom_public_suffix_list() { + PublicSuffixList customPublicSuffixList = domain -> "example.test"; + DMARCVerifier verifier = new DMARCVerifier(recordRetrieverDmarc, customPublicSuffixList); + DmarcRequestMock request = passRequests.get(0); + + DmarcValidationResult result = verifier.runDmarcCheck( + request.message(), + "pass client-ip=192.0.2.1; envelope-from=sender@different.example.test", + "different.example.test", + "fail", + "not-used.example.test"); + + assertThat(result.toString()) + .isEqualTo("dmarc=pass (p=reject) header.from=d1.example"); + } + + @Test + public void dmarc_check_returns_permerror_when_from_header_is_missing() throws Exception { + Message message = parseMessage( + "To: recipient@example.org\r\n" + + "Subject: missing from\r\n" + + "\r\n" + + "body\r\n"); + + DmarcValidationResult result = dmarcVerifier.runDmarcCheck( + message, + "pass client-ip=192.0.2.1; envelope-from=sender@example.org", + "example.org", + "pass", + "example.org"); + + assertThat(result.toString()) + .isEqualTo("dmarc=permerror reason=\"From header must contain exactly one mailbox\""); + } + + @Test + public void dmarc_check_returns_permerror_when_from_header_has_multiple_mailboxes() throws Exception { + Message message = parseMessage( + "From: Alice , Bob \r\n" + + "To: recipient@example.org\r\n" + + "Subject: multiple from\r\n" + + "\r\n" + + "body\r\n"); + + DmarcValidationResult result = dmarcVerifier.runDmarcCheck( + message, + "pass client-ip=192.0.2.1; envelope-from=sender@example.org", + "example.org", + "pass", + "example.org"); + + assertThat(result.toString()) + .isEqualTo("dmarc=permerror reason=\"From header must contain exactly one mailbox\""); + } + + private Message parseMessage(String rawMessage) throws Exception { + return new DefaultMessageBuilder().parseMessage( + new ByteArrayInputStream(rawMessage.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/dmarc/src/main/test/java/org/apache/james/dmarc/DmarcRequestMock.java b/dmarc/src/main/test/java/org/apache/james/dmarc/DmarcRequestMock.java new file mode 100644 index 0000000..d2f1b12 --- /dev/null +++ b/dmarc/src/main/test/java/org/apache/james/dmarc/DmarcRequestMock.java @@ -0,0 +1,95 @@ +/****************************************************************************** + * 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.james.dmarc; + +import org.apache.james.dmarc.exceptions.DmarcException; +import org.apache.james.jdkim.DKIMCommon; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.message.DefaultMessageBuilder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class DmarcRequestMock { + private final Message _message; + private final String _dkimResult; + private final String _dkimDomain; + private final String _spfResult; + private final String _spfDomain; + private final String _expectedResult; + + public DmarcRequestMock(String emailPath, String dkimResult, String dkimDomain, String spfResult, String spfDomain, String expectedResult) { + _dkimResult = dkimResult; + _dkimDomain = dkimDomain; + _spfResult = spfResult; + _spfDomain = spfDomain; + _expectedResult = expectedResult; + ByteArrayInputStream emailStream = null; + try { + emailStream = readFileToByteArrayInputStream(emailPath); + DefaultMessageBuilder builder = new DefaultMessageBuilder(); + _message = builder.parseMessage(emailStream); + } catch (URISyntaxException e) { + throw new DmarcException("URI Syntax Exception when loading test email file", e); + } catch (IOException e) { + throw new DmarcException("IOException when loading test email file", e); + } + } + + String dkimResult() { + return _dkimResult; + } + + String dkimDomain() { + return _dkimDomain; + } + + String spfResult() { + return _spfResult; + } + + String spfDomain() { + return _spfDomain; + } + + String expectedResult() { + return _expectedResult; + } + + Message message() { + return _message; + } + + private ByteArrayInputStream readFileToByteArrayInputStream(String fileName) throws URISyntaxException, IOException { + URL resource = this.getClass().getResource(fileName); + FileInputStream file = new FileInputStream(new File(resource.toURI())); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + DKIMCommon.streamCopy(file, byteArrayOutputStream); + String string = byteArrayOutputStream.toString(); + return new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/dmarc/src/main/test/java/org/apache/james/dmarc/MockPublicKeyRecordRetrieverDmarc.java b/dmarc/src/main/test/java/org/apache/james/dmarc/MockPublicKeyRecordRetrieverDmarc.java new file mode 100644 index 0000000..d819919 --- /dev/null +++ b/dmarc/src/main/test/java/org/apache/james/dmarc/MockPublicKeyRecordRetrieverDmarc.java @@ -0,0 +1,65 @@ +/****************************************************************************** + * 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.james.dmarc; + +import org.apache.james.dmarc.exceptions.DmarcException; +import org.apache.james.jdkim.MockPublicKeyRecordRetriever; +import org.apache.james.jdkim.exceptions.PermFailException; +import org.apache.james.jdkim.exceptions.TempFailException; + +import java.util.List; + +public class MockPublicKeyRecordRetrieverDmarc extends MockPublicKeyRecordRetriever implements PublicKeyRecordRetrieverDmarc { + public static final String DMARC = "_dmarc."; + + @Override + public String getDmarcRecord(String query) { + try { + List recs = super.getRecords("dns/txt", DMARC, query); + if (recs == null || recs.isEmpty()) { + return null; + } + return recs.get(0); + } catch (TempFailException e) { + throw new DmarcException("Temporary failure looking up DMARC record", e); + } catch (PermFailException e) { + throw new DmarcException("Permanent failure looking up DMARC record", e); + } + } + + @Override + public List getRecords(CharSequence methodAndOption, CharSequence selector, CharSequence token) throws TempFailException, PermFailException { + return List.of(); + } + + public static class DmarcRecord extends MockPublicKeyRecordRetriever.Record { + + public DmarcRecord(String domain, String dmarcRecord) { + super(DMARC, domain, dmarcRecord); + } + + public static DmarcRecord dmarcOf(String domain, String dmarcRecord) { + return new DmarcRecord(domain, dmarcRecord); + } + } + + public MockPublicKeyRecordRetrieverDmarc(Record... records) { + super(records); + } +} diff --git a/dmarc/src/main/test/java/org/apache/james/dmarc/PublicSuffixListTest.java b/dmarc/src/main/test/java/org/apache/james/dmarc/PublicSuffixListTest.java new file mode 100644 index 0000000..bed30a0 --- /dev/null +++ b/dmarc/src/main/test/java/org/apache/james/dmarc/PublicSuffixListTest.java @@ -0,0 +1,109 @@ +/****************************************************************************** + * 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.james.dmarc; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class PublicSuffixListTest { + private final PublicSuffixList publicSuffixList = new GuavaPublicSuffixList(); + + /* + `example.com` does not exist in the PSL, only `com` does + so returning the `com` plus one label before it. + */ + @Test + public void getOrgDomain_simpleMatch() { + assertEquals("example.com", publicSuffixList.getOrgDomain("example.com")); + assertEquals("example.com", publicSuffixList.getOrgDomain("aaa.example.com")); + assertEquals("example.com", publicSuffixList.getOrgDomain("bbb.aaa.example.com")); + } + + /* + Domains not covered by PSL → fallback + (should just return original domain) + */ + @Test + public void getOrgDomain_noPslMatch() { + assertEquals("unknown.private", publicSuffixList.getOrgDomain("unknown.private")); + assertEquals("my.localdomain", publicSuffixList.getOrgDomain("my.localdomain")); + assertEquals("service.internal", publicSuffixList.getOrgDomain("service.internal")); + } + + @Test + public void getOrgDomain_shouldReturnPublicSuffixIfMatched() { + assertEquals("example.co.uk", publicSuffixList.getOrgDomain("example.co.uk")); + assertEquals("replit.app", publicSuffixList.getOrgDomain("mail.replit.app")); + } + + /* + *.sapporo.jp is a wild card rule + */ + @Test + public void getOrgDomain_wildCardMatched() { + assertEquals("sapporo.jp", publicSuffixList.getOrgDomain("sapporo.jp")); + assertEquals("abc.sapporo.jp", publicSuffixList.getOrgDomain("abc.sapporo.jp")); + assertEquals("foo.abc.sapporo.jp", publicSuffixList.getOrgDomain("foo.abc.sapporo.jp")); + assertEquals("foo.abc.sapporo.jp", publicSuffixList.getOrgDomain("bar.foo.abc.sapporo.jp")); + } + + /* + !city.sapporo.jp is an exception rule + */ + @Test + public void getOrgDomain_exceptionsMatched() { + assertEquals("city.sapporo.jp", publicSuffixList.getOrgDomain("city.sapporo.jp")); + assertEquals("city.sapporo.jp", publicSuffixList.getOrgDomain("abc.city.sapporo.jp")); + assertEquals("city.sapporo.jp", publicSuffixList.getOrgDomain("x.y.city.sapporo.jp")); + } + + /* + *.ck + !www.ck + Wildcard with exception + */ + @Test + public void getOrgDomain_wildCardAndExceptionCombo() { + assertEquals("www.ck", publicSuffixList.getOrgDomain("www.ck")); // exception + assertEquals("www.ck", publicSuffixList.getOrgDomain("a.www.ck")); // exception overrides wildcard + assertEquals("abc.ck", publicSuffixList.getOrgDomain("abc.ck")); // wildcard + one left + assertEquals("foo.abc.ck", publicSuffixList.getOrgDomain("foo.abc.ck")); // wildcard + two left + assertEquals("foo.abc.ck", publicSuffixList.getOrgDomain("bar.foo.abc.ck")); // wildcard + two. we stop at two left labels + } + + /* + single-label domains should return themselves + */ + @Test + public void getOrgDomain_singleLabel() { + assertEquals("localhost", publicSuffixList.getOrgDomain("localhost")); + assertEquals("com", publicSuffixList.getOrgDomain("com")); + assertEquals("example", publicSuffixList.getOrgDomain("example")); + } + + /* + PSL match with internationalized domain names (IDN) + */ + @Test + public void getOrgDomain_openAiWildcard() { + assertEquals("三重.jp", publicSuffixList.getOrgDomain("三重.jp")); //Bare PSL match + assertEquals("北海道.三重.jp", publicSuffixList.getOrgDomain("北海道.三重.jp")); //PSL + one left + assertEquals("北海道.三重.jp", publicSuffixList.getOrgDomain("大分.北海道.三重.jp")); //PSL + two left + } +} diff --git a/dmarc/src/main/test/resources/mail/e1.eml b/dmarc/src/main/test/resources/mail/e1.eml new file mode 100644 index 0000000..2470990 --- /dev/null +++ b/dmarc/src/main/test/resources/mail/e1.eml @@ -0,0 +1,31 @@ +Return-Path: +Received: from example.org (example.org [208.69.40.157]) + by gmail.example with ESMTP id d200mr22663000ykb.93.1421363207 + for ; Thu, 14 Jan 2015 15:02:40 -0800 (PST) +Received: from segv.d1.example (segv.d1.example [72.52.75.15]) + by lists.example.org (8.14.5/8.14.5) with ESMTP id t0EKaNU9010123 + for ; Thu, 14 Jan 2015 15:01:30 -0800 (PST) + (envelope-from jqd@d1.example) +Received: from [2001:DB8::1A] (w-x-y-z.dsl.static.isp.example [w.x.y.z]) + (authenticated bits=0) + by segv.d1.example with ESMTP id t0FN4a8O084569; + Thu, 14 Jan 2015 15:00:01 -0800 (PST) + (envelope-from jqd@d1.example) +Received: from mail-ob0-f188.google.example + (mail-ob0-f188.google.example [208.69.40.157]) by + clochette.example.org with ESMTP id d200mr22663000ykb.93.1421363268 + for ; Thu, 14 Jan 2015 15:03:15 -0800 (PST) +Message-ID: <54B84785.1060301@d1.example> +Date: Thu, 14 Jan 2015 15:00:01 -0800 +From: John Q Doe +To: arc@dmarc.example +Subject: [List 2] Example 1 +DKIM-Signature: a=rsa-sha256; + b=iEn8fLQ/ymdoZ4EkI3ELK3dTcc4jqn1VOvbNZWAMzcZcFiSKSZXgJ9kgXlBv8JGqaLFjuQi3+p73Al9P2JJU4IkBF1PSHrTI6rcdPyTWMP5yL6vKrn0tu0VdPhwPmbEr4H0yhYqc0KPPPzbJw668zoharH9Ljq43W8mj6sGSN18=; + c=relaxed/relaxed; s=origin2015; d=d1.example; v=1; + bh=KWSe46TZKCcDbH4klJPo+tjk5LWJnVRlP5pvjXFZYLQ=; h=Subject:From:To; + +Hey gang, +This is a test message. +--J. + diff --git a/dmarc/src/main/test/resources/mail/e2.eml b/dmarc/src/main/test/resources/mail/e2.eml new file mode 100644 index 0000000..ac0c1f8 --- /dev/null +++ b/dmarc/src/main/test/resources/mail/e2.eml @@ -0,0 +1,14 @@ +Return-Path: +Received: from replit.app (replit.co [208.69.40.157]) + by gmail.example with ESMTP id d200mr22663000ykb.93.1421363207 + for ; Thu, 14 Jan 2015 15:02:40 -0800 (PST) +Message-ID: <54B84785.1060301@id.firewalledreplit.co> +Date: Thu, 14 Jan 2015 15:00:01 -0800 +From: John Q Doe +To: arc@dmarc.example +Subject: [List 2] Example 1 + +Hey gang, +This is a test message. +--J. + diff --git a/dmarc/src/main/test/resources/mail/e3.eml b/dmarc/src/main/test/resources/mail/e3.eml new file mode 100644 index 0000000..311fd5b --- /dev/null +++ b/dmarc/src/main/test/resources/mail/e3.eml @@ -0,0 +1,14 @@ +Return-Path: +Received: from replit.app (replit.co [208.69.40.157]) + by gmail.example with ESMTP id d200mr22663000ykb.93.1421363207 + for ; Thu, 14 Jan 2015 15:02:40 -0800 (PST) +Message-ID: <54B84785.1060301@id.firewalledreplit.co> +Date: Thu, 14 Jan 2015 15:00:01 -0800 +From: John Q Doe +To: arc@dmarc.example +Subject: [List 2] Example 1 + +Hey gang, +This is a test message. +--J. + diff --git a/main/src/main/java/org/apache/james/jdkim/tagvalue/SignatureRecordImpl.java b/main/src/main/java/org/apache/james/jdkim/tagvalue/SignatureRecordImpl.java index c2cfc88..79dd59d 100644 --- a/main/src/main/java/org/apache/james/jdkim/tagvalue/SignatureRecordImpl.java +++ b/main/src/main/java/org/apache/james/jdkim/tagvalue/SignatureRecordImpl.java @@ -274,12 +274,12 @@ public List getRecordLookupMethods() { } public void setSignature(byte[] newSignature) { - String signature = new String(Base64.getMimeDecoder().decode(newSignature)); + String signature = new String(Base64.getEncoder().encode(newSignature)); setValue("b", signature); } public void setBodyHash(byte[] newBodyHash) { - String bodyHash = new String(Base64.getMimeDecoder().decode(newBodyHash)); + String bodyHash = new String(Base64.getEncoder().encode(newBodyHash)); setValue("bh", bodyHash); // If a t=; parameter is present in the signature, make sure to // fill it with the current timestamp diff --git a/pom.xml b/pom.xml index 29fe43d..305cff4 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,8 @@ assemble main + dmarc + arc @@ -66,6 +68,8 @@ 1.8 4.13.2 11 + 1.0.5 + 33.0.0-jre @@ -108,6 +112,11 @@ assertj-core 3.27.7 + + com.google.guava + guava + ${guava.version} + diff --git a/src/site/site.xml b/src/site/site.xml index 0caafe5..5b3e776 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -33,11 +33,6 @@ - - - - -