446 lines
15 KiB
PHP
Executable File
446 lines
15 KiB
PHP
Executable File
<?php
|
|
|
|
/**
|
|
* This class implements helper functions for XML validation.
|
|
*
|
|
* @author Olav Morken, UNINETT AS.
|
|
* @package SimpleSAMLphp
|
|
*/
|
|
|
|
namespace SimpleSAML\XML;
|
|
|
|
use RobRichards\XMLSecLibs\XMLSecEnc;
|
|
use RobRichards\XMLSecLibs\XMLSecurityDSig;
|
|
use SimpleSAML\Logger;
|
|
|
|
class Validator
|
|
{
|
|
|
|
/**
|
|
* @var string This variable contains the X509 certificate the XML document
|
|
* was signed with, or NULL if it wasn't signed with an X509 certificate.
|
|
*/
|
|
private $x509Certificate;
|
|
|
|
/**
|
|
* @var array|null This variable contains the nodes which are signed.
|
|
*/
|
|
private $validNodes = null;
|
|
|
|
|
|
/**
|
|
* This function initializes the validator.
|
|
*
|
|
* This function accepts an optional parameter $publickey, which is the public key
|
|
* or certificate which should be used to validate the signature. This parameter can
|
|
* take the following values:
|
|
* - NULL/FALSE: No validation will be performed. This is the default.
|
|
* - A string: Assumed to be a PEM-encoded certificate / public key.
|
|
* - An array: Assumed to be an array returned by SimpleSAML_Utilities::loadPublicKey.
|
|
*
|
|
* @param \DOMNode $xmlNode The XML node which contains the Signature element.
|
|
* @param string|array $idAttribute The ID attribute which is used in node references. If
|
|
* this attribute is NULL (the default), then we will use whatever is the default
|
|
* ID. Can be eigther a string with one value, or an array with multiple ID
|
|
* attrbute names.
|
|
* @param array|bool $publickey The public key / certificate which should be used to validate the XML node.
|
|
* @throws \Exception
|
|
*/
|
|
public function __construct($xmlNode, $idAttribute = null, $publickey = false)
|
|
{
|
|
assert($xmlNode instanceof \DOMNode);
|
|
|
|
if ($publickey === null) {
|
|
$publickey = false;
|
|
} elseif (is_string($publickey)) {
|
|
$publickey = array(
|
|
'PEM' => $publickey,
|
|
);
|
|
} else {
|
|
assert($publickey === false || is_array($publickey));
|
|
}
|
|
|
|
// Create an XML security object
|
|
$objXMLSecDSig = new XMLSecurityDSig();
|
|
|
|
// Add the id attribute if the user passed in an id attribute
|
|
if ($idAttribute !== null) {
|
|
if (is_string($idAttribute)) {
|
|
$objXMLSecDSig->idKeys[] = $idAttribute;
|
|
} elseif (is_array($idAttribute)) {
|
|
foreach ($idAttribute as $ida) {
|
|
$objXMLSecDSig->idKeys[] = $ida;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Locate the XMLDSig Signature element to be used
|
|
$signatureElement = $objXMLSecDSig->locateSignature($xmlNode);
|
|
if (!$signatureElement) {
|
|
throw new \Exception('Could not locate XML Signature element.');
|
|
}
|
|
|
|
// Canonicalize the XMLDSig SignedInfo element in the message
|
|
$objXMLSecDSig->canonicalizeSignedInfo();
|
|
|
|
// Validate referenced xml nodes
|
|
if (!$objXMLSecDSig->validateReference()) {
|
|
throw new \Exception('XMLsec: digest validation failed');
|
|
}
|
|
|
|
|
|
// Find the key used to sign the document
|
|
$objKey = $objXMLSecDSig->locateKey();
|
|
if (empty($objKey)) {
|
|
throw new \Exception('Error loading key to handle XML signature');
|
|
}
|
|
|
|
// Load the key data
|
|
if ($publickey !== false && array_key_exists('PEM', $publickey)) {
|
|
// We have PEM data for the public key / certificate
|
|
$objKey->loadKey($publickey['PEM']);
|
|
} else {
|
|
// No PEM data. Search for key in signature
|
|
|
|
if (!XMLSecEnc::staticLocateKeyInfo($objKey, $signatureElement)) {
|
|
throw new \Exception('Error finding key data for XML signature validation.');
|
|
}
|
|
|
|
if ($publickey !== false) {
|
|
/* $publickey is set, and should therefore contain one or more fingerprints.
|
|
* Check that the response contains a certificate with a matching
|
|
* fingerprint.
|
|
*/
|
|
assert(is_array($publickey['certFingerprint']));
|
|
|
|
$certificate = $objKey->getX509Certificate();
|
|
if ($certificate === null) {
|
|
// Wasn't signed with an X509 certificate
|
|
throw new \Exception('Message wasn\'t signed with an X509 certificate,' .
|
|
' and no public key was provided in the metadata.');
|
|
}
|
|
|
|
self::validateCertificateFingerprint($certificate, $publickey['certFingerprint']);
|
|
// Key OK
|
|
}
|
|
}
|
|
|
|
// Check the signature
|
|
if ($objXMLSecDSig->verify($objKey) !== 1) {
|
|
throw new \Exception("Unable to validate Signature");
|
|
}
|
|
|
|
// Extract the certificate
|
|
$this->x509Certificate = $objKey->getX509Certificate();
|
|
|
|
// Find the list of validated nodes
|
|
$this->validNodes = $objXMLSecDSig->getValidatedNodes();
|
|
}
|
|
|
|
|
|
/**
|
|
* Retrieve the X509 certificate which was used to sign the XML.
|
|
*
|
|
* This function will return the certificate as a PEM-encoded string. If the XML
|
|
* wasn't signed by an X509 certificate, NULL will be returned.
|
|
*
|
|
* @return string The certificate as a PEM-encoded string, or NULL if not signed with an X509 certificate.
|
|
*/
|
|
public function getX509Certificate()
|
|
{
|
|
return $this->x509Certificate;
|
|
}
|
|
|
|
|
|
/**
|
|
* Calculates the fingerprint of an X509 certificate.
|
|
*
|
|
* @param string $x509cert The certificate as a base64-encoded string. The string may optionally
|
|
* be framed with '-----BEGIN CERTIFICATE-----' and '-----END CERTIFICATE-----'.
|
|
* @return string The fingerprint as a 40-character lowercase hexadecimal number. NULL is returned if the
|
|
* argument isn't an X509 certificate.
|
|
*/
|
|
private static function calculateX509Fingerprint($x509cert)
|
|
{
|
|
assert(is_string($x509cert));
|
|
|
|
$lines = explode("\n", $x509cert);
|
|
|
|
$data = '';
|
|
|
|
foreach ($lines as $line) {
|
|
// Remove '\r' from end of line if present
|
|
$line = rtrim($line);
|
|
if ($line === '-----BEGIN CERTIFICATE-----') {
|
|
// Delete junk from before the certificate
|
|
$data = '';
|
|
} elseif ($line === '-----END CERTIFICATE-----') {
|
|
// Ignore data after the certificate
|
|
break;
|
|
} elseif ($line === '-----BEGIN PUBLIC KEY-----') {
|
|
// This isn't an X509 certificate
|
|
return null;
|
|
} else {
|
|
// Append the current line to the certificate data
|
|
$data .= $line;
|
|
}
|
|
}
|
|
|
|
/* $data now contains the certificate as a base64-encoded string. The fingerprint
|
|
* of the certificate is the sha1-hash of the certificate.
|
|
*/
|
|
return strtolower(sha1(base64_decode($data)));
|
|
}
|
|
|
|
|
|
/**
|
|
* Helper function for validating the fingerprint.
|
|
*
|
|
* Checks the fingerprint of a certificate against an array of valid fingerprints.
|
|
* Will throw an exception if none of the fingerprints matches.
|
|
*
|
|
* @param string $certificate The X509 certificate we should validate.
|
|
* @param array $fingerprints The valid fingerprints.
|
|
* @throws \Exception
|
|
*/
|
|
private static function validateCertificateFingerprint($certificate, $fingerprints)
|
|
{
|
|
assert(is_string($certificate));
|
|
assert(is_array($fingerprints));
|
|
|
|
$certFingerprint = self::calculateX509Fingerprint($certificate);
|
|
if ($certFingerprint === null) {
|
|
// Couldn't calculate fingerprint from X509 certificate. Should not happen.
|
|
throw new \Exception('Unable to calculate fingerprint from X509' .
|
|
' certificate. Maybe it isn\'t an X509 certificate?');
|
|
}
|
|
|
|
foreach ($fingerprints as $fp) {
|
|
assert(is_string($fp));
|
|
|
|
if ($fp === $certFingerprint) {
|
|
// The fingerprints matched
|
|
return;
|
|
}
|
|
}
|
|
|
|
// None of the fingerprints matched. Throw an exception describing the error.
|
|
throw new \Exception('Invalid fingerprint of certificate. Expected one of [' .
|
|
implode('], [', $fingerprints) . '], but got [' . $certFingerprint . ']');
|
|
}
|
|
|
|
|
|
/**
|
|
* Validate the fingerprint of the certificate which was used to sign this document.
|
|
*
|
|
* This function accepts either a string, or an array of strings as a parameter. If this
|
|
* is an array, then any string (certificate) in the array can match. If this is a string,
|
|
* then that string must match,
|
|
*
|
|
* @param string|array $fingerprints The fingerprints which should match. This can be a single string,
|
|
* or an array of fingerprints.
|
|
* @throws \Exception
|
|
*/
|
|
public function validateFingerprint($fingerprints)
|
|
{
|
|
assert(is_string($fingerprints) || is_array($fingerprints));
|
|
|
|
if ($this->x509Certificate === null) {
|
|
throw new \Exception('Key used to sign the message was not an X509 certificate.');
|
|
}
|
|
|
|
if (!is_array($fingerprints)) {
|
|
$fingerprints = array($fingerprints);
|
|
}
|
|
|
|
// Normalize the fingerprints
|
|
foreach ($fingerprints as &$fp) {
|
|
assert(is_string($fp));
|
|
|
|
// Make sure that the fingerprint is in the correct format
|
|
$fp = strtolower(str_replace(":", "", $fp));
|
|
}
|
|
|
|
self::validateCertificateFingerprint($this->x509Certificate, $fingerprints);
|
|
}
|
|
|
|
|
|
/**
|
|
* This function checks if the given XML node was signed.
|
|
*
|
|
* @param \DOMNode $node The XML node which we should verify that was signed.
|
|
*
|
|
* @return bool TRUE if this node (or a parent node) was signed. FALSE if not.
|
|
*/
|
|
public function isNodeValidated($node)
|
|
{
|
|
assert($node instanceof \DOMNode);
|
|
|
|
while ($node !== null) {
|
|
if (in_array($node, $this->validNodes, true)) {
|
|
return true;
|
|
}
|
|
|
|
$node = $node->parentNode;
|
|
}
|
|
|
|
/* Neither this node nor any of the parent nodes could be found in the list of
|
|
* signed nodes.
|
|
*/
|
|
return false;
|
|
}
|
|
|
|
|
|
/**
|
|
* Validate the certificate used to sign the XML against a CA file.
|
|
*
|
|
* This function throws an exception if unable to validate against the given CA file.
|
|
*
|
|
* @param string $caFile File with trusted certificates, in PEM-format.
|
|
* @throws \Exception
|
|
*/
|
|
public function validateCA($caFile)
|
|
{
|
|
assert(is_string($caFile));
|
|
|
|
if ($this->x509Certificate === null) {
|
|
throw new \Exception('Key used to sign the message was not an X509 certificate.');
|
|
}
|
|
|
|
self::validateCertificate($this->x509Certificate, $caFile);
|
|
}
|
|
|
|
/**
|
|
* Validate a certificate against a CA file, by using the builtin
|
|
* openssl_x509_checkpurpose function
|
|
*
|
|
* @param string $certificate The certificate, in PEM format.
|
|
* @param string $caFile File with trusted certificates, in PEM-format.
|
|
* @return boolean|string TRUE on success, or a string with error messages if it failed.
|
|
* @deprecated
|
|
*/
|
|
private static function validateCABuiltIn($certificate, $caFile)
|
|
{
|
|
assert(is_string($certificate));
|
|
assert(is_string($caFile));
|
|
|
|
// Clear openssl errors
|
|
while (openssl_error_string() !== false) {
|
|
}
|
|
|
|
$res = openssl_x509_checkpurpose($certificate, X509_PURPOSE_ANY, array($caFile));
|
|
|
|
$errors = '';
|
|
// Log errors
|
|
while (($error = openssl_error_string()) !== false) {
|
|
$errors .= ' [' . $error . ']';
|
|
}
|
|
|
|
if ($res !== true) {
|
|
return $errors;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Validate the certificate used to sign the XML against a CA file, by using the "openssl verify" command.
|
|
*
|
|
* This function uses the openssl verify command to verify a certificate, to work around limitations
|
|
* on the openssl_x509_checkpurpose function. That function will not work on certificates without a purpose
|
|
* set.
|
|
*
|
|
* @param string $certificate The certificate, in PEM format.
|
|
* @param string $caFile File with trusted certificates, in PEM-format.
|
|
* @return bool|string TRUE on success, a string with error messages on failure.
|
|
* @throws \Exception
|
|
* @deprecated
|
|
*/
|
|
private static function validateCAExec($certificate, $caFile)
|
|
{
|
|
assert(is_string($certificate));
|
|
assert(is_string($caFile));
|
|
|
|
$command = array(
|
|
'openssl', 'verify',
|
|
'-CAfile', $caFile,
|
|
'-purpose', 'any',
|
|
);
|
|
|
|
$cmdline = '';
|
|
foreach ($command as $c) {
|
|
$cmdline .= escapeshellarg($c) . ' ';
|
|
}
|
|
|
|
$cmdline .= '2>&1';
|
|
$descSpec = array(
|
|
0 => array('pipe', 'r'),
|
|
1 => array('pipe', 'w'),
|
|
);
|
|
$process = proc_open($cmdline, $descSpec, $pipes);
|
|
if (!is_resource($process)) {
|
|
throw new \Exception('Failed to execute verification command: ' . $cmdline);
|
|
}
|
|
|
|
if (fwrite($pipes[0], $certificate) === false) {
|
|
throw new \Exception('Failed to write certificate for verification.');
|
|
}
|
|
fclose($pipes[0]);
|
|
|
|
$out = '';
|
|
while (!feof($pipes[1])) {
|
|
$line = trim(fgets($pipes[1]));
|
|
if (strlen($line) > 0) {
|
|
$out .= ' [' . $line . ']';
|
|
}
|
|
}
|
|
fclose($pipes[1]);
|
|
|
|
$status = proc_close($process);
|
|
if ($status !== 0 || $out !== ' [stdin: OK]') {
|
|
return $out;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Validate the certificate used to sign the XML against a CA file.
|
|
*
|
|
* This function throws an exception if unable to validate against the given CA file.
|
|
*
|
|
* @param string $certificate The certificate, in PEM format.
|
|
* @param string $caFile File with trusted certificates, in PEM-format.
|
|
* @throws \Exception
|
|
* @deprecated
|
|
*/
|
|
public static function validateCertificate($certificate, $caFile)
|
|
{
|
|
assert(is_string($certificate));
|
|
assert(is_string($caFile));
|
|
|
|
if (!file_exists($caFile)) {
|
|
throw new \Exception('Could not load CA file: ' . $caFile);
|
|
}
|
|
|
|
Logger::debug('Validating certificate against CA file: ' . var_export($caFile, true));
|
|
|
|
$resBuiltin = self::validateCABuiltIn($certificate, $caFile);
|
|
if ($resBuiltin !== true) {
|
|
Logger::debug('Failed to validate with internal function: ' . var_export($resBuiltin, true));
|
|
|
|
$resExternal = self::validateCAExec($certificate, $caFile);
|
|
if ($resExternal !== true) {
|
|
Logger::debug('Failed to validate with external function: ' . var_export($resExternal, true));
|
|
throw new \Exception('Could not verify certificate against CA file "'
|
|
. $caFile . '". Internal result:' . $resBuiltin .
|
|
' External result:' . $resExternal);
|
|
}
|
|
}
|
|
|
|
Logger::debug('Successfully validated certificate.');
|
|
}
|
|
}
|