<?php

/*
 * RCMCardDAV - CardDAV plugin for Roundcube webmail
 *
 * Copyright (C) 2011-2022 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>,
 *                         Michael Stilkerich <ms@mike2k.de>
 *
 * This file is part of RCMCardDAV.
 *
 * RCMCardDAV 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 of the License, or
 * (at your option) any later version.
 *
 * RCMCardDAV 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 RCMCardDAV. If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace MStilkerich\RCMCardDAV\Frontend;

use Exception;
use rcube;
use MStilkerich\RCMCardDAV\Config;

/**
 * Various utility functions used in the Frontend.
 *
 * @psalm-import-type PasswordStoreScheme from AdminSettings
 */
class Utils
{
    public static function replacePlaceholdersUsername(string $username, bool $quoteRegExp = false): string
    {
        $rcusername = (string) $_SESSION['username'];
        $rcusernameParts = explode('@', $rcusername, 2);

        $transTable = [
            '%u' => $rcusername,
            '%l' => $rcusernameParts[0],
            '%d' => $rcusernameParts[1] ?? '',
            // %V parses username for macOS, replaces periods and @ by _, work around bugs in contacts.app
            '%V' => strtr($rcusername, "@.", "__")
        ];

        if ($quoteRegExp) {
            $transTable = array_map(
                function (string $s): string {
                    return preg_quote($s);
                },
                $transTable
            );
        }

        return strtr($username, $transTable);
    }

    public static function replacePlaceholdersUrl(string $url, bool $quoteRegExp = false): string
    {
        // currently same as for username
        return self::replacePlaceholdersUsername($url, $quoteRegExp);
    }

    public static function replacePlaceholdersPassword(string $password): string
    {
        if ($password == '%p') {
            $rcube = rcube::get_instance();
            $password = $rcube->decrypt((string) $_SESSION['password']);
            if ($password === false) {
                $password = "";
            }
        }

        return $password;
    }

    /**
     * Parses a time string to seconds.
     *
     * The time string must have the format HH[:MM[:SS]]. If the format does not match, an exception is thrown.
     *
     * @param string $timeStr The time string to parse
     * @return int The time in seconds
     */
    public static function parseTimeParameter(string $timeStr): int
    {
        if (preg_match('/^(\d+)(:([0-5]?\d))?(:([0-5]?\d))?$/', $timeStr, $match)) {
            $ret = 0;

            $ret += intval($match[1] ?? 0) * 3600;
            $ret += intval($match[3] ?? 0) * 60;
            $ret += intval($match[5] ?? 0);
        } else {
            throw new Exception("Time string $timeStr could not be parsed");
        }

        return $ret;
    }

    /**
     * Converts a password to storage format according to the password storage scheme setting.
     *
     * @param string $clear The password in clear text.
     * @return string The password in storage format (e.g., encrypted with user password as key)
     */
    public static function encryptPassword(string $clear): string
    {
        $infra = Config::inst();
        $admPrefs = $infra->admPrefs();
        $scheme = $admPrefs->pwStoreScheme;

        // in case the password is a placeholder, there is not much point in encrypting it and we can avoid issues
        // resulting from password changes by storing it plaintext.
        if ($clear === '%p' || $clear === '%b') {
            $scheme = 'plain';
        }

        if (strcasecmp($scheme, 'plain') === 0) {
            return $clear;
        }

        if (strcasecmp($scheme, 'encrypted') === 0) {
            try {
                // encrypted with IMAP password
                $rcube = rcube::get_instance();

                $imap_password = self::getDesKey();
                $rcube->config->set('carddav_des_key', $imap_password);

                $crypted = $rcube->encrypt($clear, 'carddav_des_key');

                // there seems to be no way to unset a preference
                $rcube->config->set('carddav_des_key', null);

                if ($crypted === false) {
                    throw new Exception("Password encryption with user password failed");
                }
                return '{ENCRYPTED}' . $crypted;
            } catch (Exception $e) {
                $logger = Config::inst()->logger();
                $logger->warning(
                    "Could not encrypt password with 'encrypted' method, falling back to 'des_key': " . $e->getMessage()
                );
                $scheme = 'des_key';
            }
        }

        if (strcasecmp($scheme, 'des_key') === 0) {
            // encrypted with global des_key
            $rcube = rcube::get_instance();
            $crypted = $rcube->encrypt($clear);

            if ($crypted === false) {
                throw new Exception("Could not encrypt password with 'des_key' method");
            }
            return '{DES_KEY}' . $crypted;
        }

        // otherwise, it's BASE64
        return '{BASE64}' . base64_encode($clear);
    }

    /**
     * Decrypts a password in the database according to the scheme used to encrypt or encode the password.
     *
     * In case of error, an error is logged and an empty string is returned.
     */
    public static function decryptPassword(string $crypt): string
    {
        $logger = Config::inst()->logger();
        $rcube = rcube::get_instance();
        $key = null;
        $clear = '';

        try {
            if (strpos($crypt, '{ENCRYPTED}') === 0) {
                $crypt = substr($crypt, strlen('{ENCRYPTED}'));
                $imap_password = self::getDesKey();

                $key = 'carddav_des_key';
                $rcube->config->set($key, $imap_password);
            } elseif (strpos($crypt, '{DES_KEY}') === 0) {
                $crypt = substr($crypt, strlen('{DES_KEY}'));
                $key = 'des_key';
            } elseif (strpos($crypt, '{BASE64}') === 0) {
                $crypt = substr($crypt, strlen('{BASE64}'));
                $clear = base64_decode($crypt, true);

                if ($clear === false) {
                    throw new Exception('not a valid base64 string');
                }
            } else {
                // unknown scheme, assume cleartext
                $clear = $crypt;
            }

            if (isset($key)) {
                $clear = $rcube->decrypt($crypt, $key);
                if ($clear === false) {
                    throw new Exception("decryption with $key failed");
                }
            }
        } catch (Exception $e) {
            $logger->warning("Cannot decrypt password: " . $e->getMessage());
            $clear = '';
        }

        // not strictly needed, but lets not keep the cleartext IMAP password in rcube_config
        if ($key === 'carddav_des_key') {
            $rcube->config->set($key, null);
        }

        return $clear;
    }

    /**
     * Returns the 24-byte decryption key derived from the user's password.
     *
     * The returned string is made exactly 24 bytes by repeating the IMAP password or, if it is longer, truncating it.
     * This should not be required anymore, because openssl_encrypt() (and assumedly also openssl_decrypt()) pad the
     * password themselves if needed, but this is kept for backwards compatibility with existing encrypted passwords.
     *
     * In case of error, this function throws an exception.
     */
    private static function getDesKey(): string
    {
        $rcube = rcube::get_instance();

        // if the user logged in via OAuth, we do not have a password to use for encryption / decryption of carddav
        // passwords; roundcube sets SESSION[password] to the encrypted 'Bearer <accesstoken>', so we need to
        // specifically check if oauth is used for login
        if (isset($_SESSION['oauth_token'])) {
            throw new Exception("No password available to use for encryption because user logged in via OAuth2");
        }

        $imapPw = $rcube->decrypt((string) $_SESSION['password']);

        if ($imapPw === false || strlen($imapPw) == 0) {
            throw new Exception("No password available to use for encryption");
        }

        $imapPw = str_pad($imapPw, 24, $imapPw);
        return substr($imapPw, 0, 24);
    }
}

// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120
