1. Preparation: Splitting a digital ID into multiple PEM files

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 digital-id.p12 is the PKCS#12 file, you can use the following commands to extract the components into the files private-key.pem, certificate.pem, and chain.pem.

openssl pkcs12 -in digital-id.p12 -out private-key.pem -nocerts

openssl pkcs12 -in digital-id.p12 -out certificate.pem -nokeys -clcerts

openssl pkcs12 -in digital-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 private-key-password.

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.

2. 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'   => 'private-key-password',
  ),
));

client.pem is a PEM file containing the contents of private-key.pem, certificate.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.

3. Creating a signed PDF file

Only one additional statement is required in your PHP script, like this:

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

...

$pdf->setSignature(
  'file://' . realpath('certificate.pem'),
  'file://' . realpath('private-key.pem'),
  'private-key-password',
  realpath('chain.pem')
);

...

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

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

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

Important notice

According to https://www.pdf-insecurity.org/, most PDF readers, even from Adobe, do not check electronic signatures correctly. There are numerous diffent ways to trick these PDF readers into displaying forged content as genuine!

Obviously PDF internal signatures suffer from conceptual weaknesses. We therefore recommend to no longer trust PDF internal signatures at all.

Exceptions are possible if – as in the university administration – software is used for signature verification that is insensitive to all attacks mentioned on https:/www.pdf-insecurity.org. This must be checked again after each update of this website or the software.

4. 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,
  'private-key.pem',
  'private-key-password',
  'certificate.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,
  $ownkey,
  $keypass,
  $owncert,
  $ownchain=null,
  $rcptcerts=null
){
  # own key
  if(substr($ownkey,0,10)!='-----BEGIN'){
    $ownkey=realpath($ownkey);
    if($ownkey===false)return false;
    $ownkey='file://'.$ownkey;
  }
  # own certificate
  if(substr($owncert,0,10)!='-----BEGIN'){
    $owncert=realpath($owncert);
    if($owncert===false)return false;
    $owncert='file://'.$owncert;
  }
  # intermediate CA certificates
  $chainsave='';
  if(!$ownchain){
    $ownchain=null;
  }elseif(is_string($ownchain) and substr($ownchain,0,10)!='-----BEGIN'){
    # a single file name
    $ownchain=realpath($ownchain);
    if($ownchain===false)return false;
    # no prefix 'file://'
  }else{
    # collect work file
    if(!is_array($ownchain))$ownchain=array($ownchain);
    foreach($ownchain 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,$ownchain)){
      @unlink($name1);
      @unlink($name2);
      @unlink($name3);
      return false;
    }
    $ownchain=$name3;
  }
  # sign
  if((
    !file_put_contents(
      $name1,
      implode("\n",$cont)."\n\n".$body
    )
  )or(
    !openssl_pkcs7_sign(
      $name1,
      $name2,
      $owncert,
      array($ownkey,$keypass),
      # add headers here when only signing
      $encrypt ? null : $head,
      PKCS7_DETACHED,
      $ownchain
    )
  )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;
}