All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.shredzone.acme4j.smime.wrapper.SignedMail Maven / Gradle / Ivy

There is a newer version: 3.4.0
Show newest version
/*
 * acme4j - Java ACME client
 *
 * Copyright (C) 2023 Richard "Shred" Körber
 *   http://acme4j.shredzone.org
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 */
package org.shredzone.acme4j.smime.wrapper;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;

import edu.umd.cs.findbugs.annotations.Nullable;
import jakarta.mail.Header;
import jakarta.mail.Message;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import org.bouncycastle.asn1.ASN1Enumerated;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.ASN1Set;
import org.bouncycastle.asn1.ASN1String;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.cms.SignerInformation;
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;

/**
 * Represents a signed {@link Message}.
 * 

* This class is generated by {@link SignedMailBuilder}, which also takes care for * signature verification and validation. * * @see RFC 7508 * @since 2.16 */ public class SignedMail implements Mail { private static final ASN1ObjectIdentifier SECURE_HEADER_FIELDS_ID = PKCSObjectIdentifiers.pkcs_9.branch("16.2.55"); private static final Set IGNORE_HEADERS = Set.of("CONTENT-TYPE", "MIME-VERSION", "RECEIVED"); private static final Set REQUIRED_HEADERS = Set.of("FROM", "TO", "SUBJECT"); private final List headers = new ArrayList<>(); /** * This class is to be constructed only by {@link SignedMailBuilder}. */ SignedMail() { // package protected constructor } /** * Imports untrusted headers from the envelope message. *

* All previously imported headers are cleaned before that. */ public void importUntrustedHeaders(Enumeration

en) { headers.clear(); while (en.hasMoreElements()) { var h = en.nextElement(); var name = h.getName(); if (IGNORE_HEADERS.contains(name.toUpperCase())) { continue; } headers.add(new MailHeader(name, h.getValue())); } } /** * Imports secured headers from the signed, inner message. *

* The import is strict. If a secured header is also present in the envelope message, * it must match exactly. * * @throws AcmeInvalidMessageException * if the secured header was found in the envelope message, but did not match. */ public void importTrustedHeaders(Enumeration

en) throws AcmeInvalidMessageException { while (en.hasMoreElements()) { var h = en.nextElement(); var name = h.getName(); if (IGNORE_HEADERS.contains(name.toUpperCase())) { continue; } var value = h.getValue(); var count = headers.stream() .filter(mh -> mh.nameEquals(name, false) && mh.valueEquals(value, false)) .peek(MailHeader::setTrusted) .count(); if (count == 0) { throw new AcmeInvalidMessageException("Secured header '" + name + "' does not match envelope header"); } } } /** * Imports secured headers from the signed, inner message. *

* The import is relaxed. If the secured header is also found in the envelope message * header, it will replace the envelope header. */ public void importTrustedHeadersRelaxed(Enumeration

en) { while (en.hasMoreElements()) { var h = en.nextElement(); var name = h.getName(); if (IGNORE_HEADERS.contains(name.toUpperCase())) { continue; } headers.removeIf(mh -> mh.nameEquals(name, true) && !mh.trusted); headers.add(new MailHeader(name, h.getValue()).setTrusted()); } } /** * Imports secured headers from the signature. *

* Depending on the signature, the envelope header is either checked, deleted, or * modified. * * @throws AcmeInvalidMessageException * if the signature header conflicts with the envelope header. */ public void importSignatureHeaders(SignerInformation si) throws AcmeInvalidMessageException { var attr = si.getSignedAttributes().get(SECURE_HEADER_FIELDS_ID); if (attr == null) { return; } var relaxed = false; for (var element : (ASN1Set) attr.getAttributeValues()[0]) { if (element instanceof ASN1Enumerated) { var algorithm = ((ASN1Enumerated) element).intValueExact(); switch (algorithm) { case 0: relaxed = false; break; case 1: relaxed = true; break; default: throw new AcmeInvalidMessageException("Unknown algorithm: " + algorithm); } } } for (var element : (ASN1Set) attr.getAttributeValues()[0]) { if (element instanceof ASN1Sequence) { for (var sequenceElement : (ASN1Sequence) element) { var headerField = (ASN1Sequence) sequenceElement; var fieldName = ((ASN1String) headerField.getObjectAt(0)).getString(); var fieldValue = ((ASN1String) headerField.getObjectAt(1)).getString(); var fieldStatus = 0; if (headerField.size() >= 3) { fieldStatus = ((ASN1Integer) headerField.getObjectAt(2)).intValueExact(); } switch (fieldStatus) { case 0: checkDuplicatedField(fieldName, fieldValue, relaxed); break; case 1: deleteField(fieldName, fieldValue, relaxed); break; case 2: modifyField(fieldName, fieldValue, relaxed); break; default: throw new AcmeInvalidMessageException("Unknown field status " + fieldStatus); } } } } } @Override public InternetAddress getFrom() throws AcmeInvalidMessageException { try { return new InternetAddress(fetchTrustedHeader("FROM")); } catch (AddressException ex) { throw new AcmeInvalidMessageException("Invalid 'FROM' address", ex); } } @Override public InternetAddress getTo() throws AcmeInvalidMessageException { try { return new InternetAddress(fetchTrustedHeader("TO")); } catch (AddressException ex) { throw new AcmeInvalidMessageException("Invalid 'TO' address", ex); } } @Override public String getSubject() throws AcmeInvalidMessageException { return fetchTrustedHeader("SUBJECT"); } @Override public Optional getMessageId() { return headers.stream() .filter(mh -> "MESSAGE-ID".equalsIgnoreCase(mh.name)) .map(mh -> mh.value) .map(String::trim) .findFirst(); } @Override public Collection getReplyTo() throws AcmeInvalidMessageException { var replyToList = headers.stream() .filter(mh -> "REPLY-TO".equalsIgnoreCase(mh.name)) .map(mh -> mh.value) .map(String::trim) .collect(Collectors.toList()); if (replyToList.isEmpty()) { return Collections.emptyList(); } try { var result = new ArrayList(replyToList.size()); for (var replyTo : replyToList) { result.add(new InternetAddress(replyTo)); } return Collections.unmodifiableList(result); } catch (AddressException ex) { throw new AcmeInvalidMessageException("Invalid 'REPLY-TO' address", ex); } } @Override public boolean isAutoSubmitted() { return headers.stream() .filter(mh -> "AUTO-SUBMITTED".equalsIgnoreCase(mh.name)) .map(mh -> mh.value) .map(String::trim) .map(String::toLowerCase) .anyMatch(h -> h.equals("auto-generated") || h.startsWith("auto-generated;")); } /** * Returns a set of missing, but required secured headers. This list is supposed to * be empty on valid messages with secured headers. If there is at least one element, * the message must be refused. */ public Set getMissingSecuredHeaders() { var missing = new TreeSet<>(REQUIRED_HEADERS); headers.stream() .filter(mh -> mh.trusted) .map(mh -> mh.name) .map(String::toUpperCase) .forEach(missing::remove); return missing; } /** * Processes a "duplicated" header field status. The signature header must be found * with the same value in the envelope message header. * * @param header * Header name * @param value * Expected header value * @param relaxed * {@code false}: simple, {@code true}: relaxed algorithm * @throws AcmeInvalidMessageException * if a header with the same value was not found */ protected void checkDuplicatedField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { var count = headers.stream() .filter(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed)) .peek(MailHeader::setTrusted) .count(); if (count == 0) { throw new AcmeInvalidMessageException("Secured header '" + header + "' was not found in envelope header"); } } /** * Processes a "deleted" header field status. The signature header must be found * with the same value in the envelope message header, and is then removed from the * header. * * @param header * Header name * @param value * Expected header value * @param relaxed * {@code false}: simple, {@code true}: relaxed algorithm * @throws AcmeInvalidMessageException * if a header with the same value was not found */ protected void deleteField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed))) { throw new AcmeInvalidMessageException("Secured header '" + header + "' was not found in envelope header for deletion"); } } /** * Processes a "modified" header field status. The signature header must be found in * the envelope message header, and is then replaced with the given value. * * @param header * Header name * @param value * New header value * @param relaxed * {@code false}: simple, {@code true}: relaxed algorithm * @throws AcmeInvalidMessageException * if the header was not found */ protected void modifyField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException { if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed))) { throw new AcmeInvalidMessageException("Secured header '" + header + "' was not found in envelope header for modification"); } headers.add(new MailHeader(header, value).setTrusted()); } /** * Fetches a trusted header. The header must be present exactly once, and must be * marked as trusted, i.e. it was either found in the signed inner message, or was * set by the signature headers. * * @param name * Name of the header, case-insensitive * @return Header value * @throws AcmeInvalidMessageException * if the header was not found, was found more than once, or is not marked as * trusted */ private String fetchTrustedHeader(String name) throws AcmeInvalidMessageException { var candidates = headers.stream() .filter(mh -> name.equalsIgnoreCase(mh.name)) .filter(mh -> mh.trusted) .map(mh -> mh.value) .map(String::trim) .collect(Collectors.toList()); if (candidates.isEmpty()) { throw new AcmeInvalidMessageException("Protected '" + name + "' header is required, but missing"); } if (candidates.size() > 1) { throw new AcmeInvalidMessageException("Expecting exactly one protected '" + name + "' header, but found " + candidates.size()); } return candidates.get(0); } @Override public String toString() { var sb = new StringBuilder(); for (var mh : headers) { sb.append(mh.toString()).append('\n'); } return sb.toString(); } /** * A single mail header. */ private static class MailHeader { public final String name; public final String value; public boolean trusted; /** * Creates a new mail header. * * @param name Header name * @param value Header value */ public MailHeader(String name, String value) { this.name = name; this.value = value; } /** * Marks this header as trusted. * * @return itself */ public MailHeader setTrusted() { trusted = true; return this; } /** * Checks if the header name equals the expected value. * * @param expected * Expected name * @param relaxed * {@code false}: names must match exactly, {@code true}: case-insensitive * match * @return {@code true} if equal */ public boolean nameEquals(@Nullable String expected, boolean relaxed) { if (!relaxed) { return name.equals(expected); } if (expected == null) { return false; } return name.equalsIgnoreCase(expected); } /** * Checks if the header value equals the expected value. * * @param expected * Expected value, may be {@code null} * @param relaxed * {@code false}: value must match exactly, {@code true}: differences in * whitespaces are ignored * @return {@code true} if equal */ public boolean valueEquals(@Nullable String expected, boolean relaxed) { if (!relaxed) { return value.equals(expected); } if (expected == null) { return false; } var normalizedValue = value.replaceAll("\\s+", " ").trim(); var normalizedExpected = expected.replaceAll("\\s+", " ").trim(); return normalizedValue.equals(normalizedExpected); } @Override public String toString() { return (trusted ? "* " : " ") + name + ": " + value; } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy