<?php
/**
 * Copyright 2011 Ahmad Amarullah
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */


/**
 * mailDomainSigner - PHP class for Add DKIM-Signature and DomainKey-Signature on your mail
 *
 * Requirement: >= PHP 5.3.0
 *              For lower version, find "#^--for ver < PHP5.3" comment
 *
 * @link http://code.google.com/p/php-mail-domain-signer/
 *
 * @package mailDomainSigner
 * @author Ahmad Amarullah
 */
class mailDomainSigner{

  ///////////////////////
  // PRIVATE VARIABLES //
  ///////////////////////
  private $pkid=null;
  private $s;
  private $d;

  //////////////////////
  // AGENT PROPERTIES //
  //////////////////////
  private $__app_name         = "PHP mailDomainSigner";
  private $__app_ver          = "0.1-20110129";
  private $__app_url          = "http://code.google.com/p/php-mail-domain-signer/";

  /**
   * Constructor
   * @param string $private_key Raw Private Key to Sign the mail
   * @param string $d The domain name of the signing domain
   * @param string $s The selector used to form the query for the public key
   * @author Ahmad Amarullah
   */
  public function __construct($private_key,$d,$s){
    // Get a private key
    $this->pkid = openssl_pkey_get_private($private_key);

    // Save Domain and Selector
    $this->d    = $d;
    $this->s    = $s;
  }

  ///////////////////////
  // PRIVATE FUNCTIONS //
  ///////////////////////

  /**
   * The nofws ("No Folding Whitespace") Canonicalization Algorithm
   * Function implementation according to RFC4870
   *
   * @link http://tools.ietf.org/html/rfc4870#page-19
   * @param array $raw_headers Array of Mail Headers
   * @param string $raw_body Raw Mail body
   * @return string nofws Canonicalizated data
   * @access public
   * @author Ahmad Amarullah
   */
  public function nofws($raw_headers,$raw_body){
    // nofws-ed headers
    $headers = array();

    // Loop the raw_headers
    foreach ($raw_headers as $header){
      // Replace all Folding Whitespace
      $headers[] = preg_replace('/[\r\t\n ]++/','',$header);
    }

    // Join headers with LF then Add it into data
    $data = implode("\n",$headers)."\n";

    // Loop Body Lines
    foreach(explode("\n","\n".str_replace("\r","",$raw_body)) as $line)
    {
      // Replace all Folding Whitespace from current line
      // then Add it into data
      $data .= preg_replace('/[\t\n ]++/','',$line)."\n";
    }

    // Remove Trailing empty lines then split it with LF
    $data = explode("\n",rtrim($data,"\n"));

    // Join array of data with CRLF and Append CRLF
    // to the resulting line
    $data = implode("\r\n",$data)."\r\n";

    // Return Canonicalizated Data
    return $data;
  }

  /**
   * The "relaxed" Header Canonicalization Algorithm
   * Function implementation according to RFC4871
   *
   * Originally taken from RelaxedHeaderCanonicalization
   * function in PHP-DKIM by Eric Vyncke
   *
   * @link http://tools.ietf.org/html/rfc4871#page-14
   * @link http://php-dkim.sourceforge.net/
   *
   * @param string $s Header String to Canonicalization
   * @return string Relaxed Header Canonicalizated data
   * @access public
   * @author Eric Vyncke
   */
  public function headRelaxCanon($s) {
    // Replace CR,LF and spaces into single SP
    $s=preg_replace("/\r\n\s+/"," ",$s) ;

    // Explode Header Line
    $lines=explode("\r\n",$s) ;

    // Loop the lines
    foreach ($lines as $key=>$line) {
      // Split the key and value
      list($heading,$value)=explode(":",$line,2) ;

      // Lowercase heading key
      $heading=strtolower($heading);

      // Compress useless spaces
      $value=preg_replace("/\s+/"," ",$value);

      // Don't forget to remove WSP around the value
      $lines[$key]=$heading.":".trim($value);
    }

    // Implode it again
    $s=implode("\r\n",$lines);

    // Return Canonicalizated Headers
    return $s;
  }

  /**
   * The "ischedule-relaxed" Header Canonicalization Algorithm
   * Function implementation according to draft-desruisseaux-ischedule-03:
   *
   * The "ischedule-relaxed" header canonicalization algorithm is used to
   * canonicalize HTTP header fields where multiple headers fields with
   * the same name might be combined by an HTTP intermediary
   * into a single comma-separated header.
   *
   * Originally taken from headRelaxCanon above
   *
   * @param string $s Header String to Canonicalization
   * @return string Relaxed Header Canonicalizated data
   * @access public
   * @author Ralf Becker
   */
  public function headIScheduleRelaxCanon($s) {
    // Replace CR,LF and spaces into single SP
    $s=preg_replace("/\r\n\s+/"," ",$s) ;

    // Loop exploded header lines
    $lines=array();
    foreach (explode("\r\n",$s) as $line) {
      // Split the key and value
      list($heading,$value)=explode(":",$line,2) ;

      // Lowercase heading key
      $heading=strtolower($heading);

      // Compress useless spaces
      $value=preg_replace("/\s+/"," ",$value);

      // Don't forget to remove WSP around the value
      $value = trim($value);

      // remove whitespace after comma in values
      $value = preg_replace("/,\s+/",",",$value);

      // for multiple headers, add them comma-separated to existing headers
      if (isset($lines[$heading])) {
      	$lines[$heading] .= ','.$value;
      } else {
      	$lines[$heading] = $heading.':'.$value;
      }
    }

    // Implode it again
    $s=implode("\r\n",$lines);

    // Return Canonicalizated Headers
    return $s;
  }

  /**
   * The "simple" Header Canonicalization Algorithm
   *
   * Simple canonicalzation means no change to headers!
   *
   * @link https://tools.ietf.org/html/rfc4871#section-3.4.1
   *
   * @param string $s Header String to Canonicalization
   * @return string Simple Header Canonicalizated data
   * @access public
   */
  public function headSimpleCanon($s) {
  	return $s;
  }

  /**
   * The "relaxed" Body Canonicalization Algorithm
   * Function implementation according to RFC4871
   *
   * @link http://tools.ietf.org/html/rfc4871#page-15
   *
   * @param string $body Body String to Canonicalization
   * @return string Relaxed Body Canonicalizated data
   * @access public
   * @author Ahmad Amarullah
   */
  public function bodyRelaxCanon($body) {
    // Return CRLF for empty body
    if ($body == ''){
      return "\r\n";
    }

    // Replace all CRLF to LF
    $body = str_replace("\r\n","\n",$body);

    // Replace LF to CRLF
    $body = str_replace("\n","\r\n",$body);

    // Ignores all whitespace at the end of lines
    $body=rtrim($body,"\r\n");

    // Canonicalizated String Variable
    $canon_body = '';

    // Split the body into lines
    foreach(explode("\r\n",$body) as $line){
      // Reduces all sequences of White Space within a line
      // to a single SP character
      $canon_body.= rtrim(preg_replace('/[\t\n ]++/',' ',$line))."\r\n";
    }

    // Return the Canonicalizated Body
    return $canon_body;
  }

  /**
   * The "simple" Body Canonicalization Algorithm
   * Function implementation according to RFC4871
   *
   * @link https://tools.ietf.org/html/rfc6376#section-3.4.3
   *
   * @param string $body Body String to Canonicalization
   * @return string Simple Body Canonicalizated data
   * @access public
   */
  public function bodySimpleCanon($body) {
  	// remove all empty lines (CRLF pairs) at the end of the body
  	while (substr($body, -2) === "\r\n") {
  		$body = substr($body, 0, -2);
  	}

  	// add a single CRLF at the end
  	$body .= "\r\n";

    // Return the Canonicalizated Body
    return $body;
  }

  //////////////////////
  // PUBLIC FUNCTIONS //
  //////////////////////

  /**
   * DKIM-Signature Header Creator Function
   * implementation according to RFC4871
   *
   * Originally code inspired by AddDKIM
   * function in PHP-DKIM by Eric Vyncke
   * And rewrite it for better result
   *
   * The function use relaxed/relaxed canonicalization alghoritm
   * for better verifing validation
   *
   * different from original PHP-DKIM that used relaxed/simple
   * canonicalization alghoritm
   *
   * Doesn't include z, i and q tag for smaller data because
   * it doesn't really needed
   *
   * @link http://tools.ietf.org/html/rfc4871
   * @link http://php-dkim.sourceforge.net/
   *
   * @param string $h Signed header fields, A colon-separated list of header field names that identify the header fields presented to the signing algorithm
   * @param array $_h Array of headers in same order with $h (Signed header fields)
   * @param string $body Raw Email Body String
   * @param string $_c='relaxed/relaxed' header/body canonicalzation algorithm, default "relaxed/relaxed", can also be any combination of "relaxed" or "simple"
   * @param string $_a='rsa-sha1' could also be eg. 'rsa/sha256' and other hashes supported by openssl for rsa signing
   * @param string $_dkim=null template for creating DKIM signature, default as for email appropriate
   * @return string|boolean DKIM-Signature Header String, or false if an error happend, eg. unimplemented canonicalization requested
   * @access public
   * @author Ahmad Amarullah
   */
  public function getDKIM($h,$_h,$body,$_c='relaxed/relaxed',$_a='rsa-sha1',$_dkim=null) {

    // Relax Canonicalization for Body
 	list($header_canon,$body_canon) = explode('/',$_c);
  	switch($body_canon)
  	{
  		case 'relaxed':
    		$_b = $this->bodyRelaxCanon($body);
    		break;

  		case 'simple':
    		$_b = $this->bodySimpleCanon($body);
    		break;

  		default:
  			return false;	// unknown/unimplemented canonicalzation algorithm
  	}

    // Canonicalizated Body Length [tag:l]
    // use mb_strlen if availble to get length in bytes/octets, to kope with evtl. set mbstring.func_overload!
    $_l = function_exists('mb_strlen') ? mb_strlen($_b, '8bit') : strlen($_b);

    // Signature Timestamp [tag:t]
    $_t = time();

    // Hash of the canonicalized body [tag:bh]
    list(,$hash_algo) = explode('-', $_a);
    $_bh = base64_encode(hash($hash_algo,$_b,true));

    // Creating DKIM-Signature
    if (is_null($_dkim)) {
	    $_dkim = "DKIM-Signature: ".
	                "v=1; ".          // DKIM Version
	                "a=\$a; ".        // The algorithm used to generate the signature "rsa-sha1"
	                "s=\$s; ".        // The selector subdividing the namespace for the "d=" (domain) tag
	                "d=\$d; ".        // The domain of the signing entity
	                "l=\$l; ".        // Canonicalizated Body length count
	                "t=\$t; ".        // Signature Timestamp
	                "c=\$c; ".        // Message (Headers/Body) Canonicalization "relaxed/relaxed"
	                "h=\$h; ".        // Signed header fields
	                "bh=\$bh;\r\n\t". // The hash of the canonicalized body part of the message
	                "b=";             // The signature data (Empty because we will calculate it later)
    }
	$_dkim = strtr($_dkim, array(
		'$a' => $_a,
		'$s' => $this->s,
		'$d' => $this->d,
		'$l' => $_l,
		'$t' => $_t,
		'$c' => $_c,
		'$h' => $h,
		'$bh' => $_bh,
	));
    // Wrap DKIM Header
    $_dkim = wordwrap($_dkim,76,"\r\n\t");

    // Canonicalization Header Data
  	switch($header_canon)
  	{
  		case 'relaxed':
    		$_unsigned  = $this->headRelaxCanon(implode("\r\n",$_h)."\r\n{$_dkim}");
    		break;

  		case 'ischedule-relaxed':
    		$_unsigned  = $this->headIScheduleRelaxCanon(implode("\r\n",$_h)."\r\n{$_dkim}");
    		break;

    	case 'simple':
    		$_unsigned  = $this->headSimpleCanon(implode("\r\n",$_h)."\r\n{$_dkim}");
     		break;

  		default:
  			return false;	// unknown/unimplemented canonicalzation algorithm
  	}
error_log(__METHOD__."() unsigned='".str_replace(array("\r","\n"),array('\\r','\\n'),$_unsigned)."'");

    // Sign Canonicalization Header Data with Private Key
    openssl_sign($_unsigned, $_signed, $this->pkid, $hash_algo);

    // Base64 encoded signed data
    // Chunk Split it
    // Then Append it $_dkim
    $_dkim   .= chunk_split(base64_encode($_signed),76,"\r\n\t");

    // Return trimmed $_dkim
    return trim($_dkim);
  }

  /**
   * DomainKey-Signature Header Creator Function
   * implementation according to RFC4870
   *
   * The function use nofws canonicalization alghoritm
   * for better verifing validation
   *
   * NOTE: the $h and $_h arguments must be in right order
   *       if to header location upper the from header
   *       it should ordered like "to:from", don't randomize
   *       the order for better validating result.
   *
   * NOTE: if your DNS TXT contained g=*, remove it
   *
   * @link http://tools.ietf.org/html/rfc4870
   *
   * @param string $h Signed header fields, A colon-separated list of header field names that identify the header fields presented to the signing algorithm
   * @param array $_h Array of headers in same order with $h (Signed header fields)
   * @param string $body Raw Email Body String
   * @return string DomainKey-Signature Header String
   * @access public
   * @author Ahmad Amarullah
   */
  public function getDomainKey($h,$_h,$body){
    // If $h = empty, dont add h tag into DomainKey-Signature
    $hval = '';
    if ($h)
      $hval= "h={$h}; ";

    // Creating DomainKey-Signature
    $_dk = "DomainKey-Signature: ".
              "a=rsa-sha1; ".           // The algorithm used to generate the signature "rsa-sha1"
              "c=nofws; ".              // Canonicalization Alghoritm "nofws"
              "d={$this->d}; ".         // The domain of the signing entity
              "s={$this->s}; ".         // The selector subdividing the namespace for the "d=" (domain) tag
              "{$hval}";                // If Exists - Signed header fields

    // nofws Canonicalization for headers and body data
    $_unsigned  = $this->nofws($_h,$body);

    // Sign nofws Canonicalizated Data with Private Key
    openssl_sign($_unsigned, $_signed, $this->pkid, OPENSSL_ALGO_SHA1);

    // Base64 encoded signed data
    // Chunk Split it
    $b = chunk_split(base64_encode($_signed),76,"\r\n\t");

    // Append sign data into b tag in $_dk
    $_dk.="b={$b}";

    // Return Wrapped and trimmed $_dk
    return trim(wordwrap($_dk,76,"\r\n\t"));
  }

  /**
   * Auto Sign RAW Mail Data with DKIM-Signature
   * and DomailKey-Signature
   *
   * It Support auto positioning Signed header fields
   *
   * @param string $mail_data Raw Mail Data to be signed
   * @param string $suggested_h Suggested Signed Header Fields, separated by colon ":"
   *                                      Default: string("from:to:subject")
   * @param bool $create_dkim If true, it will generate DKIM-Signature for $mail_data
   *                                      Default: boolean(true)
   * @param bool $create_domainkey If true, it will generate DomailKey-Signature for $mail_data
   *                                      Default: boolean(true)
   * @param integer $out_sign_header_only If true or 1, it will only return signature headers as String
   *                                      If 2, it will only return signature headers as Array
   *                                      If false or 0, it will return signature headers with original mail data as String
   *                                      Default: boolean(false)
   * @return mixed Signature Headers with/without original data as String/Array depended on $out_sign_header_only parameter
   * @access public
   * @author Ahmad Amarullah
   */
  public function sign(
      $mail_data,                                 // Raw Mail Data
      $suggested_h = "from:to:subject",           // Suggested Signed Header Fields
      $create_dkim = true,                        // Create DKIM-Signature Header
      $create_domainkey = true,                   // Create DomainKey-Signature Header
      $out_sign_header_only = false               // Return Signature Header Only without original data
    ){

    if (!$suggested_h) $suggested_h = "from:to:subject"; // Default Suggested Signed Header Fields

    // Remove all space and Lowercase Suggested Signed header fields then split it into array
    $_h = explode(":",strtolower(preg_replace('/[\r\t\n ]++/','',$suggested_h)));

    // Split Raw Mail data into $raw_headers and $body
    list($raw_headers, $body) = explode("\r\n\r\n",$mail_data,2);

    // Explode $raw_header into $header_list
    $header_list = preg_split("/\r\n(?![\t ])/", $raw_headers);

    // Empty Header Array
    $headers = array();

    // Loop $header_list
    foreach($header_list as $header){
      // Find Header Key for Array Key
      list($key) = explode(':',$header, 2);

      // Trim and Lowercase It
      $key = strtolower(trim($key));

      // If header with current key was exists
      // Change it into array
      if (isset($headers[$key])){
        // If header not yet array set as Array
        if (!is_array($headers[$key]))
          $headers[$key] = array($headers[$key]);

        // Add Current Header as next element
        $headers[$key][] = $header;
      }

      // If header with current key not exists
      // Insert header as string
      else{
        $headers[$key] = $header;
      }
    }

    // Now, lets find accepted Suggested Signed header fields
    // and reorder it to match headers position

    $accepted_h = array();          // For Accepted Signed header fields
    $accepted_headers = array();    // For Accepted Header

    // Loop the Headers Array
    foreach ($headers as $key=>$val){
      // Check if $val wasn't array
      // We don't want to include multiple headers as Signed header fields
      if (!is_array($val)){
        // Check if this header exists in Suggested Signed header fields
        if (in_array($key,$_h)){
          // If Exists, add it into accepted headers and accepted header fields
          $accepted_h[] = $key;
          $accepted_headers[] = $val;
        }
      }
    }

    // If it doesn't contain any $accepted_h
    // return false, because we don't have enough data
    // for signing email
    if (count($accepted_h)==0)
      return false;

    // Create $_hdata for Signed header fields
    // by imploding it with colon
    $_hdata = implode(":",$accepted_h);

    // New Headers Variable
    $_nh = array("x-domain-signer"=>"X-Domain-Signer: {$this->__app_name} {$this->__app_ver} <$this->__app_url>");

    // Create DKIM First
    if ($create_dkim)
      $_nh['dkim-signature'] = $this->getDKIM($_hdata,$accepted_headers,$body);

    // Now Create Domain-Signature
    if ($create_domainkey)
      $_nh['domainKey-signature'] = $this->getDomainKey($_hdata,$accepted_headers,$body);

    // Implode $_nh with \r\n
    $to_be_appended_headers = implode("\r\n",$_nh);

    // Return Immediately if
    // * $out_sign_header_only=true (as headers string)
    // * $out_sign_header_only=2    (as headers array)
    if ($out_sign_header_only===2)
      return $_nh;
    elseif ($out_sign_header_only)
      return "{$to_be_appended_headers}\r\n";

    // Return signed headers with original data
    return "{$to_be_appended_headers}\r\n{$mail_data}";
  }
}

?>