zammad/zammad

View on GitHub
lib/secure_mailing/smime/incoming.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

class SecureMailing::SMIME::Incoming < SecureMailing::Backend::HandlerIncoming
  EXPRESSION_MIME      = %r{application/(x-pkcs7|pkcs7)-mime}i
  EXPRESSION_SIGNATURE = %r{(application/(x-pkcs7|pkcs7)-signature|signed-data)}i

  OPENSSL_PKCS7_VERIFY_FLAGS = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN

  def type
    'S/MIME'
  end

  def signed?(check_content_type = content_type)
    EXPRESSION_SIGNATURE.match?(check_content_type)
  end

  def signed_type
    @signed_type ||= begin
      # Special wrapped mime-type S/MIME signature check (e.g. for Microsoft Outlook).
      if content_type.include?('signed-data') && EXPRESSION_MIME.match?(content_type)
        'wrapped'
      else
        'inline'
      end
    end
  end

  def encrypted?(check_content_type = content_type)
    EXPRESSION_MIME.match?(check_content_type)
  end

  def decrypt
    return if !encrypted?

    success = false
    comment = __('The private key for decryption could not be found.')

    decryption_certificates.each do |cert|
      key = OpenSSL::PKey::RSA.new(cert.private_key, cert.private_key_secret)

      begin
        decrypted_data = decrypt_p7enc.decrypt(key, cert.parsed)
      rescue
        next
      end

      parse_decrypted_mail(decrypted_data)

      success = true
      comment = cert.parsed.subject.to_s
      if !cert.parsed.usable?
        comment += " (Certificate #{cert.fingerprint} with start date #{cert.parsed.not_before} and end date #{cert.parsed.not_after} expired!)"
      end

      break
    end

    set_article_preferences(
      operation: :encryption,
      comment:   comment,
      success:   success,
    )
  end

  def verify_signature
    return if !signed?

    success = false
    comment = __('The certificate for verification could not be found.')

    result = verify_certificate_chain(verify_sign_p7enc.certificates)
    if result.present?
      success = true
      comment = result

      if signed_type == 'wrapped'
        parse_decrypted_mail(verify_sign_p7enc.data)
      end

      mail[:attachments].delete_if do |attachment|
        signed?(attachment.dig(:preferences, 'Content-Type'))
      end

      if !sender_is_signer?
        success = false
        comment = __('This message was not signed by its sender.')
      end
    end

    set_article_preferences(
      operation: :sign,
      comment:   comment,
      success:   success,
    )
  end

  def verify_certificate_chain(certificates)
    return if certificates.blank?

    subjects       = certificates.map(&:subject)
    subject_hashes = subjects.map { |subject| subject.hash.to_s(16) }
    return if subject_hashes.blank?

    existing_certs = ::SMIMECertificate.where(subject_hash: subject_hashes).sort_by do |certificate|
      # ensure that we have the same order as the certificates in the mail
      subject_hashes.index(certificate.parsed.subject.hash.to_s(16))
    end
    return if existing_certs.blank?

    if subject_hashes.size > existing_certs.size
      existing_certs_subjects = existing_certs.map { |cert| cert.parsed.subject.to_s }.join(', ')
      Rails.logger.debug { "S/MIME mail signed with chain '#{subjects.join(', ')}' but only found '#{existing_certs_subjects}' in database." }
    end

    begin
      existing_certs_store = OpenSSL::X509::Store.new

      existing_certs.each do |existing_cert|
        existing_certs_store.add_cert(existing_cert.parsed)
      end

      success = verify_sign_p7enc.verify(certificates, existing_certs_store, nil, OPENSSL_PKCS7_VERIFY_FLAGS)
      return if !success

      existing_certs.map do |existing_cert|
        result = existing_cert.parsed.subject.to_s
        if !existing_cert.parsed.usable?
          result += " (Certificate #{existing_cert.fingerprint} with start date #{existing_cert.parsed.not_before} and end date #{existing_cert.parsed.not_after} expired!)"
        end
        result
      end.join(', ')
    rescue => e
      Rails.logger.error "Error while verifying mail with S/MIME certificate subjects: #{subjects}"
      Rails.logger.error e
      nil
    end
  end

  private

  def verify_sign_p7enc
    @verify_sign_p7enc ||= OpenSSL::PKCS7.read_smime(mail[:raw])
  end

  def decrypt_p7enc
    @decrypt_p7enc ||= OpenSSL::PKCS7.read_smime(mail[:raw])
  end

  def sender_is_signer?
    signers = email_addresses_from_subject_alt_name

    result = signers.include?(mail[:mail_instance].from.first.downcase)
    Rails.logger.warn { "S/MIME mail #{mail[:message_id]} signed by #{signers.join(', ')} but sender is #{mail[:mail_instance].from.first}" } if !result

    result
  end

  def email_addresses_from_subject_alt_name
    result = []

    @verify_sign_p7enc.certificates.each do |cert|
      subject_alt_name = cert.extensions.detect { |extension| extension.oid == 'subjectAltName' }
      next if subject_alt_name.nil?

      entries = subject_alt_name.value.split(%r{,\s?})
      entries.each do |entry|
        identifier, email_address = entry.split(':').map(&:downcase)

        next if identifier.exclude?('email') && identifier.exclude?('rfc822')
        next if !EmailAddressValidation.new(email_address).valid?

        result.push(email_address)
      end
    end

    result
  end

  def decryption_certificates
    certs = []

    mail[:mail_instance].to.each { |to| certs += ::SMIMECertificate.find_by_email_address(to, filter: { key: 'private', usage: :encryption }) }

    if mail[:mail_instance].cc.present?
      mail[:mail_instance].cc.each { |cc| certs += ::SMIMECertificate.find_by_email_address(cc, filter: { key: 'private', usage: :encryption }) }
    end

    certs
  end
end