package Mail::DKIM::PublicKey;
use strict;
use warnings;
our $VERSION = '1.20240923'; # VERSION
# ABSTRACT: Represents a DKIM key

# Copyright 2005 Messiah College. All rights reserved.
# Jason Long <jlong@messiah.edu>

# Copyright (c) 2004 Anthony D. Urso. All rights reserved.
# This program is free software; you can redistribute it and/or
# modify it under the same terms as Perl itself.

use base ( 'Mail::DKIM::KeyValueList', 'Mail::DKIM::Key' );
*calculate_EM = \&Mail::DKIM::Key::calculate_EM;

use Crypt::OpenSSL::RSA;
use Crypt::PK::Ed25519;
use MIME::Base64;
use Mail::DKIM::DNS;

sub new {
    my $type = shift;
    my %prms = @_;

    my $self = {};

    $self->{'GRAN'} = $prms{'Granularity'};
    $self->{'NOTE'} = $prms{'Note'};
    $self->{'TEST'} = $prms{'Testing'};
    $self->{'TYPE'} = ( $prms{'Type'} or 'rsa' );
    $self->{'DATA'} = $prms{'Data'};

    bless $self, $type;
}


sub fetch {
    my $class  = shift;
    my $waiter = $class->fetch_async(@_);
    my $self   = $waiter->();
    return $self;
}

# fetch_async() - asynchronously tries fetching a specific public key
# using a specific protocol.
#
# Usage:
#   my $fut = Mail::DKIM::PublicKey->fetch_async(
#                      Protocol => 'dns/txt',
#                      Selector => 'selector1',
#                      Domain => 'example.org',
#                      Callbacks => { ... }, #optional
#                      );
#
#   # some later time
#   my $pubkey = $fut->();    # blocks until the public key is returned
#
sub fetch_async {
    my $class = shift;
    my %prms  = @_;

    defined( $prms{Protocol} ) && $prms{Protocol} =~ m{^dns(/txt)?$}s
      or die "invalid/missing Protocol\n";

    defined( $prms{Selector} ) && length( $prms{Selector} )
      or die "invalid/missing Selector\n";

    defined( $prms{Domain} ) && length( $prms{Domain} )
      or die "invalid/missing Domain\n";

    my %callbacks = %{ $prms{Callbacks} || {} };
    my $on_success = $callbacks{Success} || sub { $_[0] };
    $callbacks{Success} = sub {
        my @resp = @_;
        unless (@resp) {

            # no requested resource records or NXDOMAIN,
            return $on_success->();
        }

        my $strn;
        foreach my $rr (@resp) {
            next unless $rr->type eq 'TXT';

            # join with no intervening spaces, RFC 6376
            if ( $rr->can('txtdata') ) {

                # must call txtdata() in a list context
                $strn = join '', $rr->txtdata;
            }
            else {
                # char_str_list method is 'historical'
                $strn = join '', $rr->char_str_list;
            }
            last;
        }

        $strn
          or return $on_success->();

        my $self = $class->parse($strn);
        $self->{Selector} = $prms{'Selector'};
        $self->{Domain}   = $prms{'Domain'};
        $self->{TYPE}     = $self->get_tag('k') || 'rsa';
        $self->check;

        return $on_success->($self);
    };

    #
    # perform DNS query for public key...
    #
    my $host = $prms{Selector} . '._domainkey.' . $prms{Domain};
    my $waiter =
      Mail::DKIM::DNS::query_async( $host, 'TXT', Callbacks => \%callbacks, );
    return $waiter;
}


# check syntax of the public key
# throw an error if any errors are detected
sub check {
    my $self = shift;

    # check public key version tag
    if ( my $v = $self->get_tag('v') ) {
        unless ( $v eq 'DKIM1' ) {
            die "unsupported version\n";
        }
    }

    # check public key granularity
    my $g = $self->granularity;

    # check key type
    if ( my $k = $self->get_tag('k') ) {
        unless ( $k eq 'rsa' || $k eq 'ed25519' ) {
            die "unsupported key type\n";
        }
    }

    # check public-key data
    my $p = $self->data;
    if ( not defined $p ) {
        die "missing p= tag\n";
    }
    if ( $p eq '' ) {
        die "revoked\n";
    }
    unless ( $p =~ /^[A-Za-z0-9\+\/\=]+$/ ) {
        die "invalid data\n";
    }

    # have OpenSSL load the key
    eval {
        local $SIG{__DIE__};
        $self->cork;
	1
    } || do {

        # see also finish_body
        chomp( my $E = $@ );
        if ( $E =~ /(OpenSSL error: .*?) at / ) {
            $E = "$1";
        }
        elsif ( $E =~ /^(panic:.*?) at / ) {
            $E = "OpenSSL $1";
        }
        elsif ( $E =~ /^FATAL: (.*) at / ) {
            $E = "Ed25519 $1";
        }
        die "$E\n";
    };

    # check service type
    if ( my $s = $self->get_tag('s') ) {
        my @list = split( /:/, $s );
        unless ( grep { $_ eq '*' || $_ eq 'email' } @list ) {
            die "does not support email\n";
        }
    }

    return 1;
}

# check_granularity() - check whether this key matches signature identity
#
# a public key record can restrict what identities it may sign with,
#   g=, granularity, restricts the local part of the identity
#   t=s, restricts whether subdomains can be used
#
# This method returns true if the given identity is allowed by this
# public key; it returns false otherwise.
# If false is returned, you can check C<$@> for an explanation of
# why.
#
sub check_granularity {
    my $self = shift;
    my ( $identity, $empty_g_means_wildcard ) = @_;

    # check granularity
    my $g = $self->granularity;

    # yuck- what is this $empty_g_means_wildcard parameter?
    # well, it turns out that with DomainKeys signatures,
    # an empty g= is the same as g=*
    if ( $g eq '' && $empty_g_means_wildcard ) {
        $g = '*';
    }

    # split i= value into a "local part" and a "domain part"
    my ( $local_part, $domain_part );
    if ( $identity =~ /^(.*)\@([^@]*)$/ ) {
        $local_part  = $1;
        $domain_part = $2;
    }
    else {
        $local_part  = '';
        $domain_part = $identity;
    }

    my ( $begins, $ends ) = split /\*/, $g, 2;
    if ( defined $ends ) {

        # the g= tag contains an asterisk

        # the local part must be at least as long as the pattern
        if (
            length($local_part) < length($begins) + length($ends)
            or

            # the local part must begin with $begins
            substr( $local_part, 0, length($begins) ) ne $begins
            or

            # the local part must end with $ends
            ( length($ends) && substr( $local_part, -length($ends) ) ne $ends )
          )
        {
            $@ = "granularity mismatch\n";
            return;
        }
    }
    else {
        if ( $g eq '' ) {
            $@ = "granularity is empty\n";
            return;
        }
        unless ( $local_part eq $begins ) {
            $@ = "granularity mismatch\n";
            return;
        }
    }

    # check subdomains
    if ( $self->subdomain_flag ) {
        unless ( lc( $domain_part ) eq lc( $self->{'Domain'} ) ) {
            $@ = "does not support signing subdomains\n";
            return;
        }
    }

    return 1;
}

# returns true if the actual hash algorithm used is listed by this
# public key; dies otherwise
#
sub check_hash_algorithm {
    my $self = shift;
    my ($hash_algorithm) = @_;

    # check hash algorithm
    if ( my $h = $self->get_tag('h') ) {
        my @list = split( /:/, $h );
        unless ( grep { $_ eq $hash_algorithm } @list ) {
            die "does not support hash algorithm '$hash_algorithm'\n";
        }
    }
    return 1;
}

# Create an OpenSSL public key object from the Base64-encoded data
# found in this public key's DNS record. The OpenSSL object is saved
# in the "cork" property.
sub _convert_rsa {
    my $self = shift;
    # have to PKCS1ify the pubkey because openssl is too finicky...
    my $cert = "-----BEGIN PUBLIC KEY-----\n";

    for ( my $i = 0 ; $i < length $self->data ; $i += 64 ) {
        $cert .= substr $self->data, $i, 64;
        $cert .= "\n";
    }

    $cert .= "-----END PUBLIC KEY-----\n";

    my $cork = Crypt::OpenSSL::RSA->new_public_key($cert)
      or die 'unable to generate public key object';

    # segfaults on my machine
    #	$cork->check_key or
    #		return;

    $self->cork($cork);
    return 1;
}

sub _convert_ed25519 {
    my $self = shift;
    my $cork = Crypt::PK::Ed25519->new
      or die 'unable to generate Ed25519 public key object';

    my $keybin = decode_base64($self->data);
    $cork->import_key_raw($keybin, 'public')
      or die 'failed to load Ed25519 public key';

    $self->cork($cork);
    return 1;
}

sub convert {
    my $self = shift;

    my $k_tag = $self->get_tag('k');
    $k_tag = 'rsa' unless defined $k_tag;

    $self->data
      or return;

    return $self->_convert_rsa if $k_tag eq 'rsa';
    return $self->_convert_ed25519 if $k_tag eq 'ed25519';
    die 'unsupported key type';
}

sub verify {
    my $self = shift;
    my %prms = @_;

    my $rtrn;

    eval {
        local $SIG{__DIE__};
        $rtrn = $self->cork->verify( $prms{'Text'}, $prms{'Signature'} );
	1
    } || do {
	$self->errorstr($@);
	return;
    };

    return $rtrn;
}


sub granularity {
    my $self = shift;

    # set new granularity if provided
    (@_)
      and $self->set_tag( 'g', shift );

    my $g = $self->get_tag('g');
    if ( defined $g ) {
        return $g;
    }
    else {
        return '*';
    }
}

sub notes {
    my $self = shift;

    (@_)
      and $self->set_tag( 'n', shift );

    return $self->get_tag('n');
}

sub data {
    my $self = shift;

    (@_)
      and $self->set_tag( 'p', shift );

    my $p = $self->get_tag('p');

    # remove whitespace (actually only LWSP is allowed) and double quote (long string delimiter)
    $p =~ tr/\015\012 \t"//d  if defined $p;
    return $p;
}

sub flags {
    my $self = shift;

    (@_)
      and $self->set_tag( 't', shift );

    return $self->get_tag('t') || '';
}

# subdomain_flag() - checks whether "s" is specified in flags
#
# returns true if "s" is found, false otherwise
#
sub subdomain_flag {
    my $self = shift;
    my @flags = split /:/, $self->flags;
    return grep { $_ eq 's' } @flags;
}

sub revoked {
    my $self = shift;

    $self->data
      or return 1;

    return;
}

sub testing {
    my $self = shift;

    my $flags = $self->flags;
    my @flaglist = split( /:/, $flags );
    if ( grep { $_ eq 'y' } @flaglist ) {
        return 1;
    }
    return undef;
}

sub verify_sha1_digest {
    my $self = shift;
    my ( $digest, $signature ) = @_;
    return $self->verify_digest( 'SHA-1', $digest, $signature );
}

sub _verify_digest_rsa {
    my $self = shift;
    my ( $digest_algorithm, $digest, $signature ) = @_;

    my $rsa_pub = $self->cork;
    if ( !$rsa_pub ) {
        $@ = $@ ne '' ? "RSA failed: $@" : 'RSA unknown problem';
        $@ .= ", s=$self->{Selector} d=$self->{Domain}";
        return;
    }

    $rsa_pub->use_no_padding;
    my $verify_result = $rsa_pub->encrypt($signature);

    my $k = $rsa_pub->size;
    my $expected = calculate_EM( $digest_algorithm, $digest, $k );
    return 1 if ( $verify_result eq $expected );

    # well, the RSA verification failed; I wonder if the RSA signing
    # was performed on a different digest value? I think we can check...

    # basically, if the $verify_result has the same prefix as $expected,
    # then only the digest was different

    my $digest_len = length $digest;
    my $prefix_len = length($expected) - $digest_len;
    if (
        substr( $verify_result, 0, $prefix_len ) eq
        substr( $expected,      0, $prefix_len ) )
    {
        $@ = 'message has been altered';
        return;
    }

    $@ = 'bad RSA signature';
    return;
}

sub _verify_digest_ed25519 {
    my $self = shift;
    my ( $digest_algorithm, $digest, $signature ) = @_;

    my $ed = $self->cork;
    if ( !$ed ) {
        $@ = $@ ne '' ? "Ed25519 failed: $@" : 'Ed25519 unknown problem';
        $@ .= ", s=$self->{Selector} d=$self->{Domain}";
        return;
    }

    my $verify_result = $ed->verify_message($signature, $digest);
    return $verify_result if ($verify_result == 1);

    $@ = 'bad Ed25519 signature';
    return;
}

# verify_digest() - returns true if the digest verifies, false otherwise
#
# if false, $@ is set to a description of the problem
#
sub verify_digest {
    my $self = shift;
    my ( $digest_algorithm, $digest, $signature ) = @_;

    my $k_tag = $self->get_tag('k') || 'rsa';

    return $self->_verify_digest_rsa($digest_algorithm, $digest, $signature) if $k_tag eq 'rsa';
    return $self->_verify_digest_ed25519($digest_algorithm, $digest, $signature) if $k_tag eq 'ed25519';
    $@ = 'unsupported key type';
    return;
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Mail::DKIM::PublicKey - Represents a DKIM key

=head1 VERSION

version 1.20240923

=head1 CONSTRUCTOR

=head2 fetch() - retrieve a public key record from DNS

  my $public_key = Mail::DKIM::PublicKey->fetch(
                      Protocol => 'dns',
                      Selector => 'brisbane',
                      Domain => 'example.com',
                    );

If the public key is found, a L<Mail::DKIM::PublicKey> object
is returned, representing the information found in DNS.
If the public key does not exist in DNS, then C<undef> is
returned.
If a DNS error occurs while fetching the key, then this method
will C<die>.
If the public key was found, but is not valid (e.g. it is "revoked"),
then this method will C<die>.

=head1 METHODS

=head2 granularity() - get or set the granularity (g=) field

  my $g = $public_key->granularity;

  $public_key->granularity('*');

Granularity of the key. The value must match the Local-part of the
effective "i=" tag of the DKIM-Signature header field.
The granularity is a literal value, or a pattern with a single '*'
wildcard character that matches zero or more characters.

If no granularity is defined, then the default value, '*', will
be returned.

=head1 AUTHORS

=over 4

=item *

Jason Long <jason@long.name>

=item *

Marc Bradshaw <marc@marcbradshaw.net>

=item *

Bron Gondwana <brong@fastmailteam.com> (ARC)

=back

=head1 THANKS

Work on ensuring that this module passes the ARC test suite was
generously sponsored by Valimail (https://www.valimail.com/)

=head1 COPYRIGHT AND LICENSE

=over 4

=item *

Copyright (C) 2013 by Messiah College

=item *

Copyright (C) 2010 by Jason Long

=item *

Copyright (C) 2017 by Standcore LLC

=item *

Copyright (C) 2020 by FastMail Pty Ltd

=back

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself, either Perl version 5.8.6 or,
at your option, any later version of Perl 5 you may have available.

=cut
