fixed many issues with dkim signing

This commit is contained in:
Ralf Becker 2012-10-06 19:42:05 +00:00
parent 81376af3f3
commit 46acebf2a7
2 changed files with 125 additions and 28 deletions

View File

@ -23,27 +23,49 @@ class ischedule_client
*/ */
const VERSION = '1.0'; const VERSION = '1.0';
/**
* Required headers in DKIM signature (DKIM-Signature is always a required header!)
*/
const REQUIRED_DKIM_HEADERS = 'Host:iSchedule-Version:iSchedule-Message-ID:Content-Type:Originator:Recipient';
/**
* URL to use to contact iSchedule receiver
*
* @var string
*/
private $url; private $url;
private $recipient; /**
* Recipient email addresses
*
* @param array
*/
private $recipients;
/**
* Originator email address
*
* @var string
*/
private $originator; private $originator;
/** /**
* Private key of originators domain * Private key of originators domain
*
* @var string
*/ */
private $dkim_private_key; private $dkim_private_key;
/** /**
* Constructor * Constructor
* *
* @param string $recipient=null recipient email-address * @param string|array $recipients=null recipient email-address(es)
* @param string $url=null ischedule url, if it should NOT be discovered * @param string $url=null ischedule url, if it should NOT be discovered
* @throws Exception in case of an error or discovery failure * @throws Exception in case of an error or discovery failure
*/ */
public function __construct($recipient, $url=null) public function __construct($recipients, $url=null)
{ {
$this->recipient = $recipient; $this->recipients = (array)$recipients;
$this->originator = $GLOBALS['egw_info']['user']['account_email']; $this->originator = $GLOBALS['egw_info']['user']['account_email'];
if (is_null($url)) if (is_null($url))
@ -177,13 +199,17 @@ class ischedule_client
'iSchedule-Message-ID' => uniqid(), 'iSchedule-Message-ID' => uniqid(),
'Content-Type' => $content_type, 'Content-Type' => $content_type,
'Originator' => $this->originator, 'Originator' => $this->originator,
'Recipient' => $this->recipient, 'Recipient' => $this->recipients,
'Cache-Control' => 'no-cache, no-transform', // required by iSchedule spec
'Content-Length' => bytes($content), 'Content-Length' => bytes($content),
); );
$header_string = ''; $header_string = '';
foreach($headers as $name => $value) foreach($headers as $name => $value)
{ {
$header_string .= $name.': '.$value."\r\n"; foreach((array)$value as $val)
{
$header_string .= $name.': '.$val."\r\n";
}
} }
$header_string .= $this->dkim_sign($headers, $content)."\r\n"; $header_string .= $this->dkim_sign($headers, $content)."\r\n";
@ -192,7 +218,7 @@ class ischedule_client
'method' => 'POST', 'method' => 'POST',
'header' => $header_string, 'header' => $header_string,
'user_agent' => 'EGroupware iSchedule client '.$GLOBALS['egw_info']['server']['versions']['phpgwapi'].' $Id$', 'user_agent' => 'EGroupware iSchedule client '.$GLOBALS['egw_info']['server']['versions']['phpgwapi'].' $Id$',
//'follow_location' => 1, // default 1=follow, but only for POST! //'follow_location' => 1, // default 1=follow, but only for GET, not POST!
//'timeout' => $timeout, // max timeout in seconds (float) //'timeout' => $timeout, // max timeout in seconds (float)
'content' => $content, 'content' => $content,
) )
@ -233,21 +259,30 @@ class ischedule_client
* @param array $headers name => value pairs, names as in $sign_headers * @param array $headers name => value pairs, names as in $sign_headers
* @param string $body * @param string $body
* @param string $selector='calendar' * @param string $selector='calendar'
* @param string $sign_headers='Content-Type:Host:Originator:Recipient' * @param string $sign_headers='iSchedule-Version:Content-Type:Originator:Recipient'
* @return string DKIM-Signature: ... * @return string DKIM-Signature: ...
*/ */
public function dkim_sign(array $headers, $body, $selector='calendar') public function dkim_sign(array $headers, $body, $selector='calendar',$sign_headers=self::REQUIRED_DKIM_HEADERS)
{ {
$dkim_headers = array(); $header_values = $header_names = array();
foreach($headers as $header => $value) foreach(explode(':', $sign_headers) as $header)
{ {
$dkim_headers[] = $header.': '.$value; foreach((array)$headers[$header] as $value)
{
$header_values[] = $header.': '.$value;
$header_names[] = $header;
}
// oversign multiple value header Recipient
if ($header == 'Recipient')
{
$header_names[] = $header;
}
} }
include_once EGW_API_INC.'/php-mail-domain-signer/lib/class.mailDomainSigner.php'; include_once EGW_API_INC.'/php-mail-domain-signer/lib/class.mailDomainSigner.php';
list(,$domain) = explode('@', $this->originator); list(,$domain) = explode('@', $this->originator);
$mds = new mailDomainSigner($this->dkim_private_key, $domain, $selector); $mds = new mailDomainSigner($this->dkim_private_key, $domain, $selector);
// generate DKIM signature according to iSchedule spec // generate DKIM signature according to iSchedule spec
$dkim = $mds->getDKIM(implode(':', array_keys($headers)), $dkim_headers, $body, 'relaxed/simple', 'rsa/sha256', $dkim = $mds->getDKIM(implode(':', $header_names), $header_values, $body, 'relaxed/simple', 'rsa-sha256',
"DKIM-Signature: ". "DKIM-Signature: ".
"v=1; ". // DKIM Version "v=1; ". // DKIM Version
"a=\$a; ". // The algorithm used to generate the signature "rsa-sha1" "a=\$a; ". // The algorithm used to generate the signature "rsa-sha1"

View File

@ -33,7 +33,8 @@ class ischedule_server extends groupdav
/** /**
* Required headers in DKIM signature (DKIM-Signature is always a required header!) * Required headers in DKIM signature (DKIM-Signature is always a required header!)
*/ */
const REQUIRED_DKIM_HEADERS = 'iSchedule-Version:iSchedule-Message-ID:Content-Type:Originator:Recipient'; const REQUIRED_DKIM_HEADERS = 'iSchedule-Version:Content-Type:Originator:Recipient';
//const REQUIRED_DKIM_HEADERS = 'iSchedule-Version:iSchedule-Message-ID:Content-Type:Originator:Recipient';
/** /**
* Constructor * Constructor
@ -130,8 +131,7 @@ class ischedule_server extends groupdav
// for multivalued Recipient header: as PHP engine agregates them ", " separated, // for multivalued Recipient header: as PHP engine agregates them ", " separated,
// we cant tell it apart from ", " separated recipients in one header, therefore we try to validate both. // we cant tell it apart from ", " separated recipients in one header, therefore we try to validate both.
// It will fail if multiple recipients in a single header are also ", " separated (just comma works fine) // It will fail if multiple recipients in a single header are also ", " separated (just comma works fine)
if (!self::dkim_validate($headers, $this->request, $error, true) && if (!self::dkim_validate($headers, $this->request, $error))
!self::dkim_validate($headers, $this->request, $error, false))
{ {
throw new Exception('Bad Request: DKIM signature invalid: '.$error, 400); throw new Exception('Bad Request: DKIM signature invalid: '.$error, 400);
} }
@ -315,6 +315,14 @@ class ischedule_server extends groupdav
$xml->endElement(); // response $xml->endElement(); // response
} }
} }
/**
* Required DKIM tags
*
* @link https://tools.ietf.org/html/rfc6376#section-3.5
*
* @var array
*/
public static $required_dkim_tags = array('v', 'a', 'b', 'bh', 'd', 'h', 's');
/** /**
* Validate DKIM signature * Validate DKIM signature
@ -344,6 +352,20 @@ class ischedule_server extends groupdav
} }
$dkim = array_combine($matches[1], $matches[2]); $dkim = array_combine($matches[1], $matches[2]);
// check required DKIM tags
if (($missing = array_diff(self::$required_dkim_tags, array_keys($dkim))))
{
$error = 'missing required DKIM tags: '.implode(', ', $missing);
return false;
}
// check dkim version, have to fail if it's not 1
if ($dkim['v'] !== '1')
{
$error = "Wrong DKIM version '$dkim[v]'";
return false;
}
// create headers array // create headers array
$dkim_headers = array(); $dkim_headers = array();
$check = $headers; $check = $headers;
@ -365,10 +387,11 @@ class ischedule_server extends groupdav
} }
$dkim_headers[] = $header.': '.$value; $dkim_headers[] = $header.': '.$value;
} }
list($dkim_unsigned) = explode('b='.$dkim['b'], 'DKIM-Signature: '.$headers['dkim-signature']); // dkim signature is obvious without content of signature, but must not necessarly be last tag
$dkim_unsigned .= 'b='; $dkim_unsigned = 'DKIM-Signature: '.str_replace($dkim['b'], '', $headers['dkim-signature']);
list($header_canon, $body_canon) = explode('/', $dkim['c']); // c defaults to 'simple/simple', check on valid canonicalization methods is performed further down
list($header_canon, $body_canon) = explode('/', isset($dkim['c']) ? $dkim['c'] : 'simple/simple');
require_once EGW_API_INC.'/php-mail-domain-signer/lib/class.mailDomainSigner.php'; require_once EGW_API_INC.'/php-mail-domain-signer/lib/class.mailDomainSigner.php';
// Canonicalization for Body // Canonicalization for Body
@ -387,8 +410,15 @@ class ischedule_server extends groupdav
return false; return false;
} }
// check signing and hashing algorithms
list($sign_algo, $hash_algo) = explode('-', $dkim['a']);
if ($sign_algo != 'rsa' || !in_array($hash_algo, array('sha1', 'sha256', 'sha384', 'sha512')))
{
$error = "Unknown or unimplemented algorithm a='$dkim[a]'";
return false;
}
// Hash of the canonicalized body [tag:bh] // Hash of the canonicalized body [tag:bh]
list(,$hash_algo) = explode('-', $dkim['a']);
$_bh = base64_encode(hash($hash_algo, $_b, true)); $_bh = base64_encode(hash($hash_algo, $_b, true));
// check body hash // check body hash
@ -410,9 +440,6 @@ class ischedule_server extends groupdav
$_unsigned = mailDomainSigner::headSimpleCanon(implode("\r\n", $dkim_headers). "\r\n".$dkim_unsigned); $_unsigned = mailDomainSigner::headSimpleCanon(implode("\r\n", $dkim_headers). "\r\n".$dkim_unsigned);
break; break;
case 'http/well-known':
// todo
default: default:
$error = "Unknown header canonicalization '$header_canon'"; $error = "Unknown header canonicalization '$header_canon'";
return false; return false;
@ -427,6 +454,10 @@ class ischedule_server extends groupdav
$public_key = self::dns_txt_pubkey($dkim['d'], $dkim['s']); $public_key = self::dns_txt_pubkey($dkim['d'], $dkim['s']);
break; break;
case 'http/well-known':
$public_key = self::well_known_pubkey($dkim['d'], $dkim['s']);
break;
case 'private-exchange': case 'private-exchange':
$public_key = self::private_exchange_pubkey($dkim['d'], $dkim['s']); $public_key = self::private_exchange_pubkey($dkim['d'], $dkim['s']);
break; break;
@ -437,13 +468,16 @@ class ischedule_server extends groupdav
} }
if ($public_key) break; if ($public_key) break;
} }
// for testing purpose allways try private-exchange, if none other match
if (!$public_key) $public_key = self::private_exchange_pubkey($dkim['d'], $dkim['s']);
if (!$public_key) if (!$public_key)
{ {
$error = "No public key for d='$dkim[d]' and s='$dkim[s]' using methods q='$dkim[q]'"; $error = "No public key for d='$dkim[d]' and s='$dkim[s]' using methods q='$dkim[q]'";
return false; return false;
} }
$ok = openssl_verify($_unsigned, base64_decode($dkim['b']), $public_key, $hash_algo); $ok = openssl_verify($_unsigned, base64_decode($dkim['b']), $public_key, $hash_algo);
//error_log(__METHOD__."() openssl_verify('$_unsigned', ..., '$public_key', '$hash_algo') returned ".array2string($ok)); error_log(__METHOD__."() openssl_verify('$_unsigned', ..., '$public_key', '$hash_algo') returned ".array2string($ok));
switch($ok) switch($ok)
{ {
@ -456,7 +490,7 @@ class ischedule_server extends groupdav
// if dkim did not validate, try not splitting Recipient header // if dkim did not validate, try not splitting Recipient header
if (!isset($split_recipients)) if (!isset($split_recipients))
{ {
return $this->dkim_validate($headers, $body, $error, $required_headers, false); return self::dkim_validate($headers, $body, $error, false);
} }
return false; return false;
} }
@ -478,18 +512,26 @@ JPeajkWy/0CJn+d6rX/ncPMGX2EYzqXy/CyVqpcnVAosToymo6VHL6ufhzlyLJFD
znLtV121CZLUZlAySQIDAQAB znLtV121CZLUZlAySQIDAQAB
-----END PUBLIC KEY-----', -----END PUBLIC KEY-----',
), ),
'bedework.org' => array( 'mysite.edu' => array(
'selector' => '-----BEGIN PUBLIC KEY----- 'selector' => '-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCuv+6UtGUdPerJ3s0HCng2sv3c MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCuv+6UtGUdPerJ3s0HCng2sv3c
R3ttma0JB6rMFfOTi1oHgk+h328MfGzhZK+SA9tsRPBcrJE/3uxs4SS2XNG9qRCG R3ttma0JB6rMFfOTi1oHgk+h328MfGzhZK+SA9tsRPBcrJE/3uxs4SS2XNG9qRCG
0YMmNFOmubht4RhQhS9drSNyMZbhy2MPVbl9lHAJULFdaDdLj1hc3xTMWy8sDa8s 0YMmNFOmubht4RhQhS9drSNyMZbhy2MPVbl9lHAJULFdaDdLj1hc3xTMWy8sDa8s
M8r0gHvp/sPSe9CQQQIDAQAB M8r0gHvp/sPSe9CQQQIDAQAB
-----END PUBLIC KEY-----',
),
'caldav.egroupware.net' => array(
'calendar' => '-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiawhLuTSVhnl1zz5pXs1A748y
N3aNE181dni8nsYqIQB1h4H32J4dZurEiAnP9nflQRjCmmg1NTvFcNz11Bem4zo1
K4r4mcfbjlheorK2Mwoh445HR3fo/pP7uV6CcXTNboBJLTxs6ZHswmQjxyuKBKmx
yXUKsIQVi3qPyPdB3QIDAQAB
-----END PUBLIC KEY-----', -----END PUBLIC KEY-----',
), ),
); );
/** /**
* Fetch public key from dns txt recored dkim q=dns/txt * Fetch public key from dns txt recored dkim q=private-exchange
* *
* @param string $d domain * @param string $d domain
* @param string $s selector * @param string $s selector
@ -504,6 +546,26 @@ M8r0gHvp/sPSe9CQQQIDAQAB
return self::$private_exchange[$d][$s]; return self::$private_exchange[$d][$s];
} }
/**
* Fetch public key via http for q=http/well-known
*
* GET request to https://$domain/.well-known/domainkey/$selector
* Content should be identical to txt record for dns/txt.
*
* @param string $d domain
* @param string $s selector
* @return string|boolean string with (full) public key or false if not found or other error retrieving it
*/
public static function well_known_pubkey($d, $s)
{
if (!($keys = @file_get_contents('https://'.$d.'/.well-known/domainkey/'.$s)) ||
preg_match('/p=([^;]+)/i', $keys, $matches))
{
return false;
}
return "-----BEGIN PUBLIC KEY-----\n".chunk_split($matches[1], 64, "\n")."-----END PUBLIC KEY-----\n";
}
/** /**
* Fetch public key from dns txt recored dkim q=dns/txt * Fetch public key from dns txt recored dkim q=dns/txt
* *
@ -684,7 +746,7 @@ M8r0gHvp/sPSe9CQQQIDAQAB
$code = $e->getCode(); $code = $e->getCode();
$msg = $e->getMessage(); $msg = $e->getMessage();
if (!in_array($code, array(400, 403, 407, 503))) $code = 500; if (!in_array($code, array(400, 403, 407, 503))) $code = 500;
header('HTTP/1.1 '.$code.' '.$msg); header('HTTP/1.1 '.$code.' '.$msg, true, $code);
// if our groupdav logging is active, log the request plus a trace, if enabled in server-config // if our groupdav logging is active, log the request plus a trace, if enabled in server-config
if (groupdav::$request_starttime && isset(self::$instance)) if (groupdav::$request_starttime && isset(self::$instance))