<?php

/**
 * Helper functions for the SAML2 library.
 *
 * @package simpleSAMLphp
 * @version $Id$
 */
class SAML2_Utils {

	/**
	 * Check the Signature in a XML element.
	 *
	 * This function expects the XML element to contain a Signature-element
	 * which contains a reference to the XML-element. This is common for both
	 * messages and assertions.
	 *
	 * Note that this function only validates the element itself. It does not
	 * check this against any local keys.
	 *
	 * If no Signature-element is located, this function will return FALSE. All
	 * other validation errors result in an exception. On successful validation
	 * an array will be returned. This array contains the information required to
	 * check the signature against a public key.
	 *
	 * @param DOMElement $root  The element which should be validated.
	 * @return array|FALSE  An array with information about the Signature-element.
	 */
	public static function validateElement(DOMElement $root) {

		/* Create an XML security object. */
		$objXMLSecDSig = new XMLSecurityDSig();

		/* Both SAML messages and SAML assertions use the 'ID' attribute. */
		$objXMLSecDSig->idKeys[] = 'ID';

		/* Locate the XMLDSig Signature element to be used. */
		$signatureElement = self::xpQuery($root, './ds:Signature');
		if (count($signatureElement) === 0) {
			/* We don't have a signature element ot validate. */
			return FALSE;
		} elseif (count($signatureElement) > 1) {
			throw new Exception('XMLSec: more than one signature element in root.');
		}
		$signatureElement = $signatureElement[0];
		$objXMLSecDSig->sigNode = $signatureElement;

		/* Canonicalize the XMLDSig SignedInfo element in the message. */
		$objXMLSecDSig->canonicalizeSignedInfo();

		/* Validate referenced xml nodes. */
		if (!$objXMLSecDSig->validateReference()) {
			throw new Exception('XMLsec: digest validation failed');
		}

		/* Check that $root is one of the signed nodes. */
		$rootSigned = FALSE;
		foreach ($objXMLSecDSig->getValidatedNodes() as $signedNode) {
			if ($signedNode->isSameNode($root)) {
				$rootSigned = TRUE;
				break;
			} elseif ($root->parentNode instanceof DOMDocument && $signedNode->isSameNode($root->ownerDocument)) {
				/* $root is the root element of a signed document. */
				$rootSigned = TRUE;
				break;
			}
		}
		if (!$rootSigned) {
			throw new Exception('XMLSec: The root element is not signed.');
		}

		/* Now we extract all available X509 certificates in the signature element. */
		$certificates = array();
		foreach (self::xpQuery($signatureElement, './ds:KeyInfo/ds:X509Data/ds:X509Certificate') as $certNode) {
			$certData = trim($certNode->textContent);
			$certData = str_replace(array("\r", "\n", "\t", ' '), '', $certData);
			$certificates[] = $certData;
		}

		$ret = array(
			'Signature' => $objXMLSecDSig,
			'Certificates' => $certificates,
			);

		return $ret;
	}


	/**
	 * Helper function to convert a XMLSecurityKey to the correct algorithm.
	 *
	 * @param XMLSecurityKey $key  The key.
	 * @param string $algorithm  The desired algorithm.
	 * @return XMLSecurityKey  The new key.
	 */
	private static function castKey(XMLSecurityKey $key, $algorithm) {
		assert('is_string($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'=>'public'));
		$newKey->loadKey($keyInfo['key']);
		return $newKey;
	}


	/**
	 * Check a signature against a key.
	 *
	 * An exception is thrown if we are unable to validate the signature.
	 *
	 * @param array $info  The information returned by the validateElement()-function.
	 * @param XMLSecurityKey $key  The publickey that should validate the Signature object.
	 */
	public static function validateSignature(array $info, XMLSecurityKey $key) {
		assert('array_key_exists("Signature", $info)');

		$objXMLSecDSig = $info['Signature'];

		$sigMethod = self::xpQuery($objXMLSecDSig->sigNode, './ds:SignedInfo/ds:SignatureMethod');
		if (empty($sigMethod)) {
			throw new Exception('Missing SignatureMethod element.');
		}
		$sigMethod = $sigMethod[0];
		if (!$sigMethod->hasAttribute('Algorithm')) {
			throw new Exception('Missing Algorithm-attribute on SignatureMethod element.');
		}
		$algo = $sigMethod->getAttribute('Algorithm');

		if ($key->type === XMLSecurityKey::RSA_SHA1 && $algo === XMLSecurityKey::RSA_SHA256) {
			$key = self::castKey($key, XMLSecurityKey::RSA_SHA256);
		}

		/* Check the signature. */
		if (! $objXMLSecDSig->verify($key)) {
			throw new Exception("Unable to validate Signature");
		}
	}


	/**
	 * Do an XPath query on an XML node.
	 *
	 * @param DOMNode $node  The XML node.
	 * @param string $query  The query.
	 * @return array  Array with matching DOM nodes.
	 */
	public static function xpQuery(DOMNode $node, $query) {
		assert('is_string($query)');
		static $xpCache = NULL;

		if ($node instanceof DOMDocument) {
			$doc = $node;
		} else {
			$doc = $node->ownerDocument;
		}

		if ($xpCache === NULL || !$xpCache->document->isSameNode($doc)) {
			$xpCache = new DOMXPath($doc);
			$xpCache->registerNamespace('soap-env', SAML2_Const::NS_SOAP);
			$xpCache->registerNamespace('saml_protocol', SAML2_Const::NS_SAMLP);
			$xpCache->registerNamespace('saml_assertion', SAML2_Const::NS_SAML);
			$xpCache->registerNamespace('saml_metadata', SAML2_Const::NS_MD);
			$xpCache->registerNamespace('ds', XMLSecurityDSig::XMLDSIGNS);
			$xpCache->registerNamespace('xenc', XMLSecEnc::XMLENCNS);
		}

		$results = $xpCache->query($query, $node);
		$ret = array();
		for ($i = 0; $i < $results->length; $i++) {
			$ret[$i] = $results->item($i);
		}

		return $ret;
	}


	/**
	 * Make an exact copy the specific DOMElement.
	 *
	 * @param DOMElement $element  The element we should copy.
	 * @param DOMElement|NULL $parent  The target parent element.
	 * @return DOMElement  The copied element.
	 */
	public static function copyElement(DOMElement $element, DOMElement $parent = NULL) {

		if ($parent === NULL) {
			$document = new DOMDocument();
		} else {
			$document = $parent->ownerDocument;
		}

		$namespaces = array();
		for ($e = $element; $e !== NULL; $e = $e->parentNode) {
			foreach (SAML2_Utils::xpQuery($e, './namespace::*') as $ns) {
				$prefix = $ns->localName;
				if ($prefix === 'xml' || $prefix === 'xmlns') {
					continue;
				}
				$uri = $ns->nodeValue;
				if (!isset($namespaces[$prefix])) {
					$namespaces[$prefix] = $uri;
				}
			}
		}

		$newElement = $document->importNode($element, TRUE);
		if ($parent !== NULL) {
			/* We need to append the child to the parent before we add the namespaces. */
			$parent->appendChild($newElement);
		}

		foreach ($namespaces as $prefix => $uri) {
			$newElement->setAttributeNS($uri, $prefix . ':__ns_workaround__', 'tmp');
			$newElement->removeAttributeNS($uri, '__ns_workaround__');
		}

		return $newElement;
	}


	/**
	 * Parse a boolean attribute.
	 *
	 * @param DOMElement $node  The element we should fetch the attribute from.
	 * @param string $attributeName  The name of the attribute.
	 * @param mixed $default  The value that should be returned if the attribute doesn't exist.
	 * @return bool|mixed  The value of the attribute, or $default if the attribute doesn't exist.
	 */
	public static function parseBoolean(DOMElement $node, $attributeName, $default = NULL) {
		assert('is_string($attributeName)');

		if (!$node->hasAttribute($attributeName)) {
			return $default;
		}
		$value = $node->getAttribute($attributeName);
		switch (strtolower($value)) {
		case '0':
		case 'false':
			return FALSE;
		case '1':
		case 'true':
			return TRUE;
		default:
			throw new Exception('Invalid value of boolean attribute ' . var_export($attributeName, TRUE) . ': ' . var_export($value, TRUE));
		}
	}


	/**
	 * Create a NameID element.
	 *
	 * The NameId array can have the following elements: 'Value', 'Format',
	 *   'NameQualifier, 'SPNameQualifier'
	 *
	 * Only the 'Value'-element is required.
	 *
	 * @param DOMElement $node  The DOM node we should append the NameId to.
	 * @param array $nameId  The name identifier.
	 */
	public static function addNameId(DOMElement $node, array $nameId) {
		assert('array_key_exists("Value", $nameId)');

		$xml = SAML2_Utils::addString($node, SAML2_Const::NS_SAML, 'saml:NameID', $nameId['Value']);

		if (array_key_exists('NameQualifier', $nameId) && $nameId['NameQualifier'] !== NULL) {
			$xml->setAttribute('NameQualifier', $nameId['NameQualifier']);
		}
		if (array_key_exists('SPNameQualifier', $nameId) && $nameId['SPNameQualifier'] !== NULL) {
			$xml->setAttribute('SPNameQualifier', $nameId['SPNameQualifier']);
		}
		if (array_key_exists('Format', $nameId) && $nameId['Format'] !== NULL) {
			$xml->setAttribute('Format', $nameId['Format']);
		}
	}


	/**
	 * Parse a NameID element.
	 *
	 * @param DOMElement $xml  The DOM element we should parse.
	 * @return array  The parsed name identifier.
	 */
	public static function parseNameId(DOMElement $xml) {

		$ret = array('Value' => trim($xml->textContent));

		foreach (array('NameQualifier', 'SPNameQualifier', 'Format') as $attr) {
			if ($xml->hasAttribute($attr)) {
				$ret[$attr] = $xml->getAttribute($attr);
			}
		}

		return $ret;
	}


	/**
	 * Insert a Signature-node.
	 *
	 * @param XMLSecurityKey $key  The key we should use to sign the message.
	 * @param array $certificates  The certificates we should add to the signature node.
	 * @param DOMElement $root  The XML node we should sign.
	 * @param DomElement $insertBefore  The XML element we should insert the signature element before.
	 */
	public static function insertSignature(XMLSecurityKey $key, array $certificates, DOMElement $root, DOMNode $insertBefore = NULL) {

		$objXMLSecDSig = new XMLSecurityDSig();
		$objXMLSecDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N);

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

		$objXMLSecDSig->sign($key);

		foreach ($certificates as $certificate) {
			$objXMLSecDSig->add509Cert($certificate, TRUE);
		}

		$objXMLSecDSig->insertSignature($root, $insertBefore);

	}


	/**
	 * Decrypt an encrypted element.
	 *
	 * This is an internal helper function.
	 *
	 * @param DOMElement $encryptedData  The encrypted data.
	 * @param XMLSecurityKey $inputKey  The decryption key.
	 * @param array &$blacklist  Blacklisted decryption algorithms.
	 * @return DOMElement  The decrypted element.
	 */
	private static function _decryptElement(DOMElement $encryptedData, XMLSecurityKey $inputKey, array &$blacklist) {

		$enc = new XMLSecEnc();

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

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

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

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

			if (in_array($symKeyInfoAlgo, $blacklist, TRUE)) {
				throw new Exception('Algorithm disabled: ' . var_export($symKeyInfoAlgo, TRUE));
			}

			if ($symKeyInfoAlgo === XMLSecurityKey::RSA_OAEP_MGF1P && $inputKeyAlgo === XMLSecurityKey::RSA_1_5) {
				/*
				 * The RSA key formats are equal, so loading an RSA_1_5 key
				 * into an RSA_OAEP_MGF1P key can be done without problems.
				 * We therefore pretend that the input key is an
				 * RSA_OAEP_MGF1P key.
				 */
				$inputKeyAlgo = XMLSecurityKey::RSA_OAEP_MGF1P;
			}

			/* Make sure that the input key format is the same as the one used to encrypt the key. */
			if ($inputKeyAlgo !== $symKeyInfoAlgo) {
				throw new Exception('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));
			}

			$encKey = $symmetricKeyInfo->encryptedCtx;
			$symmetricKeyInfo->key = $inputKey->key;

			$keySize = $symmetricKey->getSymmetricKeySize();
			if ($keySize === NULL) {
				/* To protect against "key oracle" attacks, we need to be able to create a
				 * symmetric key, and for that we need to know the key size.
				 */
				throw new Exception('Unknown key size for encryption algorithm: ' . var_export($symmetricKey->type, TRUE));
			}

			try {
				$key = $encKey->decryptKey($symmetricKeyInfo);
				if (strlen($key) != $keySize) {
					throw new Exception('Unexpected key size (' . strlen($key) * 8 . 'bits) for encryption algorithm: ' .
										var_export($symmetricKey->type, TRUE));
				}
			} catch (Exception $e) {
				/* We failed to decrypt this key. Log it, and substitute a "random" key. */
				SimpleSAML_Logger::error('Failed to decrypt symmetric key: ' . $e->getMessage());
				/* Create a replacement key, so that it looks like we fail in the same way as if the key was correctly padded. */

				/* We base the symmetric key on the encrypted key and private key, so that we always behave the
				 * same way for a given input key.
				 */
				$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->getAlgorith();
			/* Make sure that the input key has the correct format. */
			if ($inputKeyAlgo !== $symKeyAlgo) {
				throw new Exception('Algorithm mismatch between input key and key in message. ' .
					'Key was: ' . var_export($inputKeyAlgo, TRUE) . '; message was: ' .
					var_export($symKeyAlgo, TRUE));
			}
			$symmetricKey = $inputKey;
		}

		$algorithm = $symmetricKey->getAlgorith();
		if (in_array($algorithm, $blacklist, TRUE)) {
			throw new Exception('Algorithm disabled: ' . var_export($algorithm, TRUE));
		}

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

		/*
		 * This is a workaround for the case where only a subset of the XML
		 * tree was serialized for encryption. In that case, we may miss the
		 * namespaces needed to parse the XML.
		 */
		$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 (!@$newDoc->loadXML($xml)) {
			throw new Exception('Failed to parse decrypted XML. Maybe the wrong sharedkey was used?');
		}
		$decryptedElement = $newDoc->firstChild->firstChild;
		if ($decryptedElement === NULL) {
			throw new Exception('Missing encrypted element.');
		}

		if (!($decryptedElement instanceof DOMElement)) {
			throw new Exception('Decrypted element was not actually a DOMElement.');
		}

		return $decryptedElement;
	}


	/**
	 * Decrypt an encrypted element.
	 *
	 * @param DOMElement $encryptedData  The encrypted data.
	 * @param XMLSecurityKey $inputKey  The decryption key.
	 * @param array $blacklist  Blacklisted decryption algorithms.
	 * @return DOMElement  The decrypted element.
	 */
	public static function decryptElement(DOMElement $encryptedData, XMLSecurityKey $inputKey, array $blacklist = array()) {

		try {
			return self::_decryptElement($encryptedData, $inputKey, $blacklist);
		} catch (Exception $e) {
			/*
			 * Something went wrong during decryption, but for security
			 * reasons we cannot tell the user what failed.
			 */
			SimpleSAML_Logger::error('Decryption failed: ' . $e->getMessage());
			throw new Exception('Failed to decrypt XML element.');
		}
	}


	/**
	 * Extract localized strings from a set of nodes.
	 *
	 * @param DOMElement $parent  The element that contains the localized strings.
	 * @param string $namespaceURI  The namespace URI the localized strings should have.
	 * @param string $localName  The localName of the localized strings.
	 * @return array  Localized strings.
	 */
	public static function extractLocalizedStrings(DOMElement $parent, $namespaceURI, $localName) {
		assert('is_string($namespaceURI)');
		assert('is_string($localName)');

		$ret = array();
		for ($node = $parent->firstChild; $node !== NULL; $node = $node->nextSibling) {
			if ($node->namespaceURI !== $namespaceURI || $node->localName !== $localName) {
				continue;
			}

			if ($node->hasAttribute('xml:lang')) {
				$language = $node->getAttribute('xml:lang');
			} else {
				$language = 'en';
			}
			$ret[$language] = trim($node->textContent);
		}

		return $ret;
	}


	/**
	 * Extract strings from a set of nodes.
	 *
	 * @param DOMElement $parent  The element that contains the localized strings.
	 * @param string $namespaceURI  The namespace URI the string elements should have.
	 * @param string $localName  The localName of the string elements.
	 * @return array  The string values of the various nodes.
	 */
	public static function extractStrings(DOMElement $parent, $namespaceURI, $localName) {
		assert('is_string($namespaceURI)');
		assert('is_string($localName)');

		$ret = array();
		for ($node = $parent->firstChild; $node !== NULL; $node = $node->nextSibling) {
			if ($node->namespaceURI !== $namespaceURI || $node->localName !== $localName) {
				continue;
			}
			$ret[] = trim($node->textContent);
		}

		return $ret;
	}


	/**
	 * Append string element.
	 *
	 * @param DOMElement $parent  The parent element we should append the new nodes to.
	 * @param string $namespace  The namespace of the created element.
	 * @param string $name  The name of the created element.
	 * @param string $value  The value of the element.
	 * @return DOMElement  The generated element.
	 */
	public static function addString(DOMElement $parent, $namespace, $name, $value) {
		assert('is_string($namespace)');
		assert('is_string($name)');
		assert('is_string($value)');

		$doc = $parent->ownerDocument;

		$n = $doc->createElementNS($namespace, $name);
		$n->appendChild($doc->createTextNode($value));
		$parent->appendChild($n);

		return $n;
	}


	/**
	 * Append string elements.
	 *
	 * @param DOMElement $parent  The parent element we should append the new nodes to.
	 * @param string $namespace  The namespace of the created elements
	 * @param string $name  The name of the created elements
	 * @param bool $localized  Whether the strings are localized, and should include the xml:lang attribute.
	 * @param array $values  The values we should create the elements from.
	 */
	public static function addStrings(DOMElement $parent, $namespace, $name, $localized, array $values) {
		assert('is_string($namespace)');
		assert('is_string($name)');
		assert('is_bool($localized)');

		$doc = $parent->ownerDocument;

		foreach ($values as $index => $value) {
			$n = $doc->createElementNS($namespace, $name);
			$n->appendChild($doc->createTextNode($value));
			if ($localized) {
				$n->setAttribute('xml:lang', $index);
			}
			$parent->appendChild($n);
		}
	}


	/**
	 * Create a KeyDescriptor with the given certificate.
	 *
	 * @param string $x509Data  The certificate, as a base64-encoded DER data.
	 * @return SAML2_XML_md_KeyDescriptor  The keydescriptor.
	 */
	public static function createKeyDescriptor($x509Data) {
		assert('is_string($x509Data)');

		$x509Certificate = new SAML2_XML_ds_X509Certificate();
		$x509Certificate->certificate = $x509Data;

		$x509Data = new SAML2_XML_ds_X509Data();
		$x509Data->data[] = $x509Certificate;

		$keyInfo = new SAML2_XML_ds_KeyInfo();
		$keyInfo->info[] = $x509Data;

		$keyDescriptor = new SAML2_XML_md_KeyDescriptor();
		$keyDescriptor->KeyInfo = $keyInfo;

		return $keyDescriptor;
	}

}
