#
# Copyright 2024 Centreon (http://www.centreon.com/)
#
# Centreon is a full-fledged industry-strength solution that meets
# the needs in IT infrastructure and application monitoring for
# service performance.
#
# Licensed 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.
#

package centreon::common::centreonvault;

use strict;
use warnings;

use MIME::Base64;
use Crypt::OpenSSL::AES;
use Net::Curl::Easy qw(:constants);
use JSON::XS;
use Crypt::URandom qw( urandom );

my $VAULT_PATH_REGEX = qr/^secret::hashicorp_vault::([^:]+)::(.+)$/;

sub new {
    my ($class, %options) = @_;
    my $self  = bless \%options, $class;
    # mandatory options:
    # - logger: logger object
    # - config_file: either path of a JSON vault config file or the configuration as a perl hash.

    $self->{enabled} = 1;
    $self->{crypted_credentials} = 1;

    if ( !$self->init() ) {
        $self->{enabled} = 0;
        $self->{logger}->writeLogWarning("Something happened during init() method that makes Centreonvault not usable. Ignore this if you don't use Centreonvault");
    }
    return $self;
}

sub is_enabled {
    my ($self) = @_;
    return $self->{enabled};
}

sub init {
    my ($self, %options) = @_;

    $self->check_options() or return undef;

    # for unit test purpose, if the config is given as an hash, we don't try to read the config file.
    if (ref $self->{config_file} eq ref {}) {
        $self->{vault_config} = $self->{config_file};
    } else {
        # check if the following information is available
        $self->{logger}->writeLogDebug("Reading Vault configuration from file " . $self->{config_file} . ".");
        $self->{vault_config} = parse_json_file('json_file' => $self->{config_file});
        if (defined($self->{vault_config}->{error_message})) {
            $self->{logger}->writeLogInfo("Error while parsing " . $self->{config_file} . ": "
                . $self->{vault_config}->{error_message});
            return undef;
        }
    }
    $self->check_configuration() or return undef;

    $self->{logger}->writeLogDebug("Vault configuration read. Name: " . $self->{vault_config}->{name}
        . ". Url: " . $self->{vault_config}->{url} . ".");

    # Create the Curl object, it will be used several times
    $self->{curl_easy} = Net::Curl::Easy->new();
    $self->{curl_easy}->setopt( CURLOPT_USERAGENT, "Centreon VMware daemon's centreonvault.pm");

    return 1;
}

sub check_options {
    my ($self, %options) = @_;

    if ( !defined($self->{logger}) ) {
        die "FATAL: No logger given to the constructor. Centreonvault cannot be used.";
    }
    if ( !defined($self->{config_file})) {
        $self->{logger}->writeLogNotice("No config file given to the constructor. Centreonvault cannot be used.");
        return undef;
    }
    if ( ! -f $self->{config_file} and ref $self->{config_file} ne ref {}) {
        $self->{logger}->writeLogNotice("The given configuration file " . $self->{config_file}
            . " does not exist. Passwords won't be retrieved from Centreonvault. Ignore this if you don't use Centreonvault.");
        return undef;
    }

    return 1;
}

sub check_configuration {
    my ($self, %options) = @_;
    $self->{vault_url} = $self->get_vault_url();

    # Normally, the role_id and secret_id data are encrypted using AES wit the following information:
    # firstKey = APP_SECRET (environment variable)
    # secondKey = 'salt' (hashing) key given by vault.json configuration file
    # both are base64 encoded
    if ( !defined($self->{vault_config}->{salt}) || $self->{vault_config}->{salt} eq '') {
        $self->{logger}->writeLogNotice("Vault environment does not seem complete: 'salt' attribute missing from "
            . $self->{config_file}
            . ". 'role_id' and 'secret_id' won't be decrypted, so they'll be used as they're stored in the vault config file.");
        $self->{crypted_credentials} = 0;
        $self->{hash_key} = '';
    } else {
        $self->{hash_key} = $self->{vault_config}->{salt}; # key for sha3-512 hmac
    }
    $self->{encryption_key} = get_app_secret();
    if ( !defined($self->{encryption_key}) or $self->{encryption_key} eq '' ) {
        $self->{logger}->writeLogInfo("Vault environment does not seem complete. 'APP_SECRET' environment variable missing."
            . " 'role_id' and 'secret_id' won't be decrypted, so they'll be used as they're stored in the vault config file.");
        $self->{crypted_credentials} = 0;
        $self->{encryption_key} = '';
    }

    return 1;
}

sub get_app_secret {
    # this string is generated without a fixed size, but php use https://www.php.net/manual/en/function.openssl-decrypt.php,
    # which truncate the string at 256 character or add null character if the string is too short.

    my $secret = ''; # if no app_secret found return empty string so caller don't try to use it.
    if (defined($ENV{'APP_SECRET'}) && $ENV{'APP_SECRET'} ne '' ) {
        $secret = substr(decode_base64($ENV{'APP_SECRET'}), 0, 32);
    }
    elsif (-r '/usr/share/centreon/.env'){
        open(my $fh, '<', '/usr/share/centreon/.env') or return '';
        while (my $line = <$fh>) {
            chomp $line;
            if ($line =~ /^APP_SECRET=(.+)$/) {
                $secret = substr(decode_base64($1), 0, 32);
            }
        }
    }
    return '' if ($secret eq '');

    if (length($secret) < 32){
        $secret = $secret . "\0" x (32-length($secret));
    }
    return $secret
}
sub encrypt {
    my ($self, $input) = @_;
    if (!defined($input) || $input eq '') {
        $self->{logger}->writeLogNotice("No input data to encrypt. Returning undef.");
        return undef;
    }
    # with AES-256, the IV length must 16 bytes
    my $iv = urandom(16);
    # create the AES object
    $self->{logger}->writeLogDebug(
            "Creating the AES encryption object for initialization vector (IV) of length "
            . "input is " . length($input) . "B long"
            . length($iv) . "B" .  ", key is " . length($self->{encryption_key}) *8 . " bits long");

    my $cipher;
    eval {
        $cipher = Crypt::OpenSSL::AES->new($self->{encryption_key},
            {
                'cipher'  => 'AES-256-CBC',
                'iv'      => $iv,
                'padding' => 1
            }
        );
    };
    if ($@) {
        $self->{logger}->writeLogNotice("There was an error while creating the AES object: " . $@);
        return undef;
    }

    # decrypt
    $self->{logger}->writeLogDebug("Decrypting the data of length " . length($input) . "B.");
    my $encrypted_data;
    eval {$encrypted_data = $cipher->encrypt($input);};
    if ($@) {
        $self->{logger}->writeLogNotice("There was an error while decrypting one of the AES-encrypted data: " . $@);
        return undef;
    }

    return encode_base64($iv . urandom(64) . $encrypted_data);
}

sub extract_and_decrypt {
    my ($self, %options) = @_;

    my $input = decode_base64($options{data});
    if (!defined($input) || $input eq '') {
        $self->{logger}->writeLogNotice("No input data to decrypt. Returning undef.");
        return undef;
    }
    $self->{logger}->writeLogDebug("data to extract and decrypt: '" . $options{data} . "'");

    # with AES-256, the IV length must 16 bytes
    my $iv_length = 16;
    # extract the IV, the hashed data, the encrypted data
    my $iv             = substr($input, 0, $iv_length);     # initialization vector
    my $hashed_data    = substr($input, $iv_length, 64);    # hmac of the original data, for integrity control
    my $encrypted_data = substr($input, $iv_length + 64);   # data to decrypt

    # create the AES object
    $self->{logger}->writeLogDebug(
            "Creating the AES decryption object for initialization vector (IV) of length "
            . length($iv) . "B" .  ", key is " . length($self->{encryption_key}) *8 . " bits long");
    $self->{logger}->writeLogDebug(
            "input is " . length($input) . "B long, iv is " . length($iv) . "B long and data is " . length($encrypted_data) . "B long.");

    my $cipher;
    eval {
        $cipher = Crypt::OpenSSL::AES->new($self->{encryption_key},
            {
                'cipher'  => 'AES-256-CBC',
                'iv'      => $iv,
                'padding' => 1
            }
        );
    };
    if ($@) {
        $self->{logger}->writeLogNotice("There was an error while creating the AES object: " . $@);
        return undef;
    }

    # decrypt
    $self->{logger}->writeLogDebug("Decrypting the data of length " . length($encrypted_data) . "B.");
    my $decrypted_data;
    eval {$decrypted_data = $cipher->decrypt($encrypted_data);};
    if ($@) {
        $self->{logger}->writeLogNotice("There was an error while decrypting one of the AES-encrypted data: " . $@);
        return undef;
    }

    return $decrypted_data;
}

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

    # initial value: assuming the role and secret id might not be encrypted
    my $role_id   = $self->{vault_config}->{role_id};
    my $secret_id = $self->{vault_config}->{secret_id};


    if ($self->{crypted_credentials}) {
        # Then decrypt using https://github.com/perl-openssl/perl-Crypt-OpenSSL-AES
        # keep the decrypted data in local variables so that they stay in memory for as little time as possible
        $self->{logger}->writeLogDebug("Decrypting the credentials needed to authenticate to the vault.");
        $role_id   = $self->extract_and_decrypt( ('data' => $role_id ));
        $secret_id = $self->extract_and_decrypt( ('data' => $secret_id ));
        if (!defined($role_id) || !defined($secret_id)) {
            $self->{logger}->writeLogNotice("Error while decrypting role_id or secret_id. "
                . "Check that the APP_SECRET environment variable is set and that the vault config file is correct.");
            return undef;
        }
        $self->{logger}->writeLogDebug("role_id and secret_id have been decrypted.");
    } else {
        $self->{logger}->writeLogDebug("role_id and secret_id are not crypted");
    }


    # Authenticate to get the token
    my $url = $self->{vault_url} . "/v1/auth/approle/login";
    $self->{logger}->writeLogDebug("Authenticating to the vault server at URL: $url");
    $self->{curl_easy}->setopt( CURLOPT_URL, $url );

    my $post_data = '{"role_id":"' . $role_id . '","secret_id":"' . $secret_id. '"}';
    $self->{logger}->writeLogDebug("Post data: $post_data");
    my $auth_result_json;
    # to get more details (in STDERR)
    #$self->{curl_easy}->setopt(CURLOPT_VERBOSE, 1);
    $self->{curl_easy}->setopt(CURLOPT_POST, 1);
    $self->{curl_easy}->setopt(CURLOPT_POSTFIELDS, $post_data);
    $self->{curl_easy}->setopt(CURLOPT_POSTFIELDSIZE, length($post_data));
    $self->{curl_easy}->setopt(CURLOPT_WRITEDATA(), \$auth_result_json);

    eval {
        $self->{curl_easy}->perform();
    };
    if ($@) {
        $self->{logger}->writeLogError("Error while authenticating to the vault: " . $@);
        return undef;
    }


    my $auth_result_obj = transform_json_to_object($auth_result_json);
    if (defined($auth_result_obj->{errors})) {
        $self->{logger}->writeLogError("can't authenticate to vault api : " . join(", ", @{$auth_result_obj->{errors}}));
        return undef;
    }
    $self->{logger}->writeLogInfo("Authentication to the vault passed." );
    # store the token (.auth.client_token) and its expiration date (current date + .lease_duration)
    my $expiration_epoch = -1;
    my $lease_duration = $auth_result_obj->{auth}->{lease_duration};
    if ( defined($lease_duration)
            && $lease_duration =~ /\d+/
            && $lease_duration > 0 ) {
        $expiration_epoch = time() + $lease_duration;
    }
    $self->{auth} = {
        'token'            => $auth_result_obj->{auth}->{client_token},
        'expiration_epoch' => $expiration_epoch
    };
    $self->{logger}->writeLogInfo("Authenticating worked. Token valid until "
        . localtime($self->{auth}->{expiration_epoch}));

    return 1;
}

sub is_token_still_valid {
    my ($self) = @_;
    if (
            !defined($self->{auth})
            || !defined($self->{auth}->{token})
            || $self->{auth}->{token} eq ''
            || $self->{auth}->{expiration_epoch} <= time()
    ) {
        $self->{logger}->writeLogInfo("The token has expired or is invalid.");
        return undef;
    }
    $self->{logger}->writeLogDebug("The token is still valid.");
    return 1;
}

sub get_secret {
    my ($self, $secret) = @_;

    # if vault not enabled, return the secret unchanged
    return $secret if ( ! $self->{enabled});

    my ($secret_path, $secret_name) = $secret =~ $VAULT_PATH_REGEX;
    if (!defined($secret_path) || !defined($secret_name)) {
        return $secret;
    }
    $self->{logger}->writeLogDebug("Secret path: $secret_path - Secret name: $secret_name");

    if (!defined($self->{auth}) || !$self->is_token_still_valid() ) {
        $self->authenticate() or return $secret;
    }

    # prepare the GET statement
    my $get_result_json;
    my $url = $self->{vault_url} .  "/v1/" . $secret_path;
    $self->{logger}->writeLogDebug("Requesting URL: $url");

    #$self->{curl_easy}->setopt( CURLOPT_VERBOSE, 1 );
    $self->{curl_easy}->setopt( CURLOPT_URL, $url );
    $self->{curl_easy}->setopt( CURLOPT_POST, 0 );
    $self->{curl_easy}->setopt( CURLOPT_WRITEDATA(), \$get_result_json );
    $self->{curl_easy}->setopt( CURLOPT_HTTPHEADER(), ["X-Vault-Token: " . $self->{auth}->{token}]);

    eval {
        $self->{curl_easy}->perform();
    };
    if ($@) {
        $self->{logger}->writeLogError("Error while getting a secret from the vault: " . $@);
        return $secret;
    }

    $self->{logger}->writeLogDebug("Request passed.");
    # request_id

    # the result is a json string, convert it into an object
    my $get_result_obj = transform_json_to_object($get_result_json);
    if (defined($get_result_obj->{error_message})) {
        $self->{logger}->writeLogError("Error while decoding JSON '$get_result_json'. Message: "
                . $get_result_obj->{error_message});
        return $secret;
    }
    $self->{logger}->writeLogDebug("Request id is " . $get_result_obj->{request_id});

    # .data.data will contain the stored macros
    if ( !defined($get_result_obj->{data})
            || !defined($get_result_obj->{data}->{data})
            || !defined($get_result_obj->{data}->{data}->{$secret_name}) ) {
        $self->{logger}->writeLogError("Could not get secret '$secret_name' from path '$secret_path' from the vault. Enable debug for more details.");
        $self->{logger}->writeLogDebug("Response: " . $get_result_json);
        return $secret;
    }
    $self->{logger}->writeLogInfo("Secret '$secret_name' from path '$secret_path' retrieved from the vault.");
    return $get_result_obj->{data}->{data}->{$secret_name};
}

sub get_vault_url {
    my ($self) = shift;

    my $url;
    if ( !defined($self->{vault_config}->{url}) || $self->{vault_config}->{url} eq '') {
        $self->{logger}->writeLogDebug("Vault url is missing from configuration, using https://127.0.0.1");
        $url = 'https://127.0.0.1';
    }
    elsif ($self->{vault_config}->{url} !~ qr|^https?://|) { # if file don't specify http explicitly, we use https
        $url = "https://";
    }
    $url .= $self->{vault_config}->{url};

    if ( !defined($self->{vault_config}->{port}) || $self->{vault_config}->{port} eq '') {
        $self->{logger}->writeLogDebug("Vault port is missing from configuration, using 443 by default");
        $self->{vault_config}->{port} = '443';
    }
    if (defined($self->{vault_config}->{port}) and $self->{vault_config}->{port} =~ /\d+/) {
        $url .= ':' . $self->{vault_config}->{port};
    }
    else {
        $url .= ':443';
    }
    return $url;
}

sub transform_json_to_object {
    my ($json_data) = @_;

    my $json_as_object;
    eval {
        $json_as_object = decode_json($json_data);
    };
    if ($@) {
        return ({'errors' => ["Could not decode JSON from '$json_data'. Reason: " . $@]});
    };
    return($json_as_object);
}

sub parse_json_file {
    my (%options) = @_;

    my $fh;
    my $json_data = '';

    my $json_file = $options{json_file};

    open($fh, '<', $json_file) or return ('error_message' => "parse_json_file: Cannot open " . $json_file);
    for my $line (<$fh>) {
        chomp $line;
        $json_data .= $line;
    }
    close($fh);
    return transform_json_to_object($json_data);
}

1;

__END__

=head1 NAME

Centreon Vault password manager

=head1 SYNOPSIS

Allows to retrieve secrets (usually username and password) from a Hashicorp vault compatible api given a config file as constructor.

    use centreon::common::logger;
    use centreon::script::centreonvault;
    my $vault = centreon::script::centreonvault->new(
        (
            'logger'      => centreon::common::logger->new(),
            'config_file' =>  '/var/lib/centreon/vault/vault.json'
        )
    );
    my $password = $vault->get_secret('secret::hashicorp_vault::mypath/to/mysecrets::password');

=head1 METHODS

=head2 new(\%options)

Constructor of the vault object.

%options must provide:

- logger: an object of the centreon::common::logger class.

- config_file: full path and file name of the Centreon Vault JSON config file.

The default config_file path should be '/var/lib/centreon/vault/vault.json'.
The expected file format for Centreon Vault is:

    {
      "name": "hashicorp_vault",
      "url": "vault-server.mydomain.com",
      "salt": "<base64 encoded(<32 bytes long key used to hash the crypted data)>",
      "port": 443,
      "root_path": "vmware_daemon",
      "role_id": "<base64 encoded(<iv><hmac_hash><encrypted_role_id>)",
      "secret_id": "<base64 encoded(<iv><hmac_hash><encrypted_secret_id>)"
    }

This sub will not emit Error logs (only Notice and inferior) as it can be called on environment where the Vault is not used.
get_secret() can emit Error logs if vault is considered enabled.

=head2 encrypt($input)

Encrypts the given input using AES-256-CBC with the key retrieved by the constructor

Return the encrypted data as a base64 encode string with the following format:

    <iv><64 bytes long random data><encrypted data>

By default the library use AES256, so the IV is 16 bytes long. The hmac can't be constructed in perl easily, so for now it is a random 64 bytes long string to follow the same format as the decrypt method. It was created to make the tests easier, if you really need to crypt something ccc or php should be used instead.

=head2 get_secret($secret)

Returns the secret stored in the Centreon Vault at the given path.
If the format of the secret does not match the regular expression
C</^secret::hashicorp_vault::([^:]+)::(.+)$/>
or in case of any failure in the process, the method will return the secret unchanged.

=cut
