package OpenSearch::Client::Hash;
$OpenSearch::Client::Hash::VERSION = '3.009';
use Moo;
use MIME::Base64 ();
use Crypt::URandom;
use Crypt::Bcrypt 0.011;
use Crypt::Argon2 0.013;
use Crypt::PBKDF2 0.161520;
use namespace::clean;

has 'errorstring' => ( is => 'rwp', default => '' );

sub _is_integer {
    my($self, $value) = @_;
    return 0 unless defined($value);
    return ( $value =~ /^[1-9][0-9]*$/ ) ? 1 : 0;
}

sub _is_integer_between {
    my($self, $value, $low, $high) = @_;
    return 0 unless $self->_is_integer($value);
    return ( $value >= $low && $value <= $high ) ? 1 : 0;
}

sub create_bcrypt_password_hash {
    my($self, %options) = @_;
    $self->_set_errorstring('');
    $options{type} ||= '2y';
    $options{rounds} ||= 12;
    $options{password} ||= '';
    
    my $salt = Crypt::URandom::urandom(16);
    
    ## validate password
    unless($options{password}) {
        return $self->_return_on_error('you must provide a password');
    }
    {
        my $pwdlen = length($options{password});
        if ($pwdlen > 72 ) {
            return $self->_return_on_error('your password is longer than 72 characters');
        }   
    }
    
    ## validate type
    $options{type} = lc($options{type});
    unless($options{type} =~ /^2(a|b|y)$/ ) {
        return $self->_return_on_error(qq(invalid type '$options{type}' : valid options are 2y, 2a or 2b));
    }
    
    ## validate rounds
    unless( $self->_is_integer_between($options{rounds}, 4, 31) ) {
        return $self->_return_on_error(qq(Invalid rounds "$options{rounds}". Must be an integer between 4 and 31 inclusive.));
    }
    
    ## create hash
    my $hash = Crypt::Bcrypt::bcrypt($options{password}, $options{type}, $options{rounds}, $salt);
    
    ## validate hash
    unless(Crypt::Bcrypt::bcrypt_check($options{password}, $hash)) {
        return $self->_return_on_error('failed to verify created hash');
    }
    
    if ( $hash ) {
        return $hash;
    } else {
        return $self->_return_on_error('unexpected error creating hash');
    }
}

sub create_argon2_password_hash {
    my($self, %options) = @_;
    $self->_set_errorstring('');
    $options{password} ||= '';
    $options{type}   ||= 'argon2id';
    $options{length} ||= 32;
    $options{iterations} ||= 3;
    $options{memory} ||= 65536;
    
    my $salt = Crypt::URandom::urandom(32);
    
    ## validate password
    unless($options{password}) {
        return $self->_return_on_error('you must provide a password');
    }
    
    ## validate type
    $options{type} = lc($options{type});
    unless($options{type} =~ /\Aargon2i\Z|\Aargon2d\Z|\Aargon2id\Z/ ) {
        my $err_string = qq(invalid type '$options{type}' : valid options are argon2i, argon2d or argon2id);
        return $self->_return_on_error($err_string);
    }
    
    ## validate iterations
    unless($self->_is_integer($options{iterations})) {
        my $err_string = qq(Invalid iterations "$options{iterations}". Must be an integer);
        return $self->_return_on_error($err_string);
    }
    
    ## validate length
    unless($self->_is_integer($options{length})) {
        my $err_string = qq(Invalid length "$options{length}". Must be an integer);
        return $self->_return_on_error($err_string);
    }
    
    ## validate memory
    unless($self->_is_integer($options{memory})) {
        my $err_string = qq(Invalid memory "$options{memory}". Must be an integer);
        return $self->_return_on_error($err_string);
    }
    
    my $parallelism = 1;
    my $m_factor    = $options{memory} . 'k';
    
    my @cmdparams = ( $options{password}, $salt, $options{iterations}, $m_factor, $parallelism, $options{length} );
    
    my $hash = '';
    
    ## create and validate hash
    if ($options{type} eq 'argon2id') {
        $hash = Crypt::Argon2::argon2id_pass(@cmdparams);
        unless(Crypt::Argon2::argon2id_verify($hash, $options{password})) {
            return $self->_return_on_error('failed to verify created hash');
        }
    } elsif($options{type} eq 'argon2i') {
        $hash = Crypt::Argon2::argon2i_pass(@cmdparams);
        unless(Crypt::Argon2::argon2i_verify($hash, $options{password})) {
            return $self->_return_on_error('failed to verify created hash');
        }
    } elsif($options{type} eq 'argon2d') {
        $hash = Crypt::Argon2::argon2d_pass(@cmdparams);
        unless(Crypt::Argon2::argon2d_verify($hash, $options{password})) {
            return $self->_return_on_error('failed to verify created hash');
        }
    }
    
    if ( $hash ) {
        return $hash;
    } else {
        return $self->_return_on_error('unexpected error creating hash');
    }
}

sub create_pbkdf2_password_hash {
    my($self, %options) = @_;
    $self->_set_errorstring('');
    $options{password} ||= '';
    $options{length} ||= 256;
    $options{iterations} ||= 600000;
    $options{function} ||= 'SHA256';
    
    my $funcmap = {
        'SHA1'   => { number => 1, class => 'HMACSHA1', size => 0 },
        'SHA224' => { number => 2, class => 'HMACSHA2', size => 224 },
        'SHA256' => { number => 3, class => 'HMACSHA2', size => 256 },
        'SHA384' => { number => 4, class => 'HMACSHA2', size => 384 },
        'SHA512' => { number => 5, class => 'HMACSHA2', size => 512 },
    };
    
    ## validate password
    unless($options{password}) {
        return $self->_return_on_error('you must provide a password');
    }
    
    ## validate function
    unless(exists($funcmap->{$options{function}})) {
        my @allowedoptions = ( sort keys %$funcmap );
        my $err_string = qq('$options{function}' is not a valid function. Valid functions are ) . join(', ', @allowedoptions);
        return $self->_return_on_error($err_string);
    }
    
    ## validate length
    unless($self->_is_integer($options{length})) {
        my $err_string = qq(Invalid length "$options{length}". Must be an integer);
        return $self->_return_on_error($err_string);
    }
    
    if ( $options{length} % 8 ) {
        my $err_string = qq(A length of '$options{length}' bits cannot be encoded as bytes. Use a multiple of 8 ( 128, 224, 256, 384, 512 etc));
        return $self->_return_on_error($err_string);
    }
    
    ## validate iterations
    unless($self->_is_integer($options{iterations})) {
        my $err_string = qq(Invalid iterations "$options{iterations}". Must be an integer);
        return $self->_return_on_error($err_string);
    }
    
    ## create hash
    my $salt = Crypt::URandom::urandom(128);
    
    my $base64salt = MIME::Base64::encode_base64($salt, '');
    
    my $mappedfunc = $funcmap->{$options{function}};
    
    my $bytelen = int($options{length} / 8);
    
    my %hasherparams = (
        'hash_class' =>  $mappedfunc->{class},
        'iterations' =>  $options{iterations},
        'output_len' =>  $bytelen,
        'salt_len'   =>  128,
        'encoding'   =>  'crypt',
    );
    
    if ($mappedfunc->{size}) {
        $hasherparams{hash_args} = { 'sha_size' => $mappedfunc->{size} };
    }
    
    my $hasher = Crypt::PBKDF2->new(%hasherparams);
    
    my $basehash = $hasher->PBKDF2_base64($salt, $options{password});
        
    my $keyprefix = '';
    ## 32 bit perl with 32 bit int needs bigint
    {
        use bigint;
        my $prefix_bigint = ( $options{iterations} * (2 ** 32) ) + $options{length};
        $keyprefix .= $prefix_bigint;
    }
        
    my $hash = sprintf('$%s$%s$%s$%s',
        $mappedfunc->{number}, $keyprefix, $base64salt, $basehash
    );
    
    ## validate hash
        
    my $generated;
    if ($mappedfunc->{class} eq 'HMACSHA1' ) {
        $generated = sprintf('$PBKDF2$HMACSHA1:%s:%s$%s', $options{iterations}, $base64salt, $basehash );
    } else {
        $generated = sprintf('$PBKDF2$HMACSHA2{%s}:%s:%s$%s', $mappedfunc->{size}, $options{iterations}, $base64salt, $basehash );
    }
    
    unless($hasher->validate($generated, $options{password})) {
        return $self->_return_on_error('failed to verify created hash');
    }
    
    if ( $hash ) {
        return $hash;
    } else {
        return $self->_return_on_error('unexpected error creating hash');
    }
}

sub _return_on_error {
    my ($self, $errmsg) = @_;
    $self->_set_errorstring($errmsg);
    return '';
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

OpenSearch::Client::Hash - A utility to create password hashes

=head1 VERSION

version 3.009

=head1 SYNOPSYS

    use OpenSearch::Client::Hash;
    my $pwdhasher = OpenSearch::Client::Hash->new;
    my $hash1 = $pwdhasher->create_bcrypt_password_hash( password => 'my plaintext password');
    my $hash2 = $pwdhasher->create_argon2_password_hash( password => 'my plaintext password');
    my $hash3 = $pwdhasher->create_pbkdf2_password_hash( password => 'my plaintext password');
    
    # store $hash1
    # $hash1 == something like $2y$12$KCHnaCykMhrXNkfpPlDmRejRYAd2rhixnWgcpICQyTEc8HI.0G8Ca
    
    # later create users using stored hashed passwords
    
    my $cli = OpenSearch::Client->new( .... );
    my $result = $cli->security->patch_users(
        body => [
            {
                op    => 'add',
                path  => '/myuserone',
                value => {
                    description => 'Read only query user',
                    hash        => '$2y$12$KCHnaCykMhrXNkfpPlDmRejRYAd2rhixnWgcpICQyTEc8HI.0G8Ca'
                }
            },
            {
                op    => 'add',
                path  => '/myusertwo',
                value => {
                    description => 'Indexing user',
                    hash        => '$2y$12$ak6pPjW3aEbVaU7FX03HLuGWO0K5oNa3PRvUE.A6six2vdvq0uTue'
                }
            },
        ]
    );
    
=head1 DESCRIPTION

Allows creation of C<BCrypt>, C<Argon2> and C<PBKDF2> password hashes that can be stored for later use in user creation.

These are the same types of hash produced by C<plugins/opensearch-security/tools/hash.sh>.

C<BCrypt> is the default password hashing algorithm in all versions of C<OpenSearch>

C<PBKDF2> password hashes and the ability to configure password hash settings in C<opensearch.yml> are supported
from C<OpenSearch 2.16.0>

C<Argon2> password hashes are supported from C<OpenSearch 3.2.0>

NOTE : You cannot retrieve a cluster's password hashing settings using the REST API.
You must be able to access C<opensearch.yml> on a node. 

=head1 METHODS

=head2 create_bcrypt_password_hash

Create a C<BCrypt> password hash from a plain text password.

B<You cannot use hashes produced by this method> if your C<plugins.security.password.hashing.algorithm> is not the default C<BCrypt>

Returns the hashed password. On error an empty string is returned and $ph->errorstring is populated.

    use OpenSearch::Client::Hash;
    my $ph = OpenSearch::Client::Hash->new;
    
    my $hash1 = $ph->create_bcrypt_password_hash( password => 'my plaintext password' );
    
    if( $ph->errorstring ) {
        ......
    }
    
    my $hash2 = $ph->create_bcrypt_password_hash(
        password => 'my other plaintext password',
        type     => '2y',
        rounds   => 12
    );
    
    unless( $hash2 ) {
        my $error = $ph->errorstring;
        .....
    }


=over

=item C<password>

Required. The plaintext password to hash.

In C<BCrypt> passwords are limted to a maximum of 72 characters and may not contain any null-byte.

=item C<type>

Optional. Default is '2y'. Allowed values are '2y', '2a' and '2b'.

Specifies the minor version of the C<BCrypt> algorithm to use for hashing.

B<Do not set this option> unless your C<plugins.security.password.hashing.bcrypt.minor> setting is not the default C<Y> when you must match it with C<2a> for C<A> and C<2b> for C<B>.

=item C<rounds>

Optional. Default is 12.

Specifies the number of rounds to use for password hashing with C<BCrypt>. Valid values are between 4 and 31, inclusive.

B<Do not set this option> unless your C<plugins.security.password.hashing.bcrypt.rounds> setting is something other than 12 when you must match it here.

=back

=head2 create_argon2_password_hash

Create an C<Argon2> password hash from a plain text password.

B<You cannot use hashes produced by this method unless :>

=over

=item *

your C<plugins.security.password.hashing.algorithm> is C<Argon2>

=item *

your C<plugins.security.password.hashing.argon2.version> is the default C<19>

=back

Returns the hashed password. On error an empty string is returned and $ph->errorstring is populated.

    use OpenSearch::Client::Hash;
    my $ph = OpenSearch::Client::Hash->new;
    
    my $hash1 = $ph->create_argon2_password_hash( password => 'my plaintext password' );
    
    if( $ph->errorstring ) {
        ......
    }
    
    my $hash2 = $ph->create_argon2_password_hash(
        password   => 'my other plaintext password',
        type       => 'argon2id',
        length     => 32,
        iterations => 3,
        memory     => 65536,
    );
    
    unless( $hash2 ) {
        my $error = $ph->errorstring;
        .....
    }

=over

=item C<password>

Required. The plaintext password to hash.

=item C<type>

Optional. Default is 'argon2id'. Allowed values are 'argon2id', 'argon2i' and 'argon2d'.

B<Do not set this option> unless your C<plugins.security.password.hashing.argon2.type> setting is not the default C<Argon2id> when you must match it here.

=item C<length>

Optional. Desired length of the final derived key. Default is 32.

B<Do not set this option> unless your C<plugins.security.password.hashing.argon2.length> setting is not the default C<32> when you must match it here.

=item C<iterations>

Optional. Number of times the pseudo-random function is applied to the password. Default is 3.

B<Do not set this option> unless your C<plugins.security.password.hashing.argon2.iterations> setting is not the default  C<3> when you must match it here.

=item C<memory>

Optional. Amount of memory to use for hashing in KiB. Default is 65536.

B<Do not set this option> unless your C<plugins.security.password.hashing.argon2.memory> setting is not the default C<65536> when you must match it here.

=back

=head2 create_pbkdf2_password_hash

Create a C<PBKDF2> password hash from a plain text password.

B<You cannot use hashes produced by this method> unless your C<plugins.security.password.hashing.algorithm> is C<PBKDF2>

Returns the hashed password. On error an empty string is returned and $ph->errorstring is populated.

    use OpenSearch::Client::Hash;
    my $ph = OpenSearch::Client::Hash->new;
    
    my $hash1 = $ph->create_pbkdf2_password_hash( password => 'my plaintext password' );
    
    if( $ph->errorstring ) {
        ......
    }
    
    my $hash2 = $ph->create_pbkdf2_password_hash(
        password   => 'my other plaintext password',
        function   => 'SHA256',
        length     => 256,
        iterations => 600000,
    );
    
    unless( $hash2 ) {
        my $error = $ph->errorstring;
        .....
    }

=over

=item C<password>

Required. The plaintext password to hash.

=item C<function>

Optional. Default is 'SHA256'. Allowed values are 'SHA1', 'SHA224', 'SHA256', 'SHA384' and 'SHA512'.

B<Do not set this option> unless your C<plugins.security.password.hashing.pbkdf2.function> setting is not the default C<SHA256> when you must match it here.

=item C<length>

Optional. Desired length of the final derived key. Default is 256.

B<Do not set this option> unless your C<plugins.security.password.hashing.pbkdf2.length> setting is not the default C<256> when you must match it here.

=item C<iterations>

Optional. Number of times the pseudo-random function is applied to the password. Default is 600000.

B<Do not set this option> unless your C<plugins.security.password.hashing.pbkdf2.iterations> setting is not the default  C<600000> when you must match it here.

=back

=head1 SEE ALSO

L<OpenSearch::Client> an unoffical client for OpenSearch

=head1 AUTHOR

Mark Dootson E<lt>mdootson@cpan.orgE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2026 by Mark Dootson

This is free software, licensed under:

  The Apache License, Version 2.0, January 2004

=cut

