Preparation: Splitting a digital ID into multiple PEM files

This applies regardless of whether it is a digital ID for emails or a digital ID for PDF documents.

To do so, you have to use the OpenSSL software from the command prompt. On Linux and Macintosh computers this software should always be installed. Owners of Windows computers can download the software from www.openssl.org and install it.

If ID.p12 is the PKCS#12 file, you can use the following commands to extract the components into the files key.pem, cert.pem, and chain.pem.

openssl pkcs12 -in ID.p12 -out key.pem -nocerts
openssl pkcs12 -in ID.p12 -out cert.pem -nokeys -clcerts
openssl pkcs12 -in ID.p12 -out chain.pem -nokeys -cacerts

If you are using a newer OpenSSL version (from 3.0), but the digital ID is in traditional format, the above commands will not work, then please use the following commands:

openssl pkcs12 -legacy -in ID.p12 -out key.pem -nocerts
openssl pkcs12 -legacy -in ID.p12 -out cert.pem -nokeys -clcerts
openssl pkcs12 -legacy -in ID.p12 -out chain.pem -nokeys -cacerts

You will be asked for passwords several times because the private key is first unpacked and then packed again. The new password will be needed below as keypassword.

For maximum portability, you should edit all files with a simple text editor:

  • From the file chain.pem you should remove the root certificate. You can recognize the root certificate by the fact that issuer and subject are identical. It is possible that the file is empty afterwards, then please observe the remarks in the guides below.

  • From all files you should remove all comments (the texts outside of the BEGIN...END blocks) and insert empty lines between the blocks.

Setting up a TLS connection with client certificate authentification

Numerous PHP functions accept a stream context. This stream context should contain these details, among others:

$context=stream_context_create(array(
  'ssl' => array(
    # verify server certificate
    'verify_peer'  => true,
    'cafile'       => realpath('roots.pem'),
    'verify_depth' => 3, # minimum for DFN-PKI
    # present our own certificate
    'local_cert'   => realpath('client.pem'),
    'passphrase'   => 'keypassword',
  ),
));

client.pem is a PEM file containing the contents of key.pem, cert.pem, and chain.pem in this order, separated by empty lines.

roots.pem is a PEM file containing root certificates and should contain at least the root certificate relevant for the server to be contacted. This file contains all root and CA certificates of the DFN-PKI, further root certificates can be extracted from every WWW browser.

Creating a signed PDF document

If you are using the TCPDF library to create a PDF document, only one additional statement is required in your PHP script, like this:

$pdf=new TCPDF(.....);

...

$pdf->setSignature(
  'file://' . realpath('cert.pem'),
  'file://' . realpath('key.pem'),
  'keypassword',
  realpath('chain.pem') # 
or null
);

...

$pdf->Output(.....)

If the file chain.pem is empty, as with the digital IDs of our PDF-CA, realpath('chain.pem') must be replaced with null.

Using realpath() is urgently recommended even if you give absolute paths.

Sending a signed or a signed and encrypted email

After including an additional function signmail() only one additional statement is required in your PHP script just before calling the function mail() to add the signature to the contents already prepared for mail(), and to encrypt the whole mail, like this:

signmail(
  $message,
  $additional_headers,
  'key.pem',
  'keypassword',
  'cert.pem',
  'chain.pem',
  array('cert1.pem','cert2.pem')
) and mail(
  $to,
  $subject,
  $message,
  $additional_headers,
  $additional_parameters
);

If the file chain.pem is empty, replace 'chain.pem' with null.

If you want to encrypt the email, the files cert1.pem, cert2.pem, ... must contain the certificates of the recipients. You can specify any number of certificates.

If you want to sign only, simply omit the argument or do not specify any certificate: array()

In place of the file names you can give the PEM data directly, then the data must begin with -----BEGIN.

The function signmail() looks under Unix like this, it may freely be used and distributed:

function signmail(
  &$message,
  &$additional_headers,
  $key,
  $keypass,
  $cert,
  $chain=null,
  $rcptcerts=null
){
  # own key
  if(substr($key,0,10)!='-----BEGIN'){
    $key=realpath($key);
    if($key===false)return false;
    $key='file://'.$key;
  }
  # own certificate
  if(substr($cert,0,10)!='-----BEGIN'){
    $cert=realpath($cert);
    if($cert===false)return false;
    $cert='file://'.$cert;
  }
  # intermediate CA certificates
  $chainsave='';
  if(!$chain){
    $chain=null;
  }elseif(is_string($chain) and substr($chain,0,10)!='-----BEGIN'){
    # a single file name
    $chain=realpath($chain);
    if($chain===false)return false;
    # no prefix 'file://'
  }else{
    # collect work file
    if(!is_array($chain))$chain=array($chain);
    foreach($chain as $onechain){
      if(substr($onechain,0,10)!='-----BEGIN'){
        $onechain=realpath($onechain);
        if($onechain===false)return false;
        $onechain=file_get_contents($onechain);
        if($onechain===false)return false;
      }
      $chainsave.=$onechain;
      $chainsave.="\n";
    }
  }
  # if encrypting:
  # certificates of recipients
  $encrypt=array();
  if($rcptcerts!==null){
    if(!is_array($rcptcerts))$rcptcerts=array($rcptcerts);
    foreach($rcptcerts as $onecert){
      if(substr($onecert,0,10)!='-----BEGIN'){
        $onecert=realpath($onecert);
        if($onecert===false)return false;
        $onecert='file://'.$onecert;
      }
      $encrypt[]=$onecert;
    }
  }
  # prepare additional headers
  # separate MIME content from other headers
  $work=array();
  foreach(explode("\n",trim($additional_headers)) as $line){
    $line=chop($line);
    if($line!=''){
      if(in_array(substr($line,0,1),array(" ","\t"))){
        $line=array_pop($work)."\n".$line;
      }
      $work[]=$line;
    }
  }
  $head=array();
  $cont=array();
  foreach($work as $line){
    if(strtolower(substr($line,0,13))=='mime-version:'){
      # drop, we add our own
    }elseif(strtolower(substr($line,0,8))=='content-'){
      $cont[]=$line;
    }else{
      $head[]=$line;
    }
  }
  # if no structured body yet, prepare as plain UTF-8 text
  if(!$cont){
    $cont[]='Content-Type: text/plain; charset=utf-8';
    $cont[]='Content-Transfer-Encoding: quoted-printable';
    $body='';
    foreach(explode("\n",chop(
      mb_check_encoding($message,'UTF-8')
      ? $message
      # assume non-UTF-8 to be ISO-8859-1 or Windows-1252
      : mb_convert_encoding($message,'UTF-8','Windows-1252')
    )) as $line)$body.=strtr(
      # to encode spaces correctly,
      # quoted_printable_encode() requires CRLF line ends
      quoted_printable_encode(
        chop($line,"\r\n")."\r\n"
      ),
      array("\r\n"=>"\n")
    );
  }else{
    $body=$message;
  }
  # work files
  $name1=tempnam(sys_get_temp_dir(),'signmail.1.');
  $name2=tempnam(sys_get_temp_dir(),'signmail.2.');
  # if given as string, save chain to file
  if($chainsave!=''){
    $name3=tempnam(sys_get_temp_dir(),'signmail.3.');
    if(!file_put_contents($name3,$chain)){
      @unlink($name1);
      @unlink($name2);
      @unlink($name3);
      return false;
    }
    $chain=$name3;
  }
  # sign
  if((
    !file_put_contents(
      $name1,
      implode("\n",$cont)."\n\n".$body
    )
  )or(
    !openssl_pkcs7_sign(
      $name1,
      $name2,
      $cert,
      array($key,$keypass),
      # add headers here when only signing
      $encrypt ? null : $head,
      PKCS7_DETACHED,
      $chain
    )
  )or(
    ($work=file_get_contents($name2))==''
  )){
    if($chainsave!='')@unlink($name3);
    @unlink($name2);
    @unlink($name1);
    return false;
  }
  if($chainsave!='')@unlink($name3);
  # encrypt
  if($encrypt){
    # PHP does not support GCM ciphers, we cannot avoid CBC.
    # Mitigate EFail CBC/CFB Gadget Attack by prepending a header field
    # with random length and random name ('X' + up to 70 characters).
    # 70*log62(256) = 52.099 so use up to 52 random bytes with Base62.
    # 70*log16(256) = 35.000 so use up to 35 random bytes with Base16.
    # But use at least 16*8 = 128 bits of randomness.
    if((
      !file_put_contents(
        $name1,'X'.(
          extension_loaded('gmp') # Base62 available?
          ?gmp_strval('0x'.bin2hex(random_bytes(random_int(16,52))),62)
          :bin2hex(random_bytes(random_int(16,35)))
        ).":\n".$work
      )
    )or(
      !openssl_pkcs7_encrypt(
        $name1,
        $name2,
        $encrypt,
        # add headers here when also encrypting
        $head,
        0,
        OPENSSL_CIPHER_AES_256_CBC
      )
    )or(
      ($work=file_get_contents($name2))==''
    )){
      @unlink($name2);
      @unlink($name1);
      return false;
    }
  }
  # clean up
  @unlink($name2);
  @unlink($name1);
  # result (call by reference)
  list($additional_headers,$message)=explode(
    "\n\n",
    strtr($work,array("\r\n"=>"\n")),
    2
  );
  return true;
}