Digitale IDs in PHP-Skripten verwenden

Diese Anleitung richtet sich an Leute, die wissen, wie man in PHP programmiert.

Digitale IDs lassen sich auch in PHP-Skripten verwenden. Allerdings benötigen die Skripte die Komponenten der digitalen ID in einzelnen PEM-Dateien.

Vorbereitung: Digitale ID in mehrere PEM-Dateien zerlegen

Dies gilt unabhängig davon, ob es sich um eine digitale ID für E-Mails oder um eine digitale ID für PDF-Dokumente handelt.

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 ID.p12 heißt, können Sie mit den folgenden Befehlen die Bestandteile in die Dateien key.pem, cert.pem und chain.pem extrahieren:

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

Falls Sie eine neuere OpenSSL-Version (ab 3.0) verwenden, die digitale ID aber im traditionellen Format vorliegt, werden obige Befehle nicht funktionieren, dann verwenden Sie bitte folgende Befehle:

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

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 keypassword 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.

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'   => 'keypassword',
  ),
));

client.pem ist eine PEM-Datei mit den Inhalten von key.pem, cert.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.

Signiertes PDF-Dokument erzeugen

Wenn Sie zum Erzeugen eines PDF-Dokuments die TCPDF-Bibliothek verwenden, wird nur eine zusätzliche Anweisung in Ihrem PHP-Skript benötigt, etwa so:

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

...

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

...

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

Falls die Datei chain.pem leer ist wie bei den digitalen IDs unserer PDF-CA, muss realpath('chain.pem') durch null ersetzt werden.

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

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,
  'key.pem',
  'keypassword',
  'cert.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,
  $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;
}