1. Vorbereitung: Digitale ID in mehrere PEM-Dateien zerlegen

Hierzu müssen Sie in der Eingabeaufforderung die OpenSSL-Software verwenden. Auf Linux- und Macintosh-Rechnern sollte diese Software immer installiert sein. Besitzer von Windows-Rechnern können die Software von www.openssl.org herunterladen und installieren.

Wenn die PKCS#12-Datei digital-id.p12 heißt, können Sie mit den folgenden Befehlen die Bestandteile in die Dateien private-key.pem, certificate.pem und chain.pem extrahieren:

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

Sie werden dabei mehrfach nach Passwörtern gefragt, da der private Schlüssel erst aus- und dann wieder eingepackt wird. Das neue Passwort wird unten als private-key-password benötigt.

Für maximale Verwendbarkeit sollten Sie alle Dateien mit einem einfachen Texteditor bearbeiten:

  • Aus der Datei chain.pem sollten Sie das Wurzelzertifikat löschen. Sie erkennen es daran, dass subject und issuer identisch sind. Es ist möglich, dass die Datei danach leer ist, dann beachten Sie bitte die Hinweise in den nachfolgenden Anleitungen.

  • Aus allen Dateien sollten sie alle Kommentare (die Texte außerhalb der BEGIN-END-Blöcke) löschen und zwischen den Blöcken jeweils eine Leerzeile einfügen.

2. TLS-Verbindung mit Client-Zertifikat-Authentifizierung aufsetzen

Bei zahlreichen PHP-Funktionen können Sie beim Verbindungsaufbau einen Stream-Context angeben. Dieser Stream-Context sollte unter anderem folgende Angaben enthalten:

$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 ist eine PEM-Datei mit den Inhalten von private-key.pem, certificate.pem und chain.pem in dieser Reihenfolge durch jeweils eine Leerzeile getrennt.

roots.pem ist eine PEM-Datei mit Wurzelzertifikaten und sollte mindestens das für den anzusprechenden Server relevante Wurzelzertifikat enthalten. Diese Datei enthält alle Wurzel- und CA-Zertifikate der DFN-PKI; weitere Wurzelzertifikate können Sie aus jedem WWW-Browser extrahieren.

3. Signierte PDF-Datei erzeugen

Nur eine zusätzliche Anweisung wird in Ihrem PHP-Skript benötigt, etwa so:

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

...

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

...

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

Die Verwendung von realpath() wird auch dann dringend empfohlen, wenn Sie absolute Pfade angeben.

Falls die Datei chain.pem leer ist, ersetzen Sie realpath('chain.pem') durch null.

Wichtiger Hinweis

Laut https://www.pdf-insecurity.org/ werden elektronische Unterschriften durch fast alle PDF-Reader, auch von Adobe, nicht korrekt überprüft. Es gibt zahlreiche verschiedene Möglichkeiten, diese PDF-Reader so auszutricksen, dass sie gefälschte Inhalte als echt anzeigen!

Offensichtlich leiden PDF-interne Signaturen an konzeptionellen Schwächen. Wir empfehlen daher, PDF-internen Signaturen überhaupt nicht mehr zu vertrauen.

Ausnahmen sind möglich, wenn – wie in der Universitätsverwaltung – zur Signaturprüfung eine Software eingesetzt wird, die gegen sämtliche auf https://www.pdf-insecurity.org/ genannten Angriffe unempfindlich ist. Dies ist nach jedem Update dieser Webseite oder der Software erneut zu prüfen.

4. Signierte oder signierte und verschlüsselte E-Mail versenden

Nach Einbindung einer zusätzlichen Funktion signmail() wird nur eine zusätzliche Anweisung in Ihrem PHP-Skript unmittelbar vor dem Aufruf der Funktion mail() benötigt, um die für mail() bereits passend vorbereiteten Inhalte um die Signatur zu ergänzen und um die ganze E-Mail zu verschlüsseln, etwa so:

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
);

Falls die Datei chain.pem leer ist, ersetzen Sie 'chain.pem' durch null.

Falls Sie die E-Mail verschlüsseln möchten, müssen die Dateien cert1.pem, cert2.pem, ... die Zertifikate der Empfänger enthalten. Es können beliebig viele Empfängerzertifikate angegeben werden.

Falls Sie nur signieren möchten, lassen Sie das Argument einfach weg oder geben Sie einfach gar kein Empfängerzertifikat an: array()

Statt der Dateinamen können Sie auch die PEM-Daten direkt angeben, diese müssen dann unmittelbar mit -----BEGIN anfangen.

Die Funktion signmail() sieht unter Unix so aus; sie darf frei verwendet und verbreitet werden:

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;
}