# <@LICENSE>
# 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.
# </@LICENSE>

=head1 NAME

Mail::SpamAssassin::Plugin::ARC - perform ARC verification tests

=head1 SYNOPSIS

 loadplugin Mail::SpamAssassin::Plugin::ARC [/path/to/ARC.pm]

 full   ARC_SIGNED            eval:check_arc_signed()
 full   ARC_VALID             eval:check_arc_valid()
 full   ARC_TRUSTED           eval:check_arc_trusted()

=head1 DESCRIPTION

This SpamAssassin plugin implements ARC (Authenticated Received Chain)
verification as described by RFC 8617.

If the C<AuthRes> plugin is loaded and has parsed C<arc=> results from
Authentication-Results headers, those results will be re-used and native
verification is skipped.  Otherwise, the plugin performs its own
cryptographic verification using the C<Mail::DKIM::ARC::Verifier> module
(version 0.50 or later required).

=head1 SEE ALSO

C<Mail::DKIM> Mail::SpamAssassin::Plugin(3)

  https://www.rfc-editor.org/rfc/rfc8617

=cut

package Mail::SpamAssassin::Plugin::ARC;

use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::Logger;
use Mail::SpamAssassin::Timeout;
use Mail::SpamAssassin::Header::ArcAuthenticationResults;
use version;

use strict;
use warnings;
use re 'taint';

our @ISA = qw(Mail::SpamAssassin::Plugin);

sub new {
  my $class = shift;
  my $mailsaobject = shift;

  $class = ref($class) || $class;
  my $self = $class->SUPER::new($mailsaobject);
  bless ($self, $class);

  $self->register_eval_rule("check_arc_signed", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS);
  $self->register_eval_rule("check_arc_valid", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS);
  $self->register_eval_rule("check_arc_trusted", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS);

  # run after AuthRes (-20) so we can re-use arc= results,
  # but before DKIM, SPF, DMARC (0) so arc_auth_results is available
  $self->register_method_priority("parsed_metadata", -10);

  $self->set_config($mailsaobject->{conf});

  return $self;
}

###########################################################################

sub set_config {
  my($self, $conf) = @_;
  my @cmds;

=head1 USER SETTINGS

=over 4

=item arc_timeout n             (default: 5)

Timeout in seconds for ARC signature verification. If Mail::DKIM cannot
complete verification within this time, the ARC check will be aborted.

=item arc_trusted_sealers domain1 domain2 ...

Specify domains that are trusted as ARC sealers.  When the ARC chain is
cryptographically valid, the plugin will parse ARC-Authentication-Results
headers from trusted sealers and make them available to other plugins
(such as DMARC) via C<$pms-E<gt>{arc_auth_results}>.  ARC instances sealed
by untrusted domains are ignored.

If no trusted sealers are configured, ARC-Authentication-Results headers
will not be parsed.

Can be specified multiple times, additional entries are appended.

  arc_trusted_sealers google.com microsoft.com
  arc_trusted_sealers yahoo.com

=back

=cut

  push(@cmds, {
    setting => 'arc_timeout',
    default => 5,
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
  });

  push(@cmds, {
    setting => 'arc_trusted_sealers',
    default => {},
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
    code => sub {
      my ($self, $key, $value, $line) = @_;
      if (!defined $value || $value =~ /^$/) {
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
      }
      foreach my $domain (split(/\s+/, lc $value)) {
        $self->{arc_trusted_sealers}->{$domain} = 1;
      }
    }
  });

  $conf->{parser}->register_commands(\@cmds);
}

###########################################################################

sub parsed_metadata {
  my ($self, $opts) = @_;
  my $pms = $opts->{permsgstatus};

  my(@arc_signatures, @arc_valid_signatures);

  $pms->{arc_signatures_ready} = 0;
  $pms->{arc_signatures_dependable} = 0;
  $pms->{arc_signatures} = \@arc_signatures;
  $pms->{arc_valid_signatures} = \@arc_valid_signatures;
  $pms->{arc_signed} = 0;
  $pms->{arc_valid} = 0;

  my $suppl_attrib = $pms->{msg}->{suppl_attrib};
  if (defined $suppl_attrib && exists $suppl_attrib->{arc_signatures}) {
    my $provided_arc_signatures = $suppl_attrib->{arc_signatures};
    if (ref $provided_arc_signatures) {
      @arc_signatures = @$provided_arc_signatures;
      $pms->{arc_signatures_ready} = 1;
      $pms->{arc_signatures_dependable} = 1;
      dbg("arc: ARC signatures provided by the caller, %d signatures",
          scalar(@arc_signatures));
    }
  }

  if ($pms->{arc_signatures_ready}) {
    $self->_check_arc_valid_signature($pms, \@arc_signatures);
  } elsif ($self->_check_arc_from_authres($pms)) {
    # re-used ARC results from AuthRes plugin
  } elsif (!$pms->is_dns_available()) {
    dbg("arc: signature verification disabled, DNS resolving not available");
  } elsif (!$self->_arc_load_modules()) {
    # Mail::DKIM::ARC module not available
  } else {
    my $timemethod = $self->{main}->time_method("check_arc_signature");
    my $arc_verifier = Mail::DKIM::ARC::Verifier->new;
    $self->_check_signature($pms, $arc_verifier, \@arc_signatures);
  }
}

sub check_arc_signed {
  my ($self, $pms, $full_ref, @acceptable_domains) = @_;
  my $result = 0;
  if (!$pms->{arc_signed}) {
    # don't bother
  } elsif (!@acceptable_domains) {
    $result = 1;  # no additional constraints, any signing domain will do
  }
  return $result;
}

sub check_arc_valid {
  my ($self, $pms, $full_ref, @acceptable_domains) = @_;
  my $result = 0;
  if (!$pms->{arc_valid}) {
    # don't bother
  } elsif (!@acceptable_domains) {
    $result = 1;  # no additional constraints, any signing domain will do,
                  # also any signing key size will do
  }
  return $result;
}

sub check_arc_trusted {
  my ($self, $pms) = @_;
  return $pms->{arc_auth_results} ? 1 : 0;
}

sub _check_arc_from_authres {
  my ($self, $pms) = @_;

  return 0 if !$pms->{authres_parsed} || !$pms->{authres_parsed}{arc}
              || !@{$pms->{authres_parsed}{arc}};

  dbg("arc: checking for AuthRes plugin ARC results");

  foreach my $arc_result (@{$pms->{authres_parsed}{arc}}) {
    my $result = lc($arc_result->{result} || '');
    next unless $result =~ /^(pass|fail|none)$/;

    dbg("arc: re-using result from AuthRes plugin: %s", $result);

    if ($result eq 'pass') {
      $pms->{arc_signed} = 1;
      $pms->{arc_valid} = 1;
      $self->_parse_trusted_aar_from_headers($pms);
    } elsif ($result eq 'fail') {
      $pms->{arc_signed} = 1;
    }

    return 1;
  }

  return 0;
}

# ---------------------------------------------------------------------------

sub _arc_load_modules {
  my ($self) = @_;

  if (!$self->{tried_loading}) {
    $self->{service_available} = 0;
    my $timemethod = $self->{main}->time_method("arc_load_modules");
    my $eval_stat;
    eval {
      { require Mail::DKIM::ARC::Verifier }
      1;
    } or do {
      $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
    };
    $self->{tried_loading} = 1;

    if (defined $eval_stat) {
      dbg("arc: cannot load Mail::DKIM::ARC::Verifier module, ARC checks disabled: %s",
          $eval_stat);
    } else {
      my $version = Mail::DKIM::ARC::Verifier->VERSION;
      dbg("arc: using Mail::DKIM::ARC::Verifier version $version");
      if (version->parse($version) >= version->parse(0.40)) {
        # Let Mail::DKIM use our interface to Net::DNS::Resolver.
        my $res = $self->{main}->{resolver};
        dbg("arc: providing our own resolver: %s", ref $res);
        Mail::DKIM::DNS::resolver($res);
      }
      $self->{service_available} = 1;
    }
  }
  return $self->{service_available};
}

sub _check_signature {
  my($self, $pms, $verifier, $signatures) = @_;

  my $conf = $pms->{conf};
  if (!$verifier) {
    dbg("arc: cannot create Mail::DKIM::ARC::Verifier object");
    return;
  }
  $pms->{arc_verifier} = $verifier;

  eval {
    my $str = $pms->{msg}->get_pristine();
    if ($pms->{msg}->{line_ending} eq "\015\012") {
      $verifier->PRINT($str);
    } else {
      $str =~ s/\012/\015\012/gs;
      $verifier->PRINT($str);
      undef $str;
    }
    1;
  } or do {
    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
    dbg("arc: verification failed, intercepted error: $eval_stat");
    return 0;
  };

  my $timeout = $conf->{arc_timeout};
  my $timer = Mail::SpamAssassin::Timeout->new(
                { secs => $timeout, deadline => $pms->{master_deadline} });

  my $err = $timer->run_and_catch(sub {
    dbg("arc: performing public ARC key lookup and signature verification");
    $verifier->CLOSE();

    @$signatures = $verifier->UNIVERSAL::can("signatures") ?
                               $verifier->signatures : $verifier->signature;
  });
  if ($timer->timed_out()) {
    dbg("arc: public key lookup or verification timed out after %s s",
        $timeout);
  } elsif ($err) {
    chomp $err;
    dbg("arc: ARC public key lookup or verification failed: $err");
  }

  $pms->{arc_signatures_ready} = 1;
  if (!@$signatures || !$pms->{tests_already_hit}->{'__TRUNCATED'}) {
    $pms->{arc_signatures_dependable} = 1;
  }
  $self->_check_arc_valid_signature($pms, \@$signatures);
}

sub _check_arc_valid_signature {
  my($self, $pms, $signatures) = @_;

  my(@valid_signatures);
  my $conf = $pms->{conf};
  my @arc_sig;

  if ($pms->{arc_signatures_ready}) {
    foreach my $signature (@$signatures) {
      next if !defined $signature;
      next if !defined $signature->selector || $signature->selector eq "";

      my($info, $valid, $expired);
      $valid = $signature->result eq 'pass';
      $info = $valid ? 'VALID' : 'FAILED';
      if ($valid && $signature->UNIVERSAL::can("check_expiration")) {
        $expired = !$signature->check_expiration;
        $info .= ' EXPIRED' if $expired;
      }

      my %arc;
      $arc{prefix} = $signature->prefix;
      $arc{valid} = $valid;
      push(@arc_sig, \%arc);

      push(@valid_signatures, $signature) if $valid && !$expired;

      if (would_log("dbg","arc")) {
        my $d = $signature->domain;
        dbg("arc: %s i=%s %s d=%s, s=%s, a=%s, c=%s, %s",
          $info, $signature->instance, $signature->prefix,
          map(!defined $_ ? '(undef)' : $_,
            $d, $signature->selector,
            $signature->algorithm, scalar($signature->canonicalization),
            $signature->result),
        );
      }
    }

    if (@valid_signatures) {
      $pms->{arc_signed} = 1;
      my $arc_seal_valid = 0;
      my $arc_message_valid = 0;
      my $arc_message_found = 0;
      # All ARC-Seals signatures and the most recent ARC-Message-Signature must be valid
      foreach my $arc ( @arc_sig ) {
        if ($arc->{prefix} eq 'ARC-Message-Signature:') {
          next if $arc_message_found;
          $arc_message_valid = 1 if $arc->{valid};
          $arc_message_found = 1;
        }
        if ($arc->{prefix} eq 'ARC-Seal:') {
          if ($arc->{valid}) {
            $arc_seal_valid = 1;
          } else {
            $arc_seal_valid = 0;
            last;
          }
        }
      }
      if ($arc_message_valid and $arc_seal_valid) {
        $pms->{arc_valid} = 1;
        $self->_parse_trusted_aar($pms);
      }
      my $sig = $valid_signatures[0];
      my $sig_res = $sig->result_detail;
      dbg("arc: ARC signature verification result: %s", uc($sig_res));

    } elsif (@$signatures) {
      $pms->{arc_signed} = 1;
      my $sig = @$signatures[0];
      my $sig_res = $sig->result_detail;
      dbg("arc: ARC signature verification result: %s", uc($sig_res));

    } else {
      dbg("arc: ARC signature verification result: none");
    }
  }
}

sub _parse_trusted_aar {
  my ($self, $pms) = @_;

  my $trusted = $pms->{conf}->{arc_trusted_sealers};
  return if !$trusted || !%$trusted;

  my $verifier = $pms->{arc_verifier};
  return if !ref($verifier) || !defined $verifier->{seals};

  # Walk the chain from newest to oldest.  Trust the contiguous run of
  # trusted sealers at the top of the chain; as soon as we hit an
  # untrusted hop, anything older than it cannot be trusted either.
  # $verifier->{seals} is indexed by ARC instance number.
  my $seals = $verifier->{seals};
  my %trusted_indices;
  for (my $i = $#$seals; $i >= 1; $i--) {
    my $seal = $seals->[$i];
    next if !defined $seal;
    my $d = $seal->{tags_by_name}{d}{value};
    next if !defined $d;
    $d = lc $d;
    if ($trusted->{$d}) {
      $trusted_indices{$i} = 1;
      dbg("arc: seal i=%s d=%s is trusted", $i, $d);
    } else {
      dbg("arc: seal i=%s d=%s is not trusted, stopping chain walk", $i, $d);
      last;
    }
  }
  return if !%trusted_indices;

  $self->_parse_aar_by_trusted_indices($pms, \%trusted_indices);
}

sub _parse_trusted_aar_from_headers {
  my ($self, $pms) = @_;

  my $trusted = $pms->{conf}->{arc_trusted_sealers};
  return if !$trusted || !%$trusted;

  # Collect ARC-Seal domains by instance index
  my %seal_domain;
  my @seals = $pms->{msg}->get_pristine_header('ARC-Seal');
  foreach my $seal (@seals) {
    chomp $seal;
    my ($i) = $seal =~ /\bi\s*=\s*(\d+)/;
    my ($d) = $seal =~ /\bd\s*=\s*([^\s;]+)/;
    next if !defined $d || !defined $i;
    $seal_domain{$i} = lc $d;
  }

  # Walk the chain from newest to oldest.  Trust the contiguous run of
  # trusted sealers at the top of the chain; as soon as we hit an
  # untrusted hop, anything older than it cannot be trusted either.
  my %trusted_indices;
  foreach my $i (sort { $b <=> $a } keys %seal_domain) {
    my $d = $seal_domain{$i};
    if ($trusted->{$d}) {
      $trusted_indices{$i} = 1;
      dbg("arc: seal i=%s d=%s is trusted", $i, $d);
    } else {
      dbg("arc: seal i=%s d=%s is not trusted, stopping chain walk", $i, $d);
      last;
    }
  }
  return if !%trusted_indices;

  $self->_parse_aar_by_trusted_indices($pms, \%trusted_indices);
}

sub _parse_aar_by_trusted_indices {
  my ($self, $pms, $trusted_indices) = @_;

  # Parse ARC-Authentication-Results headers from trusted sealers
  my @aar = $pms->{msg}->get_pristine_header('ARC-Authentication-Results');
  return if !@aar;

  my @arc_auth_results;
  foreach my $hdr (@aar) {
    chomp $hdr;

    my $ar = eval {
      Mail::SpamAssassin::Header::ArcAuthenticationResults->new($hdr);
    };
    if ($@ || !$ar) {
      dbg("arc: failed to parse AAR header: %s", $@ || 'unknown error');
      next;
    }

    my $arc_index = $ar->arc_index();
    if (!defined $arc_index) {
      dbg("arc: AAR header missing i= tag, skipping");
      next;
    }

    if (!$trusted_indices->{$arc_index}) {
      dbg("arc: AAR i=%s not from trusted sealer, skipping", $arc_index);
      next;
    }

    my $authserv = $ar->authserv_id();
    my %results;
    foreach my $method ($ar->methods()) {
      foreach my $m ($ar->method($method)) {
        $results{$method} = {
          result     => $m->{result},
          reason     => $m->{reason},
          properties => $m->{properties},
        };
      }
    }

    push @arc_auth_results, {
      arc_index => $arc_index,
      authserv  => $authserv,
      results   => \%results,
    };

    dbg("arc: parsed trusted AAR i=%s authserv=%s methods=%s",
        $arc_index, $authserv, join(' ', sort keys %results));
  }

  if (@arc_auth_results) {
    @arc_auth_results = sort { $a->{arc_index} <=> $b->{arc_index} }
                        @arc_auth_results;
    $pms->{arc_auth_results} = \@arc_auth_results;
  }
}

1;

