#!/usr/bin/perl
#
use warnings;
use strict;
use POSIX;
use Lemonldap::NG::Common::Conf;
use Lemonldap::NG::Common::Apache::Session;
use Lemonldap::NG::Common::Session;
use Lemonldap::NG::Common::TOTP;
use JSON qw(from_json to_json);
use Getopt::Long qw(:config auto_help);
use Pod::Usage;

my ( $dryrun, $verbose, $force, $update, $oldkey, $newkey );
GetOptions(
    "n|dry-run"   => \$dryrun,
    "v|verbose"   => \$verbose,
    "f|force"     => \$force,
    "u|update"    => \$update,
    "o|old-key=s" => \$oldkey,
    "k|new-key=s" => \$newkey,
);

eval {
    POSIX::setgid( scalar( getgrnam('www-data') ) );
    POSIX::setuid( scalar( getpwnam('www-data') ) );
    my (undef, undef, undef, undef, undef, undef, undef, $homedir, undef) = getpwnam('www-data');
    $ENV{HOME} = $homedir if $homedir;
};

sub verbose {
    print STDERR @_, "\n" if $verbose;
}

sub info {
    print STDERR @_, "\n";
}

# Get config
my $res = Lemonldap::NG::Common::Conf->new();
die $Lemonldap::NG::Common::Conf::msg unless ($res);
my $conf = $res->getConf();

my $localconf = $res->getLocalConf()
  or die "Unable to get local configuration ($!)";

if ($localconf) {
    $conf->{$_} = $localconf->{$_} foreach ( keys %$localconf );
}

if ( !$conf->{totp2fEncryptSecret} ) {
    if ( !$force ) {
        die "Encryption of TOTP secrets is not enabled in configuration."
          . " Use --force to ignore this error";
    }
}

# Create TOTP object

my $decrypt_totp = Lemonldap::NG::Common::TOTP->new(
    key           => ( $oldkey || $conf->{totp2fKey} || $conf->{key} ),
    encryptSecret => 0,
);

my $encrypt_totp = Lemonldap::NG::Common::TOTP->new(
    key           => ( $newkey || $conf->{totp2fKey} || $conf->{key} ),
    encryptSecret => ( ( $newkey || "" ) eq "DECRYPT" ? 0 : 1 ),
);

# Search psessions
my $args;
if ( $conf->{"persistentStorage"} ) {
    $args = $conf->{"persistentStorageOptions"};
    $args->{backend} = $conf->{"persistentStorage"};
}
else {
    $args = $conf->{"globalStorageOptions"};
    $args->{backend} = $conf->{"globalStorage"};
}

verbose "Searching for persistent sessions";
$res = Lemonldap::NG::Common::Apache::Session->searchOn( $args, '_session_kind',
    'Persistent', '_2fDevices', '_session_uid' );

if ( ref($res) eq "HASH" ) {
    verbose "Found " . scalar( keys %{$res} ) . " persistent sessions";

    # For each found psession
    for my $k ( keys %{$res} ) {
        my $_2fDevices = $res->{$k}->{_2fDevices};
        my $uid        = $res->{$k}->{_session_uid};
        verbose "Processing psession $k for user $uid";
        encrypt_session( $k, $uid, $_2fDevices );
    }
}
else {
    die "Could not find any persistent sessions";
}

sub encrypt_session {
    my ( $k, $uid, $_2fDevices ) = @_;

    eval {
        # parse _2fDevices if found
        if ($_2fDevices) {
            $_2fDevices = from_json($_2fDevices);

            # If the session has 2f devices
            if ( ref($_2fDevices) eq "ARRAY" and @{$_2fDevices} > 0 ) {
                my $changed = convert_keys_for_user( $uid, $_2fDevices );
                if ( $changed and !$dryrun ) {
                    eval { update2fArray( $k, $_2fDevices ); };
                    if ($@) {
                        info "Error updating session for $uid: $@";
                    }
                }
            }
            else {
                verbose "User $uid does not have a TOTP";
            }
        }
        else {
            verbose "User $uid does not have a TOTP";
        }

    };
    if ($@) {
        verbose "Error on psession $k: $@";
    }
}

sub update2fArray {
    my ( $id, $_2fDevices ) = @_;

    my $session = Lemonldap::NG::Common::Session->new(
        storageModule        => $args->{backend},
        storageModuleOptions => $args,
        id                   => $id,
    );

    unless ( $session->data ) {
        die "Error while opening session $id";
    }

    unless ( $session->update( { _2fDevices => to_json($_2fDevices) } ) ) {
        die "Error while updating session $id";
    }
}

sub convert_device_for_user {
    my ( $uid, $device ) = @_;
    my $changed = 0;

    # In update mode, decrypt then encrypt
    if ($update) {
        my $cleartext_secret =
          $decrypt_totp->get_cleartext_secret( $device->{_secret} );
        if ($cleartext_secret) {
            my $newsecret =
              $encrypt_totp->get_storable_secret($cleartext_secret);
            $device->{_secret} = $newsecret;
            $changed = 1;
            verbose 'Updated secret for ' . $uid;
        }
        else {
            info 'Unable to decrypt TOTP secret for ' . $uid;
        }

        # In normal mode, only encrypt non-encrypted secrets
    }
    else {
        if ( !$encrypt_totp->is_encrypted( $device->{_secret} ) ) {
            $device->{_secret} =
              $encrypt_totp->get_storable_secret( $device->{_secret} );
            $changed = 1;
            info 'Encrypted TOTP secret for ' . $uid;
        }
        else {
            verbose 'Secret is already encrypted';
        }
    }
    return $changed;
}

sub convert_keys_for_user {
    my ( $uid, $devices ) = @_;
    my $has_totp = 0;
    my $changed  = 0;
    for my $device ( @{$devices} ) {
        if ( $device->{type} eq "TOTP" ) {
            $has_totp = 1;
            my $epoch = $device->{epoch};
            verbose "Processing device with epoch $epoch for user $uid";
            my $changed_current = convert_device_for_user( $uid, $device );
            $changed = 1 if $changed_current;
        }
    }

    if ( !$has_totp ) {
        verbose "User $uid does not have a TOTP";
    }
    return ( $has_totp, $changed );
}

__END__

=encoding utf8

=head1 NAME

encryptTotpSecret - A tool to encrypt existing TOTP secrets

=head1 SYNOPSIS

  encryptTotpSecret [options]

=head1 DESCRIPTION

This script is a migration tool that you can use after enabling TOTP secret
encryption in the Manager. It will make sure that existing secrets are
encrypted, and not just newly registered secrets.

=head1 OPTIONS

=over 8

=item B<--help>, B<-h>

Print a brief help message and exit.

=item B<--dry-run>, B<-n>

Prevent the script from saving modifications to the session database

=item B<--update>, B<-u>

By default, secrets that are already in encrypted form are skipped by the
script. Use this option to force already encrypted secrets to be decrypted,
then re-encrypted using a different key (or decrypted)

=item B<--old-key>, B<-o>

The key used to decrypt secrets in B<--update> mode.

By default, the B<totp2fKey> or B<key> LemonLDAP::NG configuration parameters
are used.

=item B<--new-key>, B<-k>

The key used to encrypt secrets. Use B<-u -k DECRYPT> to decrypt secrets instead.

By default, the B<totp2fKey> or B<key> LemonLDAP::NG configuration parameters
are used.

=item B<--force>, B<-f>

Encrypt existing TOTP secrets even if encryption is disabled in the configuration

=item B<--verbose>, B<-v>

Increase the level of details provided by the script

=back

=head1 SEE ALSO

L<http://lemonldap-ng.org/>

=head1 AUTHORS

=over

=item Maxime Besson, E<lt>maxime.besson@worteks.comE<gt>

=back

=head1 BUG REPORT

Use OW2 system to report bug or ask for features:
L<https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/issues>

=head1 DOWNLOAD

Lemonldap::NG is available at
L<https://lemonldap-ng.org/download>

=head1 COPYRIGHT AND LICENSE

=over

=item Copyright (C) 2008-2016 by Xavier Guimard, E<lt>x.guimard@free.frE<gt>

=item Copyright (C) 2008-2016 by Clément Oudot, E<lt>clem.oudot@gmail.comE<gt>

=back

This library is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2, or (at your option)
any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see L<http://www.gnu.org/licenses/>.

=cut
