<?php
/**
 * This file is part of php-saml.
 *
 * (c) OneLogin Inc
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package OneLogin
 * @author  OneLogin Inc <saml-info@onelogin.com>
 * @license MIT https://github.com/onelogin/php-saml/blob/master/LICENSE
 * @link    https://github.com/onelogin/php-saml
 */

namespace OneLogin\Saml2;

use RobRichards\XMLSecLibs\XMLSecurityKey;
use RobRichards\XMLSecLibs\XMLSecurityDSig;
use RobRichards\XMLSecLibs\XMLSecEnc;

use DOMDocument;
use DOMElement;
use DOMNodeList;
use DomNode;
use DOMXPath;
use Exception;

/**
 * Utils of OneLogin PHP Toolkit
 *
 * Defines several often used methods
 */
class Utils
{
    const RESPONSE_SIGNATURE_XPATH = "/samlp:Response/ds:Signature";
    const ASSERTION_SIGNATURE_XPATH = "/samlp:Response/saml:Assertion/ds:Signature";

    /**
     * @var bool Control if the `Forwarded-For-*` headers are used
     */
    private static $_proxyVars = false;

    /**
     * @var string|null
     */
    private static $_host;

    /**
     * @var string|null
     */
    private static $_protocol;

    /**
     * @var string
     */
    private static $_protocolRegex = '@^https?://@i';

    /**
     * @var int|null
     */
    private static $_port;

    /**
     * @var string|null
     */
    private static $_baseurlpath;

    /**
     * This function load an XML string in a save way.
     * Prevent XEE/XXE Attacks
     *
     * @param DOMDocument $dom The document where load the xml.
     * @param string      $xml The XML string to be loaded.
     *
     * @return DOMDocument|false $dom The result of load the XML at the DOMDocument
     *
     * @throws Exception
     */
    public static function loadXML(DOMDocument $dom, $xml)
    {
        assert($dom instanceof DOMDocument);
        assert(is_string($xml));

        $oldEntityLoader = libxml_disable_entity_loader(true);

        $res = $dom->loadXML($xml);

        libxml_disable_entity_loader($oldEntityLoader);

        foreach ($dom->childNodes as $child) {
            if ($child->nodeType === XML_DOCUMENT_TYPE_NODE) {
                throw new Exception(
                    'Detected use of DOCTYPE/ENTITY in XML, disabled to prevent XXE/XEE attacks'
                );
            }
        }

        if (!$res) {
            return false;
        } else {
            return $dom;
        }
    }

    /**
     * This function attempts to validate an XML string against the specified schema.
     *
     * It will parse the string into a DOMDocument and validate this document against the schema.
     *
     * @param string|DOMDocument $xml    The XML string or document which should be validated.
     * @param string             $schema The schema filename which should be used.
     * @param bool               $debug  To disable/enable the debug mode
     * @param string             $schemaPath Change schema path
     *
     * @return string|DOMDocument $dom  string that explains the problem or the DOMDocument
     *
     * @throws Exception
     */
    public static function validateXML($xml, $schema, $debug = false, $schemaPath = null)
    {
        assert(is_string($xml) || $xml instanceof DOMDocument);
        assert(is_string($schema));

        libxml_clear_errors();
        libxml_use_internal_errors(true);

        if ($xml instanceof DOMDocument) {
            $dom = $xml;
        } else {
            $dom = new DOMDocument;
            $dom = self::loadXML($dom, $xml);
            if (!$dom) {
                return 'unloaded_xml';
            }
        }

        if (isset($schemaPath)) {
            $schemaFile = $schemaPath . $schema;
        } else {
            $schemaFile = __DIR__ . '/schemas/' . $schema;
        }

        $oldEntityLoader = libxml_disable_entity_loader(false);
        $res = $dom->schemaValidate($schemaFile);
        libxml_disable_entity_loader($oldEntityLoader);
        if (!$res) {
            $xmlErrors = libxml_get_errors();
            syslog(LOG_INFO, 'Error validating the metadata: '.var_export($xmlErrors, true));

            if ($debug) {
                foreach ($xmlErrors as $error) {
                    echo htmlentities($error->message)."\n";
                }
            }
            return 'invalid_xml';
        }

        return $dom;
    }

    /**
     * Import a node tree into a target document
     * Copy it before a reference node as a sibling
     * and at the end of the copy remove
     * the reference node in the target document
     * As it were 'replacing' it
     * Leaving nested default namespaces alone
     * (Standard importNode with deep copy
     *  mangles nested default namespaces)
     *
     * The reference node must not be a DomDocument
     * It CAN be the top element of a document
     * Returns the copied node in the target document
     *
     * @param DomNode $targetNode
     * @param DomNode $sourceNode
     * @param bool $recurse
     * @return DOMNode
     * @throws Exception
     */
    public static function treeCopyReplace(DomNode $targetNode, DomNode $sourceNode, $recurse = false)
    {
        if ($targetNode->parentNode === null) {
            throw new Exception('Illegal argument targetNode. It has no parentNode.');
        }
        $clonedNode = $targetNode->ownerDocument->importNode($sourceNode, false);
        if ($recurse) {
            $resultNode = $targetNode->appendChild($clonedNode);
        } else {
            $resultNode = $targetNode->parentNode->insertBefore($clonedNode, $targetNode);
        }
        if ($sourceNode->childNodes !== null) {
            foreach ($sourceNode->childNodes as $child) {
                self::treeCopyReplace($resultNode, $child, true);
            }
        }
        if (!$recurse) {
            $targetNode->parentNode->removeChild($targetNode);
        }
        return $resultNode;
    }

    /**
     * Returns a x509 cert (adding header & footer if required).
     *
     * @param string $cert  A x509 unformated cert
     * @param bool   $heads True if we want to include head and footer
     *
     * @return string $x509 Formatted cert
     */
    public static function formatCert($cert, $heads = true)
    {
        $x509cert = str_replace(array("\x0D", "\r", "\n"), "", $cert);
        if (!empty($x509cert)) {
            $x509cert = str_replace('-----BEGIN CERTIFICATE-----', "", $x509cert);
            $x509cert = str_replace('-----END CERTIFICATE-----', "", $x509cert);
            $x509cert = str_replace(' ', '', $x509cert);

            if ($heads) {
                $x509cert = "-----BEGIN CERTIFICATE-----\n".chunk_split($x509cert, 64, "\n")."-----END CERTIFICATE-----\n";
            }

        }
        return $x509cert;
    }

    /**
     * Returns a private key (adding header & footer if required).
     *
     * @param string $key   A private key
     * @param bool   $heads True if we want to include head and footer
     *
     * @return string $rsaKey Formatted private key
     */
    public static function formatPrivateKey($key, $heads = true)
    {
        $key = str_replace(array("\x0D", "\r", "\n"), "", $key);
        if (!empty($key)) {
            if (strpos($key, '-----BEGIN PRIVATE KEY-----') !== false) {
                $key = Utils::getStringBetween($key, '-----BEGIN PRIVATE KEY-----', '-----END PRIVATE KEY-----');
                $key = str_replace(' ', '', $key);

                if ($heads) {
                    $key = "-----BEGIN PRIVATE KEY-----\n".chunk_split($key, 64, "\n")."-----END PRIVATE KEY-----\n";
                }
            } else if (strpos($key, '-----BEGIN RSA PRIVATE KEY-----') !== false) {
                $key = Utils::getStringBetween($key, '-----BEGIN RSA PRIVATE KEY-----', '-----END RSA PRIVATE KEY-----');
                $key = str_replace(' ', '', $key);

                if ($heads) {
                    $key = "-----BEGIN RSA PRIVATE KEY-----\n".chunk_split($key, 64, "\n")."-----END RSA PRIVATE KEY-----\n";
                }
            } else {
                $key = str_replace(' ', '', $key);

                if ($heads) {
                    $key = "-----BEGIN RSA PRIVATE KEY-----\n".chunk_split($key, 64, "\n")."-----END RSA PRIVATE KEY-----\n";
                }
            }
        }
        return $key;
    }

    /**
     * Extracts a substring between 2 marks
     *
     * @param string $str   The target string
     * @param string $start The initial mark
     * @param string $end   The end mark
     *
     * @return string A substring or an empty string if is not able to find the marks
     *                or if there is no string between the marks
     */
    public static function getStringBetween($str, $start, $end)
    {
        $str = ' ' . $str;
        $ini = strpos($str, $start);

        if ($ini == 0) {
            return '';
        }

        $ini += strlen($start);
        $len = strpos($str, $end, $ini) - $ini;
        return substr($str, $ini, $len);
    }

    /**
     * Executes a redirection to the provided url (or return the target url).
     *
     * @param string $url        The target url
     * @param array  $parameters Extra parameters to be passed as part of the url
     * @param bool   $stay       True if we want to stay (returns the url string) False to redirect
     *
     * @return string|null $url
     *
     * @throws Error
     */
    public static function redirect($url, array $parameters = array(), $stay = false)
    {
        assert(is_string($url));

        if (substr($url, 0, 1) === '/') {
            $url = self::getSelfURLhost() . $url;
        }

        /**
         * Verify that the URL matches the regex for the protocol.
         * By default this will check for http and https
         */
        $wrongProtocol = !preg_match(self::$_protocolRegex, $url);
        $url = filter_var($url, FILTER_VALIDATE_URL);
        if ($wrongProtocol || empty($url)) {
            throw new Error(
                'Redirect to invalid URL: ' . $url,
                Error::REDIRECT_INVALID_URL
            );
        }

        /* Add encoded parameters */
        if (strpos($url, '?') === false) {
            $paramPrefix = '?';
        } else {
            $paramPrefix = '&';
        }

        foreach ($parameters as $name => $value) {
            if ($value === null) {
                $param = urlencode($name);
            } else if (is_array($value)) {
                $param = "";
                foreach ($value as $val) {
                    $param .= urlencode($name) . "[]=" . urlencode($val). '&';
                }
                if (!empty($param)) {
                    $param = substr($param, 0, -1);
                }
            } else {
                $param = urlencode($name) . '=' . urlencode($value);
            }

            if (!empty($param)) {
                $url .= $paramPrefix . $param;
                $paramPrefix = '&';
            }
        }

        if ($stay) {
            return $url;
        }

        header('Pragma: no-cache');
        header('Cache-Control: no-cache, must-revalidate');
        header('Location: ' . $url);
        exit();
    }

     /**
     * @param $protocolRegex string
     */
    public static function setProtocolRegex($protocolRegex)
    {
        if (!empty($protocolRegex)) {
            self::$_protocolRegex = $protocolRegex;
        }
    }

    /**
     * Set the Base URL value.
     *
     * @param string $baseurl The base url to be used when constructing URLs
     */
    public static function setBaseURL($baseurl)
    {
        if (!empty($baseurl)) {
            $baseurlpath = '/';
            $matches = array();
            if (preg_match('#^https?://([^/]*)/?(.*)#i', $baseurl, $matches)) {
                if (strpos($baseurl, 'https://') === false) {
                    self::setSelfProtocol('http');
                    $port = '80';
                } else {
                    self::setSelfProtocol('https');
                    $port = '443';
                }

                $currentHost = $matches[1];
                if (false !== strpos($currentHost, ':')) {
                    list($currentHost, $possiblePort) = explode(':', $matches[1], 2);
                    if (is_numeric($possiblePort)) {
                        $port = $possiblePort;
                    }
                }

                if (isset($matches[2]) && !empty($matches[2])) {
                    $baseurlpath = $matches[2];
                }

                self::setSelfHost($currentHost);
                self::setSelfPort($port);
                self::setBaseURLPath($baseurlpath);
            }
        } else {
                self::$_host = null;
                self::$_protocol = null;
                self::$_port = null;
                self::$_baseurlpath = null;
        }
    }

    /**
     * @param bool $proxyVars Whether to use `X-Forwarded-*` headers to determine port/domain/protocol
     */
    public static function setProxyVars($proxyVars)
    {
        self::$_proxyVars = (bool)$proxyVars;
    }

    /**
     * @return bool
     */
    public static function getProxyVars()
    {
        return self::$_proxyVars;
    }

    /**
     * Returns the protocol + the current host + the port (if different than
     * common ports).
     *
     * @return string The URL
     */
    public static function getSelfURLhost()
    {
        $currenthost = self::getSelfHost();

        $port = '';

        if (self::isHTTPS()) {
            $protocol = 'https';
        } else {
            $protocol = 'http';
        }

        $portnumber = self::getSelfPort();

        if (isset($portnumber) && ($portnumber != '80') && ($portnumber != '443')) {
            $port = ':' . $portnumber;
        }

        return $protocol."://" . $currenthost . $port;
    }

    /**
     * @param string $host The host to use when constructing URLs
     */
    public static function setSelfHost($host)
    {
        self::$_host = $host;
    }

    /**
     * @param string $baseurlpath The baseurl path to use when constructing URLs
     */
    public static function setBaseURLPath($baseurlpath)
    {
        if (empty($baseurlpath)) {
            self::$_baseurlpath = null;
        } else if ($baseurlpath == '/') {
            self::$_baseurlpath = '/';
        } else {
            self::$_baseurlpath = '/' . trim($baseurlpath, '/') . '/';
        }
    }

    /**
     * @return string The baseurlpath to be used when constructing URLs
     */
    public static function getBaseURLPath()
    {
        return self::$_baseurlpath;
    }

    /**
     * @return string The raw host name
     */
    protected static function getRawHost()
    {
        if (self::$_host) {
            $currentHost = self::$_host;
        } elseif (self::getProxyVars() && array_key_exists('HTTP_X_FORWARDED_HOST', $_SERVER)) {
            $currentHost = $_SERVER['HTTP_X_FORWARDED_HOST'];
        } elseif (array_key_exists('HTTP_HOST', $_SERVER)) {
            $currentHost = $_SERVER['HTTP_HOST'];
        } elseif (array_key_exists('SERVER_NAME', $_SERVER)) {
            $currentHost = $_SERVER['SERVER_NAME'];
        } else {
            if (function_exists('gethostname')) {
                $currentHost = gethostname();
            } else {
                $currentHost = php_uname("n");
            }
        }
        return $currentHost;
    }

    /**
     * @param int $port The port number to use when constructing URLs
     */
    public static function setSelfPort($port)
    {
        self::$_port = $port;
    }

    /**
     * @param string $protocol The protocol to identify as using, usually http or https
     */
    public static function setSelfProtocol($protocol)
    {
        self::$_protocol = $protocol;
    }

    /**
     * @return string http|https
     */
    public static function getSelfProtocol()
    {
        $protocol = 'http';
        if (self::$_protocol) {
            $protocol = self::$_protocol;
        } elseif (self::getSelfPort() == 443) {
            $protocol = 'https';
        } elseif (self::getProxyVars() && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
            $protocol = $_SERVER['HTTP_X_FORWARDED_PROTO'];
        } elseif (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
            $protocol = 'https';
        }
        return $protocol;
    }

    /**
     * Returns the current host.
     *
     * @return string $currentHost The current host
     */
    public static function getSelfHost()
    {
        $currentHost = self::getRawHost();

        // strip the port
        if (false !== strpos($currentHost, ':')) {
            list($currentHost, $port) = explode(':', $currentHost, 2);
        }

        return $currentHost;
    }

    /**
     * @return null|string The port number used for the request
     */
    public static function getSelfPort()
    {
        $portnumber = null;
        if (self::$_port) {
            $portnumber = self::$_port;
        } else if (self::getProxyVars() && isset($_SERVER["HTTP_X_FORWARDED_PORT"])) {
            $portnumber = $_SERVER["HTTP_X_FORWARDED_PORT"];
        } else if (isset($_SERVER["SERVER_PORT"])) {
            $portnumber = $_SERVER["SERVER_PORT"];
        } else {
            $currentHost = self::getRawHost();

            // strip the port
            if (false !== strpos($currentHost, ':')) {
                list($currentHost, $port) = explode(':', $currentHost, 2);
                if (is_numeric($port)) {
                    $portnumber = $port;
                }
            }
        }
        return $portnumber;
    }

    /**
     * Checks if https or http.
     *
     * @return bool $isHttps False if https is not active
     */
    public static function isHTTPS()
    {
        return self::getSelfProtocol() == 'https';
    }

    /**
     * Returns the URL of the current host + current view.
     *
     * @return string
     */
    public static function getSelfURLNoQuery()
    {
        $selfURLNoQuery = self::getSelfURLhost();

        $infoWithBaseURLPath = self::buildWithBaseURLPath($_SERVER['SCRIPT_NAME']);
        if (!empty($infoWithBaseURLPath)) {
            $selfURLNoQuery .= $infoWithBaseURLPath;
        } else {
            $selfURLNoQuery .= $_SERVER['SCRIPT_NAME'];
        }

        if (isset($_SERVER['PATH_INFO'])) {
            $selfURLNoQuery .= $_SERVER['PATH_INFO'];
        }

        return $selfURLNoQuery;
    }

    /**
     * Returns the routed URL of the current host + current view.
     *
     * @return string
     */
    public static function getSelfRoutedURLNoQuery()
    {
        $selfURLhost = self::getSelfURLhost();
        $route = '';

        if (!empty($_SERVER['REQUEST_URI'])) {
            $route = $_SERVER['REQUEST_URI'];
            if (!empty($_SERVER['QUERY_STRING'])) {
                $route = self::strLreplace($_SERVER['QUERY_STRING'], '', $route);
                if (substr($route, -1) == '?') {
                    $route = substr($route, 0, -1);
                }
            }
        }

        $infoWithBaseURLPath = self::buildWithBaseURLPath($route);
        if (!empty($infoWithBaseURLPath)) {
            $route = $infoWithBaseURLPath;
        }

        $selfRoutedURLNoQuery = $selfURLhost . $route;

        $pos = strpos($selfRoutedURLNoQuery, "?");
        if ($pos !== false) {
            $selfRoutedURLNoQuery = substr($selfRoutedURLNoQuery, 0, $pos-1);
        }

        return $selfRoutedURLNoQuery;
    }

    public static function strLreplace($search, $replace, $subject)
    {
        $pos = strrpos($subject, $search);

        if ($pos !== false) {
            $subject = substr_replace($subject, $replace, $pos, strlen($search));
        }

        return $subject;
    }

    /**
     * Returns the URL of the current host + current view + query.
     *
     * @return string
     */
    public static function getSelfURL()
    {
        $selfURLhost = self::getSelfURLhost();

        $requestURI = '';
        if (!empty($_SERVER['REQUEST_URI'])) {
            $requestURI = $_SERVER['REQUEST_URI'];
            $matches = array();
            if ($requestURI[0] !== '/' && preg_match('#^https?://[^/]*(/.*)#i', $requestURI, $matches)) {
                $requestURI = $matches[1];
            }
        }

        $infoWithBaseURLPath = self::buildWithBaseURLPath($requestURI);
        if (!empty($infoWithBaseURLPath)) {
            $requestURI = $infoWithBaseURLPath;
        }

        return $selfURLhost . $requestURI;
    }

    /**
     * Returns the part of the URL with the BaseURLPath.
     *
     * @param string $info Contains path info
     *
     * @return string
     */
    protected static function buildWithBaseURLPath($info)
    {
        $result = '';
        $baseURLPath = self::getBaseURLPath();
        if (!empty($baseURLPath)) {
            $result = $baseURLPath;
            if (!empty($info)) {
                $path = explode('/', $info);
                $extractedInfo = array_pop($path);
                if (!empty($extractedInfo)) {
                    $result .= $extractedInfo;
                }
            }
        }
        return $result;
    }

    /**
     * Extract a query param - as it was sent - from $_SERVER[QUERY_STRING]
     *
     * @param string $name The param to-be extracted
     *
     * @return string
     */
    public static function extractOriginalQueryParam($name)
    {
        $index = strpos($_SERVER['QUERY_STRING'], $name.'=');
        $substring = substr($_SERVER['QUERY_STRING'], $index + strlen($name) + 1);
        $end = strpos($substring, '&');
        return $end ? substr($substring, 0, strpos($substring, '&')) : $substring;
    }

    /**
     * Generates an unique string (used for example as ID for assertions).
     *
     * @return string  A unique string
     */
    public static function generateUniqueID()
    {
        return 'ONELOGIN_' . sha1(uniqid((string)mt_rand(), true));
    }

    /**
     * Converts a UNIX timestamp to SAML2 timestamp on the form
     * yyyy-mm-ddThh:mm:ss(\.s+)?Z.
     *
     * @param string|int $time The time we should convert (DateTime).
     *
     * @return string $timestamp SAML2 timestamp.
     */
    public static function parseTime2SAML($time)
    {
        $date = new \DateTime("@$time", new \DateTimeZone('UTC'));
        $timestamp = $date->format("Y-m-d\TH:i:s\Z");
        return $timestamp;
    }

    /**
     * Converts a SAML2 timestamp on the form yyyy-mm-ddThh:mm:ss(\.s+)?Z
     * to a UNIX timestamp. The sub-second part is ignored.
     *
     * @param string $time The time we should convert (SAML Timestamp).
     *
     * @return int $timestamp  Converted to a unix timestamp.
     *
     * @throws Exception
     */
    public static function parseSAML2Time($time)
    {
        $matches = array();

        /* We use a very strict regex to parse the timestamp. */
        $exp1 = '/^(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)';
        $exp2 = 'T(\\d\\d):(\\d\\d):(\\d\\d)(?:\\.\\d+)?Z$/D';
        if (preg_match($exp1 . $exp2, $time, $matches) == 0) {
            throw new Exception(
                'Invalid SAML2 timestamp passed to' .
                ' parseSAML2Time: ' . $time
            );
        }

        /* Extract the different components of the time from the
         * matches in the regex. int cast will ignore leading zeroes
         * in the string.
         */
        $year = (int) $matches[1];
        $month = (int) $matches[2];
        $day = (int) $matches[3];
        $hour = (int) $matches[4];
        $minute = (int) $matches[5];
        $second = (int) $matches[6];

        /* We use gmmktime because the timestamp will always be given
         * in UTC.
         */
        $ts = gmmktime($hour, $minute, $second, $month, $day, $year);

        return $ts;
    }


    /**
     * Interprets a ISO8601 duration value relative to a given timestamp.
     *
     * @param string   $duration  The duration, as a string.
     * @param int|null $timestamp The unix timestamp we should apply the
     *                            duration to. Optional, default to the
     *                            current time.
     *
     * @return int The new timestamp, after the duration is applied.
     *
     * @throws Exception
     */
    public static function parseDuration($duration, $timestamp = null)
    {
        assert(is_string($duration));
        assert(is_null($timestamp) || is_int($timestamp));

        $matches = array();

        /* Parse the duration. We use a very strict pattern. */
        $durationRegEx = '#^(-?)P(?:(?:(?:(\\d+)Y)?(?:(\\d+)M)?(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?)|(?:(\\d+)W))$#D';
        if (!preg_match($durationRegEx, $duration, $matches)) {
            throw new Exception('Invalid ISO 8601 duration: ' . $duration);
        }

        $durYears = (empty($matches[2]) ? 0 : (int)$matches[2]);
        $durMonths = (empty($matches[3]) ? 0 : (int)$matches[3]);
        $durDays = (empty($matches[4]) ? 0 : (int)$matches[4]);
        $durHours = (empty($matches[5]) ? 0 : (int)$matches[5]);
        $durMinutes = (empty($matches[6]) ? 0 : (int)$matches[6]);
        $durSeconds = (empty($matches[7]) ? 0 : (int)$matches[7]);
        $durWeeks = (empty($matches[8]) ? 0 : (int)$matches[8]);

        if (!empty($matches[1])) {
            /* Negative */
            $durYears = -$durYears;
            $durMonths = -$durMonths;
            $durDays = -$durDays;
            $durHours = -$durHours;
            $durMinutes = -$durMinutes;
            $durSeconds = -$durSeconds;
            $durWeeks = -$durWeeks;
        }

        if ($timestamp === null) {
            $timestamp = time();
        }

        if ($durYears !== 0 || $durMonths !== 0) {
            /* Special handling of months and years, since they aren't a specific interval, but
             * instead depend on the current time.
             */

            /* We need the year and month from the timestamp. Unfortunately, PHP doesn't have the
             * gmtime function. Instead we use the gmdate function, and split the result.
             */
            $yearmonth = explode(':', gmdate('Y:n', $timestamp));
            $year = (int)$yearmonth[0];
            $month = (int)$yearmonth[1];

            /* Remove the year and month from the timestamp. */
            $timestamp -= gmmktime(0, 0, 0, $month, 1, $year);

            /* Add years and months, and normalize the numbers afterwards. */
            $year += $durYears;
            $month += $durMonths;
            while ($month > 12) {
                $year += 1;
                $month -= 12;
            }
            while ($month < 1) {
                $year -= 1;
                $month += 12;
            }

            /* Add year and month back into timestamp. */
            $timestamp += gmmktime(0, 0, 0, $month, 1, $year);
        }

        /* Add the other elements. */
        $timestamp += $durWeeks * 7 * 24 * 60 * 60;
        $timestamp += $durDays * 24 * 60 * 60;
        $timestamp += $durHours * 60 * 60;
        $timestamp += $durMinutes * 60;
        $timestamp += $durSeconds;

        return $timestamp;
    }

    /**
     * Compares 2 dates and returns the earliest.
     *
     * @param string|null     $cacheDuration The duration, as a string.
     * @param string|int|null $validUntil    The valid until date, as a string or as a timestamp
     *
     * @return int|null $expireTime  The expiration time.
     *
     * @throws Exception
     */
    public static function getExpireTime($cacheDuration = null, $validUntil = null)
    {
        $expireTime = null;

        if ($cacheDuration !== null) {
            $expireTime = self::parseDuration($cacheDuration, time());
        }

        if ($validUntil !== null) {
            if (is_int($validUntil)) {
                $validUntilTime = $validUntil;
            } else {
                $validUntilTime = self::parseSAML2Time($validUntil);
            }
            if ($expireTime === null || $expireTime > $validUntilTime) {
                $expireTime = $validUntilTime;
            }
        }

        return $expireTime;
    }


    /**
     * Extracts nodes from the DOMDocument.
     *
     * @param DOMDocument      $dom     The DOMDocument
     * @param string           $query   \Xpath Expression
     * @param DOMElement|null  $context Context Node (DOMElement)
     *
     * @return DOMNodeList The queried nodes
     */
    public static function query(DOMDocument $dom, $query, DOMElement $context = null)
    {
        $xpath = new DOMXPath($dom);
        $xpath->registerNamespace('samlp', Constants::NS_SAMLP);
        $xpath->registerNamespace('saml', Constants::NS_SAML);
        $xpath->registerNamespace('ds', Constants::NS_DS);
        $xpath->registerNamespace('xenc', Constants::NS_XENC);
        $xpath->registerNamespace('xsi', Constants::NS_XSI);
        $xpath->registerNamespace('xs', Constants::NS_XS);
        $xpath->registerNamespace('md', Constants::NS_MD);

        if (isset($context)) {
            $res = $xpath->query($query, $context);
        } else {
            $res = $xpath->query($query);
        }
        return $res;
    }

    /**
     * Checks if the session is started or not.
     *
     * @return bool true if the sessíon is started
     */
    public static function isSessionStarted()
    {
        if (PHP_VERSION_ID >= 50400) {
            return session_status() === PHP_SESSION_ACTIVE ? true : false;
        } else {
            return session_id() === '' ? false : true;
        }
    }

    /**
     * Deletes the local session.
     */
    public static function deleteLocalSession()
    {

        if (Utils::isSessionStarted()) {
            session_destroy();
        }

        unset($_SESSION);
    }

    /**
     * Calculates the fingerprint of a x509cert.
     *
     * @param string $x509cert x509 cert formatted
     * @param string $alg      Algorithm to be used in order to calculate the fingerprint
     *
     * @return null|string Formatted fingerprint
     */
    public static function calculateX509Fingerprint($x509cert, $alg = 'sha1')
    {
        assert(is_string($x509cert));

        $arCert = explode("\n", $x509cert);
        $data = '';
        $inData = false;

        foreach ($arCert as $curData) {
            if (! $inData) {
                if (strncmp($curData, '-----BEGIN CERTIFICATE', 22) == 0) {
                    $inData = true;
                } elseif ((strncmp($curData, '-----BEGIN PUBLIC KEY', 21) == 0) || (strncmp($curData, '-----BEGIN RSA PRIVATE KEY', 26) == 0)) {
                    /* This isn't an X509 certificate. */
                    return null;
                }
            } else {
                if (strncmp($curData, '-----END CERTIFICATE', 20) == 0) {
                    break;
                }
                $data .= trim($curData);
            }
        }

        if (empty($data)) {
            return null;
        }

        $decodedData = base64_decode($data);

        switch ($alg) {
            case 'sha512':
            case 'sha384':
            case 'sha256':
                $fingerprint = hash($alg, $decodedData, false);
                break;
            case 'sha1':
            default:
                $fingerprint = strtolower(sha1($decodedData));
                break;
        }
        return $fingerprint;
    }

    /**
     * Formates a fingerprint.
     *
     * @param string $fingerprint fingerprint
     *
     * @return string Formatted fingerprint
     */
    public static function formatFingerPrint($fingerprint)
    {
        $formatedFingerprint = str_replace(':', '', $fingerprint);
        $formatedFingerprint = strtolower($formatedFingerprint);
        return $formatedFingerprint;
    }

    /**
     * Generates a nameID.
     *
     * @param string      $value  fingerprint
     * @param string      $spnq   SP Name Qualifier
     * @param string|null $format SP Format
     * @param string|null $cert   IdP Public cert to encrypt the nameID
     * @param string|null $nq     IdP Name Qualifier
     *
     * @return string $nameIDElement DOMElement | XMLSec nameID
     *
     * @throws Exception
     */
    public static function generateNameId($value, $spnq, $format = null, $cert = null, $nq = null)
    {

        $doc = new DOMDocument();

        $nameId = $doc->createElement('saml:NameID');
        if (isset($spnq)) {
            $nameId->setAttribute('SPNameQualifier', $spnq);
        }
        if (isset($nq)) {
            $nameId->setAttribute('NameQualifier', $nq);
        }
        if (isset($format)) {
            $nameId->setAttribute('Format', $format);
        }
        $nameId->appendChild($doc->createTextNode($value));

        $doc->appendChild($nameId);

        if (!empty($cert)) {
            $seckey = new XMLSecurityKey(XMLSecurityKey::RSA_1_5, array('type'=>'public'));
            $seckey->loadKey($cert);

            $enc = new XMLSecEnc();
            $enc->setNode($nameId);
            $enc->type = XMLSecEnc::Element;

            $symmetricKey = new XMLSecurityKey(XMLSecurityKey::AES128_CBC);
            $symmetricKey->generateSessionKey();
            $enc->encryptKey($seckey, $symmetricKey);

            $encryptedData = $enc->encryptNode($symmetricKey);

            $newdoc = new DOMDocument();

            $encryptedID = $newdoc->createElement('saml:EncryptedID');

            $newdoc->appendChild($encryptedID);

            $encryptedID->appendChild($encryptedID->ownerDocument->importNode($encryptedData, true));

            return $newdoc->saveXML($encryptedID);
        } else {
            return $doc->saveXML($nameId);
        }
    }


    /**
     * Gets Status from a Response.
     *
     * @param DOMDocument $dom The Response as XML
     *
     * @return array $status The Status, an array with the code and a message.
     *
     * @throws ValidationError
     */
    public static function getStatus(DOMDocument $dom)
    {
        $status = array();

        $statusEntry = self::query($dom, '/samlp:Response/samlp:Status');
        if ($statusEntry->length != 1) {
            throw new ValidationError(
                "Missing Status on response",
                ValidationError::MISSING_STATUS
            );
        }

        $codeEntry = self::query($dom, '/samlp:Response/samlp:Status/samlp:StatusCode', $statusEntry->item(0));
        if ($codeEntry->length != 1) {
            throw new ValidationError(
                "Missing Status Code on response",
                ValidationError::MISSING_STATUS_CODE
            );
        }
        $code = $codeEntry->item(0)->getAttribute('Value');
        $status['code'] = $code;

        $status['msg'] = '';
        $messageEntry = self::query($dom, '/samlp:Response/samlp:Status/samlp:StatusMessage', $statusEntry->item(0));
        if ($messageEntry->length == 0) {
            $subCodeEntry = self::query($dom, '/samlp:Response/samlp:Status/samlp:StatusCode/samlp:StatusCode', $statusEntry->item(0));
            if ($subCodeEntry->length == 1) {
                $status['msg'] = $subCodeEntry->item(0)->getAttribute('Value');
            }
        } else if ($messageEntry->length == 1) {
            $msg = $messageEntry->item(0)->textContent;
            $status['msg'] = $msg;
        }

        return $status;
    }

    /**
     * Decrypts an encrypted element.
     *
     * @param DOMElement     $encryptedData The encrypted data.
     * @param XMLSecurityKey $inputKey      The decryption key.
     * @param bool           $formatOutput  Format or not the output.
     *
     * @return DOMElement  The decrypted element.
     *
     * @throws ValidationError
     */
    public static function decryptElement(DOMElement $encryptedData, XMLSecurityKey $inputKey, $formatOutput = true)
    {

        $enc = new XMLSecEnc();

        $enc->setNode($encryptedData);
        $enc->type = $encryptedData->getAttribute("Type");

        $symmetricKey = $enc->locateKey($encryptedData);
        if (!$symmetricKey) {
            throw new ValidationError(
                'Could not locate key algorithm in encrypted data.',
                ValidationError::KEY_ALGORITHM_ERROR
            );
        }

        $symmetricKeyInfo = $enc->locateKeyInfo($symmetricKey);
        if (!$symmetricKeyInfo) {
            throw new ValidationError(
                "Could not locate <dsig:KeyInfo> for the encrypted key.",
                ValidationError::KEYINFO_NOT_FOUND_IN_ENCRYPTED_DATA
            );
        }

        $inputKeyAlgo = $inputKey->getAlgorithm();
        if ($symmetricKeyInfo->isEncrypted) {
            $symKeyInfoAlgo = $symmetricKeyInfo->getAlgorithm();

            if ($symKeyInfoAlgo === XMLSecurityKey::RSA_OAEP_MGF1P && $inputKeyAlgo === XMLSecurityKey::RSA_1_5) {
                $inputKeyAlgo = XMLSecurityKey::RSA_OAEP_MGF1P;
            }

            if ($inputKeyAlgo !== $symKeyInfoAlgo) {
                throw new ValidationError(
                    'Algorithm mismatch between input key and key used to encrypt ' .
                    ' the symmetric key for the message. Key was: ' .
                    var_export($inputKeyAlgo, true) . '; message was: ' .
                    var_export($symKeyInfoAlgo, true),
                    ValidationError::KEY_ALGORITHM_ERROR
                );
            }

            $encKey = $symmetricKeyInfo->encryptedCtx;
            $symmetricKeyInfo->key = $inputKey->key;
            $keySize = $symmetricKey->getSymmetricKeySize();
            if ($keySize === null) {
                // To protect against "key oracle" attacks
                throw new ValidationError(
                    'Unknown key size for encryption algorithm: ' . var_export($symmetricKey->type, true),
                    ValidationError::KEY_ALGORITHM_ERROR
                );
            }

            $key = $encKey->decryptKey($symmetricKeyInfo);
            if (strlen($key) != $keySize) {
                $encryptedKey = $encKey->getCipherValue();
                $pkey = openssl_pkey_get_details($symmetricKeyInfo->key);
                $pkey = sha1(serialize($pkey), true);
                $key = sha1($encryptedKey . $pkey, true);

                /* Make sure that the key has the correct length. */
                if (strlen($key) > $keySize) {
                    $key = substr($key, 0, $keySize);
                } elseif (strlen($key) < $keySize) {
                    $key = str_pad($key, $keySize);
                }
            }
            $symmetricKey->loadKey($key);
        } else {
            $symKeyAlgo = $symmetricKey->getAlgorithm();
            if ($inputKeyAlgo !== $symKeyAlgo) {
                throw new ValidationError(
                    'Algorithm mismatch between input key and key in message. ' .
                    'Key was: ' . var_export($inputKeyAlgo, true) . '; message was: ' .
                    var_export($symKeyAlgo, true),
                    ValidationError::KEY_ALGORITHM_ERROR
                );
            }
            $symmetricKey = $inputKey;
        }

        $decrypted = $enc->decryptNode($symmetricKey, false);

        $xml = '<root xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'.$decrypted.'</root>';
        $newDoc = new DOMDocument();
        if ($formatOutput) {
            $newDoc->preserveWhiteSpace = false;
            $newDoc->formatOutput = true;
        }
        $newDoc = self::loadXML($newDoc, $xml);
        if (!$newDoc) {
            throw new ValidationError(
                'Failed to parse decrypted XML.',
                ValidationError::INVALID_XML_FORMAT
            );
        }

        $decryptedElement = $newDoc->firstChild->firstChild;
        if ($decryptedElement === null) {
            throw new ValidationError(
                'Missing encrypted element.',
                ValidationError::MISSING_ENCRYPTED_ELEMENT
            );
        }

        return $decryptedElement;
    }

     /**
      * Converts a XMLSecurityKey to the correct algorithm.
      *
      * @param XMLSecurityKey $key       The key.
      * @param string         $algorithm The desired algorithm.
      * @param string         $type      Public or private key, defaults to public.
      *
      * @return XMLSecurityKey The new key.
      *
      * @throws Exception
      */
    public static function castKey(XMLSecurityKey $key, $algorithm, $type = 'public')
    {
        assert(is_string($algorithm));
        assert($type === 'public' || $type === 'private');

        // do nothing if algorithm is already the type of the key
        if ($key->type === $algorithm) {
            return $key;
        }

        if (!Utils::isSupportedSigningAlgorithm($algorithm)) {
            throw new Exception('Unsupported signing algorithm.');
        }

        $keyInfo = openssl_pkey_get_details($key->key);
        if ($keyInfo === false) {
            throw new Exception('Unable to get key details from XMLSecurityKey.');
        }
        if (!isset($keyInfo['key'])) {
            throw new Exception('Missing key in public key details.');
        }
        $newKey = new XMLSecurityKey($algorithm, array('type'=>$type));
        $newKey->loadKey($keyInfo['key']);
        return $newKey;
    }

    /**
     * @param $algorithm
     *
     * @return bool
     */
    public static function isSupportedSigningAlgorithm($algorithm)
    {
        return in_array(
            $algorithm,
            array(
                XMLSecurityKey::RSA_1_5,
                XMLSecurityKey::RSA_SHA1,
                XMLSecurityKey::RSA_SHA256,
                XMLSecurityKey::RSA_SHA384,
                XMLSecurityKey::RSA_SHA512
            )
        );
    }

    /**
     * Adds signature key and senders certificate to an element (Message or Assertion).
     *
     * @param string|DOMDocument $xml             The element we should sign
     * @param string             $key             The private key
     * @param string             $cert            The public
     * @param string             $signAlgorithm   Signature algorithm method
     * @param string             $digestAlgorithm Digest algorithm method
     *
     * @return string
     *
     * @throws Exception
     */
    public static function addSign($xml, $key, $cert, $signAlgorithm = XMLSecurityKey::RSA_SHA256, $digestAlgorithm = XMLSecurityDSig::SHA256)
    {
        if ($xml instanceof DOMDocument) {
            $dom = $xml;
        } else {
            $dom = new DOMDocument();
            $dom = self::loadXML($dom, $xml);
            if (!$dom) {
                throw new Exception('Error parsing xml string');
            }
        }

        /* Load the private key. */
        $objKey = new XMLSecurityKey($signAlgorithm, array('type' => 'private'));
        $objKey->loadKey($key, false);

        /* Get the EntityDescriptor node we should sign. */
        $rootNode = $dom->firstChild;

        /* Sign the metadata with our private key. */
        $objXMLSecDSig = new XMLSecurityDSig();
        $objXMLSecDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N);

        $objXMLSecDSig->addReferenceList(
            array($rootNode),
            $digestAlgorithm,
            array('http://www.w3.org/2000/09/xmldsig#enveloped-signature', XMLSecurityDSig::EXC_C14N),
            array('id_name' => 'ID')
        );

        $objXMLSecDSig->sign($objKey);

        /* Add the certificate to the signature. */
        $objXMLSecDSig->add509Cert($cert, true);

        $insertBefore = $rootNode->firstChild;
        $messageTypes = array('AuthnRequest', 'Response', 'LogoutRequest','LogoutResponse');
        if (in_array($rootNode->localName, $messageTypes)) {
            $issuerNodes = self::query($dom, '/'.$rootNode->tagName.'/saml:Issuer');
            if ($issuerNodes->length == 1) {
                $insertBefore = $issuerNodes->item(0)->nextSibling;
            }
        }

        /* Add the signature. */
        $objXMLSecDSig->insertSignature($rootNode, $insertBefore);

        /* Return the DOM tree as a string. */
        $signedxml = $dom->saveXML();

        return $signedxml;
    }

    /**
     * Validates a signature (Message or Assertion).
     *
     * @param string|\DomNode   $xml            The element we should validate
     * @param string|null       $cert           The pubic cert
     * @param string|null       $fingerprint    The fingerprint of the public cert
     * @param string|null       $fingerprintalg The algorithm used to get the fingerprint
     * @param string|null       $xpath          The xpath of the signed element
     * @param array|null        $multiCerts     Multiple public certs
     *
     * @return bool
     *
     * @throws Exception
     */
    public static function validateSign($xml, $cert = null, $fingerprint = null, $fingerprintalg = 'sha1', $xpath = null, $multiCerts = null)
    {
        if ($xml instanceof DOMDocument) {
            $dom = clone $xml;
        } else if ($xml instanceof DOMElement) {
            $dom = clone $xml->ownerDocument;
        } else {
            $dom = new DOMDocument();
            $dom = self::loadXML($dom, $xml);
        }

        $objXMLSecDSig = new XMLSecurityDSig();
        $objXMLSecDSig->idKeys = array('ID');

        if ($xpath) {
            $nodeset = Utils::query($dom, $xpath);
            $objDSig = $nodeset->item(0);
            $objXMLSecDSig->sigNode = $objDSig;
        } else {
            $objDSig = $objXMLSecDSig->locateSignature($dom);
        }

        if (!$objDSig) {
            throw new Exception('Cannot locate Signature Node');
        }

        $objKey = $objXMLSecDSig->locateKey();
        if (!$objKey) {
            throw new Exception('We have no idea about the key');
        }

        if (!Utils::isSupportedSigningAlgorithm($objKey->type)) {
            throw new Exception('Unsupported signing algorithm.');
        }

        $objXMLSecDSig->canonicalizeSignedInfo();

        try {
            $retVal = $objXMLSecDSig->validateReference();
        } catch (Exception $e) {
            throw $e;
        }

        XMLSecEnc::staticLocateKeyInfo($objKey, $objDSig);

        if (!empty($multiCerts)) {
            // If multiple certs are provided, I may ignore $cert and
            // $fingerprint provided by the method and just check the
            // certs on the array
            $fingerprint = null;
        } else {
            // else I add the cert to the array in order to check
            // validate signatures with it and the with it and the
            // $fingerprint value
            $multiCerts = array($cert);
        }

        $valid = false;
        foreach ($multiCerts as $cert) {
            if (!empty($cert)) {
                $objKey->loadKey($cert, false, true);
                if ($objXMLSecDSig->verify($objKey) === 1) {
                    $valid = true;
                    break;
                }
            } else {
                if (!empty($fingerprint)) {
                    $domCert = $objKey->getX509Certificate();
                    $domCertFingerprint = Utils::calculateX509Fingerprint($domCert, $fingerprintalg);
                    if (Utils::formatFingerPrint($fingerprint) == $domCertFingerprint) {
                        $objKey->loadKey($domCert, false, true);
                        if ($objXMLSecDSig->verify($objKey) === 1) {
                            $valid = true;
                            break;
                        }
                    }
                }
            }
        }
        return $valid;
    }

    /**
     * Validates a binary signature
     *
     * @param string $messageType                    Type of SAML Message
     * @param array  $getData                        HTTP GET array
     * @param array  $idpData                        IdP setting data
     * @param bool   $retrieveParametersFromServer   Indicates where to get the values in order to validate the Sign, from getData or from $_SERVER
     *
     * @return bool
     *
     * @throws Exception
     */
    public static function validateBinarySign($messageType, $getData, $idpData, $retrieveParametersFromServer = false)
    {
        if (!isset($getData['SigAlg'])) {
            $signAlg = XMLSecurityKey::RSA_SHA1;
        } else {
            $signAlg = $getData['SigAlg'];
        }

        if ($retrieveParametersFromServer) {
            $signedQuery = $messageType.'='.Utils::extractOriginalQueryParam($messageType);
            if (isset($getData['RelayState'])) {
                $signedQuery .= '&RelayState='.Utils::extractOriginalQueryParam('RelayState');
            }
            $signedQuery .= '&SigAlg='.Utils::extractOriginalQueryParam('SigAlg');
        } else {
            $signedQuery = $messageType.'='.urlencode($getData[$messageType]);
            if (isset($getData['RelayState'])) {
                $signedQuery .= '&RelayState='.urlencode($getData['RelayState']);
            }
            $signedQuery .= '&SigAlg='.urlencode($signAlg);
        }

        if ($messageType == "SAMLRequest") {
            $strMessageType = "Logout Request";
        } else {
            $strMessageType = "Logout Response";
        }
        $existsMultiX509Sign = isset($idpData['x509certMulti']) && isset($idpData['x509certMulti']['signing']) && !empty($idpData['x509certMulti']['signing']);
        if ((!isset($idpData['x509cert']) || empty($idpData['x509cert'])) && !$existsMultiX509Sign) {
            throw new Error(
                "In order to validate the sign on the ".$strMessageType.", the x509cert of the IdP is required",
                Error::CERT_NOT_FOUND
            );
        }

        if ($existsMultiX509Sign) {
            $multiCerts = $idpData['x509certMulti']['signing'];
        } else {
            $multiCerts = array($idpData['x509cert']);
        }

        $signatureValid = false;
        foreach ($multiCerts as $cert) {
            $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA1, array('type' => 'public'));
            $objKey->loadKey($cert, false, true);

            if ($signAlg != XMLSecurityKey::RSA_SHA1) {
                try {
                    $objKey = Utils::castKey($objKey, $signAlg, 'public');
                } catch (Exception $e) {
                    $ex = new ValidationError(
                        "Invalid signAlg in the recieved ".$strMessageType,
                        ValidationError::INVALID_SIGNATURE
                    );
                    if (count($multiCerts) == 1) {
                        throw $ex;
                    }
                }
            }

            if ($objKey->verifySignature($signedQuery, base64_decode($getData['Signature'])) === 1) {
                $signatureValid = true;
                break;
            }
        }
        return $signatureValid;
    }
}
