This repository has been archived on 2023-02-21. You can view files and clone it, but cannot push or open issues or pull requests.
spip-saml/inc/simplesamlphp/lib/SAML2/Utils.php

621 lines
20 KiB
PHP

<?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;
}
}